resources/lib/itvx.py — ITVX auth + live/VOD resolver
Role
ITVX is the only addon provider that requires an account. This module handles OAuth password-grant auth (with refresh-token lifecycle), sticks the resulting JWT into Bearer headers, then uses it to call ITV's "magni" playlist API for both live simulcast and on-demand playlists.
ITVX auth constants
Five different ITV-hosted endpoints:
AUTH_URL— initial password grant (POST).REFRESH_URL— refresh-token exchange (GET with?refresh=).SIMULCAST_URL— live channel playlist (POST with WEB_REQ_DATA).VOD_URL— VOD programme playlist (POST with VOD_REQ_DATA). VOD endpoint is on a different host than the live one:magni.itv.comvs.simulcast.itv.com.
One JSON file holds itv_session (access + refresh JWTs), an Itv.Session
cookie string, and a refreshed timestamp.
Static device description payloads POSTed to the playlist URLs. Note the differences:
- WEB_REQ_DATA is for live simulcast — uses "platformTag": "dotcom" and "featureset": {"min": ["mpeg-dash", "widevine"], "max": ["hd", "mpeg-dash", "widevine", "inband-webvtt"]}.
- VOD_REQ_DATA uses "platformTag": "dotcom" (browser identity).
Only
"dotcom"and"ctv"are accepted — all others return HTTP 400."dotcom"applies a 720p video filter (no 1080p);"ctv"serves the full resolution ladder. Both return the same DRM entitlement from the Irdeto license server — the VMP entitlement (itvx_ctv_soft_protect_hd) is content-level, not platform-level. Audio always fails on L3 Widevine regardless of platformTag: the CDM oscillates the key between kUsable and kExpired/SystemCode5 due to VMP requirements. Video works via V4L2 hardware decode (NOSECUREDECODER) which bypasses the CDM; audio has no hardware path. Live already uses"dotcom"via WEB_REQ_DATA. When upstream IA PR #2021 lands, the CDM audio decoder interface will bypass the single-decrypt VMP check. Flat featureset: ["mpeg-dash", "widevine", "outband-webvtt", "hd", "single-track"].
Hardcoded Firefox/Linux UA for ITVX playlist calls only — different
from the shared
get_ua() desktop-Chrome UA.
ITVX sniffs UA and rejects things that look Roku-shaped; Firefox 131 is
what their own web player uses, so we mimic exactly.
Session management
JWT base64-payload decoder. Token expiry check allows a 30-min margin
(margin=1800) so we refresh before the actual expiration.
Refresh-token expiry uses a 1-day margin (exp - 86400).
The session-state machine. Steps:
- Load the session file. If no access/refresh token, return "".
- If refresh-token expired → log warning, return "". User must
re-login via
?action=itvx_login. - If access-token expired → call
_refresh_session()to get a new one. Reload from disk. - Proactive 4h refresh: if the session was last refreshed > 4h ago (line 140), refresh again regardless of JWT expiry. This catches the case where ITV silently invalidates older refreshes before the JWT exp claim.
Refreshes via REFRESH_URL + ?refresh=. On success
updates the session file, the refreshed timestamp, and re-builds the
Itv.Session sticky cookie (line 92's
_build_cookie wraps the tokens in a {"sticky": true,
"tokens": {"content": …}} blob). Cookies stored alongside in the
session file for itvx_vod.py's
catalogue fetches.
Initial login. Reads itvx_email / itvx_password
addon settings if not supplied. POSTs to AUTH_URL with the
password grant_type (NOT the cookies-based OAuth code flow
that the website uses — ITV's API directly accepts password grant for the
app.10ft.itv.com "Origin" the resolver sends in
AUTH_HEADERS).
On 400 → "invalid email or password" notification; 403 → "forbidden,
try again later" (rate-limit); other → "HTTP N" generic. On success
extracts the user nickname from the JWT payload
(_decode_jwt_payload(...).get("name", "")) and notifies
"ITVX signed in as
Deletes the session file. UI notification "ITVX signed out".
Resolvers
Live channel playlist. Calls _get_access_token(); if that
returns empty (no session), it tries login() once as a
fallback (uses the saved credentials in settings → fully automatic). Then
POSTs WEB_REQ_DATA with the token to SIMULCAST_URL % channel.
On HTTP 400/401/403: log "auth error N, attempting refresh", call
_refresh_session(), rebuild the request, retry once. On a
second failure raises "session expired - sign in again".
Reads data["Playlist"]["Video"]["VideoLocations"][0] →
dash_url = loc["Url"], key_service =
loc["KeyServiceUrl"]. If key_service is set,
we're playing DRM content — pass to
setup_dash_item with the license
URL + headers. Else clear DASH.
Same shape as live but against the magni VOD URL. Has an additional
"version retry" fallback (lines 357-372): on a 404 from a
default-constructed URL, tries .001 through .009
version suffixes — ITV's API exposes "versions" of a programme as separate
URLs (e.g. signed, AD, repeat episodes), and the very first version isn't
always the canonical "1". The retry finds the first one that returns
200.
If the caller passes playlist_url (the URL the show dict
holds, captured during catalogue scrape), we use it verbatim and skip the
version retry — the catalogued URL is authoritative. This is the path
most VOD episodes take; the production_id + version-suffix fallback only
kicks in when the cataloguing pipeline couldn't pick up a playlist URL.
If a 404 comes back with a caller-supplied playlist_url, we re-raise rather than retrying — the URL has been observed stale by the caller and we trust that signal rather than spam ITV with version probes for content we already know is gone.
Inbound routes
| Route | Function | Triggered by |
|---|---|---|
?action=itvx_login | login() | UI "Sign in" button menu action |
?action=itvx_logout | logout() | UI "Sign out" button |
?action=resolve_vod&provider=itvx&… | resolve_vod() | Play episode click in the library |
?action=resolve_live&provider=itvx&… | resolve(channel, item) | Live channel click in Live TV |