Engineering case study
Watchario
A media-tracking product that keeps four independent third-party services in lockstep. One .NET 10 codebase serves a Blazor web app, a native Android and Android TV app, a Stremio addon and a cross-browser extension — with a public, documented REST API underneath all of them. Three iterations and several years in, I designed it, wrote it, deploy it and answer for it when it breaks.
The problem
People who track what they watch typically keep accounts on several services at once — AniList, Kitsu, MyAnimeList, Trakt — because their friends are spread across them. Every existing tool syncs with exactly one. So progress is updated by hand, in one place, and the other lists quietly rot.
That is not a hard problem to describe. It is a hard problem to build, because those four services disagree about almost everything: identifiers, list statuses, score scales, authentication, rate limits, and whether an episode belongs to season 2 or is episode 27 of a single flat season. Everything interesting in this project sits in that disagreement.
This is the third iteration. It began as AnimeList, a personal tracker against a single service. It became AniSync once keeping several services aligned turned out to be the part actually worth building. It became Watchario when the same machinery proved it worked for films and series too — at which point every anime-shaped assumption still sitting in the data model had to come out. Each rewrite was triggered by the same thing: the scope outgrew a decision I had made too early, and patching around it would have cost more than redoing it.
Architecture
Blazor web app Stremio addon Browser extension Android (MAUI)
│ │ │ │
└──────────────────┴─────────┬─────────┴──────────────────┘
│ HTTPS · /api/v1 · 213 endpoints
┌────────────────▼────────────┐
│ ASP.NET Core · .NET 10 │
├─────────────────────────────┤
│ Sync + write fan-out │
│ OAuth token custody │
│ Episode scheduler │
│ Billing webhooks │
│ Media-server webhooks │
└───────┬─────────────┬───────┘
│ │
┌───────────────────┘ └─────────┐
▼ ▼
SQLite + Litestream AniList · Kitsu · MAL
continuous replication Trakt · TMDb · AniSkip · …
Five .NET projects: a shared client component library, a server, a Blazor Web host, its WebAssembly client, and the MAUI head. The web app and the native app render the same Razor components — the difference between them is the host, not the UI. Playback is the one place that deliberately is not shared: phones run libVLC behind a custom player chrome, Android TV runs Media3 / ExoPlayer with D-pad-first controls and HDR-to-SDR tone mapping, because a touch player and a ten-foot player are not the same product.
Decisions worth defending
The parts of this project I would actually want to be asked about in an interview.
Fanning a single write out to four services that disagree
When a user finishes an episode, that one event has to land on every account they
have linked. The providers do not share a vocabulary: one calls a list state
CURRENT, another watching; scores are 0–10 on one service,
0–100 on another, and five smiley faces on a third.
The write goes to the primary provider first, because that is the one the user sees in their catalogue and the one whose failure must surface immediately. Only then do the secondaries fan out concurrently, each through a normalisation layer that maps the canonical status and score into that provider's model. A secondary failing is logged and retried, not surfaced as an error — the user's own list already updated.
The same path is reused by every entry point: the web UI, the Stremio addon, the browser extension, and Plex/Jellyfin/Emby webhooks. There is exactly one place in the codebase where a progress update is written, which is the only reason four surfaces stay consistent.
Making four services agree on what a title is
Everything above depends on a question none of the providers can answer: is the thing the user just watched on service A the same thing as this entry on service B? Each maintains its own identifier namespace, and the film and television databases the metadata comes from maintain three more.
So a title has to resolve across seven namespaces — AniList, Kitsu, MyAnimeList, IMDb, TMDb, TheTVDB and AniDB — and the mapping is not static: new titles appear weekly and existing entries get corrected. An automated pipeline refreshes it daily from the open mapping datasets, enriches the gaps, and publishes the result through the public API so other projects can use it too.
The hard cases are structural rather than missing rows. A franchise that one service models as seasons 1..N is often a single flat season elsewhere, so a split-cour show has to be sliced back into per-cour numbering using the preceding cours' episode counts — otherwise opening the second cour of a series shows all forty episodes instead of its own twelve. Reconstructing that requires walking the relation graph, not a lookup table.
OAuth against four providers, including one that only accepts a downgraded PKCE
Each provider gets its own native flow, so no password ever passes through infrastructure I control unless the provider gives me no alternative: AniList uses authorization code, Trakt likewise, MyAnimeList uses PKCE, and Kitsu offers only a resource-owner password grant.
MyAnimeList advertises PKCE but accepts only
code_challenge_method=plain — the variant every guide tells you not to
use. There is no way to make it S256; the choice is to implement it as
specified or not support MyAnimeList. I implemented it, and documented why in the
repository so the next person to read that code does not "fix" it.
Refresh failures do not cascade. A provider whose refresh token has expired is flagged for lazy re-authentication and its pill in the UI shows a needs reauth badge; the other three keep working. Losing one linked account should never mean re-linking all of them.
Treating user-supplied URLs as hostile
Users can point Watchario at their own third-party addon endpoints, which means the server makes outbound HTTP requests to hosts a user chose. That is a server-side request forgery primitive unless it is handled deliberately.
Checking the hostname before the request is not enough: the default handler follows
redirects, so a URL on a legitimate public host can answer 302 pointing
at [::1] or a private cloud address, and a naive client will follow it
and hand back the body. The real boundary has to be at the socket.
So user-supplied traffic goes through a dedicated named HttpClient whose
ConnectCallback vets the resolved address of every hop, with a
connect timeout and a response-size cap. The pre-flight hostname check stays, but only
to produce a decent error message when someone pastes something wrong.
A related trap cost me a real leak: those addon URLs embed per-user API keys, and
HttpClient's own framework logging writes the full request URI at
Information. The keys were going into production logs without a single
log statement of mine being involved. The fix pins that client's log category to
Warning, and an automated check now fails the build if anyone lowers it.
The same instinct governs crash reporting: diagnostics are opt-out, and the payload is redacted on the device before it is uploaded, so keys and tokens never leave the user's phone in the first place. Redacting at the receiving end would have been less code and the wrong boundary.
Episode notifications that fire to the second, with no cron server
When an episode a user is watching airs, they should get a notification at the moment it airs — not up to an hour later. The obvious implementation is a minute-resolution polling job, which is both wasteful and still imprecise.
Instead an in-process BackgroundService pulls the airing schedule once a
day and arms one timer per upcoming episode. Notifications fire exactly on time, the
database is not polled, and there is no external scheduler to operate.
That design has one honest weakness: the machine auto-stops when idle to keep hosting costs near zero, and a stopped machine has no timers. A small Cloudflare Worker therefore pings the app at the airing moment to wake it — free-tier cron triggers and KV, no Durable Objects. The in-process scheduler remains the mechanism; the Worker only guarantees the process is alive to run it.
SQLite in production — and the backup story that makes it defensible
One SQLite file holds every account: tokens, linked providers, notifications, cached state. For a single-region service with this read pattern it is faster than a network database and dramatically cheaper. The objection is not performance, it is durability — a single file on a single volume is a single point of failure.
Litestream answers it by streaming the write-ahead log continuously to object storage, so the recovery point is seconds rather than the last nightly dump. The container entrypoint restores from the newest replica automatically when it starts on an empty volume, which means the restore path runs on every fresh deploy rather than being a document nobody has tested.
I have also rehearsed the restore deliberately, and my local development environment works by pulling a real replica down and scrubbing the credentials out of it. The backup is exercised weekly whether or not anything has gone wrong.
Every push deploys — so the gate had to move before the push
Pushing to the working branch merges to master and Fly deploys it. There is no review step, because there is no second engineer. That is a fine arrangement right up until a commit that compiles but throws during startup ships, and the machine crash-loops.
The CI pipeline that used to guard this took over three minutes and ran after the push — by which point production was already broken. I replaced it with a pre-flight script that runs the same assertions locally in about a minute, and a git hook that refuses to push unless it has passed for the exact commit being pushed, with a clean tree.
It checks the failure modes that have actually happened, not the ones that sound plausible: the app boots and answers its health endpoint twice, ten seconds apart, because answering once does not prove the process survives; the published Blazor boot script returns 200, because a build reordering once shipped a site where every page rendered a static skeleton while the server logs stayed perfectly clean; and the payment webhook returns its handler's status code rather than one rewritten by middleware, because that bug burned all sixty of the payment provider's retries on an unfulfilled purchase.
Every check in that script is a scar. When something new breaks, the fix is not finished until there is a check that would have caught it.
Two languages, enforced by the build
The UI ships in English and Greek. Localisation projects fail in a predictable way: someone adds a string in a hurry, forgets the translation table, and that language silently falls back to English for the next six months.
So it is not a convention here, it is a gate. A guardrail in the pre-flight script scans the markup for translated literals and fails the push if any key is missing from any language table, or if a translation's format placeholders do not match the original — a mismatch that would otherwise be a runtime exception in front of a user.
The native head, and why the web build cannot vouch for it
The Android app is a .NET MAUI Blazor Hybrid head over the same component library. The deployment pipeline compiles only the web head, so a MAUI-specific mistake — a native binding that does not match the Java API — would not surface until it was on a device. Every change touching that project therefore gets built for Android locally, in both Debug and Release, before it goes anywhere.
The instructive bug was the video player failing to draw under the display cutout. It needed three separate fixes: window flags on the modal's own window, because modern MAUI hosts modal pages in a dialog fragment whose window is not the activity's; opting the page out of safe-area insets; and opting the layouts inside it out as well, since insets are applied per-layout and a root grid will happily pad its children away from the notch on its own.
I found the third piece by rendering the window flags and view positions as an overlay on the player itself, after several blind attempts had failed. Making the invisible state visible solved in one build what guessing had not solved in five.
By the numbers
213 REST endpoints, versioned and OpenAPI-documented
100 Razor components shared across web and native
54 Application services behind the API surface
15+ Third-party APIs integrated, each with its own failure modes
What this project is really evidence of: not that I can write a lot of C#, but that I can own a system in production — decide its architecture, integrate services that actively resist integration, keep its data recoverable, ship to it safely without a second pair of eyes, and be the person who fixes it at 11pm when something breaks.
What I would do differently
- Automated tests from day one. The pre-flight gate proves the app builds, boots and answers — it proves nothing about correctness. That was a deliberate trade for speed early on, and it is the decision I would reverse first.
- An integration seam per provider, sooner. The normalisation layer emerged after the third provider rather than before the second, so the first two needed reworking once the shape became clear.
- Structured logging with correlation IDs earlier. Tracing a single write across four concurrent provider calls is harder than it should be, and I only felt that once something went wrong in production.
- Ship the container check. The pre-flight runs the published output on the host, not the image — so the entrypoint, the base image and the replication sidecar are covered by nothing. I know exactly where that gap is; it is not yet closed.