Mediary

Mediary home page

Letterboxd is very good at films and has no opinion about anything else. So the shelf of things I have actually finished is spread across four apps that do not know about each other, and the question “what did I get through last month?” has no answer anywhere.

Mediary is that question having an answer. Eight formats — films, shows, games, books, manga, music, board games, with anime still to come — one library, one diary, one set of statistics.

Eight APIs, one type

Films come from TMDB, games from IGDB, board games from BoardGameGeek, books from a choice of three, music from a choice of four. Sixteen adapters in total, every one of them returning a different shape, with different names for the same field and different ideas about what a date is.

The rule the whole codebase rests on is that the UI never sees any of that. Every adapter produces a MediaItem, and a component that wants a poster asks for posterUrl whether it is drawing a film or a box of cardboard.

// The single UI contract. Every adapter (TMDB, IGDB, …) produces `MediaItem`.
// The UI never consumes a raw API payload — only this type.
export const MEDIA_TYPES = [
  "movie", "tv", "game", "book",
  "music", "anime", "manga", "boardgame"
] as const

It is not a clever pattern. It is just the one that means adding a ninth source touches one file instead of forty.

Nothing gets asked twice

Every external call goes through one cached() wrapper. No component, no adapter, no exceptions — a third-party API is never reached directly.

It runs on either the Next data cache or Upstash Redis, chosen by an environment variable, and on Redis it does three things worth the trouble:

  • Stale-while-revalidate. Past its TTL but inside the grace window, the page gets the stale answer immediately and a refresh runs behind it.
  • Batched reads. Keys are queued and flushed one microtask later as a single MGET. A detail page wants about nine of them; that is one round trip rather than nine, and two callers wanting the same key share the read.
  • Failing open. A Redis read that errors is treated as a miss, never a 500. The page is slower and still renders.

Sitting beside it is a circuit breaker, which exists because three adapters arrived at the same problem independently — AniList when its public API is switched off, OMDb when the free tier’s daily cap is spent, CheapShark on a 429.

A refusal must not be cached as an answer: a rate limit is not a fact about a film. But that leaves every page making its own doomed call into a source that has already said no, and that call sits in the wave the first paint is waiting on. So one failure stands for the next ten minutes, at a cost of one wasted call per window to find out the source has come back.

Per process, deliberately. The job is to collapse a burst on one server, not to hold a global lock — and a breaker living in the cache would outlive a deploy and need clearing by hand on the day someone fixed the key.

Mediary media details page

Two tables that look like one

items is the current state of a thing for a user. diary_entries are the individual logs — a film watched three times is one item and three entries.

Keeping them apart is what makes a universal diary possible at all: a single chronological feed across all eight formats, which is the feature I actually wanted and the reason the schema looks the way it does. Collapse them into one table and the third rewatch overwrites the first two.

Status is unified across formats — planned, in_progress, finished, paused, dropped — with a finer sub-status per type. The detail page only ever offers three of them; “On hold” and “Abandoned” live among the sub-statuses and write the canonical status behind the scenes. A dropped series and a finished one should not be two clicks apart.

Where the optimism stops

Tracking is optimistic to the point of having no spinners in it at all. Marking a film watched updates instantly and reconciles later, because a lost write there costs nothing and a spinner on every tick makes the app feel like a form.

The moderation backoffice does the exact opposite, on purpose. Hiding a review or suspending an account is not a tick you can afford to guess at — the moderator has to watch the write land, and an optimistic rollback would show them a state that never existed. Pending state, server response wins.

A few things the admin side got wrong first and then got right:

The role does not live on profiles. That table’s RLS policy is using (true), so a role column on it would publish the list of moderators to any visitor who asked politely. Staff live in their own table with no select policy at all — invisible through PostgREST.

The layout is not a security boundary. app/admin/layout.tsx calls notFound() rather than redirecting, because a 404 does not even confirm the route exists. But every server action re-checks for itself: an action is a POST endpoint, and a POST endpoint never goes through a layout.

Hidden content is filtered twice. RLS handles it, and lib/db/queries.ts does it again by hand, because Drizzle connects with the privileged role and RLS does not apply to it. Two chances to get it right, because one of them is easy to forget.

Nothing is silent. Every action appends to an audit table with no update or delete policy, and every sanction writes a notice the user reads in their own settings, with a right of reply. A banned account can still change its theme and still delete itself — that last one is not optional.

What it runs on

Next.js App Router, Supabase for Postgres and auth, Drizzle, Tailwind and shadcn/ui, Zod at every boundary, RLS on every user table. Installable as a PWA with no service worker, which was a decision rather than an omission: Chrome stopped requiring one, and a cache in front of a server-rendered authenticated app costs more than it returns.

TMDB, IGDB and BoardGameGeek attribution sits in the footer, which is a legal condition of using them and the cheapest clause in the project to honour.