diff --git a/UPDATES.md b/UPDATES.md index 78b9791..388bdaa 100644 --- a/UPDATES.md +++ b/UPDATES.md @@ -1,5 +1,10 @@ **Updates** +**19 Jan 2026** + +- upstream envied to 2.3.0 +- correction RTE vinefeeder service + **31 Dec 2025** **Altered:** diff --git a/packages/envied/pyproject.toml b/packages/envied/pyproject.toml index 403ce23..1598cfa 100644 --- a/packages/envied/pyproject.toml +++ b/packages/envied/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "uv_build" [project] name = "envied" -version = "2.1.0" +version = "2.3.0" description = "Modular Movie, TV, and Music Archival Software." authors = [{ name = "rlaphoenix and others" }] requires-python = ">=3.10,<3.13" diff --git a/packages/envied/src/envied/CHANGELOG.md b/packages/envied/src/envied/CHANGELOG.md index b612805..954da95 100644 --- a/packages/envied/src/envied/CHANGELOG.md +++ b/packages/envied/src/envied/CHANGELOG.md @@ -5,6 +5,39 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.3.0] - 2026-01-18 + +### Added + +- **Unicode Filenames Option**: New `unicode_filenames` config option to preserve native characters + - Allows disabling ASCII transliteration in filenames + - Preserves Korean, Japanese, Chinese, and other native language characters + - Closes #49 + +### Fixed + +- **WebVTT Cue Handling**: Handle WebVTT cue identifiers and overlapping multi-line cues + - Added detection and sanitization for cue identifiers (Q0, Q1, etc.) before timing lines + - Added merging of overlapping cues with different line positions into multi-line subtitles + - Fixes parsing issues with pysubs2/pycaption on certain WebVTT files +- **Widevine PSSH Filtering**: Filter Widevine PSSH by system ID instead of sorting + - Fixes KeyError crash when unsupported DRM systems are present in init segments +- **TTML Negative Values**: Handle negative values in multi-value TTML attributes + - Fixes pycaption parse errors for attributes like `tts:extent="-5% 7.5%"` + - Closes #47 +- **ASS Font Names**: Strip whitespace from ASS font names + - Handles ASS subtitle files with spaces after commas in Style definitions + - Fixes #57 +- **Shaka-Packager Error Messages**: Include shaka-packager binary path in error messages +- **N_m3u8DL-RE Merge and Decryption**: Handle merge and decryption properly + - Prevents audio corruption ("Box 'OG 2' size is too large") with DASH manifests + - Fixes duplicate init segment writing when using N_m3u8DL-RE +- **DASH Placeholder KIDs**: Handle placeholder KIDs and improve DRM init from segments + - Detects and replaces placeholder/test KIDs in Widevine PSSH + - Adds CENC namespace support for kid/default_KID attributes +- **PlayReady PSSH Comparison**: Correct PSSH system ID comparison in PlayReady + - Removes erroneous `.bytes` accessor from PSSH.SYSTEM_ID comparisons + ## [2.2.0] - 2026-01-15 ### Added diff --git a/packages/envied/src/envied/cache/ITV/titles_3af2e526e527e877_gb.json b/packages/envied/src/envied/cache/ITV/titles_3af2e526e527e877_gb.json deleted file mode 100644 index decebb8..0000000 --- a/packages/envied/src/envied/cache/ITV/titles_3af2e526e527e877_gb.json +++ /dev/null @@ -1 +0,0 @@ -{"data": {"py/object": "envied.core.titles.episode.Series", "_len": 16, "_load": 1000, "_lists": [[{"py/object": "envied.core.titles.episode.Episode", "id": "10/5106/0001", "service": {"py/type": "ITV.ITV"}, "language": {"py/object": "langcodes.Language", "language": "en", "extlangs": null, "script": null, "territory": null, "variants": null, "extensions": null, "private": null, "_simplified": null, "_searchable": null, "_broader": null, "_assumed": null, "_filled": null, "_macrolanguage": null, "_str_tag": "en", "_dict": null, "_disp_separator": null, "_disp_pattern": null}, "data": {"ccid": "z60cfmh", "accessibilityTags": ["ad", "s", "bsl"], "audioDescribed": true, "availabilityFeatures": ["MPEG_DASH", "OUTBAND_WEBVTT", "PLAYREADY", "WIDEVINE", "INBAND_AUDIO_DESCRIPTION"], "availabilityFrom": "2024-05-13T21:12:02Z", "availabilityUntil": "2029-03-10T23:59:00Z", "genres": [{"id": "ENTERTAINMENT", "name": "Entertainment & Reality"}], "channel": "ITVBE", "fullSeriesRange": [], "heroCtaLabel": {"label": "Watch Now", "ariaLabel": "Watch episode 1 of series 1, Check In is Now Open", "episodeLabel": "S1: E1 - Check In is Now Open"}, "contentInfo": "S1:E1", "contentOwner": null, "dateTime": "2025-04-21T22:00:00.000Z", "description": "Stephen Mangan invites ten pairs for a devious game in a luxury resort.", "duration": "55m", "episode": 1, "episodeId": "10/5106/0001", "encodedEpisodeId": {"letterA": "10a5106a0001", "underscore": "10_5106_0001"}, "episodeTitle": "Check In is Now Open", "guidance": "With some strong language.", "image": "https://ovp.itv.com/v2/images/episode/z60cfmh/itv_hub/08_Episodic/16x9?distributionPartner=itv_hub&fallback=standard&w={width}&h={height}&q={quality}&blur={blur}&bg={bg}", "isFullSeries": true, "linearContent": false, "longDescription": "Stephen Mangan hosts the first episode of a thrilling game show set in a luxury resort as ten pairs of contestants play a high-stakes game of cat and mouse to win \u00a3250,000.", "longRunning": false, "nextProductionId": "10/5106/0002", "notFormattedDuration": "PT55M", "partnership": null, "playlistUrl": "https://magni.itv.com/playlist/itvonline/ITV/10_5106_0001.001", "premium": false, "productionId": "10/5106/0001#001", "productionType": "EPISODE", "contentType": "episode", "productionYear": null, "programmeId": "10/5106", "series": 1, "subtitled": true, "tier": ["FREE"], "visuallySigned": true, "bslPlaylistUrl": "https://magni.itv.com/playlist/itvonline/ITV/10_5106_0001.002", "regionalisation": false, "broadcastDateTime": "2025-04-21T22:00:00Z"}, "tracks": {"py/object": "envied.core.tracks.tracks.Tracks", "videos": [], "audio": [], "subtitles": [], "chapters": {"py/object": "envied.core.tracks.chapters.Chapters", "_len": 0, "_load": 1000, "_lists": [], "_keys": [], "_maxes": [], "_index": [], "_offset": 0}, "attachments": []}, "title": "The Fortune Hotel", "season": 1, "number": 1, "name": "Check In is Now Open", "year": null, "description": null}, {"py/object": "envied.core.titles.episode.Episode", "id": "10/5106/0002", "service": {"py/type": "ITV.ITV"}, "language": {"py/id": 5}, "data": {"ccid": "d1ptzbk", "accessibilityTags": ["ad", "s", "bsl"], "audioDescribed": true, "availabilityFeatures": ["MPEG_DASH", "OUTBAND_WEBVTT", "PLAYREADY", "WIDEVINE", "INBAND_AUDIO_DESCRIPTION"], "availabilityFrom": "2024-05-14T13:30:00Z", "availabilityUntil": "2029-03-10T23:59:00Z", "genres": [{"id": "ENTERTAINMENT", "name": "Entertainment & Reality"}], "channel": "ITVBE", "fullSeriesRange": [], "heroCtaLabel": {"label": "Watch Now", "ariaLabel": "Watch episode 2 of series 1, X Marks the Spot", "episodeLabel": "S1: E2 - X Marks the Spot"}, "contentInfo": "S1:E2", "contentOwner": null, "dateTime": "2025-04-22T21:55:00.000Z", "description": "Stephen Mangan hosts as the pairs face a room service challenge.", "duration": "47m", "episode": 2, "episodeId": "10/5106/0002", "encodedEpisodeId": {"letterA": "10a5106a0002", "underscore": "10_5106_0002"}, "episodeTitle": "X Marks the Spot", "guidance": "With some strong language.", "image": "https://ovp.itv.com/v2/images/episode/d1ptzbk/itv_hub/08_Episodic/16x9?distributionPartner=itv_hub&fallback=standard&w={width}&h={height}&q={quality}&blur={blur}&bg={bg}", "isFullSeries": true, "linearContent": false, "longDescription": "Stephen Mangan hosts a high-stakes bluffing game in a luxury resort. Guests sample dubious cocktails in the first Room Service game and dig for treasure in the Day Trip Challenge.", "longRunning": false, "nextProductionId": "10/5106/0003", "notFormattedDuration": "PT47M", "partnership": null, "playlistUrl": "https://magni.itv.com/playlist/itvonline/ITV/10_5106_0002.001", "premium": false, "productionId": "10/5106/0002#001", "productionType": "EPISODE", "contentType": "episode", "productionYear": null, "programmeId": "10/5106", "series": 1, "subtitled": true, "tier": ["FREE"], "visuallySigned": true, "bslPlaylistUrl": "https://magni.itv.com/playlist/itvonline/ITV/10_5106_0002.002", "regionalisation": false, "broadcastDateTime": "2025-04-22T21:55:00Z"}, "tracks": {"py/object": "envied.core.tracks.tracks.Tracks", "videos": [], "audio": [], "subtitles": [], "chapters": {"py/object": "envied.core.tracks.chapters.Chapters", "_len": 0, "_load": 1000, "_lists": [], "_keys": [], "_maxes": [], "_index": [], "_offset": 0}, "attachments": []}, "title": "The Fortune Hotel", "season": 1, "number": 2, "name": "X Marks the Spot", "year": null, "description": null}, {"py/object": "envied.core.titles.episode.Episode", "id": "10/5106/0003", "service": {"py/type": "ITV.ITV"}, "language": {"py/id": 5}, "data": {"ccid": "vrv0782", "accessibilityTags": ["ad", "s", "bsl"], "audioDescribed": true, "availabilityFeatures": ["MPEG_DASH", "OUTBAND_WEBVTT", "PLAYREADY", "WIDEVINE", "INBAND_AUDIO_DESCRIPTION"], "availabilityFrom": "2024-05-15T06:00:00Z", "availabilityUntil": "2029-03-10T23:59:00Z", "genres": [{"id": "ENTERTAINMENT", "name": "Entertainment & Reality"}], "channel": "ITVBE", "fullSeriesRange": [], "heroCtaLabel": {"label": "Watch Now", "ariaLabel": "Watch episode 3 of series 1, A Twist in the River Run", "episodeLabel": "S1: E3 - A Twist in the River Run"}, "contentInfo": "S1:E3", "contentOwner": null, "dateTime": "2025-04-23T22:00:00.000Z", "description": "Stephen Mangan hosts as a twist changes everything.", "duration": "46m", "episode": 3, "episodeId": "10/5106/0003", "encodedEpisodeId": {"letterA": "10a5106a0003", "underscore": "10_5106_0003"}, "episodeTitle": "A Twist in the River Run", "guidance": "With some strong language.", "image": "https://ovp.itv.com/v2/images/episode/vrv0782/itv_hub/08_Episodic/16x9?distributionPartner=itv_hub&fallback=standard&w={width}&h={height}&q={quality}&blur={blur}&bg={bg}", "isFullSeries": true, "linearContent": false, "longDescription": "Game show hosted by Stephen Mangan. The guests get personal in a devious Room Service game and rapid on a river for a thrilling Day Trip challenge.", "longRunning": false, "nextProductionId": "10/5106/0004", "notFormattedDuration": "PT46M", "partnership": null, "playlistUrl": "https://magni.itv.com/playlist/itvonline/ITV/10_5106_0003.002", "premium": false, "productionId": "10/5106/0003#002", "productionType": "EPISODE", "contentType": "episode", "productionYear": null, "programmeId": "10/5106", "series": 1, "subtitled": true, "tier": ["FREE"], "visuallySigned": true, "bslPlaylistUrl": "https://magni.itv.com/playlist/itvonline/ITV/10_5106_0003.003", "regionalisation": false, "broadcastDateTime": "2025-04-23T22:00:00Z"}, "tracks": {"py/object": "envied.core.tracks.tracks.Tracks", "videos": [], "audio": [], "subtitles": [], "chapters": {"py/object": "envied.core.tracks.chapters.Chapters", "_len": 0, "_load": 1000, "_lists": [], "_keys": [], "_maxes": [], "_index": [], "_offset": 0}, "attachments": []}, "title": "The Fortune Hotel", "season": 1, "number": 3, "name": "A Twist in the River Run", "year": null, "description": null}, {"py/object": "envied.core.titles.episode.Episode", "id": "10/5106/0004", "service": {"py/type": "ITV.ITV"}, "language": {"py/id": 5}, "data": {"ccid": "fb2mpy9", "accessibilityTags": ["ad", "s", "bsl"], "audioDescribed": true, "availabilityFeatures": ["MPEG_DASH", "OUTBAND_WEBVTT", "PLAYREADY", "WIDEVINE", "INBAND_AUDIO_DESCRIPTION"], "availabilityFrom": "2024-05-16T06:00:00Z", "availabilityUntil": "2029-03-10T23:59:00Z", "genres": [{"id": "ENTERTAINMENT", "name": "Entertainment & Reality"}], "channel": "ITVBE", "fullSeriesRange": [], "heroCtaLabel": {"label": "Watch Now", "ariaLabel": "Watch episode 4 of series 1, Speeding to Victory", "episodeLabel": "S1: E4 - Speeding to Victory"}, "contentInfo": "S1:E4", "contentOwner": null, "dateTime": "2025-04-24T22:15:00.000Z", "description": "Stephen Mangan hosts as the pairs fight it out in a speedboat challenge.", "duration": "47m", "episode": 4, "episodeId": "10/5106/0004", "encodedEpisodeId": {"letterA": "10a5106a0004", "underscore": "10_5106_0004"}, "episodeTitle": "Speeding to Victory", "guidance": "With some strong language.", "image": "https://ovp.itv.com/v2/images/episode/fb2mpy9/itv_hub/08_Episodic/16x9?distributionPartner=itv_hub&fallback=standard&w={width}&h={height}&q={quality}&blur={blur}&bg={bg}", "isFullSeries": true, "linearContent": false, "longDescription": "Stephen Mangan hosts the game show as alliances are formed, suspicions grow, the pairs are all at sea in an epic Day Trip Challenge and one team is targeted.", "longRunning": false, "nextProductionId": "10/5106/0005", "notFormattedDuration": "PT47M", "partnership": null, "playlistUrl": "https://magni.itv.com/playlist/itvonline/ITV/10_5106_0004.004", "premium": false, "productionId": "10/5106/0004#004", "productionType": "EPISODE", "contentType": "episode", "productionYear": null, "programmeId": "10/5106", "series": 1, "subtitled": true, "tier": ["FREE"], "visuallySigned": true, "bslPlaylistUrl": "https://magni.itv.com/playlist/itvonline/ITV/10_5106_0004.003", "regionalisation": false, "broadcastDateTime": "2025-04-24T22:15:00Z"}, "tracks": {"py/object": "envied.core.tracks.tracks.Tracks", "videos": [], "audio": [], "subtitles": [], "chapters": {"py/object": "envied.core.tracks.chapters.Chapters", "_len": 0, "_load": 1000, "_lists": [], "_keys": [], "_maxes": [], "_index": [], "_offset": 0}, "attachments": []}, "title": "The Fortune Hotel", "season": 1, "number": 4, "name": "Speeding to Victory", "year": null, "description": null}, {"py/object": "envied.core.titles.episode.Episode", "id": "10/5106/0005", "service": {"py/type": "ITV.ITV"}, "language": {"py/id": 5}, "data": {"ccid": "69436dd", "accessibilityTags": ["ad", "s", "bsl"], "audioDescribed": true, "availabilityFeatures": ["MPEG_DASH", "OUTBAND_WEBVTT", "PLAYREADY", "WIDEVINE", "INBAND_AUDIO_DESCRIPTION"], "availabilityFrom": "2024-05-20T06:00:00Z", "availabilityUntil": "2029-03-10T23:59:00Z", "genres": [{"id": "ENTERTAINMENT", "name": "Entertainment & Reality"}], "channel": "ITVBE", "fullSeriesRange": [], "heroCtaLabel": {"label": "Watch Now", "ariaLabel": "Watch episode 5 of series 1, Over and Out", "episodeLabel": "S1: E5 - Over and Out"}, "contentInfo": "S1:E5", "contentOwner": null, "dateTime": "2025-04-28T22:00:00.000Z", "description": "Stephen Mangan hosts as alliances grow and plots are hatched...", "duration": "46m", "episode": 5, "episodeId": "10/5106/0005", "encodedEpisodeId": {"letterA": "10a5106a0005", "underscore": "10_5106_0005"}, "episodeTitle": "Over and Out", "guidance": "With some strong language.", "image": "https://ovp.itv.com/v2/images/episode/69436dd/itv_hub/08_Episodic/16x9?distributionPartner=itv_hub&fallback=standard&w={width}&h={height}&q={quality}&blur={blur}&bg={bg}", "isFullSeries": true, "linearContent": false, "longDescription": "Stephen Mangan hosts as communication skills are tested, the pairs get closer to the fortune, plots are hatched and a late swap at the Lady Luck Bar foils everything.", "longRunning": false, "nextProductionId": "10/5106/0006", "notFormattedDuration": "PT46M", "partnership": null, "playlistUrl": "https://magni.itv.com/playlist/itvonline/ITV/10_5106_0005.001", "premium": false, "productionId": "10/5106/0005#001", "productionType": "EPISODE", "contentType": "episode", "productionYear": null, "programmeId": "10/5106", "series": 1, "subtitled": true, "tier": ["FREE"], "visuallySigned": true, "bslPlaylistUrl": "https://magni.itv.com/playlist/itvonline/ITV/10_5106_0005.002", "regionalisation": false, "broadcastDateTime": "2025-04-28T22:00:00Z"}, "tracks": {"py/object": "envied.core.tracks.tracks.Tracks", "videos": [], "audio": [], "subtitles": [], "chapters": {"py/object": "envied.core.tracks.chapters.Chapters", "_len": 0, "_load": 1000, "_lists": [], "_keys": [], "_maxes": [], "_index": [], "_offset": 0}, "attachments": []}, "title": "The Fortune Hotel", "season": 1, "number": 5, "name": "Over and Out", "year": null, "description": null}, {"py/object": "envied.core.titles.episode.Episode", "id": "10/5106/0006", "service": {"py/type": "ITV.ITV"}, "language": {"py/id": 5}, "data": {"ccid": "lyr07yz", "accessibilityTags": ["ad", "s", "bsl"], "audioDescribed": true, "availabilityFeatures": ["MPEG_DASH", "OUTBAND_WEBVTT", "PLAYREADY", "WIDEVINE", "INBAND_AUDIO_DESCRIPTION"], "availabilityFrom": "2024-05-21T06:00:00Z", "availabilityUntil": "2029-03-10T23:59:00Z", "genres": [{"id": "ENTERTAINMENT", "name": "Entertainment & Reality"}], "channel": "ITVBE", "fullSeriesRange": [], "heroCtaLabel": {"label": "Watch Now", "ariaLabel": "Watch episode 6 of series 1, Coconuts at Dawn", "episodeLabel": "S1: E6 - Coconuts at Dawn"}, "contentInfo": "S1:E6", "contentOwner": null, "dateTime": "2025-04-29T21:55:00.000Z", "description": "Stephen Mangan hosts as the stakes are raised when the guests take aim at their rivals.", "duration": "46m", "episode": 6, "episodeId": "10/5106/0006", "encodedEpisodeId": {"letterA": "10a5106a0006", "underscore": "10_5106_0006"}, "episodeTitle": "Coconuts at Dawn", "guidance": "With some strong language.", "image": "https://ovp.itv.com/v2/images/episode/lyr07yz/itv_hub/08_Episodic/16x9?distributionPartner=itv_hub&fallback=standard&w={width}&h={height}&q={quality}&blur={blur}&bg={bg}", "isFullSeries": true, "linearContent": false, "longDescription": "Stephen Mangan hosts as new alliances hatch, the pairs take aim at rivals, and one pair's hopes are dashed as a proposition at the Night Cap backfires dramatically.", "longRunning": false, "nextProductionId": "10/5106/0007", "notFormattedDuration": "PT46M", "partnership": null, "playlistUrl": "https://magni.itv.com/playlist/itvonline/ITV/10_5106_0006.001", "premium": false, "productionId": "10/5106/0006#001", "productionType": "EPISODE", "contentType": "episode", "productionYear": null, "programmeId": "10/5106", "series": 1, "subtitled": true, "tier": ["FREE"], "visuallySigned": true, "bslPlaylistUrl": "https://magni.itv.com/playlist/itvonline/ITV/10_5106_0006.002", "regionalisation": false, "broadcastDateTime": "2025-04-29T21:55:00Z"}, "tracks": {"py/object": "envied.core.tracks.tracks.Tracks", "videos": [], "audio": [], "subtitles": [], "chapters": {"py/object": "envied.core.tracks.chapters.Chapters", "_len": 0, "_load": 1000, "_lists": [], "_keys": [], "_maxes": [], "_index": [], "_offset": 0}, "attachments": []}, "title": "The Fortune Hotel", "season": 1, "number": 6, "name": "Coconuts at Dawn", "year": null, "description": null}, {"py/object": "envied.core.titles.episode.Episode", "id": "10/5106/0007", "service": {"py/type": "ITV.ITV"}, "language": {"py/id": 5}, "data": {"ccid": "yp00y76", "accessibilityTags": ["ad", "s", "bsl"], "audioDescribed": true, "availabilityFeatures": ["MPEG_DASH", "OUTBAND_WEBVTT", "PLAYREADY", "WIDEVINE", "INBAND_AUDIO_DESCRIPTION"], "availabilityFrom": "2024-05-22T06:00:00Z", "availabilityUntil": "2029-03-10T23:59:00Z", "genres": [{"id": "ENTERTAINMENT", "name": "Entertainment & Reality"}], "channel": "ITVBE", "fullSeriesRange": [], "heroCtaLabel": {"label": "Watch Now", "ariaLabel": "Watch episode 7 of series 1, Don't Rock the Boat", "episodeLabel": "S1: E7 - Don't Rock the Boat"}, "contentInfo": "S1:E7", "contentOwner": null, "dateTime": "2025-04-30T22:00:00.000Z", "description": "Stephen Mangan hosts as the games get personal and the remaining pairs fight to survive.", "duration": "46m", "episode": 7, "episodeId": "10/5106/0007", "encodedEpisodeId": {"letterA": "10a5106a0007", "underscore": "10_5106_0007"}, "episodeTitle": "Don't Rock the Boat", "guidance": null, "image": "https://ovp.itv.com/v2/images/episode/yp00y76/itv_hub/08_Episodic/16x9?distributionPartner=itv_hub&fallback=standard&w={width}&h={height}&q={quality}&blur={blur}&bg={bg}", "isFullSeries": true, "linearContent": false, "longDescription": "Stephen Mangan hosts as the final is within touching distance, but one pair's hopes are sunk and a late swap at the Lady Luck Bar leaves another pair distraught.", "longRunning": false, "nextProductionId": "10/5106/0008", "notFormattedDuration": "PT46M", "partnership": null, "playlistUrl": "https://magni.itv.com/playlist/itvonline/ITV/10_5106_0007.001", "premium": false, "productionId": "10/5106/0007#001", "productionType": "EPISODE", "contentType": "episode", "productionYear": null, "programmeId": "10/5106", "series": 1, "subtitled": true, "tier": ["FREE"], "visuallySigned": true, "bslPlaylistUrl": "https://magni.itv.com/playlist/itvonline/ITV/10_5106_0007.002", "regionalisation": false, "broadcastDateTime": "2025-04-30T22:00:00Z"}, "tracks": {"py/object": "envied.core.tracks.tracks.Tracks", "videos": [], "audio": [], "subtitles": [], "chapters": {"py/object": "envied.core.tracks.chapters.Chapters", "_len": 0, "_load": 1000, "_lists": [], "_keys": [], "_maxes": [], "_index": [], "_offset": 0}, "attachments": []}, "title": "The Fortune Hotel", "season": 1, "number": 7, "name": "Don't Rock the Boat", "year": null, "description": null}, {"py/object": "envied.core.titles.episode.Episode", "id": "10/5106/0008", "service": {"py/type": "ITV.ITV"}, "language": {"py/id": 5}, "data": {"ccid": "8xlqdp5", "accessibilityTags": ["ad", "s", "bsl"], "audioDescribed": true, "availabilityFeatures": ["MPEG_DASH", "OUTBAND_WEBVTT", "PLAYREADY", "WIDEVINE", "INBAND_AUDIO_DESCRIPTION"], "availabilityFrom": "2024-05-23T06:00:00Z", "availabilityUntil": "2029-03-10T23:59:00Z", "genres": [{"id": "ENTERTAINMENT", "name": "Entertainment & Reality"}], "channel": "ITVBE", "fullSeriesRange": [], "heroCtaLabel": {"label": "Watch Now", "ariaLabel": "Watch episode 8 of series 1, A Race to the Fortune", "episodeLabel": "S1: E8 - A Race to the Fortune"}, "contentInfo": "S1:E8", "contentOwner": null, "dateTime": "2025-05-01T22:00:00.000Z", "description": "Stephen Mangan hosts as the finalists race to the hotel to win a life-changing fortune.", "duration": "55m", "episode": 8, "episodeId": "10/5106/0008", "encodedEpisodeId": {"letterA": "10a5106a0008", "underscore": "10_5106_0008"}, "episodeTitle": "A Race to the Fortune", "guidance": "With some strong language.", "image": "https://ovp.itv.com/v2/images/episode/8xlqdp5/itv_hub/08_Episodic/16x9?distributionPartner=itv_hub&fallback=standard&w={width}&h={height}&q={quality}&blur={blur}&bg={bg}", "isFullSeries": true, "linearContent": false, "longDescription": "Stephen Mangan hosts the game show as a life-changing amount of cash must be won and the finalists face a big decision that could change their lives forever.", "longRunning": false, "nextProductionId": "10/5106/0009", "notFormattedDuration": "PT55M", "partnership": null, "playlistUrl": "https://magni.itv.com/playlist/itvonline/ITV/10_5106_0008.001", "premium": false, "productionId": "10/5106/0008#001", "productionType": "EPISODE", "contentType": "episode", "productionYear": null, "programmeId": "10/5106", "series": 1, "subtitled": true, "tier": ["FREE"], "visuallySigned": true, "bslPlaylistUrl": "https://magni.itv.com/playlist/itvonline/ITV/10_5106_0008.002", "regionalisation": false, "broadcastDateTime": "2025-05-01T22:00:00Z"}, "tracks": {"py/object": "envied.core.tracks.tracks.Tracks", "videos": [], "audio": [], "subtitles": [], "chapters": {"py/object": "envied.core.tracks.chapters.Chapters", "_len": 0, "_load": 1000, "_lists": [], "_keys": [], "_maxes": [], "_index": [], "_offset": 0}, "attachments": []}, "title": "The Fortune Hotel", "season": 1, "number": 8, "name": "A Race to the Fortune", "year": null, "description": null}, {"py/object": "envied.core.titles.episode.Episode", "id": "10/5106/0009", "service": {"py/type": "ITV.ITV"}, "language": {"py/id": 5}, "data": {"ccid": "4ccgv48", "accessibilityTags": ["ad", "s"], "audioDescribed": true, "availabilityFeatures": ["MPEG_DASH", "OUTBAND_WEBVTT", "PLAYREADY", "WIDEVINE", "INBAND_AUDIO_DESCRIPTION"], "availabilityFrom": "2025-08-06T20:59:48Z", "availabilityUntil": "2030-08-27T22:59:00Z", "genres": [{"id": "ENTERTAINMENT", "name": "Entertainment & Reality"}], "channel": "ITV", "fullSeriesRange": [], "heroCtaLabel": {"label": "Watch Now", "ariaLabel": "Watch episode 1 of series 2, The Race to Check In", "episodeLabel": "S2: E1 - The Race to Check In"}, "contentInfo": "S2:E1", "contentOwner": null, "dateTime": "2025-08-06T20:00:00.000Z", "description": "A new set of guests dive into an epic first challenge.", "duration": "48m", "episode": 1, "episodeId": "10/5106/0009", "encodedEpisodeId": {"letterA": "10a5106a0009", "underscore": "10_5106_0009"}, "episodeTitle": "The Race to Check In", "guidance": "With some strong language.", "image": "https://ovp.itv.com/v2/images/episode/4ccgv48/itv_hub/08_Episodic/16x9?distributionPartner=itv_hub&fallback=standard&w={width}&h={height}&q={quality}&blur={blur}&bg={bg}", "isFullSeries": true, "linearContent": true, "longDescription": "A new group of guests dive into an epic first challenge where a land and sea race determine who is at risk of heading home.", "longRunning": false, "nextProductionId": "10/5106/0010", "notFormattedDuration": "PT48M", "partnership": null, "playlistUrl": "https://magni.itv.com/playlist/itvonline/ITV/10_5106_0009.003", "premium": false, "productionId": "10/5106/0009#003", "productionType": "EPISODE", "contentType": "episode", "productionYear": null, "programmeId": "10/5106", "series": 2, "subtitled": true, "tier": ["FREE"], "visuallySigned": false, "regionalisation": true, "broadcastDateTime": "2025-08-06T20:00:00Z"}, "tracks": {"py/object": "envied.core.tracks.tracks.Tracks", "videos": [], "audio": [], "subtitles": [], "chapters": {"py/object": "envied.core.tracks.chapters.Chapters", "_len": 0, "_load": 1000, "_lists": [], "_keys": [], "_maxes": [], "_index": [], "_offset": 0}, "attachments": []}, "title": "The Fortune Hotel", "season": 2, "number": 1, "name": "The Race to Check In", "year": null, "description": null}, {"py/object": "envied.core.titles.episode.Episode", "id": "10/5106/0010", "service": {"py/type": "ITV.ITV"}, "language": {"py/id": 5}, "data": {"ccid": "f9f7yy8", "accessibilityTags": ["ad", "s"], "audioDescribed": true, "availabilityFeatures": ["MPEG_DASH", "OUTBAND_WEBVTT", "PLAYREADY", "WIDEVINE", "INBAND_AUDIO_DESCRIPTION"], "availabilityFrom": "2025-08-07T20:59:38Z", "availabilityUntil": "2030-08-27T22:59:00Z", "genres": [{"id": "ENTERTAINMENT", "name": "Entertainment & Reality"}], "channel": "ITV", "fullSeriesRange": [], "heroCtaLabel": {"label": "Watch Now", "ariaLabel": "Watch episode 2 of series 2, Picking Up the Pieces", "episodeLabel": "S2: E2 - Picking Up the Pieces"}, "contentInfo": "S2:E2", "contentOwner": null, "dateTime": "2025-08-07T20:00:00.000Z", "description": "Memory meets mayhem in a hilarious Hotel Game.", "duration": "46m", "episode": 2, "episodeId": "10/5106/0010", "encodedEpisodeId": {"letterA": "10a5106a0010", "underscore": "10_5106_0010"}, "episodeTitle": "Picking Up the Pieces", "guidance": "With some strong language.", "image": "https://ovp.itv.com/v2/images/episode/f9f7yy8/itv_hub/08_Episodic/16x9?distributionPartner=itv_hub&fallback=standard&w={width}&h={height}&q={quality}&blur={blur}&bg={bg}", "isFullSeries": true, "linearContent": true, "longDescription": "Memory meets mayhem in a hilarious Hotel Game before a high-stakes Day Trip Challenge has the pairs solving puzzles at sea.", "longRunning": false, "nextProductionId": "10/5106/0011", "notFormattedDuration": "PT46M", "partnership": null, "playlistUrl": "https://magni.itv.com/playlist/itvonline/ITV/10_5106_0010.001", "premium": false, "productionId": "10/5106/0010#001", "productionType": "EPISODE", "contentType": "episode", "productionYear": null, "programmeId": "10/5106", "series": 2, "subtitled": true, "tier": ["FREE"], "visuallySigned": false, "regionalisation": true, "broadcastDateTime": "2025-08-07T20:00:00Z"}, "tracks": {"py/object": "envied.core.tracks.tracks.Tracks", "videos": [], "audio": [], "subtitles": [], "chapters": {"py/object": "envied.core.tracks.chapters.Chapters", "_len": 0, "_load": 1000, "_lists": [], "_keys": [], "_maxes": [], "_index": [], "_offset": 0}, "attachments": []}, "title": "The Fortune Hotel", "season": 2, "number": 2, "name": "Picking Up the Pieces", "year": null, "description": null}, {"py/object": "envied.core.titles.episode.Episode", "id": "10/5106/0011", "service": {"py/type": "ITV.ITV"}, "language": {"py/id": 5}, "data": {"ccid": "05s19fr", "accessibilityTags": ["ad", "s"], "audioDescribed": true, "availabilityFeatures": ["MPEG_DASH", "OUTBAND_WEBVTT", "PLAYREADY", "WIDEVINE", "INBAND_AUDIO_DESCRIPTION"], "availabilityFrom": "2025-08-13T20:59:36Z", "availabilityUntil": "2030-08-27T22:59:00Z", "genres": [{"id": "ENTERTAINMENT", "name": "Entertainment & Reality"}], "channel": "ITV", "fullSeriesRange": [], "heroCtaLabel": {"label": "Watch Now", "ariaLabel": "Watch episode 3 of series 2, Searching for Stephen", "episodeLabel": "S2: E3 - Searching for Stephen"}, "contentInfo": "S2:E3", "contentOwner": null, "dateTime": "2025-08-13T20:00:00.000Z", "description": "A postcard trail sends the pairs across paradise.", "duration": "48m", "episode": 3, "episodeId": "10/5106/0011", "encodedEpisodeId": {"letterA": "10a5106a0011", "underscore": "10_5106_0011"}, "episodeTitle": "Searching for Stephen", "guidance": "", "image": "https://ovp.itv.com/v2/images/episode/05s19fr/itv_hub/08_Episodic/16x9?distributionPartner=itv_hub&fallback=standard&w={width}&h={height}&q={quality}&blur={blur}&bg={bg}", "isFullSeries": true, "linearContent": true, "longDescription": "A postcard trail sends the pairs across paradise to find Stephen, as one pair wins the power to decide what happens next.", "longRunning": false, "nextProductionId": "10/5106/0012", "notFormattedDuration": "PT48M", "partnership": null, "playlistUrl": "https://magni.itv.com/playlist/itvonline/ITV/10_5106_0011.001", "premium": false, "productionId": "10/5106/0011#001", "productionType": "EPISODE", "contentType": "episode", "productionYear": null, "programmeId": "10/5106", "series": 2, "subtitled": true, "tier": ["FREE"], "visuallySigned": false, "regionalisation": true, "broadcastDateTime": "2025-08-13T20:00:00Z"}, "tracks": {"py/object": "envied.core.tracks.tracks.Tracks", "videos": [], "audio": [], "subtitles": [], "chapters": {"py/object": "envied.core.tracks.chapters.Chapters", "_len": 0, "_load": 1000, "_lists": [], "_keys": [], "_maxes": [], "_index": [], "_offset": 0}, "attachments": []}, "title": "The Fortune Hotel", "season": 2, "number": 3, "name": "Searching for Stephen", "year": null, "description": null}, {"py/object": "envied.core.titles.episode.Episode", "id": "10/5106/0012", "service": {"py/type": "ITV.ITV"}, "language": {"py/id": 5}, "data": {"ccid": "0bm4jxc", "accessibilityTags": ["ad", "s"], "audioDescribed": true, "availabilityFeatures": ["MPEG_DASH", "OUTBAND_WEBVTT", "PLAYREADY", "WIDEVINE", "INBAND_AUDIO_DESCRIPTION"], "availabilityFrom": "2025-08-14T21:00:22Z", "availabilityUntil": "2030-08-27T22:59:00Z", "genres": [{"id": "ENTERTAINMENT", "name": "Entertainment & Reality"}], "channel": "ITV", "fullSeriesRange": [], "heroCtaLabel": {"label": "Watch Now", "ariaLabel": "Watch episode 4 of series 2, A Twist in the Tale", "episodeLabel": "S2: E4 - A Twist in the Tale"}, "contentInfo": "S2:E4", "contentOwner": null, "dateTime": "2025-08-14T20:00:00.000Z", "description": "A turbo-charged challenge sends the pairs racing.", "duration": "48m", "episode": 4, "episodeId": "10/5106/0012", "encodedEpisodeId": {"letterA": "10a5106a0012", "underscore": "10_5106_0012"}, "episodeTitle": "A Twist in the Tale", "guidance": "With some strong language.", "image": "https://ovp.itv.com/v2/images/episode/0bm4jxc/itv_hub/08_Episodic/16x9?distributionPartner=itv_hub&fallback=standard&w={width}&h={height}&q={quality}&blur={blur}&bg={bg}", "isFullSeries": true, "linearContent": true, "longDescription": "A revealing Hotel Game stirs up suspicion before the guests shift into gear for a full-throttle Day Trip Challenge.", "longRunning": false, "nextProductionId": "10/5106/0013", "notFormattedDuration": "PT48M", "partnership": null, "playlistUrl": "https://magni.itv.com/playlist/itvonline/ITV/10_5106_0012.001", "premium": false, "productionId": "10/5106/0012#001", "productionType": "EPISODE", "contentType": "episode", "productionYear": null, "programmeId": "10/5106", "series": 2, "subtitled": true, "tier": ["FREE"], "visuallySigned": false, "regionalisation": true, "broadcastDateTime": "2025-08-14T20:00:00Z"}, "tracks": {"py/object": "envied.core.tracks.tracks.Tracks", "videos": [], "audio": [], "subtitles": [], "chapters": {"py/object": "envied.core.tracks.chapters.Chapters", "_len": 0, "_load": 1000, "_lists": [], "_keys": [], "_maxes": [], "_index": [], "_offset": 0}, "attachments": []}, "title": "The Fortune Hotel", "season": 2, "number": 4, "name": "A Twist in the Tale", "year": null, "description": null}, {"py/object": "envied.core.titles.episode.Episode", "id": "10/5106/0013", "service": {"py/type": "ITV.ITV"}, "language": {"py/id": 5}, "data": {"ccid": "s2fjj61", "accessibilityTags": ["ad", "s"], "audioDescribed": true, "availabilityFeatures": ["MPEG_DASH", "OUTBAND_WEBVTT", "PLAYREADY", "WIDEVINE", "INBAND_AUDIO_DESCRIPTION"], "availabilityFrom": "2025-08-20T20:59:38Z", "availabilityUntil": "2030-08-27T22:59:00Z", "genres": [{"id": "ENTERTAINMENT", "name": "Entertainment & Reality"}], "channel": "ITV", "fullSeriesRange": [], "heroCtaLabel": {"label": "Watch Now", "ariaLabel": "Watch episode 5 of series 2, Knowledge is Power", "episodeLabel": "S2: E5 - Knowledge is Power"}, "contentInfo": "S2:E5", "contentOwner": null, "dateTime": "2025-08-20T20:00:00.000Z", "description": "The guests tackle an explosive beach challenge.", "duration": "47m", "episode": 5, "episodeId": "10/5106/0013", "encodedEpisodeId": {"letterA": "10a5106a0013", "underscore": "10_5106_0013"}, "episodeTitle": "Knowledge is Power", "guidance": "", "image": "https://ovp.itv.com/v2/images/episode/s2fjj61/itv_hub/08_Episodic/16x9?distributionPartner=itv_hub&fallback=standard&w={width}&h={height}&q={quality}&blur={blur}&bg={bg}", "isFullSeries": true, "linearContent": true, "longDescription": "The remaining guests make a dash in a classic poolside Hotel Game and head to the beach for a dramatic tower-building contest.", "longRunning": false, "nextProductionId": "10/5106/0014", "notFormattedDuration": "PT47M", "partnership": null, "playlistUrl": "https://magni.itv.com/playlist/itvonline/ITV/10_5106_0013.001", "premium": false, "productionId": "10/5106/0013#001", "productionType": "EPISODE", "contentType": "episode", "productionYear": null, "programmeId": "10/5106", "series": 2, "subtitled": true, "tier": ["FREE"], "visuallySigned": false, "regionalisation": true, "broadcastDateTime": "2025-08-20T20:00:00Z"}, "tracks": {"py/object": "envied.core.tracks.tracks.Tracks", "videos": [], "audio": [], "subtitles": [], "chapters": {"py/object": "envied.core.tracks.chapters.Chapters", "_len": 0, "_load": 1000, "_lists": [], "_keys": [], "_maxes": [], "_index": [], "_offset": 0}, "attachments": []}, "title": "The Fortune Hotel", "season": 2, "number": 5, "name": "Knowledge is Power", "year": null, "description": null}, {"py/object": "envied.core.titles.episode.Episode", "id": "10/5106/0014", "service": {"py/type": "ITV.ITV"}, "language": {"py/id": 5}, "data": {"ccid": "ktw99nw", "accessibilityTags": ["ad", "s"], "audioDescribed": true, "availabilityFeatures": ["MPEG_DASH", "OUTBAND_WEBVTT", "PLAYREADY", "WIDEVINE", "INBAND_AUDIO_DESCRIPTION"], "availabilityFrom": "2025-08-21T20:59:14Z", "availabilityUntil": "2030-08-27T22:59:00Z", "genres": [{"id": "ENTERTAINMENT", "name": "Entertainment & Reality"}], "channel": "ITV", "fullSeriesRange": [], "heroCtaLabel": {"label": "Watch Now", "ariaLabel": "Watch episode 6 of series 2, Under the Cloche", "episodeLabel": "S2: E6 - Under the Cloche"}, "contentInfo": "S2:E6", "contentOwner": null, "dateTime": "2025-08-21T20:00:00.000Z", "description": "The remaining pairs face a game of lollipop roulette.", "duration": "46m", "episode": 6, "episodeId": "10/5106/0014", "encodedEpisodeId": {"letterA": "10a5106a0014", "underscore": "10_5106_0014"}, "episodeTitle": "Under the Cloche", "guidance": "With some strong language.", "image": "https://ovp.itv.com/v2/images/episode/ktw99nw/itv_hub/08_Episodic/16x9?distributionPartner=itv_hub&fallback=standard&w={width}&h={height}&q={quality}&blur={blur}&bg={bg}", "isFullSeries": true, "linearContent": true, "longDescription": "A high-stakes ice lolly roulette game puts targets on backs before the guests hit the streets to hunt for some secret words.", "longRunning": false, "nextProductionId": "10/5106/0015", "notFormattedDuration": "PT46M", "partnership": null, "playlistUrl": "https://magni.itv.com/playlist/itvonline/ITV/10_5106_0014.002", "premium": false, "productionId": "10/5106/0014#002", "productionType": "EPISODE", "contentType": "episode", "productionYear": null, "programmeId": "10/5106", "series": 2, "subtitled": true, "tier": ["FREE"], "visuallySigned": false, "regionalisation": true, "broadcastDateTime": "2025-08-21T20:00:00Z"}, "tracks": {"py/object": "envied.core.tracks.tracks.Tracks", "videos": [], "audio": [], "subtitles": [], "chapters": {"py/object": "envied.core.tracks.chapters.Chapters", "_len": 0, "_load": 1000, "_lists": [], "_keys": [], "_maxes": [], "_index": [], "_offset": 0}, "attachments": []}, "title": "The Fortune Hotel", "season": 2, "number": 6, "name": "Under the Cloche", "year": null, "description": null}, {"py/object": "envied.core.titles.episode.Episode", "id": "10/5106/0015", "service": {"py/type": "ITV.ITV"}, "language": {"py/id": 5}, "data": {"ccid": "6p40fb0", "accessibilityTags": ["ad", "s"], "audioDescribed": true, "availabilityFeatures": ["MPEG_DASH", "OUTBAND_WEBVTT", "PLAYREADY", "WIDEVINE", "INBAND_AUDIO_DESCRIPTION"], "availabilityFrom": "2025-08-28T20:58:48Z", "availabilityUntil": "2030-08-27T22:59:00Z", "genres": [{"id": "ENTERTAINMENT", "name": "Entertainment & Reality"}], "channel": "ITV", "fullSeriesRange": [], "heroCtaLabel": {"label": "Watch Now", "ariaLabel": "Watch episode 7 of series 2, Locked Up", "episodeLabel": "S2: E7 - Locked Up"}, "contentInfo": "S2:E7", "contentOwner": null, "dateTime": "2025-08-28T20:00:00.000Z", "description": "A tropical prison break challenge tests the pairs.", "duration": "48m", "episode": 7, "episodeId": "10/5106/0015", "encodedEpisodeId": {"letterA": "10a5106a0015", "underscore": "10_5106_0015"}, "episodeTitle": "Locked Up", "guidance": "", "image": "https://ovp.itv.com/v2/images/episode/6p40fb0/itv_hub/08_Episodic/16x9?distributionPartner=itv_hub&fallback=standard&w={width}&h={height}&q={quality}&blur={blur}&bg={bg}", "isFullSeries": true, "linearContent": true, "longDescription": "A strategic Hotel Game twist threatens to turn allies into adversaries before a tropical prison break challenge puts pairs to the test.", "longRunning": false, "nextProductionId": "10/5106/0016", "notFormattedDuration": "PT48M", "partnership": null, "playlistUrl": "https://magni.itv.com/playlist/itvonline/ITV/10_5106_0015.001", "premium": false, "productionId": "10/5106/0015#001", "productionType": "EPISODE", "contentType": "episode", "productionYear": null, "programmeId": "10/5106", "series": 2, "subtitled": true, "tier": ["FREE"], "visuallySigned": false, "regionalisation": true, "broadcastDateTime": "2025-08-28T20:00:00Z"}, "tracks": {"py/object": "envied.core.tracks.tracks.Tracks", "videos": [], "audio": [], "subtitles": [], "chapters": {"py/object": "envied.core.tracks.chapters.Chapters", "_len": 0, "_load": 1000, "_lists": [], "_keys": [], "_maxes": [], "_index": [], "_offset": 0}, "attachments": []}, "title": "The Fortune Hotel", "season": 2, "number": 7, "name": "Locked Up", "year": null, "description": null}, {"py/object": "envied.core.titles.episode.Episode", "id": "10/5106/0016", "service": {"py/type": "ITV.ITV"}, "language": {"py/id": 5}, "data": {"ccid": "s2lq8p3", "accessibilityTags": ["ad", "s"], "audioDescribed": true, "availabilityFeatures": ["MPEG_DASH", "OUTBAND_WEBVTT", "PLAYREADY", "WIDEVINE", "INBAND_AUDIO_DESCRIPTION"], "availabilityFrom": "2025-08-29T21:00:53Z", "availabilityUntil": "2030-08-27T22:59:00Z", "genres": [{"id": "ENTERTAINMENT", "name": "Entertainment & Reality"}], "channel": "ITV", "fullSeriesRange": [], "heroCtaLabel": {"label": "Watch Now", "ariaLabel": "Watch episode 8 of series 2, And the Winner is...", "episodeLabel": "S2: E8 - And the Winner is..."}, "contentInfo": "S2:E8", "contentOwner": null, "dateTime": "2025-08-29T20:00:00.000Z", "description": "The final race is on - three pairs, one fortune.", "duration": "48m", "episode": 8, "episodeId": "10/5106/0016", "encodedEpisodeId": {"letterA": "10a5106a0016", "underscore": "10_5106_0016"}, "episodeTitle": "And the Winner is...", "guidance": "With some strong language.", "image": "https://ovp.itv.com/v2/images/episode/s2lq8p3/itv_hub/08_Episodic/16x9?distributionPartner=itv_hub&fallback=standard&w={width}&h={height}&q={quality}&blur={blur}&bg={bg}", "isFullSeries": true, "linearContent": true, "longDescription": "The remaining pairs must complete a trio of puzzles and challenges to secure their place in the final Night Cap.", "longRunning": false, "notFormattedDuration": "PT48M", "partnership": null, "playlistUrl": "https://magni.itv.com/playlist/itvonline/ITV/10_5106_0016.001", "premium": false, "productionId": "10/5106/0016#001", "productionType": "EPISODE", "contentType": "episode", "productionYear": null, "programmeId": "10/5106", "series": 2, "subtitled": true, "tier": ["FREE"], "visuallySigned": false, "regionalisation": true, "broadcastDateTime": "2025-08-29T20:00:00Z"}, "tracks": {"py/object": "envied.core.tracks.tracks.Tracks", "videos": [], "audio": [], "subtitles": [], "chapters": {"py/object": "envied.core.tracks.chapters.Chapters", "_len": 0, "_load": 1000, "_lists": [], "_keys": [], "_maxes": [], "_index": [], "_offset": 0}, "attachments": []}, "title": "The Fortune Hotel", "season": 2, "number": 8, "name": "And the Winner is...", "year": null, "description": null}]], "_keys": [[{"py/tuple": [1, 1, 0]}, {"py/tuple": [1, 2, 0]}, {"py/tuple": [1, 3, 0]}, {"py/tuple": [1, 4, 0]}, {"py/tuple": [1, 5, 0]}, {"py/tuple": [1, 6, 0]}, {"py/tuple": [1, 7, 0]}, {"py/tuple": [1, 8, 0]}, {"py/tuple": [2, 1, 0]}, {"py/tuple": [2, 2, 0]}, {"py/tuple": [2, 3, 0]}, {"py/tuple": [2, 4, 0]}, {"py/tuple": [2, 5, 0]}, {"py/tuple": [2, 6, 0]}, {"py/tuple": [2, 7, 0]}, {"py/tuple": [2, 8, 0]}]], "_maxes": [{"py/tuple": [2, 8, 0]}], "_index": [], "_offset": 0}, "expiration": {"py/object": "datetime.datetime", "__reduce__": [{"py/type": "datetime.datetime"}, ["B+kJAw4MLAIwQw=="]]}, "version": 1, "crc32": 3084742324} \ No newline at end of file diff --git a/packages/envied/src/envied/cache/global/ip_info.json b/packages/envied/src/envied/cache/global/ip_info.json deleted file mode 100644 index 1cb1f14..0000000 --- a/packages/envied/src/envied/cache/global/ip_info.json +++ /dev/null @@ -1 +0,0 @@ -{"data": {"ip": "134.65.156.36", "city": "Seaford", "region": "England", "country": "GB", "loc": "50.7714,0.1027", "org": "AS48101 Trooli Ltd.", "postal": "BN25", "timezone": "Europe/London", "readme": "https://ipinfo.io/missingauth", "_provider": "ipinfo"}, "expiration": {"py/object": "datetime.datetime", "__reduce__": [{"py/type": "datetime.datetime"}, ["B+kJBAwwFAVuGw=="]]}, "version": 1, "crc32": 4153548394} \ No newline at end of file diff --git a/packages/envied/src/envied/commands/dl.py b/packages/envied/src/envied/commands/dl.py index ad6a581..4eb1ff8 100644 --- a/packages/envied/src/envied/commands/dl.py +++ b/packages/envied/src/envied/commands/dl.py @@ -1609,7 +1609,7 @@ class dl: if subtitle.codec == Subtitle.Codec.SubStationAlphav4: for line in subtitle.path.read_text("utf8").splitlines(): if line.startswith("Style: "): - font_names.append(line.removesuffix("Style: ").split(",")[1]) + font_names.append(line.removesuffix("Style: ").split(",")[1]).strip() font_count, missing_fonts = self.attach_subtitle_fonts( font_names, title, temp_font_files diff --git a/packages/envied/src/envied/core/__init__.py b/packages/envied/src/envied/core/__init__.py index 8a124bf..55e4709 100644 --- a/packages/envied/src/envied/core/__init__.py +++ b/packages/envied/src/envied/core/__init__.py @@ -1 +1 @@ -__version__ = "2.2.0" +__version__ = "2.3.0" diff --git a/packages/envied/src/envied/core/config.py b/packages/envied/src/envied/core/config.py index a58fab0..be08269 100644 --- a/packages/envied/src/envied/core/config.py +++ b/packages/envied/src/envied/core/config.py @@ -95,6 +95,7 @@ class Config: self.update_check_interval: int = kwargs.get("update_check_interval", 24) self.scene_naming: bool = kwargs.get("scene_naming", True) self.series_year: bool = kwargs.get("series_year", True) + self.unicode_filenames: bool = kwargs.get("unicode_filenames", False) self.title_cache_time: int = kwargs.get("title_cache_time", 1800) # 30 minutes default self.title_cache_max_retention: int = kwargs.get("title_cache_max_retention", 86400) # 24 hours default diff --git a/packages/envied/src/envied/core/drm/playready.py b/packages/envied/src/envied/core/drm/playready.py index 1044561..4acdd8a 100644 --- a/packages/envied/src/envied/core/drm/playready.py +++ b/packages/envied/src/envied/core/drm/playready.py @@ -168,7 +168,7 @@ class PlayReady: pssh_boxes.extend(list(get_boxes(init_data, b"pssh"))) tenc_boxes.extend(list(get_boxes(init_data, b"tenc"))) - pssh = next((b for b in pssh_boxes if b.system_ID == PSSH.SYSTEM_ID.bytes), None) + pssh = next((b for b in pssh_boxes if b.system_ID == PSSH.SYSTEM_ID), None) if not pssh: raise PlayReady.Exceptions.PSSHNotFound("PSSH was not found in track data.") @@ -197,7 +197,7 @@ class PlayReady: if enc_key_id: kid = UUID(bytes=base64.b64decode(enc_key_id)) - pssh = next((b for b in pssh_boxes if b.system_ID == PSSH.SYSTEM_ID.bytes), None) + pssh = next((b for b in pssh_boxes if b.system_ID == PSSH.SYSTEM_ID), None) if not pssh: raise PlayReady.Exceptions.PSSHNotFound("PSSH was not found in track data.") @@ -415,7 +415,7 @@ class PlayReady: p.wait() if p.returncode != 0 or had_error: - raise subprocess.CalledProcessError(p.returncode, arguments) + raise subprocess.CalledProcessError(p.returncode, [binaries.ShakaPackager, *arguments]) path.unlink() if not stream_skipped: diff --git a/packages/envied/src/envied/core/drm/widevine.py b/packages/envied/src/envied/core/drm/widevine.py index bdd832f..6a22a6c 100644 --- a/packages/envied/src/envied/core/drm/widevine.py +++ b/packages/envied/src/envied/core/drm/widevine.py @@ -100,9 +100,7 @@ class Widevine: pssh_boxes.extend(list(get_boxes(init_data, b"pssh"))) tenc_boxes.extend(list(get_boxes(init_data, b"tenc"))) - pssh_boxes.sort(key=lambda b: {PSSH.SystemId.Widevine: 0, PSSH.SystemId.PlayReady: 1}[b.system_ID]) - - pssh = next(iter(pssh_boxes), None) + pssh = next((b for b in pssh_boxes if b.system_ID == PSSH.SystemId.Widevine), None) if not pssh: raise Widevine.Exceptions.PSSHNotFound("PSSH was not found in track data.") @@ -141,9 +139,7 @@ class Widevine: if enc_key_id: kid = UUID(bytes=base64.b64decode(enc_key_id)) - pssh_boxes.sort(key=lambda b: {PSSH.SystemId.Widevine: 0, PSSH.SystemId.PlayReady: 1}[b.system_ID]) - - pssh = next(iter(pssh_boxes), None) + pssh = next((b for b in pssh_boxes if b.system_ID == PSSH.SystemId.Widevine), None) if not pssh: raise Widevine.Exceptions.PSSHNotFound("PSSH was not found in track data.") @@ -371,7 +367,7 @@ class Widevine: p.wait() if p.returncode != 0 or had_error: - raise subprocess.CalledProcessError(p.returncode, arguments) + raise subprocess.CalledProcessError(p.returncode, [binaries.ShakaPackager, *arguments]) path.unlink() if not stream_skipped: diff --git a/packages/envied/src/envied/core/manifests/dash.py b/packages/envied/src/envied/core/manifests/dash.py index 6684f38..a426f0f 100644 --- a/packages/envied/src/envied/core/manifests/dash.py +++ b/packages/envied/src/envied/core/manifests/dash.py @@ -5,6 +5,7 @@ import html import logging import math import re +import shutil import sys from copy import copy from functools import partial @@ -466,7 +467,7 @@ class DASH: track.data["dash"]["timescale"] = int(segment_timescale) track.data["dash"]["segment_durations"] = segment_durations - if not track.drm and isinstance(track, (Video, Audio)): + if init_data and isinstance(track, (Video, Audio)): if isinstance(cdm, PlayReadyCdm): try: track.drm = [PlayReady.from_init_data(init_data)] @@ -527,8 +528,16 @@ class DASH: max_workers=max_workers, ) + skip_merge = False if downloader.__name__ == "n_m3u8dl_re": - downloader_args.update({"filename": track.id, "track": track}) + skip_merge = True + downloader_args.update( + { + "filename": track.id, + "track": track, + "content_keys": drm.content_keys if drm else None, + } + ) debug_logger = get_debug_logger() if debug_logger: @@ -543,6 +552,7 @@ class DASH: "downloader": downloader.__name__, "has_drm": bool(track.drm), "drm_types": [drm.__class__.__name__ for drm in (track.drm or [])], + "skip_merge": skip_merge, "save_path": str(save_path), "has_init_data": bool(init_data), }, @@ -563,42 +573,56 @@ class DASH: control_file.unlink() segments_to_merge = [x for x in sorted(save_dir.iterdir()) if x.is_file()] - with open(save_path, "wb") as f: - if init_data: - f.write(init_data) - if len(segments_to_merge) > 1: - progress(downloaded="Merging", completed=0, total=len(segments_to_merge)) - for segment_file in segments_to_merge: - segment_data = segment_file.read_bytes() - # TODO: fix encoding after decryption? - if ( - not drm - and isinstance(track, Subtitle) - and track.codec not in (Subtitle.Codec.fVTT, Subtitle.Codec.fTTML) - ): - segment_data = try_ensure_utf8(segment_data) - segment_data = ( - segment_data.decode("utf8") - .replace("‎", html.unescape("‎")) - .replace("‏", html.unescape("‏")) - .encode("utf8") - ) - f.write(segment_data) - f.flush() - segment_file.unlink() - progress(advance=1) + + if skip_merge: + # N_m3u8DL-RE handles merging and decryption internally + shutil.move(segments_to_merge[0], save_path) + if drm: + track.drm = None + events.emit(events.Types.TRACK_DECRYPTED, track=track, drm=drm, segment=None) + else: + with open(save_path, "wb") as f: + if init_data: + f.write(init_data) + if len(segments_to_merge) > 1: + progress(downloaded="Merging", completed=0, total=len(segments_to_merge)) + for segment_file in segments_to_merge: + segment_data = segment_file.read_bytes() + # TODO: fix encoding after decryption? + if ( + not drm + and isinstance(track, Subtitle) + and track.codec not in (Subtitle.Codec.fVTT, Subtitle.Codec.fTTML) + ): + segment_data = try_ensure_utf8(segment_data) + segment_data = ( + segment_data.decode("utf8") + .replace("‎", html.unescape("‎")) + .replace("‏", html.unescape("‏")) + .encode("utf8") + ) + f.write(segment_data) + f.flush() + segment_file.unlink() + progress(advance=1) track.path = save_path events.emit(events.Types.TRACK_DOWNLOADED, track=track) - if drm: + if not skip_merge and drm: progress(downloaded="Decrypting", completed=0, total=100) drm.decrypt(save_path) track.drm = None events.emit(events.Types.TRACK_DECRYPTED, track=track, drm=drm, segment=None) progress(downloaded="Decrypting", advance=100) - save_dir.rmdir() + # Clean up empty segment directory + if save_dir.exists() and save_dir.name.endswith("_segments"): + try: + save_dir.rmdir() + except OSError: + # Directory might not be empty, try removing recursively + shutil.rmtree(save_dir, ignore_errors=True) progress(downloaded="Downloaded") @@ -766,6 +790,11 @@ class DASH: @staticmethod def get_drm(protections: list[Element]) -> list[DRM_T]: drm: list[DRM_T] = [] + PLACEHOLDER_KIDS = { + UUID("00000000-0000-0000-0000-000000000000"), # All zeros (key rotation default) + UUID("00010203-0405-0607-0809-0a0b0c0d0e0f"), # Sequential 0x00-0x0f + UUID("00010203-0405-0607-0809-101112131415"), # Shaka Packager test pattern + } for protection in protections: urn = (protection.get("schemeIdUri") or "").lower() @@ -775,17 +804,27 @@ class DASH: if not pssh_text: continue pssh = PSSH(pssh_text) + kid_attr = protection.get("kid") or protection.get("{urn:mpeg:cenc:2013}kid") + kid = UUID(bytes=base64.b64decode(kid_attr)) if kid_attr else None - kid = protection.get("kid") - if kid: - kid = UUID(bytes=base64.b64decode(kid)) + if not kid: + default_kid_attr = protection.get("default_KID") or protection.get( + "{urn:mpeg:cenc:2013}default_KID" + ) + kid = UUID(default_kid_attr) if default_kid_attr else None - default_kid = protection.get("default_KID") - if default_kid: - kid = UUID(default_kid) + if not kid: + kid = next( + ( + UUID(p.get("default_KID") or p.get("{urn:mpeg:cenc:2013}default_KID")) + for p in protections + if p.get("default_KID") or p.get("{urn:mpeg:cenc:2013}default_KID") + ), + None, + ) - if not pssh.key_ids and not kid: - kid = next((UUID(p.get("default_KID")) for p in protections if p.get("default_KID")), None) + if kid and (not pssh.key_ids or all(k.int == 0 or k in PLACEHOLDER_KIDS for k in pssh.key_ids)): + pssh.set_key_ids([kid]) drm.append(Widevine(pssh=pssh, kid=kid)) diff --git a/packages/envied/src/envied/core/tracks/subtitle.py b/packages/envied/src/envied/core/tracks/subtitle.py index 7757e56..e87f507 100644 --- a/packages/envied/src/envied/core/tracks/subtitle.py +++ b/packages/envied/src/envied/core/tracks/subtitle.py @@ -91,6 +91,12 @@ class Subtitle(Track): return Subtitle.Codec.TimedTextMarkupLang raise ValueError(f"The Content Profile '{profile}' is not a supported Subtitle Codec") + # WebVTT sanitization patterns (compiled once for performance) + _CUE_ID_PATTERN = re.compile(r"^[A-Za-z]+\d+$") + _TIMING_START_PATTERN = re.compile(r"^\d+:\d+[:\.]") + _TIMING_LINE_PATTERN = re.compile(r"^((?:\d+:)?\d+:\d+[.,]\d+)\s*-->\s*((?:\d+:)?\d+:\d+[.,]\d+)(.*)$") + _LINE_POS_PATTERN = re.compile(r"line:(\d+(?:\.\d+)?%?)") + def __init__( self, *args: Any, @@ -239,6 +245,11 @@ class Subtitle(Track): # Sanitize WebVTT timestamps before parsing text = Subtitle.sanitize_webvtt_timestamps(text) + # Remove cue identifiers that confuse parsers like pysubs2 + text = Subtitle.sanitize_webvtt_cue_identifiers(text) + # Merge overlapping cues with line positioning into single multi-line cues + text = Subtitle.merge_overlapping_webvtt_cues(text) + preserve_formatting = config.subtitle.get("preserve_formatting", True) if preserve_formatting: @@ -277,6 +288,240 @@ class Subtitle(Track): # Replace negative timestamps with 00:00:00.000 return re.sub(r"(-\d+:\d+:\d+\.\d+)", "00:00:00.000", text) + @staticmethod + def has_webvtt_cue_identifiers(text: str) -> bool: + """ + Check if WebVTT content has cue identifiers that need removal. + + Parameters: + text: The WebVTT content as string + + Returns: + True if cue identifiers are detected, False otherwise + """ + lines = text.split("\n") + + for i, line in enumerate(lines): + line = line.strip() + if Subtitle._CUE_ID_PATTERN.match(line): + # Look ahead to see if next non-empty line is a timing line + j = i + 1 + while j < len(lines) and not lines[j].strip(): + j += 1 + if j < len(lines) and ("-->" in lines[j] or Subtitle._TIMING_START_PATTERN.match(lines[j].strip())): + return True + return False + + @staticmethod + def sanitize_webvtt_cue_identifiers(text: str) -> str: + """ + Remove WebVTT cue identifiers that can confuse subtitle parsers. + + Some services use cue identifiers like "Q0", "Q1", etc. + that appear on their own line before the timing line. These can be + incorrectly parsed as part of the previous cue's text content by + some parsers (like pysubs2). + + Parameters: + text: The WebVTT content as string + + Returns: + Sanitized WebVTT content with cue identifiers removed + """ + if not Subtitle.has_webvtt_cue_identifiers(text): + return text + + lines = text.split("\n") + sanitized_lines = [] + + i = 0 + while i < len(lines): + line = lines[i].strip() + + # Check if this line is a cue identifier followed by a timing line + if Subtitle._CUE_ID_PATTERN.match(line): + # Look ahead to see if next non-empty line is a timing line + j = i + 1 + while j < len(lines) and not lines[j].strip(): + j += 1 + if j < len(lines) and ("-->" in lines[j] or Subtitle._TIMING_START_PATTERN.match(lines[j].strip())): + # This is a cue identifier, skip it + i += 1 + continue + + sanitized_lines.append(lines[i]) + i += 1 + + return "\n".join(sanitized_lines) + + @staticmethod + def _parse_vtt_time(t: str) -> int: + """Parse WebVTT timestamp to milliseconds. Returns 0 for malformed input.""" + try: + t = t.replace(",", ".") + parts = t.split(":") + if len(parts) == 2: + m, s = parts + h = "0" + elif len(parts) >= 3: + h, m, s = parts[:3] + else: + return 0 + sec_parts = s.split(".") + secs = int(sec_parts[0]) + # Handle variable millisecond digits (e.g., .5 = 500ms, .50 = 500ms, .500 = 500ms) + ms = int(sec_parts[1].ljust(3, "0")[:3]) if len(sec_parts) > 1 else 0 + return int(h) * 3600000 + int(m) * 60000 + secs * 1000 + ms + except (ValueError, IndexError): + return 0 + + @staticmethod + def has_overlapping_webvtt_cues(text: str) -> bool: + """ + Check if WebVTT content has overlapping cues that need merging. + + Detects cues with start times within 50ms of each other and the same end time, + which indicates multi-line subtitles split into separate cues. + + Parameters: + text: The WebVTT content as string + + Returns: + True if overlapping cues are detected, False otherwise + """ + timings = [] + for line in text.split("\n"): + match = Subtitle._TIMING_LINE_PATTERN.match(line) + if match: + start_str, end_str = match.group(1), match.group(2) + timings.append((Subtitle._parse_vtt_time(start_str), Subtitle._parse_vtt_time(end_str))) + + # Check for overlapping cues (within 50ms start, same end) + for i in range(len(timings) - 1): + curr_start, curr_end = timings[i] + next_start, next_end = timings[i + 1] + if abs(curr_start - next_start) <= 50 and curr_end == next_end: + return True + + return False + + @staticmethod + def merge_overlapping_webvtt_cues(text: str) -> str: + """ + Merge WebVTT cues that have overlapping/near-identical times but different line positions. + + Some services use separate cues for each line of a multi-line subtitle, with + slightly different start times (1ms apart) and different line: positions. + This merges them into single cues with proper line ordering based on the + line: position (lower percentage = higher on screen = first line). + + Parameters: + text: The WebVTT content as string + + Returns: + WebVTT content with overlapping cues merged + """ + if not Subtitle.has_overlapping_webvtt_cues(text): + return text + + lines = text.split("\n") + cues = [] + header_lines = [] + in_header = True + i = 0 + + while i < len(lines): + line = lines[i] + + if in_header: + if "-->" in line: + in_header = False + else: + header_lines.append(line) + i += 1 + continue + + match = Subtitle._TIMING_LINE_PATTERN.match(line) + if match: + start_str, end_str, settings = match.groups() + line_pos = 100.0 # Default to bottom + line_match = Subtitle._LINE_POS_PATTERN.search(settings) + if line_match: + pos_str = line_match.group(1).rstrip("%") + line_pos = float(pos_str) + + content_lines = [] + i += 1 + while i < len(lines) and lines[i].strip() and "-->" not in lines[i]: + content_lines.append(lines[i]) + i += 1 + + cues.append( + { + "start_ms": Subtitle._parse_vtt_time(start_str), + "end_ms": Subtitle._parse_vtt_time(end_str), + "start_str": start_str, + "end_str": end_str, + "line_pos": line_pos, + "content": "\n".join(content_lines), + "settings": settings, + } + ) + else: + i += 1 + + # Merge overlapping cues (within 50ms of each other with same end time) + merged_cues = [] + i = 0 + while i < len(cues): + current = cues[i] + group = [current] + + j = i + 1 + while j < len(cues): + other = cues[j] + if abs(current["start_ms"] - other["start_ms"]) <= 50 and current["end_ms"] == other["end_ms"]: + group.append(other) + j += 1 + else: + break + + if len(group) > 1: + # Sort by line position (lower % = higher on screen = first) + group.sort(key=lambda x: x["line_pos"]) + # Use the earliest start time from the group + earliest = min(group, key=lambda x: x["start_ms"]) + merged_cues.append( + { + "start_str": earliest["start_str"], + "end_str": group[0]["end_str"], + "content": "\n".join(c["content"] for c in group), + "settings": "", + } + ) + else: + merged_cues.append( + { + "start_str": current["start_str"], + "end_str": current["end_str"], + "content": current["content"], + "settings": current["settings"], + } + ) + + i = j if len(group) > 1 else i + 1 + + result_lines = header_lines[:] + if result_lines and result_lines[-1].strip(): + result_lines.append("") + + for cue in merged_cues: + result_lines.append(f"{cue['start_str']} --> {cue['end_str']}{cue['settings']}") + result_lines.append(cue["content"]) + result_lines.append("") + + return "\n".join(result_lines) + @staticmethod def sanitize_webvtt(text: str) -> str: """ @@ -631,7 +876,7 @@ class Subtitle(Track): text = try_ensure_utf8(data).decode("utf8") text = text.replace("tt:", "") # negative size values aren't allowed in TTML/DFXP spec, replace with 0 - text = re.sub(r'"(-\d+(\.\d+)?(px|em|%|c|pt))"', '"0"', text) + text = re.sub(r"-(\d+(?:\.\d+)?)(px|em|%|c|pt)", r"0\2", text) caption_set = pycaption.DFXPReader().read(text) elif codec == Subtitle.Codec.fVTT: caption_lists: dict[str, pycaption.CaptionList] = defaultdict(pycaption.CaptionList) diff --git a/packages/envied/src/envied/core/utilities.py b/packages/envied/src/envied/core/utilities.py index 566167f..dfd2f53 100644 --- a/packages/envied/src/envied/core/utilities.py +++ b/packages/envied/src/envied/core/utilities.py @@ -120,9 +120,14 @@ def sanitize_filename(filename: str, spacer: str = ".") -> str: The spacer is safer to be a '.' for older DDL and p2p sharing spaces. This includes web-served content via direct links and such. + + Set `unicode_filenames: true` in config to preserve native language + characters (Korean, Japanese, Chinese, etc.) instead of transliterating + them to ASCII equivalents. """ - # replace all non-ASCII characters with ASCII equivalents - filename = unidecode(filename) + # optionally replace non-ASCII characters with ASCII equivalents + if not config.unicode_filenames: + filename = unidecode(filename) # remove or replace further characters as needed filename = "".join(c for c in filename if unicodedata.category(c) != "Mn") # hidden characters diff --git a/uv.lock b/uv.lock index d2691a3..2598187 100644 --- a/uv.lock +++ b/uv.lock @@ -552,7 +552,7 @@ wheels = [ [[package]] name = "envied" -version = "2.1.0" +version = "2.3.0" source = { editable = "packages/envied" } dependencies = [ { name = "aiohttp-swagger3" },