MovieFinder: A Multi-Tenant Film Recommender, Built in a Weekend
A film recommender that learns your taste from what you have already seen and surfaces films you have never heard of, with a trailer and a reason it can prove. Multi-tenant, live, free, £0 a month on Azure. Built in one weekend with Claude Code, and broken three times on the Sunday morning by checks that passed for the wrong reason.
- Problem
- Every streaming home page shows the same fifty films. The one you would love is, almost by definition, one you have never heard of.
- What I built
- A web app that learns film taste from ratings, imports and feedback, then runs discovery batches with trailers, a match score and a “because you loved X” line that has to earn its place or is left out.
- How it ran
- One continuous Claude Code session, Saturday 07:00 to Sunday 09:00. TDD on the domain, Bicep for both environments, a PR as the unit of work.
- What it cost
- Three self-inflicted outages in ninety minutes, each of which had passed its checks. The fix is a post-deploy smoke test that does the round trip my checks never did.
- Why it's here
- A personal project, built off the clock. What carries over to client work is the discipline: the pipeline, the tests, and a refusal to trust a check that passed for the wrong reason.
The problem
Every streaming home page shows the same fifty films, and the film you would actually love is one you have never heard of. I had already built TuneFinder to solve this for music, scoring new releases against a taste profile built from my own DJ mixes. Films looked easier: TMDB gives you genres, credits, keywords and vote counts for free, and YouTube has the trailers.
The first commit's decision log says “good idea confirmed” at 07:00 on a Saturday. Twenty-six hours later the public homepage was in production. In the three hours before that, I broke production three times.
What MovieFinder is
You tell it what you think of films you know. It finds films you don't, each with a trailer, a match score, and a sentence explaining why in terms of your own library: “because you loved Thief and Heat.”
Four screens:
- Train your taste. An endless, resumable calibration deck. Poster-led, four rating buttons (Loved / Liked / Meh / Hated) plus “haven't seen it”. It never repeats a film, and a knowledge meter shows how well the system knows you.
- Discover. A batch of around eighteen films you have never seen and never been shown, with trailer, cast, tags, match score and the because-line. Interested / Seen it / Skip. Filters per batch: genre, era, a rating floor, English only.
- Watchlist. What you said yes to.
- My Taste. Every rating you have ever given, searchable, re-ratable, removable.
Multi-tenant from the first commit. Sign-in is Entra External ID with an emailed one-time code, every record is partitioned by user, and the API validates every bearer token itself. Anyone can sign up. It is free and carries no ads. But a recommender with no users has nothing to recommend, so the founder account is also the test account, and everything below was built on my own viewing history.
A recommender you can argue with
No LLM anywhere in the recommender. Not because it would cost much (a weekly re-rank would be pennies per user), but because a scorer I can read beats one I have to trust. Three ideas carry it.
Every tap is an event, and a rating is just the latest one. Rate a film, change your mind, undo a mis-tap: same mechanism, no special cases. That is why you can edit anything you have ever rated.
The calibration deck fills gaps. Your taste is a grid of genres, eras and how obscure you like things. The deck serves films from the emptiest cells, and the knowledge meter reads the same grid. You cannot max it out by rating forty westerns.
A reason has to earn its place. Shared links between two films are scored: a director counts three, a lead who stars in both counts two, a supporting actor or a keyword counts one. “Because you loved Heat” only appears when the links add up to two or more. Below that it says nothing. That rule exists because the first version explained a western by a heist film they shared a fourth-billed actor with.
Two days, one sitting
The whole thing was built with Claude Code in one sitting, Saturday morning to Sunday morning. The model wrote fast, but that is not what got it shipped. What got it shipped was treating a weekend hack like client work: a domain with no dependencies and tests on it, Bicep for both environments from the second PR, and a pipeline where merging the branch is the deployment. Sixty-odd pull requests in a day sounds absurd until the PR is the unit of work and nothing else is.
The four screens, as they are on a phone. Most of the Sunday was reviewed on one.
Seeding taste from a Plex server, and a Notion trick
A recommender with an empty profile is a random number generator, and I had no IMDb or Letterboxd history. What I had was a Plex server with 764 films on it.
Exporting it was easy and nearly useless: 711 films marked watched, and exactly nine with a star rating. “Seen” is not taste. You cannot build a profile from “I have seen Alvin and the Chipmunks: Chipwrecked.”
I put the list in Notion with four checkboxes per row, Loved / Liked / Meh / Hated, ticked them on the sofa, and read the ticks back through Notion's API. That left 672 films to rate once the ones already rated in the app were taken out. Rating 672 films in a column of checkboxes is twenty minutes on a phone. Rating them one card at a time in the app is an evening.
The scaling review
The brief was £0 a month, which on Azure means the free tiers: Static Web Apps, Functions on Consumption, and one Cosmos DB account with 1,000 request units a second shared between dev and prod, 600 of them for prod. Cosmos does not bill you when you exceed that. It returns 429s, the SDK retries, and the app slows down. So the only question that matters is how many people can use it at once before it crawls.
Once the app worked, I had the model go back over every database call and look for anything that would not scale. It found plenty. The My Taste page read the catalogue once per film I had ever rated. A discovery run read it once per film I had ever been shown. The knowledge meter in the nav did the same on every page load. All of it grew with how much I had used the app, which for the only user was already about 700 films.
The fixes were the same shape every time: copy the few facts a page needs onto the event when it is written, read the catalogue only for what is on screen, and let old “shown” records expire. The interesting result was not the numbers. It was what the cost model showed: cost scales with how much history each person has, not how many people there are. It says thirty-five users with my library cost more than three hundred casual ones. Modelled, not measured, for now: every call logs its real cost, so after a week of use the measured figures replace these.
The morning I broke production three times before breakfast
That review is also how I broke production. Three times, in ninety minutes, all before breakfast.
An infra deploy that was only meant to touch Cosmos quietly blanked the identity provider, so the API served everyone as the same anonymous user. I noticed when production showed me an empty account. The hole had been open for forty minutes. The same deploy dropped the custom domain from the allowed origins, so the site loaded and every call from a browser was blocked. And a change to expire old records wrote "ttl": null on everything else, which Cosmos refuses, so you could load a batch and not answer it.
Every one of them had passed its checks. That was the lesson. A curl returning 200 read as “the API is up” when it meant “authentication is off”. curl cannot tell you a browser is blocked. Unit tests over in-memory stores never serialise a document, so a shape the database rejects is invisible until the real thing fails. A hundred and thirty-five green tests is exactly how the ttl bug got out. I was verifying a proxy for the thing, not the thing.
There is a detail that makes this worse. I couldn't sleep, and from 03:47 that morning I was directing all of it from my phone in bed: chat, screenshots and a green tick in GitHub Actions. The process held up well enough that I didn't notice the conditions, and a deployment preview with the breaking change below the bit I had asked about is easy to skim at 05:00 on a six-inch screen. I approved it from bed. Don't approve infra deploys at 05:00.
The same process is why the damage was small. Every fix was a PR and a pipeline run, and production was clean again before most people were awake. The structural fixes came later that day: the Cosmos document shapes now have serialisation tests, the API refuses to start without an identity provider, and every deploy runs a smoke test that signs in, writes a rating, reads it back, and asks the CORS question a browser would ask.
The week I spent forty agents on one function
A week after launch I pointed three sessions and something like forty subagents at a single method: the one that builds the pool of candidate films. It was the best-spent compute in the project, because the pool and the scoring turned out to disagree about what a good film is. The generator fetched TMDB’s popularity charts; the scorer was tuned to punish exactly those films. Recommendation had quietly collapsed into the least mainstream of the mainstream, and nothing flagged it: no error, no alert, no failing test. The fix was to aim the pool at what the scorer rewards.
The part worth writing about is reviewing my own fixes. The implementation was test-first and the suite went green. Three rounds of adversarial review then found forty-three defects that green suite had no opinion about, most of them nowhere near the code I had changed; thirty-five were fixed, the rest declined in writing. The convergence is what told me to stop: round one said “this can un-ban a user”, round three said “this variable could have a better name”.
None of that made it done. Ten minutes on a real phone found five things the whole apparatus never saw, like a training deck that still dealt anime after anime was switched off. Forty agents found bugs that would have degraded recommendations for years; ten minutes on a phone found what a user notices in the first sixty seconds. Neither would have found the other’s list.
The homepage
With the app stable the brief was a public homepage with some wow factor. It is a dark screening room with one lit, letterboxed screen, and the pitch and a working demo are projected onto it: rate four films, watch your taste profile draw itself, get one back with “because you loved Parasite”. It runs in the browser with no API and no account. The design review sent it back with eight fixes, and the one worth repeating is a scrolling poster strip I had added below the fold. It was exactly the streaming carousel the product is against. It went.
Art direction
A few days after launch I recast the montage, and none of that work was code. Each genre shows one still, cropped from the same file two ways — 2.39:1 on desktop, 2:3 on a phone — and on desktop the FIND MY FILM strapline covers the bottom-left of the image. So a frame has to survive both crops and keep that corner quiet. Claude pulled every backdrop TMDB holds, binned anything under 1920 pixels wide, and gave me comparison sheets with both crops and the strapline zone drawn in red. I clicked through and chose. It caught the old Jaws being the poster painting stretched to 16:9, and ruled out the best frame on its own sheet because the boat sat under the strapline. That frame shipped anyway — mirrored, once we'd checked nothing in shot reads as text. Building a site usually takes an art director and a developer. Here I was both.
Results
Live at moviefinder.changsta.com. Free, no ads, sign in with an email code, £0 a month. The database cost no longer grows with a user's history on any page except the one that has to, and every deploy since has had to pass an authenticated smoke test before it is allowed to be live.
One user so far, with 711 films of history and a rated library that grows most evenings. The watchlist has films on it I could not have named a week ago. The repo is private; I am happy to walk through it.
What I'm taking away
- The PR is the unit of work and the pipeline is the deployment. That is what made sixty-odd of them in a day possible, and what made three outages cheap.
- Verify the thing, not a proxy for the thing. A status code is not a working app, and an in-memory store is not a database.
- Defaults are live configuration. An infra parameter with an empty default will overwrite whatever is in Azure, and nobody will see it in the diff.
- Cost scales with what each user has, not how many there are.
- A green suite proves the design, not the absence of bugs. Test-first got the recommender’s shape right and still let it aim the wrong way; the defects that mattered lived in the seams between services, where no unit test was looking.
- Spend the expensive attention where failure is silent. A crash pages you. A recommender that recommends badly just makes Tuesday slightly worse, forever.
Tech stack
Try it, or tell me where it's wrong
MovieFinder is live at moviefinder.changsta.com. Free, no ads, no card. If you have a viewing history and an opinion, sign in and tell me where the recommendations are wrong.
If you are building something with a ranking problem, a multi-tenant Azure footprint that has to stay cheap, or an AI-assisted process you can actually trust, get in touch.