Skip to content
Zain Mahmood
Selected work

MediaListory

It started as MyGameList, a game tracker. It is now one library for movies, shows, anime and games, stitched from three catalogues that disagree about IDs, filters and what counts as a title.

Type
Production web app
State
Live
Role
Solo, end to end
  • Cloudflare Workers
  • Neon Postgres
  • Hyperdrive
  • Durable Objects
  • Vanilla JS
  • Playwright

At a glance

  • Movies, shows, anime and games in one schema
  • Imports from Letterboxd, MAL and Trakt
  • Unit, smoke and Playwright suites plus Semgrep on every push

What I owned

Solo project. The Cloudflare Worker API and runtime-neutral route modules, Neon/Postgres schema and migrations, Hyperdrive data layer, OAuth and sessions, no-build vanilla JS frontend, admin and moderation tools, and CI/deploy pipeline.

Decisions, and what they cost

  1. One catalog table, discriminated by media type

    Every title lives in one catalog table with a media_type, a provider and a provider_id, and a unique index on (provider, media_type, provider_id) guarantees IDs from different providers can never collide. Tracking, episode progress and custom lists reference that one table, so they work for every media type unchanged, and a new category slots in without schema churn.

    What I gave upEvery query has to carry media_type, and the row shape is the union of what four kinds of media need. Separate tables would be tidier per type and much worse for a list that mixes a film with a game.

  2. Filters come from the provider, not the page

    Each category offers only the filters its provider genuinely supports, and nothing is filtered client-side after the fact. That is why an exact title match comes back first instead of whatever happened to be on the current page of results.

    What I gave upThe four category pages are not symmetrical. Movies filter by runtime and language, anime by season and age rating, games by platform and mode, and the UI has to explain that difference instead of hiding it.

  3. Durable Objects hold the limits that must work across instances

    The production API runs on Cloudflare Workers. A Durable Object owns the exact auth rate limit and one-time OAuth codes, while Worker Rate Limiting protects catalog routes. OAuth returns a short-lived one-time code to the frontend, which exchanges it for a bearer JWT across the separate frontend and API origins.

    What I gave upThis adds Cloudflare-specific resources and deployment configuration, but the security-sensitive counters and codes stay consistent across Worker instances.

Known limitations

  • Catalog browsing still depends on TMDB, Kitsu and IGDB; when a provider fails, degraded mode can only fall back to rows already stored in the database.
  • The frontend and API live on different sites, so authenticated requests use bearer JWTs instead of a first-party session cookie.
  • Hyperdrive query caching is disabled to preserve read-after-write consistency, trading some cache savings for predictable state.

Where it stands

In production on Cloudflare Workers with Hyperdrive, Neon Postgres and a Durable Object. Episode progress, custom lists, a merged release calendar, year-in-review, imports from Letterboxd, MAL and Trakt, taste matching, follows and a feed, plus admin and moderator dashboards. CI runs unit, smoke and Playwright suites alongside security checks.