A self-hosted, mobile-first website for managing a home library: scan a book's barcode (or search title/author), and file it on the shelves or the wishlist. Built for shopping trips — scanning any edition of a book tells you whether you already own that story in another binding.
Every ISBN identifies one specific edition (hardcover vs. paperback vs. special edition all have different ISBNs). book-bot stores a shared catalog plus per-library and per-user layers:
- works — the story itself, grouped by the Open Library work key (which links all editions of a book), with a normalized title+author key as fallback when Open Library doesn't know the edition. Shared catalog.
- editions — one row per ISBN: pure catalog metadata (title, format, cover, …). Also shared.
- libraries / library_books — who owns what. A library is a shelf that
one or several users own together (a couple shares one home library);
a
library_booksrow is that library's copy of an edition, with status (libraryfor a physical copy /digitalfor ebooks & audiobooks /wishlist), shared notes, and acopiescount — so a hardback plus two identical softbacks is two rows (one per edition) withcopies = 2on the softback. - read_states — per-user reading history, Goodreads-style: one row per
user+work with status (
want to read/reading/read), rating, private notes, and started/finished dates. Independent of ownership, so the app has a "read but don't own" view for library loans and borrowed books, and your spouse's shelves never inherit your ratings.
So when a scan finds an ISBN that isn't in the database, the app still resolves its work and answers: "not this edition, but you have this book: hardcover — in library" — and "you read this in 2023 ★★★★".
The read tab's 🏆 trophies filter crosses the two layers: books you've read but own no physical copy of (borrowed, library loans, or digital-only), each one a tap away from the wishlist for when you want it on the shelf.
The same keys are the future Goodreads-sync seams: a Goodreads export
matches editions by ISBN-13 (falling back to the work's normalized
title+author key) and lands shelves/read-dates/ratings in read_states.
Book barcodes are Bookland EAN-13 (start 978/979) and are the ISBN. Older mass-market paperbacks sometimes carry a retail UPC that doesn't encode the ISBN — the app detects that and suggests scanning the barcode inside the cover or searching by title.
- backend — FastAPI (Python, run with
uv). Metadata from Google Books + Open Library, merged. - data — the shared
appsPostgres via PostgREST, with logins through the postgrest-auth service (identical pattern to load-log:book_botschema,book_bot_userrole, JWT bearer tokens). A SQLite dev mode runs everything locally with no Postgres/Docker. - frontend — vanilla JS PWA in the terminal-navy style (style-terminal-navy tokens). Barcode scanning via the native BarcodeDetector API where available, vendored ZXing elsewhere (iPhone Safari). Installable to the home screen.
- shelves — the library tab renders a real-time 3D rotunda (vendored Three.js): wooden bookcases in an arc, each book a physical object textured with its cover, GSAP-driven flights when regrouping by genre / type / author. Falls back to a CSS bookcase without WebGL.
Users log in with their own account, or create one right from the login
screen ("create an account" — set SIGNUP_ENABLED=false to go
invite-only). The app fronts its own login hardening instead of sitting
behind Authelia: bcrypt hashes, per-username/per-IP lockout (5 failures
in 15 minutes), signup throttling and security headers.
Everyone also sees the shared ✳ Sample Library: one view-only shelf
of 300 well-known books. The app stocks it automatically at startup the
first time (app/bootstrap.py; a no-op once the shelf has books, from
any process — local uvicorn or the container). Browse it from the ▤
button — nobody can edit it. First login
auto-creates a personal
library; from the library view's ▤ button you can rename it, start
another, or share it with another user by username — members see and
manage the same shelves. Users who existed before the multi-user
migration all co-own the migrated Family Library; users created later
start with an empty library of their own and can't see anyone else's. In
production this is enforced twice: the API scopes every query by
membership, and Postgres row-level security does the same underneath
PostgREST.
Reading history is never shared: read status, ratings, read dates and reading notes are always per-user, whichever library the book sits in.
Everything the ▤ button does (and a bit more) is also scriptable.
scripts/manage_library.py talks to whatever backend the environment
selects — the local SQLite file in dev mode, or Postgres directly (with
the superuser POSTGRES_* env vars, bypassing the API) in production:
# see every library, its members and book counts
uv run python scripts/manage_library.py list
# create a shared library with members in one go
uv run python scripts/manage_library.py create --name "Cabin Books" \
--member jason --member beca
# add someone to an existing library (name or uuid)
uv run python scripts/manage_library.py add-member \
--library "Family Library" --username becaUsers themselves are created with scripts/create_user.py; members must
exist before they can be added. In production, run both scripts inside
the book-bot container, which has the right env (see
deploy/README.md).
uv sync
uv run python scripts/create_user.py --username beca --password 'choose-one'
uv run uvicorn app.main:app --host 0.0.0.0 --port 8010Open http://127.0.0.1:8010. Data lands in data/book_bot.db (gitignored).
A database from before multi-user libraries is migrated in place on first
open.
uv sync --group dev
uv run pytestCamera scanning needs a secure origin:
http://localhostworks on the same machine, but to scan from a phone you need HTTPS (deploy behind the reverse proxy, or use manual ISBN entry / title search).
See deploy/README.md: three idempotent SQL files add
the book_bot schema/role/users to the shared apps database, PostgREST
gets book_bot appended to PGRST_DB_SCHEMAS, and the app runs with
POSTGREST_URL/AUTH_URL/JWT_SECRET set, behind SWAG with HTTPS.
Earlier barcode scans (JSONL in ~/SyncthingDB/Book-Bot, states
Wrapped/Wishlist) can be replayed through the live API with full
metadata enrichment:
uv run python scripts/import_scans.py --file ~/SyncthingDB/Book-Bot/HoneyCrisp.jsonl \
--username beca --password '...' --dry-runapp/ FastAPI backend + static frontend (app/static)
deploy/ one-time SQL + notes for the shared PostgREST stack
scripts/ create_user.py, manage_library.py, import_scans.py,
seed_books.py
tests/ pytest suite (API against a throwaway SQLite database)