The failure was familiar in our house. We sat down at nine. We opened four streaming apps. We scrolled for thirty minutes. We read the same six synopses from last Tuesday. Then we watched nothing and went 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. Not one of them knew what was on my own server. So my Friday movie-night email recommended films with full confidence. Some films I already owned. Some I already watched. Some I already rejected twice, on purpose.
The recommender was not wrong. It had no way to know. I modeled taste. I did not model state.

A decision tool, not a catalog
watch. is the seventh app in my family app fleet. Its one job is to answer one question at nine o’clock on a Tuesday: what do we watch.
One design decision made the rest simple: the app owns exactly one thing and computes the rest.
The store is a single file of items with this shape:
{ id, type: movie|show, title, year, tmdb_id,
state: want | dismissed | watched,
pick, sources[], added_at, dismissed_at, note }
That is all. The app does not store library membership, watch status, or the next episode of a partly watched series. It reads them from the local media server at request time, behind a 12-hour cache. Stored data must sync. Synced data is a second copy of the truth, and that copy drifts.
I imported the old services once: 64 titles from one, 13 from another, 70 unique after dedup. I never read from them again. 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 recommend. The marquee must never pitch a birthday video as tonight’s best bet.
The fix was a filter
The endpoint /api/pool feeds the Friday email. Its full contribution is subtraction. Dismissed titles drop out. Watched titles drop out. Titles that the media server reports as played drop out.
The first live run after the repoint cut 64 raw candidates to 46. Same worker, same schedule, same prompt. The email stopped embarrassing itself, because a fifth of the pool was noise the whole time.
The rule below it applies to any recommender:
A dismissal is deliberate signal. Never resurrect a dismissed title.
A dismissal is one of the highest-quality data points a user gives you. The user saw the item, thought about it, and said no on purpose. Some systems treat that as a temporary UI state that a cache flush or a re-import can clear. That throws away the clearest preference in the system.
My operational notes contain this principle as a warning to my future self. A seed script can re-import the original services. The note beside it says: do not run this. The script is idempotent. It would work perfectly. It would resurrect every title I rejected on purpose.
Taste lives elsewhere. The app reads its preferences from a markdown file in my vault, not from hardcoded values. State and taste are different problems, and I keep them in different places.
The bug worth telling: the same movie, every night
This section is for every person who 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 the added date. That is a pure function of the store. The expression does not change between Monday and Tuesday. It changes only when the store changes.
So The Menu led every night, forever, until I added, watched, or dismissed something. The app had one opinion. It repeated that opinion until I intervened.
The embarrassing part: I already fixed this. The day before, I wrote a day-keyed seeded shuffle and wired it into the morning email’s endpoint. The email rotated well. The website never called that function. The website is the thing people look at while they decide. A half-shipped fix is worse than no fix, because it closes the ticket in your head.
Round one: which timezone is your day key in?
The fix looked small. Run the same seeded shuffle over the page’s full pool. Key it to the calendar day. Then the order is stable within a day and fresh the next day.
A cross-model review before shipping caught a problem I would find the hard way. My day key came from UTC. UTC rolls over at 8pm Eastern. So the slate would reshuffle in the middle of the evening we use it. You look at the list at 7:55. You get up for popcorn. You come back at 8:05 to a different set of movies. UTC is a timezone for servers, not for popcorn.
The day key now anchors to New York time. The same reviewer found a second bug. A non-integer count parameter — a hand-edited URL like ?n=1.5 — rendered the empty state instead of a list.
Round two: independent shuffles collide
A post-ship review found the deeper problem. This is the one I like.
Each day shuffled independently from its own seed. Independent draws have no memory. So nothing prevented the same lead on two consecutive days. The probability each night is approximately one divided by the pool size. That sounds small. But the feature exists because a repeat was the exact complaint.
It was not theoretical. Two consecutive days in the historical data both led with index 24 of a 30-item pool. I verified this against the real function. I did not only reason about it.
The fix stays stateless, and that pleases me. A day’s slate can derive yesterday’s slate from yesterday’s key. No storage, no history table, only a pure function computed again with a different input. If today’s lead matches yesterday’s lead, the runner-up takes its place.
The residual odds of a repeat are approximately one divided by the pool size squared. At the current pool size, that is approximately once in five years. I accepted the odds, documented them, and moved on.
The site and the email share that function now. The marquee and the morning brief always agree about the day’s best bet. Two systems that recommend different movies to the same household on the same day lose trust in both.
The behavior now: the slate holds all evening, so “not tonight → next” cycles a fixed order. It does not reshuffle under your thumb. The order is fresh at midnight Eastern. The lead is guaranteed different from yesterday. The full pool surfaces over time, not the newest handful forever. Tests cover three things: the shuffle is a true permutation, the lead rotates across days, and the no-repeat guard passes against the real colliding pair.
Writes back to the media server
The newest layer pushes state the other way. Three collections in the media library — movie night, kids, up next — rebuild from the app’s own state. Two decisions made it trustworthy.
Authoritative overwrite, with a narrow scope. The app is the source of truth for exactly those three collections. Hand-edits to them lose on the next reconcile. The app never reads or writes anything else in the library. A sync that owns everything is a sync you cannot trust. A sync 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 does not block, on purpose. So a problem on the media server never fails a user’s request. “Mark as watched” must not fail because a different machine is busy. Mutations debounce on a short window, so a burst of five picks becomes one write pass.
In the other direction, a sweep finds want-state items that the server now reports as played. It flips them to watched. The first run caught a pile of old drift: films watched before the feature existed. That was an expected one-time catch-up, not a bug. Predict it out loud before it scares you.
One honest limit: the button that marks a whole series watched is coarse. Per-episode truth lives in the media server. So the button means “we are completely done with this.” It does not mean “caught up.”
The feature I built and then deleted
I built a Trakt export: device-flow OAuth, its own token handling, functional end to end. I dropped it one day later. I never authorized it.
The reason: watch. is now the system of record for watchlist state. A mirror of that state back to Trakt would split the exact thing I just consolidated. The gain was approximately nothing. The recommendations from Trakt come from viewing history, and that history syncs through a different path.
The code stays in the repo with a note that explains why it is dormant. The build was not waste. It surfaced a real gap on the way: some named storage volumes sat outside my backup set. I fixed that instead. Better trade.
What is still rough
The kids’ surface works differently from the rest of the app. It is driven by the library, not by the want-list, and it has per-kid up and down votes. One down-vote pulls a title into a visible shelf, and anyone can put it back. This became an unexpectedly effective sibling negotiation mechanism. A short list of standing rewatch approvals bypasses every filter. The whole family will watch Happy Gilmore again, and no algorithm gets a vote.
The thirty-minute scroll is gone. In its place: one screen, one recommendation, and a button that says next.