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 / my5.py

resources/lib/my5.py — My5 Cognito auth + Cassie key extractor + resolver

Path /resources/lib/my5.py Lines 480 Type playback resolver (DASH + Widevine) + user-auth helper ← called by default.py:_resolve_vod, default.py:_resolve_channel, ?action=my5_login / ?action=my5_logout → calls PyCryptodome (AES + HMAC), AWS Cognito IDP, Cassie JSON API, setup_dash_item

Role

Resolves both My5 live channels (Channel 5, 5 Star, 5 USA, 5 Action, 5 Select) and on-demand episodes. The interesting part is that every request to Cassie (My5's backend) is HMAC-signed AND the response is AES-CBC-encrypted — and both keys are XOR-obfuscated inside C5's live player JavaScript. The addon extracts them at runtime, caches them in memory, and re-extracts if a 403 "Failed to authenticate HMAC" comes back (which happens when C5 rotates the keys, approximately weekly).

The module is also responsible for optional Channel5 user sign-in via AWS Cognito (the My5 web app's auth backend). When a session is active, the access token is attached to Cassie requests as Authorization: Bearer … so premium content unlocks. Free content continues to play anonymously — see the auth section below.

Constants

CASSIE_BASE_VOD/LIVE_URL = https://cassie.channel5.com/api/v2/{media|live_media} · CASSIE_PLATFORM_ID = "my5desktopng" · lines 16-18

Cassie API roots. The desktop platform ID is what the C5 web player sends — important because the keys are bound to this platform. Changing the platform ID without re-extracting keys would 403.

COGNITO_CLIENT_ID = "10ap8l6jp0vhreaac79c3qr1lq" · COGNITO_REGION = "eu-west-2" · COGNITO_IDP_URL = "https://cognito-idp.eu-west-2.amazonaws.com/" · lines 28-30

AWS Cognito user-pool client ID and IDP endpoint. Same client_id the My5 web app uses for its hosted-UI OAuth implicit flow; the addon calls InitiateAuth with USER_PASSWORD_AUTH directly, which Cognito also accepts for this client. Config source is https://cassie.channel5.com/platform/my5desktopng/config.jsonuserServiceSettings.userServiceSdkConfig.apiUrl (hosted at userservice-api.channel5.com for user-profile calls; the addon skips that and only talks to the IDP endpoint).

SESSION_DIR = addon profile dir · SESSION_FILE = "$SESSION_DIR/my5_session" · lines 32-33

Where the Cognito session dict ({access_token, id_token, refresh_token, expires_at, email}) is persisted as JSON. Sibling to itv_session used by itvx.py.

PLAYER_JS_URL = "https://player.akamaized.net/html5player/core/html5-c5-player.js" · line 41

The Akamai-hosted player script that holds the obfuscated key blob. This is the file we fetch on every fresh-key extraction.

_cassie_keys = {"hmac_b64": None, "aes_b64": None} · line 43

Module-level cache. Lives for the addon's Python interpreter lifetime (which is short-lived in the plugin case — every directory-listing action spawns a new interpreter — but long-lived for the service), then re-extracted on next call.

Crypto primitives

_b64url_encode/_b64url_decode · lines 55, 59

URL-safe base64 (+-, /_, strips padding on encode, re-pads on decode). Cassie uses URL-safe base64 for signatures and IVs.

try: from Crypto.Cipher import AES ... except ImportError: from Cryptodome.Cipher import AES · lines 47-52

Two import paths — pycrypto (legacy) or pycryptodomex (newer). Whatever's installed. Kodi on OSMC ships PyCryptodome.

AWS Cognito user authentication

My5's web app authenticates users via AWS Cognito (user pool my5userpool in eu-west-2). The browser flow is hosted-UI OAuth implicit — the resulting localStorage.My5_SessionId is the Cognito access_token. Channel5's corona/cassie clients attach it as Authorization: Bearer on requests that need a signed-in user. The addon mirrors this by calling Cognito's InitiateAuth (USER_PASSWORD_AUTH flow) directly with the same client_id the website uses, saving the access token to my5_session.

_load_session() / _save_session(session) / _clear_session() · lines 127, 136, 146

JSON-backed load / save / wipe of the session dict at SESSION_FILE. _save_session lazily os.makedirs(SESSION_DIR) so a fresh addon profile dir works without a preflight. All three swallow IOError and return {} / log + continue — never raise.

_cognito_call(amz_target, body_dict) → dict · line 154

POST a JSON body to the Cognito IDP endpoint with the X-Amz-Target: . header. Returns the parsed JSON response. On HTTP error, reads the AWS error body and raises Exception("My5: Cognito failed: ") so callers can pattern-match on substring ("Incorrect", "NotAuthorized") to produce user-friendly Kodi notifications.

login(email=None, password=None) → bool · line 176

Wired to ?action=my5_login (settings UI button — see default.py's dispatch table). Reads credentials from the addon's my5_email/my5_password settings if not passed as args. Calls Cognito InitiateAuth(USER_PASSWORD_AUTH), lower-cases the email (Cognito is case-sensitive on the sub). On success persists {access_token, id_token, refresh_token, expires_at, email} to my5_session and pops a success notification.

Failure modes are individually surfaced: missing creds (warning), Incorrect/NotAuthorized (invalid-credentials error), MFA / new-device / SRP / password-reset challenge (not-supported error pointing the user at channel5.com to complete the challenge, after which direct USER_PASSWORD_AUTH will work), or no AccessToken in the response (unexpected-response error). Never raises.

logout() · line 241

Wired to ?action=my5_logout. Best-effort GlobalSignOut against Cognito using the saved access_token (network error / expired token / already-revoked are fine — logged at LOGDEBUG and ignored), then unconditionally removes my5_session locally so the user appears signed out.

_refresh_session() → str · line 261

Uses the saved refresh_token to call InitiateAuth(REFRESH_TOKEN_AUTH) and get a new access token. Updates expires_at on the session dict and persists it. Cognito's refresh response sometimes omits a new RefreshToken; the old one is kept (reusable until revoked by GlobalSignOut). On any failure the local session is cleared so _get_access_token next call returns "" (caller will see anonymous).

_get_access_token() → str · line 296

Top-level accessor. Returns the cached access token if it has > 60 s of life left, otherwise calls _refresh_session() to renew. Returns "" when no session exists (user not signed in) or refresh failed — i.e. always returns a string, never raises.

Why we attach Bearer then fall back anonymous

Cassie's free-to-air content doesn't require auth, but some premium content returns 401/403 to an anonymous request. The strategy in _fetch_cassie below is: if a Cognito session exists, attempt the request with Authorization: Bearer ; if that gets 401 or a non-HMAC 403, retry without the header. Free-to-air content keeps playing even when the saved session has expired and refresh has failed.

The key heist — _extract_cassie_keys

_extract_cassie_keys() → (aes_b64, hmac_b64) · line 65

This is the wild part. Steps:

  1. Fetch the live C5 player JS from PLAYER_JS_URL.
  2. Find the 6-character XOR key — looks like })('XXXXXX') in the source.
  3. Find the obfuscated config blob — a long string literal (≥3000 characters) returned by a closure. Try double-quotes first, fall back to single-quotes.
  4. URL-decode the blob (it's %xx-escaped).
  5. XOR-decode character-by-character against the 6-char key cyclically — this unwraps the obfuscation.
  6. Scan the decoded text for 24-character base64 strings (22 alphanumeric + 1 or 2 = padding characters) that decode to exactly 16 bytes.
  7. Take keys[1] as AES, keys[0] as HMAC (verified empirically — comment at line 108).
Key extraction regex MUST require ≥1 = pad

The regex at line 97 — r'(?requires at least one = pad character. Why:

  • Standard base64 of a 16-byte buffer is 22 chars + == (24 total).
  • The C5 player JS at various points strips one trailing =, yielding 22 chars + 1 =.
  • Loosening the regex to ={0,2} matches ~130 unrelated JS method names (e.g. "getTextTrackContainer+") which happen to match the loose b64-alphabet pattern and decode to 16 bytes by coincidence — they would corrupt the cache and 403 every request.

Plus the negative-lookbehind/lookahead (? / (?![A-Za-z0-9+/]) ensures we don't match a substring of a longer base64 blob.

Re-pad before base64.b64decode()

Line 101: c_padded = c + "=" * ((4 - len(c) % 4) % 4). base64.b64decode silently accepts misaligned 22-char strings and returns 16 bytes if they happen to align — but the alignment is unpredictable. Always re-pad to a multiple of 4 before decoding.

_get_cassie_keys(force_refresh=False) → (aes_b64, hmac_b64) · line 112

Cached accessor. Extracts fresh if force_refresh=True or the cache is empty. Always returns in the order (AES, HMAC).

URL signing + decryption

_build_cassie_url(content_id, is_live=False, timestamp=None, hmac_key_b64=None) → str · line 314

Constructs the signed Cassie URL:

  1. Path = CASSIE_BASE_{VOD|LIVE}_URL//.json.
  2. Append ?timestamp=.
  3. HMAC-SHA256 the URL-with-timestamp string using the HMAC key as bytes.
  4. URL-safe base64-encode the signature, append as &auth=.

The signature is bound to the timestamp, so requests expire after ~1 hour (C5's TTL).

_fetch_cassie(content_id, is_live=False) → dict · line 327

Performs the signed fetch in two stages:

  1. Call _get_access_token() from the Cognito auth section. If a non-empty token comes back, attempt the request with Authorization: Bearer . This unlocks premium / signed-in-only content.
  2. If the Bearer attempt gets a 401 or a non-HMAC 403 (token rejected / expired), retry the request without the header so free-to-air content keeps playing even when the saved session has gone stale.

The recursive helper _attempt(extra_headers) handles HMAC-key rotation transparently: on a 403 with body "Failed to authenticate HMAC" it re-extracts keys (_get_cassie_keys(force_refresh=True)), rebuilds URL, retries once. Any other HTTP error or a non-HMAC 403 re-raises. The successful response is JSON with iv + data fields, both URL-safe base64 — passed to _decrypt_cassie().

_decrypt_cassie(encrypted_response, aes_b64=None) → dict · line 377

AES-CBC decrypt with PKCS7 unpadding. Returns the parsed JSON dict that contains the stream manifest URL + license server URL.

_extract_stream(data) → (stream_url, keyserver) · line 388

Iterates data["assets"], picks the first one where asset["drm"] == "widevine", takes its keyserver (license URL) and the first rendition's url (manifest URL). Throws "My5: no widevine stream found" if no Widevine asset is available — purely-free content isn't supported by this resolver path.

Resolvers

resolve(item_id, listitem) · line 403 — for live

Called by default.py:_resolve_channel for the Channel 5 / 5 Star / 5 USA / 5 Action / 5 Select channels. Fetches Cassie in live mode, extracts the Widevine stream + keyserver, hands them to setup_dash_item() with the standard My5 license headers.

Always swallows exceptions and logs a warning — never raises. The calling _resolve_channel does the same, so a key-extraction failure on a live channel doesn't crash the plugin.

_resolve_episode_guid(episode_f_name, show_f_name=None) → str · line 421

Helper used by resolve_vod when the caller knows the episode's friendly name (a slug) but Cassie needs the GUID (e.g. C5XXXXXX). Walks my5_vod.list_seasons + list_episodes looking for a matching f_name, falls back to corona's episodes/next.json endpoint.

resolve_vod(episode_id, listitem, standalone=False, f_name=None, show_f_name=None) · line 444

VOD playback. Three resolution paths:

  • If standalone=True: call my5_vod.get_standalone_episode_id to translate the show f_name into a single-episode GUID.
  • If episode_id doesn't start with "C": it's a slug, run _resolve_episode_guid to get the GUID.
  • Otherwise: episode_id is already the GUID, use directly.

Then fetch Cassie, extract stream + license, hand to setup_dash_item().

Notable details

  • stream_url = stream_url.replace("subtitles=off", "subtitles=on") at lines 407 & 469 — C5's manifests ship with subtitles disabled by default for direct stream URLs; flipping the query param re-enables them before Kodi opens the manifest.
  • License headers shape: "User-Agent=&Referer=https://www.channel5.com/&Content-Type=application/octet-stream" — Cassie's widevine endpoint sniffs both UA + Referer and rejects Roku/ Android UAs, hence the desktop-Chrome UA from get_ua().
  • license_payload=None is passed explicitly so setup_dash_item() uses the {SSM} placeholder path (not the pre-built-payload path My5 used to need).

EasyPlayTV docs — Documentation for the EasyPlayTV Kodi video add-on

This site contains affiliate links. We may earn a commission if you purchase through these links.

© 2025 Scottrix | GitHub | Home | Contact