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 inepg.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.