Disclaimer: This documentation is provided for convenience and may contain errors. Always verify information against the official Kodi and provider documentation. Report issues.
Fastmail
Home / Files / c4_vod.py

easyplaytv-service/…/providers/c4_vod.py — Channel 4 catalogue

Moved: now lives in the standalone service runtime

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.

Path easyplaytv-service/src/easyplaytv_service/vod_sync/providers/c4_vod.py Lines 196 Type catalogue scraper (HTML + __PARAMS__ inline JSON) ← called by library_sync.py via _provider_module("c4_vod"), providers/__init__.py list_shows("film", 0, 200) → calls channel4.com categories + brand HTML pages

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_ROOT = "https://www.channel4.com" · URL_CATEGORY_SHOWS = URL_ROOT + "/categories/%s" · URL_BRAND = URL_ROOT + "/programmes/%s" · lines 14-16

URL roots. URL_CATEGORY_SHOWS is the JSON-returning category listing; URL_BRAND the per-show brand page.

GENRE_SLUGS = {"drama":"drama", "comedy":"comedy", "documentaries":"factual", ...} · lines 18-27

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

_api_get(url, params=None, ua=None) · line 30

Standard gzip-aware fetch with optional query-string parameters.

_extract_brand_data(html) → dict | None · line 43

The interesting helper. Brand pages have an inline __PARAMS__ = {…}; JavaScript object — a deeply-nested JSON blob describing the show. We:

  1. Find the substring "__PARAMS__".
  2. Track to the next { — that's the start of the JSON.
  3. Walk character-by-character with a brace-depth counter until depth returns to zero.
  4. Replace JS undefined with JSON null so json.loads doesn't choke.
  5. Wrap in json.loads(...).
4 MB cap — long-running daily strips

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

list_shows(genre_slug, offset=0, limit=60) · line 68

Iterates paginated category lookups by appending ?json=true&offset=&sort=title. C4 returns a JSON envelope with brands.items[] and noOfShows.

Films filter — line 88 onward

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

list_seasons(websafe_title) → tuple[list[season], list[episode]] · line 112

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

get_episodes_for_series(all_episodes, series_number) · line 160

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.

list_episodes(websafe_title, series_number) · line 194

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.