โ† Kevin Yoder
Personal, Family & Home

Pickflix

A self-hosted movie & TV finder for the household that surfaces only what's actually streamable tonight on our subscriptions โ€” rating-gated for kids, and learning each person's taste over time.

In use Built May 2026 Self-hosted ยท LAN-only 201 tests

โš™ 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.

Kid-safe

Family

Genre- and rating-gated picks. MPAA / TV-rating filters block adult content, and kid profiles only ever see what passes.

Adults

Mom & Dad

Documentary- and true-crime-leaning picks for the grown-ups; kid profiles are refused server-side, not just hidden in the UI.

Personalized

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.

LAN browser :8250 pickflix FastAPI + APScheduler ยท one process SQLite ยท bind-mounted pickflix.db /api/* routers TMDB discover ยท providers ยท credits OMDb Rotten Tomatoes ยท Metacritic ยท IMDB Trakt trending rank LiteLLM Claude + Gemini ยท Smart mode only Roku ECP LAN sweep ยท launch app

Fig. 1 โ€” the app ingests and scores on a schedule; Smart mode adds a two-model critique; Roku launches double as watch signals.

  1. Ingest โ€” pull candidate movies and shows from TMDB across the household's 9 services.
  2. Enrich โ€” layer OMDb (Rotten Tomatoes, Metacritic, IMDB) and Trakt trending onto each title.
  3. Filter โ€” keep only what's currently streamable on the enabled subscriptions; rating-gate for kid profiles.
  4. Score โ€” a composite of critic, audience, Metacritic, and trending signals, then a six-factor per-profile score for personalization.
  5. Critique โ€” in Smart mode, a Claude + Gemini ensemble reviews the shortlist and a deterministic consensus merge decides what stays.
  6. 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

Languages
Python 3.12TypeScriptReact 18SQLBash
ML / AI
Claude + Gemini ensembledeterministic consensus mergeLiteLLM proxy routingprompt-as-JSON-contractLLM-hallucination guardsprompt-hash caching (TTL)weighted recommender scoring
Infra / Ops
multi-stage DockerAPScheduler in-process cronbind-mounted SQLitecompose-fragment conventionRoku ECP over LAN
Frontend
ViteTailwindno-router SPA (by design)hand-rolled modals & dual slidersdebounced autocompleteyoutube-nocookie embeds
Data
SQLAlchemy 2Alembic ยท 8 migrationsmulti-source fusion (TMDB ยท OMDb ยท Trakt)rate-budget countersnatural-key upserts
Techniques
TDD ยท 201 testsrespx HTTP mockingspec โ†’ plan โ†’ subagent workflowdefense-in-depth content safetysemaphore-bounded fan-out

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.

LLM guardrails

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.

Content safety

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.

Networking / IoT

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.

The invisible bug

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

9
streaming services aggregated
3
recommendation modes (Family ยท adults ยท Smart)
201
passing tests
76
commits over a ~9-day build
11
data-model tables ยท 8 migrations
121
titles in the seeded public demo catalog

Sources: the project repository, its test suite, and the running container on the home server.

07 Screenshot

The Pickflix home view: a Top 10 row and themed shelves of movie and show posters, each currently streamable on the household's subscriptions.
Pickflix surfacing titles currently streamable on the household's subscriptions, composite-scored โ€” running from a seeded public demo catalog.

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 at http://localhost:8250 rendered 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. See demo.png for the rendered result. The clean clone lives in clone/. All three tiers are covered โ€” a prebuilt container (a single ~201 MB docker save tarball, 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.example with 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):

  1. Local Ollama โ€” LITELLM_URL=http://host.docker.internal:11434, no key, two local models.
  2. 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 --build The proxy config is clone/litellm.config.yaml (secret-free โ€” keys come from the environment).
  3. 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 to data/pickflix.db on first run when data/ is empty. Delete data/pickflix.db to re-seed, or add a TMDB_TOKEN and 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 โ€” they drop_all/create_all on 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.py reads it (falling back to alembic.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-llm state; 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.example ships. The LiteLLM master key (baked into the old .env.example and 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 standalone docker-compose.yml, a first-run seed-bootstrap entrypoint, and a clean README.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 llm rather than a required service.