โ How it works ๐ผ Screenshot ๐ Skills & tech
01 Overview
Streaming is scattered across a stack of subscriptions, and most "what should we watch" tools recommend things the family can't actually stream. Pickflix is a small web app for our house that fixes exactly that: it pulls candidate titles from TMDB, enriches them with OMDb (Rotten Tomatoes, Metacritic, IMDB) and Trakt trending data, keeps only titles currently included in our 9 streaming services, and ranks them by a blended composite score. It runs in Docker on my home server, LAN-only, behind three recommendation modes.
Family
Genre- and rating-gated picks. MPAA / TV-rating filters block adult content, and kid profiles only ever see what passes.
Mom & Dad
Documentary- and true-crime-leaning picks for the grown-ups; kid profiles are refused server-side, not just hidden in the UI.
Smart
Per-profile scoring over each person's likes and dislikes, with a Claude + Gemini ensemble reviewing the shortlist before it's shown.
02 Why I built it
The original itch was practical: we'd sit down to pick something and half the suggestions turned out to be on a service we don't pay for. I wanted one screen that only shows titles we can start right now, safe for the kids by default, that also quietly learns what each of us likes. Most of the interesting work came after the first version shipped โ it was driven by real family complaints (a kid's session that surfaced only two of a favorite series, "these are all on Disney+ but Pickflix says they aren't," "just launch the show on the TV already"). Each of those turned into a concrete fix, and the fixes are where the engineering lives.
03 What I built & how it works
One FastAPI process fuses three data sources, an optional LLM ensemble, and LAN device control.
Fig. 1 โ the app ingests and scores on a schedule; Smart mode adds a two-model critique; Roku launches double as watch signals.
- Ingest โ pull candidate movies and shows from TMDB across the household's 9 services.
- Enrich โ layer OMDb (Rotten Tomatoes, Metacritic, IMDB) and Trakt trending onto each title.
- Filter โ keep only what's currently streamable on the enabled subscriptions; rating-gate for kid profiles.
- Score โ a composite of critic, audience, Metacritic, and trending signals, then a six-factor per-profile score for personalization.
- Critique โ in Smart mode, a Claude + Gemini ensemble reviews the shortlist and a deterministic consensus merge decides what stays.
- Serve โ a Top 10, themed shelves, and per-service rows; a title can be launched straight onto a Roku from its detail modal.
04 ๐ Skills & tech used
05 Notable challenges & decisions
Most of these came from the app doing something wrong in front of the family โ the fixes are the honest record.
Treat every LLM-emitted ID as untrusted
The Smart ensemble can suggest new titles to add, referenced by external ID โ and it confidently returns wrong ones (one "Owl House" suggestion resolved to a completely different show). The fix treats any model-emitted identifier as a hint, not a fact: resolve by ID, fall back to search-by-title, and run a second-signal match check before anything enters the catalog. It's now a standing rule in the project's lessons doc.
Kid safety is layers, not a single gate
A rating cap at ingest wasn't enough โ the adult refresh, the cast/crew graph expansion, and TV-ratings ingest each found a way around it. Safety ended up as filters at several points: rating caps, an unrated title treated as unsafe for kids, TV-rating blocks, and a genre-overlap requirement. The one deliberate asymmetry โ PG-13 movies allowed but TV-14 blocked โ was a specific parenting choice, documented in code.
Roku discovery from inside a Docker bridge
The usual multicast discovery Rokus advertise on doesn't survive a Docker bridge network, so devices are found with a bounded TCP sweep of the LAN instead. An early unbounded version saturated the HTTP client pool and timed out; capping concurrency with a semaphore fixed it. Devices are keyed by hardware serial, never IP, so a DHCP lease change doesn't lose the TV.
A missing commit that quietly ate the catalog
New titles imported during a request kept vanishing. The cause was undramatic and easy to miss: the request-scoped DB session didn't auto-commit, so live imports rolled back at request end. It surfaced as a family complaint ("only two of that series showed up") long before it looked like a database problem โ a reminder that user-visible symptoms and root causes rarely match.
Personalization had to visibly win. Early versions ranked purely on quality and felt generic โ "no my algorithm feel." So per-profile taste was weighted to clearly dominate the score, while a few themed shelves were kept deliberately objective as a neutral anchor. Some scope was cut on purpose too: no accounts or passwords (LAN-only, trust-based), and the household names and taste data never leave the local network.
06 Results
Sources: the project repository, its test suite, and the running container on the home server.
07 Screenshot
08 Honest status
Pickflix is genuinely in use โ it runs in Docker on my home server on port 8250, LAN-only, and the family adds to it week to week. It's built for our house, not as a product: there's no authentication of any kind (a deliberate trust-based choice for a private network, not something I'd ship as-is), and the nightly refresh cron has a known bug where its runs don't finish cleanly, so the catalog currently stays current through pick-time imports rather than the schedule. A separate three-tier runnable clone exists with a seeded 121-title demo catalog โ key-free, with no family data โ which is what the screenshot above is from. It's a family utility, honestly scoped as one.
Pickflix โ Build Recipe
Take a bare machine to a running copy of Pickflix โ a self-hosted family movie & TV discovery app. It aggregates streaming-available titles from TMDB / OMDb / Trakt, scores them by a composite of RT-critic + RT-audience + Metacritic + Trakt signals, and serves three modes โ Family, Mom & Dad, and Smart (personalized, with an optional LLM ensemble) โ from a React (Vite) SPA backed by a FastAPI + SQLite service. ~200 tests.
Status: โ Verified 2026-07-31 โ built from this clean clone on a laptop with a single
docker compose up --build. The image builds (Node builds the SPA โ Python serves it), the container comes up healthy, and it seeds a bundled demo catalog on first run so the app is fully browsable with ZERO API keys โ a real browser athttp://localhost:8250rendered Tonight's Top 10, all four themed shelves, and per-service rows with real posters (loaded from TMDB's public image CDN, which needs no key). The 201-test suite passes bare-metal (Python 3.12, ~39 s, fully offline). Smart mode is off by default and degrades to "algorithmic-only" cleanly. Seedemo.pngfor the rendered result. The clean clone lives inclone/. All three tiers are covered โ a prebuilt container (a single ~201 MBdocker savetarball, verified 2026-08-01), build-from-source, and bare-metal.Sensitive data โ read this. The real app holds a family's profiles, watch history, and Roku devices. None of that is here. A fresh install seeds zero profiles/history/Rokus by construction โ the only migration that ever named household members was a no-op on a new database and has been neutralized anyway. The bundled demo catalog is public movie/TV metadata only (titles, ratings, availability โ no personal linkage). Real API keys were never copied; only
.env.examplewith placeholders ships.Attribution: the demo catalog is derived from TMDB (and enriched via OMDb). This product uses the TMDB API but is not endorsed or certified by TMDB. Movie/TV metadata and images are ยฉ their respective owners.
What it is
One FastAPI process serves the built SPA at / and a JSON API under /api (port 8250), over a
SQLite database managed by Alembic. External data comes from TMDB/OMDb/Trakt (all optional โ see
below). The Smart-mode LLM ensemble is an optional layer that only affects Smart mode's re-ranking
and title suggestions; Family and Mom & Dad modes are 100% LLM-independent.
Prerequisites
New machine? Install the base tools first โ see ../SETUP.md. Then, per tier:
| Tier | You need |
|---|---|
| 1 โ prebuilt container (recommended) | Docker Desktop (see ../SETUP.md) and the on-request pickflix-image.tar bundle. Nothing to build. |
| 2 โ build from source | Docker Desktop (see ../SETUP.md). Nothing else โ the image builds the SPA itself and seeds the demo catalog. |
| 3 โ bare-metal | Python 3.12+ and Node 20+. |
Configuration โ everything is optional
The app runs with no configuration. cp .env.example .env and you're done; the four data keys and
the LLM are all blank by default and the app serves the bundled demo catalog. Fill in keys only to
unlock more:
| Key | Unlocks | Without it |
|---|---|---|
TMDB_TOKEN |
Live catalog refresh + search/add/trailers | Browse the seeded demo catalog only |
OMDB_KEY |
RT/Metacritic/IMDB rating enrichment on refresh | TMDB-only scores |
TRAKT_CLIENT_ID |
"Trending" shelf + Trakt score component | That component is skipped |
SAA_API_KEY |
Streaming-availability supplement (Disney+ kids) | TMDB watch_providers only |
| LLM (see below) | Smart-mode LLM polish | Smart mode runs algorithmic-only |
Sign-up links are in clone/.env.example.
Tier 1 โ Run the prebuilt container(s) (recommended)
Availability: the prebuilt image bundle is available on request โ it is not published or linked anywhere. Ask Kevin for it, or build everything from source via Tier 2/3 below.
Skip the build โ load a saved image and start it. Produce the tarball once on a machine that has already built the image (Tier 2):
docker save clone-pickflix -o pickflix-image.tar # ~201 MB (verified 2026-08-01)
Consume it anywhere โ no toolchain, no build:
docker load -i pickflix-image.tar
cd clone
cp .env.example .env
docker compose up -d # โ http://localhost:8250 (no --build; image is loaded)
curl http://localhost:8250/api/health # โ {"status":"ok","db":"ok",...}
The tarball holds only the single clone-pickflix app image. The opt-in Smart-mode LLM proxy
(--profile llm) pulls its own image from ghcr and is not in this tarball โ docker save
ghcr.io/berriai/litellm:main-latest separately if you want it offline too.
Tier 2 โ Build the image from source
cd clone
cp .env.example .env
docker compose up -d --build # โ http://localhost:8250
curl http://localhost:8250/api/health # โ {"status":"ok","db":"ok",...}
Open http://localhost:8250 โ the demo catalog renders immediately, no keys. To populate a live,
current catalog: put a TMDB_TOKEN (+ optionally OMDb/Trakt) in .env, restart, and click Refresh
in Settings (or POST /api/refresh).
Tier 3 โ Bare-metal (no Docker)
cd clone
python -m venv .venv && . .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -e .
cd frontend && npm ci && npm run build && cd .. # produces frontend/dist (the SPA mount needs it)
cp .env.example .env
mkdir -p data && cp seed/pickflix.seed.db data/pickflix.db # optional: start from the demo catalog
alembic upgrade head
uvicorn pickflix.main:app --host 0.0.0.0 --port 8250
(Skip the seed copy to start empty. alembic upgrade head on the seeded DB is a no-op โ it's stamped
at head.)
Optional: Smart-mode LLM
Smart mode works without an LLM (the UI shows "LLM ensemble disabled"). The ensemble needs one
OpenAI-compatible endpoint that fronts both model names. Three ways (all in .env.example):
- Local Ollama โ
LITELLM_URL=http://host.docker.internal:11434, no key, two local models. - Bundled LiteLLM proxy โ reproduces the real Claude + Gemini ensemble:
bash # set ANTHROPIC_API_KEY + GEMINI_API_KEY + LITELLM_* in .env, then: docker compose --profile llm up -d --buildThe proxy config isclone/litellm.config.yaml(secret-free โ keys come from the environment). - OpenRouter โ one key fronts both Claude and Gemini over the OpenAI protocol, no proxy, no code change.
(Why a proxy for option 2: Anthropic's native API isn't OpenAI-compatible, so a single-endpoint ensemble needs a gateway/aggregator to serve both models. LiteLLM or OpenRouter both do this.)
Verify
curl http://localhost:8250/api/health
# โ {"status":"ok","db":"ok","last_refresh":null,"omdb_today":0}
curl "http://localhost:8250/api/picks?mode=family&kind=movie"
# โ JSON with composite[] + themed shelves + service_shelves, all from the seeded catalog
Run the tests (bare-metal, Python 3.12) โ fully offline, no keys needed:
pip install -e ".[dev]"
# The API tests drop/recreate schema on DATABASE_URL โ point it at a throwaway file:
# POSIX: mkdir -p data && DATABASE_URL=sqlite:///data/test.db python -m pytest
# PowerShell: $env:DATABASE_URL='sqlite:///data/test.db'; python -m pytest
# โ 201 passed
Notes & gotchas
- Zero-key browsing works because a demo catalog is bundled (
clone/seed/pickflix.seed.db, ~220 KB, 121 titles). The entrypoint copies it todata/pickflix.dbon first run whendata/is empty. Deletedata/pickflix.dbto re-seed, or add aTMDB_TOKENand Refresh to build your own live catalog. - Posters need no key โ the frontend loads them from
image.tmdb.org, so the seeded catalog renders fully offline of any credential. - The tests are destructive to
DATABASE_URLโ theydrop_all/create_allon whatever it points at. Always run them against a throwaway DB (above), never your seeded/live one. - The bundled demo catalog skews toward documentaries (the Mom & Dad discovery pass is documentary-heavy) and the "Trending" shelf is empty (built without a working Trakt key). Both are cosmetic โ add keys and Refresh for a fuller, current catalog.
- Alembic and the app share
DATABASE_URLโalembic/env.pyreads it (falling back toalembic.ini), so one variable drives both. Changing it changes where migrations and the app look. - Smart mode never crashes on LLM absence โ a missing/unreachable endpoint is caught and surfaced as
the
no-llmstate; Smart mode returns its algorithmic picks.
Provenance โ data / model (no training, no GPU)
Pickflix trains nothing โ there are no datasets and no GPU. The bundled demo catalog was produced by running the app's own refresh pipeline once against public APIs (TMDB discover + OMDb ratings; ~290 TMDB / ~60 OMDb calls, well under free-tier daily caps), then stripped to catalog-only tables. It contains public movie/TV metadata only โ no personal data. Attribution: TMDB (see the Status note). The Smart-mode LLM is external and user-supplied; no model weights ship.
What was stripped from the personal version (de-identification + recipe minimalism)
- All real data โ the family's SQLite DB, profiles, watch history, and Roku devices โ never copied (and never created on a fresh install). The shipped catalog is public metadata only.
- Real secrets โ the working-tree
.env(live TMDB/OMDb/Trakt keys) โ excluded; only.env.exampleships. The LiteLLM master key (baked into the old.env.exampleand several docs) โ removed. (These live keys were sitting in the source tree โ worth rotating.) - Personal identifiers in docs/tests โ kid names, Roku device names, a Roku IP + serial, and the
home server's host IP โ scrubbed or replaced with synthetic values; migration
0004's name-based kid seed โ neutralized to a pure schema change. - Home-server deploy scaffolding โ
docker-compose-fragment.yml,caddy-fragment.txt, the scp/no-git- remote deploy docs,SYSTEM_MAP.md, and the operational docs โ dropped. Replaced with a standalonedocker-compose.yml, a first-run seed-bootstrap entrypoint, and a cleanREADME.md. - The LLM's hard LiteLLM coupling โ the app now depends only on "an OpenAI-compatible endpoint,"
off by default; LiteLLM is bundled as an opt-in
--profile llmrather than a required service.