← All projects

Catlg

In development

One catalog for everything you watch, read, play, and listen to, with collections you can share and fork and recommendations that explain themselves.

Web AppFull-StackMediaRecommendations

At a glance

  • Outcome: One catalog for everything you watch, read, play, and listen to, with shareable, forkable Collections and recommendations that name the thing that produced them.
  • Status: Early product, live at catlg.app since the production cutover in May 2026. Built across 25 working days between March and June 2026; last substantial work June 2026.
  • Role: Solo: product, data model, API, web app, and deployment.
  • Stack & libraries: pnpm + Turborepo monorepo; Vite + React with TanStack Router/Query; Fastify with a generated OpenAPI contract; PostgreSQL; pg-boss job worker; Dokku.
  • Source: Private while the product takes shape; the demo is public.
  • Validation: 124 test files: 65 unit, 49 integration, 2 contract, and 8 Playwright specs covering auth, catalog and library, collections, comments, admin and reports, account deletion, browser-mode sync, and a cross-browser smoke matrix. They run over 39 SQL migrations. Integration tests are pinned to a single fork so they execute serially against a real PostgreSQL database rather than racing each other.
  • Limitations: Not release-ready: the v1 release checklist has 1 of 77 items checked. An earlier production instance ran 286 commits behind for weeks before the last cutover caught it. The worker package’s own test script is still a placeholder, so running tests from inside that package alone proves nothing. Its real coverage lives in the root test project.

The bet

Most media trackers cover one format. Catlg’s bet is that cross-media taste is itself the object worth modeling: a game, a novel, a film, and an album can sit in the same collection, share one status model, and feed one recommendation pipeline.

The repo puts it plainly: Catlg should not behave like several single-medium trackers living under one logo. Every structural decision below follows from that.

The two decisions that cash it out

Everything in your library is one row with one status: saved, in progress, completed, paused, or dropped. That’s what lets a film and a game sit in the same collection and answer the same question. The parts that genuinely differ hang off that row in their own tables: playtime and achievements for games, episodes watched for TV, pages for books, listen state for music. Films need no extension at all. Without this, “cross-media” would just be five trackers again.

Collections belong to a content identity, not to a user. A collection’s owner is a row whose kind is either user or anonymous. Deleting an account flips that identity to anonymous and erases the personal data, while the collection, its forks, and the attribution chain stay intact, because the database refuses to drop an identity out from under published work.

The ADR records the alternatives that lost: soft-deleting users, snapshotting the author’s name onto the row, cascade-deleting collections, and moving orphaned collections to a second table. The cost is stated too: one extra join on every collection read. That’s the rare case where the reasoning is legible afterward.

What a Collection is

A Collection is a publishable cross-media object, not a saved list. Groups give it structure, so a plain collection opens with one group and a tier list opens with S through F, and forking preserves the type.

Each row stores its source collection, its fork root, and its depth in the fork tree, so lineage is queryable instead of just recorded. Forks are written in one transaction, and publishing is idempotent.

Keeping three things apart in the catalog

A catalog item is Catlg’s canonical record of a work, with a merge state tracking whether it’s still pending, canonical, or folded into another item. A source record holds one provider’s raw view of it, keyed on the provider and its own id, whether that’s TMDB, IGDB, Last.fm, Google Books, Steam or a manual entry. Your library entry and progress live in a third set of tables.

Refreshing a provider therefore can’t overwrite what you recorded, and two providers describing the same film can converge on one item without either becoming authoritative.

Recommendations that name their cause

Background jobs precompute candidates from six strategies: franchise, genre, collection affinity, social, trending, and exploration.

Each candidate stores its reasons as structure rather than text: a type, whether it’s primary or supporting, and references to the item or collection that caused it, against a small set of copy templates like “Because you liked X” or “Appears in N collections you follow”. The API ranks candidates and resolves those references into text at read time.

So an explanation names the thing that produced the result, and a reference to a deleted item resolves to nothing instead of a stale title.

How it’s built

  • A pnpm + Turborepo monorepo. The web app is Vite + React with TanStack Router/Query; the API is Fastify with a generated OpenAPI contract.
  • PostgreSQL with SQL migrations and deterministic seed data, plus a job worker for background work. Worker retry exhaustion, dead-letter routing, error capture, and Prometheus failure and queue-lag metrics all carry unit tests.
  • API-owned auth via emailed verification codes and HTTP-only session cookies, with contract tests enforcing the auth boundary across the route table and an SSRF guard on outbound provider fetches.
  • Dokku staging/prod deployment docs and scripts.