A release calendar for every genre. Follow a genre or a name — a show, a film, an artist, a rocket — and get told before it is out.
Sibling to tipoffwatch.com, which does the same thing for
sport. /sports here redirects there rather than being reimplemented thinly.
A sports fixture always has a kickoff. A release very often has a date and no time. TMDB says a film opens on 16 December; MusicBrainz says an album is out in 2026 and nothing finer; The Space Devs says outright that a launch date is accurate only to the month.
So every row carries time_known and precision, and that flag travels all the way
through:
| Layer | What it does with it |
|---|---|
| Adapters | Store an undated release at noon UTC, never midnight — midnight is the previous evening for the Americas |
| Scheduler | Two reminder classes: minute offsets (60, 1) for timed events, date offsets (1440, 0) for dated ones |
| Pages | Never print a clock for a time nobody announced; an undated row is visibly different |
| ICS | Emits an all-day DTSTART;VALUE=DATE entry, alarmed 09:00 on the day rather than 60 minutes before a noon anchor |
| RSS + API | Say "Out Friday 4 September", not "Starts 12:00:00 GMT" |
precision of month or year is browsable but never remindable — a
representative day is not a promise.
category → genre → subject → event. A subject belongs to many genres (a show is
Drama and Sci-Fi and Thriller), so that edge is a join table, not a column.
| Category | Source | Key? | Notes |
|---|---|---|---|
| tv | TVmaze | no | /schedule/full returns the entire forward schedule in one request |
| anime | AniList | no | Real per-episode airing timestamps; 30–90 req/min |
| film | TMDB | free | The only key. Without it, film is skipped rather than broken |
| music | MusicBrainz | no | 1 req/sec, hard. See below |
| space | The Space Devs | no | 15 requests per hour, for the whole deployment |
| sports | — | — | Redirects to tipoffwatch.com |
Cadence is per adapter and enforced against the last completed sync recorded in
the database (genres.synced_at), never against a job timer — a repeatable's timer
resets on every deploy, so on a busy day a timer-based sweep can be pushed forward
forever and never run.
Measured against the live API on 2026-08-21, over a four-month forward window:
- MusicBrainz knows about 2,228 official releases.
- 11% carry a day. The rest are "2026" or "2026-09".
- Of the artists behind those, 25% have any genre tag.
The alternatives were measured too and are worse: Wikidata has 46 forward music
releases in six months, iTunes Search does not expose pre-orders at all, and
Deezer's genre-to-artist mapping files Bad Bunny under Rock. There is no free source
with volume, dates and genres together. Artist genres are resolved incrementally
(MUSIC_LOOKUP_BUDGET per pass) and cached — including the negatives, which is
what stops each pass re-asking about the same untagged artists.
The five adapters above answer what is coming. They do not answer what exists — TMDB's back-catalogue walk is popularity-ordered and stops after a few thousand films — and that gap is what makes a reader's own VOD folder unresolvable: a film released last month sits in their folder, has no row here, and so has no page it could be offered on.
So packages/catalog/src/imdb.js walks IMDb's daily dumps. Free, keyless,
datasets.imdbws.com, rebuilt every night.
- It streams. Production Postgres is internal to Railway with no public proxy,
so this runs inside the container rather than through
psql \copy, and the container cannot land a gigabyte on disk. Nothing bigger than one line is held. - It links before it inserts. A candidate is matched against what we already
hold on
(category, normalised title, year), so an IMDb row for a film TMDB already gave us enriches that row instead of creating a second page. - It is bounded and resumable. A pass stops at
IMDB_DEADLINE_MSand records the tconst it reached; the next resumes there. The first full walk takes a few nightly runs.
What it deliberately does not give you is a release date. title.basics carries
a start year and nothing finer, so these rows are precision: 'year',
time_known: false — browsable, searchable and matchable against a playlist, never
alarmable. Anything with a real date still comes from TMDB, TVmaze or AniList, and
this pass never overwrites one.
Measured against the real dump on 2026-08-23: 1,708,507 rated titles, 428,513 above
100 votes (a 92MB map), and roughly 4% of title.basics rows kept. tvEpisode is
excluded outright — 8.5M rows, and an episode is only interesting through its show,
which TVmaze already gives us with a real air time.
| Knob | Default | What it is for |
|---|---|---|
IMDB_BACKFILL |
1 |
0 turns the whole pass off without a deploy |
IMDB_MIN_VOTES |
100 |
A memory budget, not a taste one — the map scales with it |
IMDB_RECENT_YEARS |
2 |
Titles this new are kept with no votes. This is the half that fixes new releases |
IMDB_DEADLINE_MS |
900000 |
Wall-clock ceiling on one pass |
bun run imdb runs it by hand; --deadline=60 gives it a minute instead of fifteen,
and --restart clears the cursor.
The four screen sources above -- TMDB, TVmaze, AniList and the IMDb dumps -- are
also fetched once, for every site we run, by nichedb.dev
into its screen collection. Set
CATALOG_PROVIDERS=nichedb,musicbrainz,spacedevs
and film, tv and anime are mirrored from there instead of polled here. Naming
nichedb is the whole switch: the local tmdb, tvmaze and anilist adapters
stand down even if still listed, the TMDB detail, home-release, artwork and
back-catalogue passes skip, and the IMDb worker answers "comes from nichedb"
before it reads its progress row. Music and space are not in nichedb and keep
their own adapters. Take nichedb back out and everything local resumes from its
own cursors; nothing is deleted.
Every mirrored row lands on the same (provider, provider_key) the local
adapter would have written -- tmdb:movie:603, tvmaze:episode:3707960,
tt0133093, tmdb:stream:603 -- so every slug and URL ever handed out still
resolves, and a page cannot tell which way its rows arrived. The three TMDB
release rows per film, TVmaze's episode kinds, AniList's airing timestamps and
the IMDb year-anchored events are all reproduced, and an IMDb title is linked to
a TMDB or TVmaze row on (category, normalised title, year) exactly as the
local backfill linked it.
packages/catalog/src/nichedb.js walks two item kinds, title then release,
each with a cursor in nichedb_cursor. nichedb's read API pages by id and filters
by since on updated_at, and the wire shape carries no updated_at, so the
cursor is the floor a walk began at plus the last id written inside it: a pass
that runs out of its page budget resumes with after=<id>, and a drained walk
moves the floor to ten minutes before it started. Releases are not walked until
the title walk has drained once, so an event never arrives before its subject.
| Knob | Default | What it is for |
|---|---|---|
NICHEDB_URL |
https://nichedb.dev |
The mirror's origin |
NICHEDB_PAGES_PER_PASS |
250 |
Pages of 200 one pass may fetch, across both kinds. nichedb allows 600 anonymous requests an hour |
NICHEDB_DEADLINE_MS |
600000 |
Wall-clock ceiling on one pass |
At steady state a pass is two requests, one drained page per kind, plus a page
per 200 rows that changed. The first mirror is the IMDb half of the collection
(about 430,000 titles, some 2,200 pages) arriving over a working day of hourly
passes; the local rows serve the pages meanwhile. bun run sync --only=nichedb
runs a pass by hand.
Optional, per account, and private by default. Add the M3U your provider already
gave you and the site tells you which of your entries is carrying something you
follow, and shows your provider's own group-title values as your own genre index
at /my/channels.
The playlist URL carries your credentials, so it is encrypted at rest and never
rendered into a page. VLC, Infuse and the .m3u download hand the entry straight to
your own player and never touch our servers. "Play here" is the exception, for a
television or a locked-down desktop with no app to hand a file to: it proxies the
bytes through the site, to your session only, never cached and never shared.
user_playlists.shared is an explicit, owner-set opt-out from the default. Two
facts shape what it can be:
- The address is the credential. So a shared entry plays through the proxy and
nowhere else — no VLC link, no Infuse link, no
.m3u. A shared list that also handed out the address would last as long as it took one person to paste one. - The connection ceiling belongs to the line, not the audience. Slots are claimed against the owner's id, and a busy line refuses a stranger with a 409 rather than evicting whoever is already watching. The owner is never locked out of their own line.
/shared lists who is sharing and how much, and nothing else — no titles, no
groups, no addresses.
bun install
cp .env.example .env # only DATABASE_URL is required
bun run migrate
bun run sync # seed the catalogue; --force ignores intervals
bun run devbun test runs the migrations and the scale-critical queries against a real
Postgres 18 in-process (PGlite) — no server, no Docker.
One container runs both roles; ROLES=web,worker picks which. Lives in the shared
"Profullstack, Inc." Railway project as service genrewatch.com, with its own
Postgres-iVtY and Redis-wSsW.
PORT must match the domain's target port (8080 here). With the app on 3000 and
the domain targeting 8080, every request 404s while the container reports healthy.
SITE_URL is what every generated URL is built from — canonicals, og:image, RSS
self-links, calendar feed URLs, notification click targets, every link in a reminder
email, and the passkey rpID. Changing it invalidates every passkey already
registered.