easyplaytv-service/…/providers/c4_vod.py — Channel 4 catalogue
Since the 3-package split this catalogue scraper ships in the shared runtime at easyplaytv-service/src/easyplaytv_service/vod_sync/providers/c4_vod.py (and the vendored copy inside service.easyplaytv). The video addon is playback-only and no longer contains a c4_vod.py.
Role
Scrapes channel4.com's category listing (a JSON-returning query-string
mode) and per-brand detail pages. The brand pages embed an enormous JSON
blob inside a __PARAMS__ JS variable (Fox's internal page-data
serialization — Channel 4's site is built on a custom SSR framework
rather than Next.js like ITVX).
Constants
URL roots. URL_CATEGORY_SHOWS is the JSON-returning category
listing; URL_BRAND the per-show brand page.
Translation table from addon-unified-genre slugs (key) — what genres.py's UNIFIED_GENRES uses — to C4's own URL slug (value). Note C4 puts documentaries and factual-history-and- arts under the single "factual" category.
API helpers
Standard gzip-aware fetch with optional query-string parameters.
The interesting helper. Brand pages have an inline __PARAMS__ = {…};
JavaScript object — a deeply-nested JSON blob describing the show. We:
- Find the substring "__PARAMS__".
- Track to the next
{— that's the start of the JSON. - Walk character-by-character with a brace-depth counter until depth returns to zero.
- Replace JS
undefinedwith JSONnullsojson.loadsdoesn't choke. - Wrap in
json.loads(...).
Line 52:
for i in range(start_pos, min(len(html), start_pos + 4_000_000)).
Long-running daily strips (Come Dine with Me, The Bill) produce
__PARAMS__ JSON blocks over 600 KB. Without the cap raised to
4 MB these shows used to silently drop the brand data and fall through to
the "no episodes" path, producing empty/partial library entries. 4 MB
matches ITVX's largest known brand page.
Listing functions
Iterates paginated category lookups by appending
?json=true&offset=. C4 returns a JSON
envelope with brands.items[] and noOfShows.
The category API does NOT expose programmeType (only the
brand page does), but it DOES tag films two equivalent ways:
expandedTile.availableContent == "Film" and
"Film" appears in expandedTile.genres. Films
appear under drama/comedy/etc. categories (e.g. "Boiling Point", "The
Banshees of Inisherin") with no series — including them in the series
catalog would create ~67 stray 1-episode film rows per C4 sync. The
filter at lines 93–94 skips them so neither a single standalone film nor
a fresh entry slips through. When browsing the film category
itself the filter keeps them (they're the point of that browse).
Fetches the brand page, parses __PARAMS__, splits into
seasons and a flat list of all episodes. Each season has a
series_number; each episode has asset_id, programme_id, series_number,
episode_number, title. The dual return shape (seasons + all episodes) is
so the caller can either:
a) group episodes by series_number (library_sync's collection), or
b) filter later via get_episodes_for_series for the per-season browse.
If the brand page has no explicit series list but
episodes carry series numbers, the seasons are synthesized
from the episode set (series 0 bucket only when nothing's numbered).
Filters the already-fetched list_seasons episode list to one
series_number without re-fetching. Also builds each episode's display title
(fullTitle, appending originalTitle when it adds
information) and normalizes the image field to a URL string.
Backs list_episodes and the film/series collection in
library_sync.py, so both hit the same
episode-shaping code.
Single-season episode list. Calls list_seasons internally
and filters through get_episodes_for_series for the requested
series_number. Used by the per-season browse.