Case Studies
Engineering Case Study

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.

Type Engineering Case Study
Domain Recommender systems · Azure · AI-assisted delivery
Stack .NET 10 · Blazor WebAssembly · Azure Functions · Cosmos DB · Bicep · TMDB
Status Live · Free · Open to anyone
26 h
first commit to live homepage
135
tests, plus a smoke test per deploy
£0
a month, dev and prod
3
outages on day two, all mine
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.
MovieFinder Discover screen: a full-bleed backdrop for A Beautiful Mind, a 34% match badge, the line Because you loved American Gangster and L.A. Confidential, cast and tags, and Play trailer, Interested, Seen it and Skip buttons, with the rest of the batch as a poster strip below
Discover. One card at a time, a match score, and a because-line that names films from your own library. The two titles are links to the ratings that earned the recommendation.

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.

Train your taste: The Blues Brothers poster on the deck with Hated it, Meh, Liked it, Loved it and Haven't seen buttons, and a How well we know you panel at 62% with 843 films rated and a bar per genre
Train your taste. One poster, four buttons, and the knowledge meter on the right reading the same grid the deck is filling.
GENRE \ ERA 1960s1970s1980s1990s2000s2010s2020s CrimeThrillerSci-fiWesternAnimationRomance Each cell fills as films in it are rated and saturates at five. Dashed: where the deck serves from next. 0 rated2 rated5, full
The coverage grid, simplified (the real one is 14 genres × 8 eras × 3 obscurity bands). Full cells are what the profile already knows. The deck serves from the empty ones, so playing it always teaches the system something new.

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.

Train your taste on a phone: the Mighty Joe Young poster with Hated, Meh, Liked and Loved buttons, a Haven't seen button, and a 58% known meter
Train your taste
Discover on a phone: A Beautiful Mind, card 4 of 18, 34% match, because you loved American Gangster and L.A. Confidential
Discover
Watchlist on a phone: six films, about eleven hours of viewing, The Ninth at the top
Watchlist
My Taste on a phone: 828 ratings, a search box, and each film with its Hated, Meh, Liked or Loved rating selectable
My Taste

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.

Seeding the first profile
Plex server · 764 films · 9 star ratings
↓ plex-export, a small .NET tool
Notion database · one row per film · Loved / Liked / Meh / Hated checkboxes
672 rows ticked on a phone, on the sofa, in about twenty minutes
↓ Notion API
MovieFinder import · matched by title and year · existing ratings never overwritten
The year column is load-bearing: two rows called Drop and two called Parallel are different films. The import respects ratings already in the app, so re-running it cannot clobber a considered opinion with a sofa tick.

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.

MODELLED · SUSTAINED COST PER USER, RU/S ~2.5 ~16 Light user 50 ratings · 500 films shown · ~240 fit in prod's 600 RU/s Mature user (me) 700 ratings · two years of batches · ~35 fit
Modelled from Cosmos rules of thumb, not yet measured. The dominant cost left is reading a user's whole event log, three or four times a session, and it grows with that user's history. When it matters, the fix is a materialised current-state document per user, rebuilt from the log.

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.

A status code is not a working app. An in-memory store is not a database. A tool that ignores CORS cannot test CORS.

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.

Post-deploy smoke test · every deploy, dev and prod
Deploy finishes
↓
Sign in as its own app identity
Write a rating
Read it back
Remove it
Confirm removal
↓
CORS preflight from the production origin, the question a browser asks
↓
Any step fails → the run is red
Auth off: the app identity would resolve to the anonymous user, so the read-back would not match. CORS dropped: the preflight fails. A document shape Cosmos rejects: the write fails.
The round trip my checks never did. It would have caught all three outages, and it is the check I could not do from bed.

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 failure mode of a recommender is silence. A crash gets a stack trace and an alert. A pool aimed thirty degrees off gets you a slightly disappointing Tuesday night, forever, and no signal at all.

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.

The MovieFinder homepage: a dark screening room with a letterboxed screen under a gold projector beam, Find my film projected over a still of Uma Thurman raising a katana, with Try it, How it works and Sign in below
The screening room. One lit screen, a projector beam, and nothing else on the page. Scroll, or press Try it, and the demo takes the screen.

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.

Two rows of a generated comparison sheet for the Jaws montage slot. The first, labelled ruled out, shows the Orca at sunset with a red dashed strapline zone over the boat. The second, labelled current, shows the Jaws poster painting with the note: not a still.
The Jaws sheet: the frame ruled out for the boat under the strapline, and the poster art caught pretending to be a still.

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

Frontend
Blazor WebAssembly .NET 10 Static landing page (HTML/CSS/JS) Azure Static Web Apps Free
API & data
Azure Functions, isolated worker Consumption plan Cosmos DB free tier Per-user partitions Ratings as state over an event log Entra External ID, email one-time code JWT validation in the API
Catalogue & seeding
TMDB metadata and artwork YouTube trailer embeds Plex export Notion checkboxes Import API
Infra & process
Bicep App Insights GitHub Actions develop → dev, main → prod Smoke test on every deploy Claude Code impeccable (design critique and the homepage) TDD on Core
Case Studies

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.