Self-hosted tracker for your books, records, movies, TV shows and games — one shelf for everything you own, play, read, watch and want. Built as an installable PWA with a FastAPI backend, React frontend and PostgreSQL.
Stack: FastAPI · SQLAlchemy · Alembic · PostgreSQL (JSONB + full-text) · React 19 · TypeScript · Vite · TanStack Query · Tailwind v4 · nginx.
cp .env.example .env # optional: add API keys (see below)
docker compose up --buildThen open http://localhost:8080, create an account — or seed demo data:
docker compose exec backend python -m app.seed
# login: demo@example.com / demo1234Podman? Works out of the box:
podman compose up --build(needspodman-compose, e.g.uv tool install podman-compose).
The app runs without any key: books use Open Library and music uses
MusicBrainz (both keyless), and every type supports manual entry. Keys
unlock movie/game search and Steam import. Put them in .env:
| Key | Unlocks | Where to get it |
|---|---|---|
TMDB_API_KEY |
Movie & TV search + metadata | themoviedb.org/settings/api — free account, use the v3 API key |
TWITCH_CLIENT_ID + TWITCH_CLIENT_SECRET |
Game search + artwork via IGDB | dev.twitch.tv/console/apps — register an app (any redirect URL) |
STEAM_API_KEY |
Steam library import | steamcommunity.com/dev/apikey — domain can be localhost |
DISCOGS_TOKEN |
Deeper music search (Discogs instead of MusicBrainz) | discogs.com/settings/developers — "Generate new token" |
JWT_SECRET |
Session token signing | openssl rand -hex 32 — set this in production |
The Epic, GOG and PlayStation imports need no keys: Epic/GOG read a Heroic or Legendary library file you upload, and PSN uses a pasted NPSSO token.
Reading a photographed cover takes a vision backend. Two ship, and
VISION_BACKENDS picks which run and in what order — the first that answers
wins, so a fast one leads and a local one catches what it drops:
| Setting | Default | What it does |
|---|---|---|
VISION_BACKENDS |
gemini,ollama |
Order to try. Unconfigured backends drop out; with none left the photo tab hides |
OLLAMA_URL |
(empty) | Local backend. http://host.containers.internal:11434 from the container (host.docker.internal on Docker), http://localhost:11434 on the host |
VISION_MODEL |
gemma3:4b |
Local model. Reads title, console and publisher in one ~4.6 s call — ollama pull gemma3:4b |
GEMINI_API |
(empty) | Gemini backend. ~1 s per photo, and a free tier that covers this use many times over |
GEMINI_VISION_MODEL |
gemini-flash-lite-latest |
Flash-Lite beat the bigger Flash on both speed and reliability |
Two caveats. Ollama listens on 127.0.0.1 by default, which a container
cannot reach — run it with OLLAMA_HOST=0.0.0.0:11434 or point OLLAMA_URL
at wherever it actually listens. And Gemini's free tier may use your prompts
to improve Google's products; the paid tier doesn't, and at this volume costs
around 3 cents a month. Run ollama alone if you'd rather nothing left the
machine at all.
docker compose up -d # start
docker compose logs -f backend # follow API logs
docker compose exec backend pytest # backend tests
docker compose exec backend alembic upgrade head # run migrations manually
docker compose down # stop (data persists in volumes)
docker compose down -v # stop AND wipe database + coversMigrations run automatically when the backend container starts. To create a new migration after changing models:
docker compose exec backend alembic revision --autogenerate -m "describe change"Layer docker-compose.dev.yml on top for a hot-reloading backend (source
bind-mounted, --reload) plus pgAdmin on http://localhost:5050
(admin@example.com / admin):
docker compose -f docker-compose.yml -f docker-compose.dev.yml upIt re-runs uv sync on every start, so changing a dependency in
backend/pyproject.toml just needs another up — no rebuild and no
down -v to refresh the cached venv.
Backend (needs a Postgres; podman run -d -e POSTGRES_PASSWORD=test -e POSTGRES_DB=collector_test -p 5433:5432 docker.io/library/postgres:16-alpine):
cd backend
uv sync
uv run pytest # tests hit Postgres on :5433
uv run uvicorn app.main:app --reload # API on :8000Frontend (Vite dev server proxies /api and /media to :8000):
cd frontend
npm install
npm run dev # app on :5173
npm test # vitestbrowser ──:8080──▶ frontend (nginx)
├─ / static React build (PWA: manifest + SW)
├─ /api/* ─▶ backend (FastAPI) ─▶ postgres
└─ /media/* ─▶ backend (cover images, media volume)
- One origin — nginx proxies the API, so no CORS and the service worker can cache covers.
- Items are user-owned rows with type-specific data in a JSONB
metadatacolumn; enum-ish fields are TEXT + CHECK constraints (enums live in code:backend/app/domain/enums.py,frontend/src/lib/types.ts). - Bundles are an id on the copies, not a table: items sharing a
bundle_idare copies of one release and one of them carriesbundle_front. Deleting a copy can't strand a group, and the library collapses bundles with a window function after filtering — so a filter always surfaces a copy that matches it (backend/app/core/bundles.py). - Every mutation writes an
activity_eventsrow in the same transaction (seebackend/app/core/events.py). This append-only log pluscompleted_at/acquisition_datetimestamps makes future dashboards (reads per year, hours over time, spend) pure queries — no backfill. - Metadata is fetched once: external lookups cached 7 days in
provider_cache; covers downloaded once to the media volume and served locally. Providers implement one interface (backend/app/providers/base.py) — add a source by subclassing and registering it.
-
Add items by catalog search (Open Library / TMDB / IGDB / MusicBrainz or Discogs), by barcode, or manually. Missing keys degrade to manual entry with a hint. Game search takes a platform filter (only games released on it come back, and it's preselected as the platform you file the copy under), and the search term and filter survive stepping into the confirm form and back via ← results. Half-typed titles work: IGDB's search index only matches whole words, so "sekir" would find nothing — a query that comes back empty is retried as a name-contains lookup, most-rated first.
-
Library search covers titles (Postgres full-text plus substring), the people behind an item — authors, artists, directors, studios — and synopses. So "batman" finds Batman Begins and The Dark Knight, and "radiohead" or "herbert" finds everything by them. Results are tiered: title matches, then creator matches, then description-only ones, whatever sort is active. The box clears with the × at its right edge (or Esc), which drops
?q=and keeps every other filter. Filters live in the URL, and opening an item carries that URL along, so the Library button on the item page returns you to the list exactly as you left it — same filters, same search, same sort. An item opened cold (deep link, barcode scan) has no list to return to and falls back to its own section. -
Barcode scanning uses the camera (native
BarcodeDetector, falls back to@zxing/browser). ISBNs auto-fill books, and sleeve barcodes (UPC/EAN) auto-fill records — the music catalogs index them, and a match picks the medium for you. Movie and game barcodes have no public catalog: the code is stored on the item and the UI drops you into title search. There's also a type-the-digits fallback. Scanning something you already added opens that item with You already own this item (or …already on your wishlist) instead of starting a duplicate — matched against the ISBN/UPC/sleeve barcode stored on your items, in either ISBN form, and it skips the catalog call entirely. Camera access needs HTTPS orlocalhost. The nicest setup is Tailscale on the server plus the Tailscale app on your phone:sudo tailscale set --operator=$USER # once tailscale serve --bg http://localhost:8080
Enable HTTPS certificates + Serve for the node when the CLI links you to the admin console (leave Funnel off unless you want the app on the public internet). You get https://..ts.net with a valid certificate — full scanning + installable PWA from anywhere, visible only to your tailnet. Alternatives:
adb reverse tcp:8080 tcp:8080(Android, USB), or any HTTPS reverse proxy. Signing in says "Couldn't reach the server"? The installed PWA paints its cached shell with no connection at all, so that message means the phone can't reach the host — reconnect Tailscale (tailscale statuson the server lists the phone as offline) rather than hunting for a bad password. -
Photo of the cover (needs a vision backend — see API keys): the answer for discs and game boxes, which no public barcode catalog covers. Snap the front and one call reads every line printed on it — the title, the console and the publisher — which become ordinary catalog searches. You still pick the match, so a partial read costs nothing, and for games the console narrows the search to the edition in your hands (and becomes the platform the copy is filed under). A console that doesn't produce a hit is dropped rather than carried into the search, so a misread can't filter the list to nothing. Nothing matched at all? The read text is still in the search box, one word away from right. Three details are load-bearing, each learned the hard way. Photos are uprighted and downscaled to 1024px first — EXIF rotation alone turns "BLADE" into "Letter Blade", and a 3 MB phone photo answers slower and sometimes not at all. The prompt asks for the text on the box, never for the title: asked "what is the title?", every local model tested answered a photo of the back of a box with a confident invention. And a lone word is tried after whole phrases, because covers split logos across lines and the catalog will match bare "BLADE" to the wrong Blade. Backends are swappable (
app/core/vision/): a new one implementsread_linesand lands in one registry entry — nothing above that seam changes. -
Library imports live under Import & settings (sidebar footer / avatar menu). All of them skip already-imported games, so re-runs are safe, and covers arrive in the background:
- Steam — SteamID64 or vanity name; playtime prefills progress. Steam "Game details" privacy must be public.
- Epic & GOG — upload the library file a launcher already maintains:
Heroic's store cache (
store_cache/legendary_library.json/gog_library.json, also under~/.var/app/com.heroicgameslauncher.hgl/config/heroic/for the Flatpak) or alegendary list --jsondump. DLC and other runners are dropped outright. - PlayStation Network — paste an NPSSO token (used once, never stored). Games link to their console platform (PS5, PS4, …) and import hours played; optional toggles include PS Plus games (marked) or skip PS4 twins of PS5 games.
Epic, GOG and PSN imports run as background jobs with live progress and pause for review before anything is created: real games come preselected, while non-games (companion apps, demos/betas/playtests, media apps, launcher redistributables), PS Plus-gated claims and titles already in your collection are auto-excluded — each with its reason, in a collapsed list, all rescuable with a checkbox.
-
Metadata enrichment: storefront imports arrive without a catalog link, so the first detail-page visit matches games to IGDB by title and pulls description, hero art and screenshots. Wrong or missing match? The Re-link action on the detail page lets you pick the correct record — import provenance and playtime survive the swap.
-
Fields you can fix by hand: the detail page edits things in place — tap the title to rename an item (Escape abandons, an emptied field reverts), and in Details: disc media, storefront, a release date that is still unknown, and the author of a book. Open Library leaves the author out for plenty of editions (and a manual add can be saved without one), and books have no Re-link, so the Author row is tap-to-type: one name, or several separated by commas. Clearing it empties the field again.
-
Music, pressing by pressing: records are tracked as the copy you own, not just the album. Search returns releases — artist, year, carrier, label, catalogue number, country — because that's what tells a 2000 UK 2×LP from a later reissue. Picking one stores the tracklist (with side labels: A1, A2, …), which the detail page lists, and the carrier (Vinyl LP / 12" / 10" / 7" / CD / Cassette) becomes a filterable badge on the poster. Sleeve art comes from the Cover Art Archive or Discogs and is downloaded once like every other cover. MusicBrainz is the default and needs no key;
DISCOGS_TOKENswitches search to Discogs, which knows more about physical pressings. Either way the storedexternal_idsays which catalogue matched (mb:…/discogs:…), so re-linking keeps working after you add the token. -
Upcoming: a release timeline of everything in your library or wishlist with a future release date, grouped by month, with countdown chips; partial dates ("2027", "09-2026") stay listed until their period ends.
-
TV seasons & episodes: show pages list their seasons as a compact accordion — poster thumbnail, air year, episode count, watch progress. Open one for its ownership (with disc format), the whole-season watched control and a per-episode checklist. Episode lists come from TMDB the first time a season is opened (never at add time), and watch state syncs both ways: ticking the last episode marks the season watched, marking the season ticks every episode. Refresh re-checks TMDB for a running show's new episodes.
-
Stats: per-type tiles, continue-playing/reading, loans and recent activity — all read from the event log.
-
Wishlist is first-class: no price/format until you hit Acquire, which records the acquisition and moves it to your backlog. On desktop that button appears over the poster on hover; on narrow screens the poster stays uncovered and you acquire from the item page ("Mark as owned").
-
Loans: lend to a name, mark returned — both logged. What is out shows as a badge on the poster and is listed under Stats; the library itself stays uncluttered.
-
Bundled copies — for the things you own twice: a game on PS5 and PC, a DVD you later replaced with the Blu-ray. Each copy stays its own item with its own platform, format, status, rating and progress; the library shows one entry for the bundle, badged with the number of copies and captioned with what tells them apart ("PS5 · PC", "DVD · Blu-ray"). Bundle from the Copies panel on an item page ("Bundle another copy…", searches your library for the same type), and unbundle a copy there too. You choose which copy the library shows ("Show in library"); bundling with an already-bundled copy merges the two groups. Filters pick the copy that matches, so Platform: PC surfaces the PC copy even when the PS5 one fronts the bundle — and a wishlisted upgrade bundled with what you own shows up on the wishlist while the owned copy stays in the library. Stats still counts copies individually: two copies are two games.
-
Theming: dark ("graphite", the designed theme) and a derived light variant, OS-aware with a persisted toggle. All colors are CSS custom properties in
frontend/src/styles/tokens.css; a new theme is one more[data-theme="…"]block. -
PWA: add to home screen on iOS/Android; the app shell and covers are cached for quick loads.
backend/
app/domain/enums.py the enum single-source-of-truth
app/models/ SQLAlchemy tables (users, items, item_seasons,
item_episodes, platforms, activity_events, provider_cache)
app/api/ routers: auth, items, bundles, seasons, episodes, enrich,
steam, epic, gog, psn, stats, platforms (epic/gog share
store_import)
app/core/ security (argon2+JWT), events, covers, artwork, seasons,
episodes, platforms, bundles, barcodes, library_import,
import_jobs, store_filters
app/core/vision/ cover reading: backend-agnostic pipeline + one module
per backend (ollama, gemini)
app/providers/ MetadataProvider ABC + openlibrary/tmdb/igdb/steam/psn +
music (musicbrainz/discogs behind one front) + formats + cache
app/tests/ pytest suite (runs against real Postgres)
alembic/versions/ migrations
frontend/
src/lib/ api client, TanStack Query hooks, types, dates, upcoming,
music, images (photo downscaling)
src/theme/ design tokens + theme store
src/components/ PosterCard, ItemTable, BarcodeScanner, SeasonsPanel,
SearchBox, …
src/pages/ Shelf, Wishlist, Upcoming, Stats, AddItem, ItemDetail,
Settings, Login
Richer dashboards (reads per year, hours over time, spend) beyond the current stats tiles. The append-only event log and timestamps already capture everything they'll need, so they remain pure queries — no migration or backfill when they land.
- TMDB — movie & TV metadata and images. This product uses the TMDB API but is not endorsed or certified by TMDB.
- IGDB — game metadata and artwork, via IGDB.com (a Twitch service).
- Open Library — book metadata and covers, from Open Library (an Internet Archive project).
- MusicBrainz & the Cover Art Archive — music metadata and sleeve art, from MusicBrainz and the Cover Art Archive (MetaBrainz Foundation). Used keyless, within their rate limit (1 request/second) and with an identifying User-Agent, as their API terms ask.
- Discogs — music release data and images, from Discogs, when a token is configured. This project is not affiliated with or endorsed by Discogs.
- Steam / PlayStation Network — library imports use the Steam Web API and PSN. This project is not affiliated with Valve or Sony.
- Heroic & Legendary — Epic and GOG imports read the library files maintained by the open-source Heroic Games Launcher and Legendary. This project is not affiliated with them, Epic Games or GOG.
Cover art and posters shown in the app (and in screenshots) remain the property of their respective rights holders. The MIT license covers this project's code only, not third-party metadata or imagery.