Eight sources, one catalog

src/catalog/sources/ holds one adapter per upstream, each independently testable:

famelack

iptv

samsung

nasa

pluto

roku

plex

launches

src/catalog/merge.ts combines their output into one channel list; src/catalog/fetch.ts (atlas.catalog.v* in KV/IndexedDB — see Architecture) is the client’s build-time and runtime entry point into that merged catalog.

Identity: channel_aliases

A single real-world channel can arrive from more than one source with a different external id in each. src/catalog/aliases.ts is the register of every id space an external identifier can live in — one per source, plus a ninth, xmltv, for the EPG grab’s own id space (iptv-org channel ids, which arrive through a different pipeline than the iptv source itself and so get their own namespace). (source, external_id) is unique in the schema. First writer wins, and the order matters: a merge states every surviving channel’s own identities before it states any rescued from a record the merge dropped, so a live channel’s id can never be taken over by a dead twin that happened to share it.

Catalog slices

.github/workflows/catalog-slices.yml (every 3 hours, plus workflow_dispatch) runs scripts/build-catalog-slices.ts, the merged and filtered cut of the catalog for each platform, and publishes web.json and native.json to the public catalog bucket in Supabase Storage with scripts/publish-object.mjs. The app reads them straight from the bucket (sliceUrl in src/catalog/slice.ts) and falls back to the sources when a slice is missing, thin, or older than three days. The slices used to be built by pnpm build:web and shipped inside every deploy, which made each deploy about 60 MB and tied catalog freshness to deploy frequency. A deploy now carries only the app.

Sync + EPG refresh

scripts/sync-catalog.ts upserts the public catalog (via toPublicChannel — no stream URLs) and its aliases into Supabase, run manually with a Supabase CLI access token:
.github/workflows/epg.yml (every 3 hours, plus workflow_dispatch) regenerates the EPG guide. It is split into two jobs on purpose: grab checks out the third-party iptv-org/epg grabber (pinned to a specific commit, npm ci --ignore-scripts, read-only permissions, no deploy secret in scope at all) and shards the scrape across up to ~2,000 channels; publish — which never touches that third-party code — re-checks the publish threshold on the downloaded artifact, syncs the rich per-programme detail into Supabase, and publishes the compact guide (epg.json) to the public guide bucket in Supabase Storage. The app reads it back through GET /api/epg. Nothing is committed to main and nothing is deployed: a guide refresh is a data change, and the app only deploys when its code changes. That split means a compromised dependency in the grabber’s tree can never reach the Supabase service role.
The grab writes a grab-status.json unconditionally (even on failure), and a final “Report the grab’s real outcome” step runs if: always(), after the publish, so a bad grab degrades gracefully to the last good guide while still turning the workflow run red with a named cause — rather than silently reporting success on an empty result.

Programmes retention

The rich per-programme detail (synopsis, episode, rating, credits — the part that does not fit in epg.json) is synced into the programmes table rather than committed as thousands of JSON files, which used to cost roughly 2 GB a month of git history for data that expires in 24 hours. The EPG workflow requires landing at least 90% of the rows it was handed or it reports failure loudly, even though the guide itself has already published successfully by that point.