The failure mode was familiar in our house. We would sit down at nine, open four streaming apps, scroll for thirty minutes, read the same six synopses from last Tuesday, and then watch nothing and go to bed annoyed.
I had a specific technical version of that problem. My want-to-watch data lived in three places — the Plex watchlist, Letterboxd, and Trakt — and not one of them knew what was on my own server. So the Friday movie-night email I’d built would confidently recommend a film I already owned. Or one I’d watched. Or one I’d deliberately passed on twice.
The recommender wasn’t wrong. It had no way to know. Taste was modeled; state was not.
A decision tool, not a catalog
watch. is the seventh app in my family app fleet, and its entire job is to answer one question at nine o’clock on a Tuesday: what are we watching.
The design decision that made everything else simple: the app owns exactly one thing, and computes the rest.
The store is a single file of items shaped like this:
{ id, type: movie|show, title, year, tmdb_id,
state: want | dismissed | watched,
pick, sources[], added_at, dismissed_at, note }
That’s it. Whether a title is in my library, whether I’ve watched it, what’s next up in a series I’m partway through — none of that is stored. It’s read from the local media server at request time behind a 12-hour cache. Storing it would mean syncing it, and syncing it would mean owning a second copy of the truth that drifts.
I imported the old services once — 64 titles from one, 13 from another, 70 unique after dedup — and have never read from them since. The store is canonical now. One deliberate exclusion: the app never reads the home-video or music sections of the library. Family footage is not content to be recommended.
The fix was a filter
The endpoint that feeds the Friday email is /api/pool, and its whole contribution is subtraction. Dismissed titles drop out. Watched titles drop out. Anything the media server reports as already played drops out.
First live run after the repoint: 64 raw candidates became 46. Same worker, same schedule, same prompt — the email just stopped embarrassing itself, because a fifth of the pool had been noise the whole time.
The rule underneath is the one I’d carry to any recommender:
Dismissals are deliberate signal. Never resurrect a dismissed title.
A dismissal is one of the highest-quality data points a user ever gives you — they looked at the thing, considered it, and said no on purpose. Treating that as a temporary UI state, something a cache flush or a re-import can clear, throws away the clearest preference in the system.
That principle is written into my operational notes as a warning to my future self. There’s a seed script that can re-import the original services, and the note beside it says: don’t run this. It’s idempotent, it would work perfectly, and it would resurrect every title I deliberately passed on.
Taste is handled separately — the app reads its preferences from a markdown file in my vault rather than from anything hardcoded. State and taste are different problems and I keep them in different places.
The bug worth telling: the same movie, every night
This one’s for anyone who has ever shipped a recommender.
The complaint arrived unstructured, the way real complaints do: “it keeps recommending the same movies and it’s frustrating.”
The homepage leads with a marquee — one best bet, big, above everything else. That marquee was ready[0], the first element of a list sorted by when things were added. Which is a pure function of the store. Nothing about that expression changes between Monday and Tuesday. It changes only when the store changes.
So The Menu led every single night, forever, until I added something, watched something, or dismissed something. The app had one opinion and repeated it until I intervened.
The embarrassing part: I had already fixed this. The day before, I’d written a day-keyed seeded shuffle and wired it into the morning email’s endpoint. The email rotated beautifully. The website — the thing anyone actually looks at while deciding — never called that function. A half-shipped fix is worse than no fix, because it closes the ticket in your head.
Round one: what timezone is your day key in?
The fix looked trivial: run the same seeded shuffle over the page’s full pool, keyed to the calendar day, so the order is stable within a day and fresh tomorrow.
A cross-model review pass before shipping caught something I’d have discovered the hard way. My day key was derived in UTC, which rolls over at 8pm Eastern. In other words, the slate would reshuffle in the middle of the evening we were using it — you’d look at the list at 7:55, get up for popcorn, come back at 8:05 to a completely different set of movies.
The day key now anchors to New York time. Same reviewer also noticed that a non-integer count parameter — someone hand-editing a URL to ?n=1.5 — rendered the empty state instead of a list.
Round two: independent shuffles collide
Post-ship review found the deeper problem, and this is the one I like.
Each day shuffled independently from its own seed. Independent draws have no memory, so nothing prevented two consecutive days from landing on the same lead. The probability is roughly one over the pool size, per night — which sounds negligible until you remember that the entire feature exists because a repeat is the exact thing the user complained about.
It wasn’t theoretical. Two consecutive days in the historical data both led with index 24 of a 30-item pool. Verified against the real function, not reasoned about.
The fix stays stateless, which I’m pleased with. A day’s slate can derive yesterday’s slate from yesterday’s key — no storage, no history table, just recomputing a pure function with a different input. If today’s lead matches yesterday’s, swap in the runner-up.
Residual odds of a repeat drop to about one over the pool size squared: at the current pool, roughly once every five years. Accepted, documented, moved on.
The site and the email share that function now, so the marquee and the morning brief always agree about the day’s best bet. Two systems recommending different movies to the same household on the same day is how you lose trust in both.
Behavior now: the slate holds all evening, so “not tonight → next” cycles a fixed order instead of reshuffling under your thumb. Fresh order at midnight Eastern, lead guaranteed different from yesterday, and the whole pool surfaces over time instead of the newest handful forever. Tests cover the shuffle being a genuine permutation, the lead rotating across days, and the no-repeat guard checked against that real colliding pair.
Writing back to the media server
The newest layer pushes state back the other way: three collections in the media library — movie night, kids, up next — rebuilt from the app’s own state. Two decisions made it trustworthy.
Authoritative overwrite, narrowly scoped. The app is the source of truth for exactly those three collections, and hand-edits to them lose on the next reconcile. Nothing else in the library is ever read or written. A sync that owns everything is a sync you can’t trust; one that owns three named things is one you can reason about.
Writes are fire-and-forget. The reconcile logic is a pure function and the IO is deliberately non-blocking, so a hiccup on the media server never fails a user’s request. Marking something watched shouldn’t be able to fail because a different machine is busy. Mutations debounce on a short window, so a burst of five picks becomes one write pass.
Going the other direction, a sweep finds want-state items the server now reports as played and flips them to watched. The first run caught a pile of pre-existing drift — films watched before the feature existed. Expected one-time catch-up, not a bug, but worth predicting out loud before it scares you.
One honest caveat: marking a whole series watched is coarse. Per-episode truth lives in the media server, so it’s the “we’re completely done with this” button, not the “caught up” button.
The feature I built and then deleted
I built a Trakt export — device-flow OAuth, its own token handling, working end to end — and then dropped it a day later without ever authorizing it.
The reasoning: watch. is now the system of record for watchlist state. Mirroring that state back out to Trakt would re-shard exactly the thing I’d just consolidated, in exchange for approximately nothing, since the recommendations I’d get from it are driven by viewing history that syncs through a different path anyway.
The code is still in the repo with a note explaining why it’s dormant. Building it wasn’t wasted — it surfaced a real gap on the way, which was that some named storage volumes were sitting outside my backup set. Fixed that instead. Better trade.
What’s still rough
The kids’ surface works differently from the rest of the app — driven by what’s in the library rather than the want-list, with per-kid up and down votes. A single down-vote pulls a title into a visible shelf where anyone can put it back, which has become an unexpectedly effective sibling negotiation mechanism. A short list of standing rewatch approvals bypasses every filter, because the whole family is going to watch Happy Gilmore again regardless of what any algorithm thinks.
The thirty-minute scroll is gone. What replaced it is one screen, one recommendation, and a button that says next.