service updates

This commit is contained in:
VineFeeder
2026-03-21 10:56:22 +00:00
parent a91ffa4c12
commit 1486c4bd37
14 changed files with 185 additions and 319 deletions
-152
View File
@@ -5,158 +5,6 @@ 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/), 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). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [4.0.0] - 2026-03-17
### Features
- *hybrid*: Add L5 active area and dynamic L6 luminance metadata
- *debug*: Add JSONL debug logging to decryption, muxing, and all downloaders
- *debug*: Log binary tool versions at session start
- *dl*: Add --repack flag to insert REPACK tag in output filenames
- *core*: Add TrackRequest system for multi-codec/multi-range support
- *dl*: Add --output option to override download directory
- *tracks*: Add edition tags to output filenames
- *templates*: [**breaking**] Add customizable output filename templates
- *templates*: Add configurable language tagging rule engine
- Update unshackle version to 4.0.0
- *dl*: Add --animeapi and --enrich options for anime metadata and tagging
- *dl*: Add skip messages for --no-audio and --no-chapters flags
- *dl*: Extract closed captions from HLS manifests and improve CC extraction
- *dl*: Add --worst flag and SHIELD OkHttp fingerprint preset
### Bug Fixes
- *n_m3u8dl_re*: Pass all content keys for DualKey DRM decryption
- *hybrid*: Skip bitrate filter for DV tracks in HYBRID mode
- *attachment*: Sanitize filenames with illegal Windows characters
- *hybrid*: Accept HDR10+ tracks as valid base layer for HYBRID mode
- *dl*: Allow selection of audio tracks for 'all' languages in addition to 'best'
- *dl*: Overwrite existing files on re-download and use atomic replace
- *dl*: Handle cross-device moves when temp and downloads differ
- *track*: Fallback to requests downloader from n_m3u8dl_re for unsupported track types
- *n_m3u8dl_re*: Disable segment count validation for duration-based DASH
- Correct formatting and add missing newlines in selector and EXAMPLE service
- *dependencies*: Update pyplayready version to 0.8.3 and adjust dependencies
- *drm*: Update PlayReady KID extraction for pyplayready 0.8.3 compatibility
- *api*: Resolve Sentinel serialization, missing params, and add search endpoint
- *dash*: Pass period_filter to n_m3u8dl_re via filtered MPD file
- *title*: Add HDR Vivid Format HDR Tag
- *ism*: Prevent duplicate track IDs for audio tracks with same lang/codec/bitrate
- *aria2c*: Correct progress bar tracking for HLS downloads
- *dl*: Filter CC subtitle languages with --s-lang and extract all manifest CCs
### Documentation
- Update and correct configuration documentation
### Changes
- *hybrid*: Replace log.info with console status and add JSONL debug logging
- *dl*: Remove legacy multi-fetch loop for unmigrated services
- *example*: Migrate EXAMPLE service to track_request pattern
- *providers*: Extract metadata providers into modular system
## [3.0.0] - 2026-02-15
### Features
- *titles*: Use track source attribute for service name in filenames
- *debug*: Add download output verification logging
- Gluetun VPN integration and remote service enhancements
- *gluetun*: Improve VPN connection display and Windscribe support
- *serve*: Add PlayReady CDM support alongside Widevine
- *cdm*: Add remote PlayReady CDM support via pyplayready RemoteCdm
- *env*: Add ML-Worker binary for DRM licensing
- *video*: Detect interlaced scan type from MPD manifests
- *drm*: Add MonaLisa DRM support to core infrastructure
- *audio*: Codec lists and split muxing
- *proxy*: Add specific server selection for WindscribeVPN
- *cdm*: Normalize CDM detection for local and remote implementations
- *HLS*: Improve audio codec handling with error handling for codec extraction
- *tracks*: Prioritize Atmos audio tracks over higher bitrate non-Atmos
### Bug Fixes
- *subs*: Update SubtitleEdit CLI syntax and respect conversion_method
- *n_m3u8dl_re*: Include language in DASH audio track selection
- *hls*: Prefer media playlist keys over session keys for accurate KID matching
- *deps*: Upgrade vulnerable dependencies for security alerts
- *serve*: Use correct pywidevine users config format
- *cdm*: Correct error key casing in Decrypt Labs API response parsing
- *api*: Validate Bearer prefix before extracting API key
- *serve*: Correct PlayReady RemoteCDM server validation
- *n_m3u8dl_re*: Remove duplicate --write-meta-json argument causing download failures
- *manifests*: Correct DRM type selection for remote PlayReady CDMs
- *proxies*: Fixes WindscribeVPN server authentication
- *subs*: Route pycaption-unsupported formats to pysubs2 in auto mode
- *proxy*: Remove regional restrictions from WindscribeVPN
- *proxy*: Collect servers from all locations in WindscribeVPN
- *downloader*: Correct progress bar tracking for segmented downloads
- *binaries*: Search subdirectories for binary files
- *dash*: Handle high startNumber in SegmentTimeline for DVR manifests
- *drm*: Hide Shaka Packager message for MonaLisa decryption
- *dash*: Add CENC namespace support for PSSH extraction
- *dash*: Preserve MPD DRM instead of overwriting from init segment
- *subtitles*: Preserve sidecar originals
- *mux*: Avoid audio codec suffix on split-audio outputs
- *dl*: Prevent attachment downloads during --skip-dl
- *progress*: Force track bar completion on terminal states
- *progress*: Bind per-track bars and force terminal completion
- *dl*: Keep descriptive and standard audio for requested langs
- *dl*: Always clean up hybrid temp hevc outputs
- *hls*: Finalize n_m3u8dl_re outputs
- *downloader*: Restore requests progress for single-url downloads
- *dl*: Invert audio codec suffixing when splitting
- *dl*: Support snake_case keys for RemoteCdm
- *aria2c*: Warn on config mismatch and wait for RPC ready
- *serve*: [**breaking**] Make PlayReady users config consistently a mapping
- *dl*: Preserve proxy_query selector (not resolved URI)
- *gluetun*: Stop leaking proxy/vpn secrets to process list
- *monalisa*: Avoid leaking secrets and add worker safety
- *dl*: Avoid selecting all variants when multiple audio codecs requested
- *hls*: Keep range offset numeric and align MonaLisa licensing
- *titles*: Remove trailing space from HDR dynamic range label
- *config*: Normalize playready_remote remote_cdm keys
- *titles*: Avoid None/double spaces in HDR tokens
- *naming*: Keep technical tokens with scene_naming off
- *api*: Log PSSH extraction failures
- *proxies*: Harden surfshark and windscribe selection
- *service*: Redact proxy credentials in logs
- *monalisa*: Harden wasm calls and license handling
- *hls*: Remove no-op encryption_data reassignment
- *serve*: Default PlayReady access to none
- *tracks*: Close temp session and improve path type error
- *main*: Update copyright year dynamically in version display
### Reverts
- *monalisa*: Pass key via argv again
### Documentation
- Add configuration documentation WIP
- *changelog*: Add 2.4.0 release notes
- *changelog*: Update cliff config and regenerate changelog
- *changelog*: Complete 2.4.0 notes
- *config*: Clarify sdh_method uses subtitle-filter
### Performance Improvements
- *aria2c*: Improve download performance with singleton manager
### Changes
- *remote_auth*: Remove unused requests.Session
- Remove remote-service code until feature is more complete
### Maintenance
- *api*: Remove remote services
### Chore
- *release*: [**breaking**] Bump version to 3.0.0
## [2.3.0] - 2026-01-18 ## [2.3.0] - 2026-01-18
### Added ### Added
@@ -117,12 +117,12 @@ key_vaults:
#- type: SQLite #- type: SQLite
# name: Local vault # name: Local vault
# path: key_store.db # path: key_store.db
- type: HTTP - type: HTTPAPI
name: DRMLab name: drmlab
host: http://api.drmlab.io/vault/ host: http://api.drmlab.io/vault/
password: gEX75q7I5YVkvgF5SUkcNd41IbGrDtTT password: gEX75q7I5YVkvgF5SUkcNd41IbGrDtTT
api_mode: json
no_push: false
# Choose what software to use to download data # Choose what software to use to download data
downloader: n_m3u8dl_re downloader: n_m3u8dl_re
@@ -28,7 +28,7 @@ class ALL4(Service):
Service code for Channel 4's All4 streaming service (https://channel4.com). Service code for Channel 4's All4 streaming service (https://channel4.com).
\b \b
Version: 1.0.1 Version: 1.0.2
Author: stabbedbybrick Author: stabbedbybrick
Authorization: Credentials Authorization: Credentials
Robustness: Robustness:
@@ -177,7 +177,7 @@ class ALL4(Service):
title=data["brand"]["title"], title=data["brand"]["title"],
season=episode["seriesNumber"], season=episode["seriesNumber"],
number=episode["episodeNumber"], number=episode["episodeNumber"],
name=episode["originalTitle"], name=self._clean_episode(episode.get("originalTitle", "")),
language="en", language="en",
data=episode["assetInfo"].get("streaming") or episode["assetInfo"].get("download"), data=episode["assetInfo"].get("streaming") or episode["assetInfo"].get("download"),
) )
@@ -194,7 +194,7 @@ class ALL4(Service):
title=data["brand"]["title"], title=data["brand"]["title"],
season=data["selectedEpisode"]["seriesNumber"] or 0, season=data["selectedEpisode"]["seriesNumber"] or 0,
number=data["selectedEpisode"]["episodeNumber"] or 0, number=data["selectedEpisode"]["episodeNumber"] or 0,
name=data["selectedEpisode"]["originalTitle"], name=self._clean_episode(data.get("selectedEpisode", {}).get("originalTitle", "")),
language="en", language="en",
data=data["selectedEpisode"], data=data["selectedEpisode"],
) )
@@ -225,7 +225,7 @@ class ALL4(Service):
title=data["brand"]["title"], title=data["brand"]["title"],
season=episode["seriesNumber"], season=episode["seriesNumber"],
number=episode["episodeNumber"], number=episode["episodeNumber"],
name=episode["originalTitle"], name=self._clean_episode(episode.get("originalTitle", "")),
language="en", language="en",
data=episode["assetInfo"].get("streaming") or episode["assetInfo"].get("download"), data=episode["assetInfo"].get("streaming") or episode["assetInfo"].get("download"),
) )
@@ -308,7 +308,7 @@ class ALL4(Service):
return r.json()["license"] return r.json()["license"]
# Service specific functions # Service-specific functions
def sort_assets(self, title: Union[Movie, Episode], android_assets: tuple, web_assets: tuple) -> tuple: def sort_assets(self, title: Union[Movie, Episode], android_assets: tuple, web_assets: tuple) -> tuple:
android_heights = None android_heights = None
@@ -417,3 +417,7 @@ class ALL4(Service):
except Exception: except Exception:
self.log.error(f"Failed to get episode for {url}") self.log.error(f"Failed to get episode for {url}")
sys.exit(1) sys.exit(1)
@staticmethod
def _clean_episode(text: str) -> str:
return re.sub(r"(?i)(?:series|season|episode|ep|s|e)\s*\d+\s*(?:episode|ep|e)?\s*\d*\s*:?\s*", "", text)
@@ -24,7 +24,7 @@ class AUBC(Service):
Service code for ABC iView streaming service (https://iview.abc.net.au/). Service code for ABC iView streaming service (https://iview.abc.net.au/).
\b \b
Version: 1.0.3 Version: 1.0.5
Author: stabbedbybrick Author: stabbedbybrick
Authorization: None Authorization: None
Robustness: Robustness:
@@ -119,8 +119,8 @@ class AUBC(Service):
captions = next((x.get("captions") for x in playlist if x["type"] == "program"), None) captions = next((x.get("captions") for x in playlist if x["type"] == "program"), None)
title.data["protected"] = streams.get("protected", False) title.data["protected"] = streams.get("protected", False)
if "720" in streams: if hd := streams.get("720"):
streams["1080"] = streams["720"].replace("720", "1080") streams["1080"] = hd.replace("720.mpd", "1080.mpd")
manifest = next( manifest = next(
(url for key in ["1080", "720", "sd", "sd-low"] if key in streams (url for key in ["1080", "720", "sd", "sd-low"] if key in streams
@@ -180,8 +180,11 @@ class AUBC(Service):
def _series(self, title: str) -> Episode: def _series(self, title: str) -> Episode:
data = self._request("GET", "/v3/series/{}".format(title)) data = self._request("GET", "/v3/series/{}".format(title))
content = data if isinstance(data, list) else [data]
seasons = data if isinstance(data, list) else [data] # Remove duplicate season entries
seen = set()
seasons = [x for x in content if (key := x.get("id")) not in seen and not seen.add(key)]
episodes = [ episodes = [
self.create_episode(episode) self.create_episode(episode)
@@ -24,7 +24,7 @@ class CWTV(Service):
Service code for CWTV streaming service (https://www.cwtv.com/). Service code for CWTV streaming service (https://www.cwtv.com/).
\b \b
Version: 1.0.1 Version: 1.0.2
Author: stabbedbybrick Author: stabbedbybrick
Authorization: None Authorization: None
Geofence: US (API and downloads) Geofence: US (API and downloads)
@@ -171,7 +171,7 @@ class CWTV(Service):
# Service specific # Service specific
def _series(self, guid: str) -> list[Episode]: def _series(self, guid: str) -> list[Episode]:
series = self._request("GET", f"/feed/app-2/videos/show_{guid}/type_episodes/apiversion_24/device_androidtv") series = self._request("GET", f"/feed/app-2/videos/show_{guid}/type_episodes/apiversion_25/device_androidtv")
if not series.get("items"): if not series.get("items"):
raise ValueError(f"Could not find any episodes with ID {guid}") raise ValueError(f"Could not find any episodes with ID {guid}")
@@ -193,7 +193,7 @@ class CWTV(Service):
return episodes return episodes
def _movie(self, guid: str) -> Movie: def _movie(self, guid: str) -> Movie:
data = self._request("GET", f"/feed/app-2/videos/show_{guid}/type_episodes/apiversion_24/device_androidtv") data = self._request("GET", f"/feed/app-2/videos/show_{guid}/type_episodes/apiversion_25/device_androidtv")
if not data.get("items"): if not data.get("items"):
raise ValueError(f"Could not find any data for ID {guid}") raise ValueError(f"Could not find any data for ID {guid}")
@@ -212,7 +212,7 @@ class CWTV(Service):
return movies return movies
def _episode(self, guid: str) -> Episode: def _episode(self, guid: str) -> Episode:
data = self._request("GET", f"/feed/app-2/video-meta/guid_{guid}/apiversion_24/device_androidtv") data = self._request("GET", f"/feed/app-2/video-meta/guid_{guid}/apiversion_25/device_androidtv")
if not data.get("video"): if not data.get("video"):
raise ValueError(f"Could not find any data for ID {guid}") raise ValueError(f"Could not find any data for ID {guid}")
@@ -238,7 +238,9 @@ class CWTV(Service):
response = self.session.send(prep) response = self.session.send(prep)
if response.status_code != 200: if response.status_code != 200:
raise ConnectionError(f"{response.text}") if response.status_code == 452:
raise ConnectionError(f"{response.status_code} - possible geo-restriction")
raise ConnectionError(f"{response.content}")
try: try:
return json.loads(response.content) return json.loads(response.content)
@@ -2,7 +2,7 @@ headers:
User-Agent: Mozilla/5.0 (Linux; Android 11; Smart TV Build/AR2101; wv) User-Agent: Mozilla/5.0 (Linux; Android 11; Smart TV Build/AR2101; wv)
endpoints: endpoints:
base_url: https://images.cwtv.com base_url: https://data.cwtv.com
playback: https://edge.api.brightcove.com/playback/v1/accounts/6415823816001/videos/{} playback: https://edge.api.brightcove.com/playback/v1/accounts/6415823816001/videos/{}
policy_key: BCpkADawqM0t2qFXB_K2XdHv2JmeRgQjpP6De9_Fl7d4akhL5aeqYwErorzsAxa7dyOF2FdxuG5wWVOREHEwb0DI-M8CGBBDpqwvDBEPfDKQg7kYGnccdNDErkvEh2O28CrGR3sEG6MZBlZ03I0xH7EflYKooIhfwvNWWw policy_key: BCpkADawqM0t2qFXB_K2XdHv2JmeRgQjpP6De9_Fl7d4akhL5aeqYwErorzsAxa7dyOF2FdxuG5wWVOREHEwb0DI-M8CGBBDpqwvDBEPfDKQg7kYGnccdNDErkvEh2O28CrGR3sEG6MZBlZ03I0xH7EflYKooIhfwvNWWw
@@ -33,7 +33,7 @@ class DSCP(Service):
Credit to @sp4rk.y for the subtitle fix. Credit to @sp4rk.y for the subtitle fix.
\b \b
Version: 1.0.1 Version: 1.0.3
Author: stabbedbybrick Author: stabbedbybrick
Authorization: Cookies for subscription, none for freely available titles Authorization: Cookies for subscription, none for freely available titles
Robustness: Robustness:
@@ -291,26 +291,31 @@ class DSCP(Service):
return tracks return tracks
def get_chapters(self, title: Movie | Episode) -> Chapters: def get_chapters(self, title: Movie | Episode) -> Chapters:
if not title.data.get("chapters"): if not (chapters := title.data.get("chapters")):
return Chapters() return Chapters()
chapters = [] ch_map = {0: Chapter(timestamp=0)}
for chapter in title.data["chapters"]:
if "recap" in chapter.get("secondaryType", "").lower():
chapters.append(Chapter(name="Recap", timestamp=chapter["start"]))
if chapter.get("end"):
chapters.append(Chapter(timestamp=chapter.get("end")))
if "intro" in chapter.get("secondaryType", "").lower():
chapters.append(Chapter(name="Intro", timestamp=chapter["start"]))
if chapter.get("end"):
chapters.append(Chapter(timestamp=chapter.get("end")))
elif "credits" in chapter.get("type", "").lower():
chapters.append(Chapter(name="Credits", timestamp=chapter["start"]))
if not any(c.timestamp == "00:00:00.000" for c in chapters): for c in chapters:
chapters.append(Chapter(timestamp=0)) s_type, c_type = c.get("secondaryType", "").lower(), c.get("type", "").lower()
return sorted(chapters, key=lambda x: x.timestamp) name = None
if "recap" in s_type:
name = "Recap"
elif "intro" in s_type:
name = "Intro"
elif "credits" in c_type:
name = "Credits"
if (end := c.get("end")) is not None:
ch_map.setdefault(end, Chapter(timestamp=end))
if (start := c.get("start")) is not None:
if start not in ch_map or name:
ch_map[start] = Chapter(name=name, timestamp=start)
sorted_chapters = sorted(ch_map.values(), key=lambda x: x.timestamp)
return Chapters(sorted_chapters)
def get_widevine_service_certificate(self, challenge: bytes, title: Episode | Movie, **_: Any) -> str: def get_widevine_service_certificate(self, challenge: bytes, title: Episode | Movie, **_: Any) -> str:
if not (license_url := title.data.get("license_url")): if not (license_url := title.data.get("license_url")):
@@ -515,13 +520,17 @@ class DSCP(Service):
] ]
def _sport(self, title: str) -> Movie: def _sport(self, title: str) -> Movie:
title_id = title
if len(title_id.split("/")) > 1:
title_id = title_id.split("/")[-1]
params = { params = {
"include": "default", "include": "default",
"decorators": "viewingHistory,badges,isFavorite,contentAction", "decorators": "viewingHistory,badges,isFavorite,contentAction",
} }
data = self._request("GET", "/cms/routes/sport/{}".format(title), params=params) data = self._request("GET", "/cms/routes/sport/{}".format(title), params=params)
content = next((x for x in data["included"] if x.get("attributes", {}).get("alternateId", "") == title), None) content = next((x for x in data["included"] if x.get("attributes", {}).get("alternateId", "") == title_id), None)
if not content: if not content:
raise ValueError(f"Content not found for title: {title}") raise ValueError(f"Content not found for title: {title}")
@@ -24,7 +24,7 @@ class ITV(Service):
Service code for ITVx streaming service (https://www.itv.com/). Service code for ITVx streaming service (https://www.itv.com/).
\b \b
Version: 1.0.2 Version: 1.0.3
Author: stabbedbybrick Author: stabbedbybrick
Authorization: Cookies (Optional for free content | Required for premium content) Authorization: Cookies (Optional for free content | Required for premium content)
Robustness: Robustness:
@@ -97,7 +97,7 @@ class ITV(Service):
headers = { headers = {
"Host": "auth.prd.user.itv.com", "Host": "auth.prd.user.itv.com",
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:126.0) Gecko/20100101 Firefox/126.0", "User-Agent": "okhttp/4.9.3",
"Accept": "application/vnd.user.auth.v2+json", "Accept": "application/vnd.user.auth.v2+json",
"Accept-Language": "en-US,en;q=0.8", "Accept-Language": "en-US,en;q=0.8",
"Origin": "https://www.itv.com", "Origin": "https://www.itv.com",
@@ -22,7 +22,7 @@ class PLUTO(Service):
Credit to @wks_uwu for providing an alternative API, making the codebase much cleaner Credit to @wks_uwu for providing an alternative API, making the codebase much cleaner
\b \b
Version: 1.0.2 Version: 1.0.3
Author: stabbedbybrick Author: stabbedbybrick
Authorization: None Authorization: None
Robustness: Robustness:
@@ -55,7 +55,7 @@ class PLUTO(Service):
ALIASES = ("plu", "plutotv") ALIASES = ("plu", "plutotv")
TITLE_RE = ( TITLE_RE = (
r"^" r"^"
r"(?:https?://(?:www\.)?pluto\.tv(?:/[a-z]{2})?)?" r"(?:https?://(?:www\.)?pluto\.tv(?:/[a-z]{2,})?)?"
r"(?:/on-demand)?" r"(?:/on-demand)?"
r"/(?P<type>movies|series)" r"/(?P<type>movies|series)"
r"/(?P<id>[a-z0-9-]+)" r"/(?P<id>[a-z0-9-]+)"
@@ -24,7 +24,7 @@ class RTE(Service):
Service code for RTE Player streaming service (https://www.rte.ie/player/). Service code for RTE Player streaming service (https://www.rte.ie/player/).
\b \b
Version: 1.0.3 Version: 1.0.6
Author: stabbedbybrick Author: stabbedbybrick
Authorization: None Authorization: None
Robustness: Robustness:
@@ -46,6 +46,13 @@ class RTE(Service):
""" """
# GEOFENCE = ("ie",) # GEOFENCE = ("ie",)
TITLE_RE = (
r"https://www\.rte\.ie/player"
r"/(?P<type>series|movie)"
r"/(?P<slug>[a-zA-Z0-9%_.-]+)"
r"/(?P<id>[a-zA-Z0-9_\-=?]+)/?$"
)
SEASON_RE = re.compile(r"S(\d+)\s*E\d+", re.IGNORECASE)
@staticmethod @staticmethod
@click.command(name="RTE", short_help="https://www.rte.ie/player/", help=__doc__) @click.command(name="RTE", short_help="https://www.rte.ie/player/", help=__doc__)
@@ -62,10 +69,12 @@ class RTE(Service):
self.feed = self.config["endpoints"]["feed"] self.feed = self.config["endpoints"]["feed"]
self.license = self.config["endpoints"]["license"] self.license = self.config["endpoints"]["license"]
self.session.headers.update(self.config["headers"])
def search(self) -> Generator[SearchResult, None, None]: def search(self) -> Generator[SearchResult, None, None]:
params = { params = {
"byProgramType": "Series|Movie", "byProgramType": "Series|Movie",
"q": f"title:({self.title})", "q": f"*{self.title}*",
"range": "0-40", "range": "0-40",
"schema": "2.15", "schema": "2.15",
"sort": "rte$rank|desc", "sort": "rte$rank|desc",
@@ -94,14 +103,8 @@ class RTE(Service):
) )
def get_titles(self) -> Titles_T: def get_titles(self) -> Titles_T:
title_re = (
r"https://www\.rte\.ie/player"
r"/(?P<type>series|movie)"
r"/(?P<slug>[a-zA-Z0-9%_.-]+)"
r"/(?P<id>[a-zA-Z0-9_\-=?]+)/?$"
)
try: try:
kind, _, title_id = (re.match(title_re, self.title).group(i) for i in ("type", "slug", "id")) kind, _, title_id = (re.match(self.TITLE_RE, self.title).group(i) for i in ("type", "slug", "id"))
except Exception: except Exception:
raise ValueError("- Could not parse ID from input") raise ValueError("- Could not parse ID from input")
@@ -168,7 +171,7 @@ class RTE(Service):
return r.json()["getWidevineLicenseResponse"]["license"] return r.json()["getWidevineLicenseResponse"]["license"]
# Service specific functions # Service-specific functions
def _movie(self, title: str) -> Movie: def _movie(self, title: str) -> Movie:
params = {"count": "true", "entries": "true", "byId": title} params = {"count": "true", "entries": "true", "byId": title}
@@ -180,49 +183,74 @@ class RTE(Service):
service=self.__class__, service=self.__class__,
name=movie.get("plprogram$longTitle"), name=movie.get("plprogram$longTitle"),
year=movie.get("plprogram$year"), year=movie.get("plprogram$year"),
language=movie["plprogram$languages"][0] if movie.get("plprogram$languages") else "eng", language=(movie.get("plprogram$languages") or ["eng"])[0],
data=movie, data=movie,
) )
for movie in data for movie in data
] ]
def _get_season(self, episode: dict) -> int:
val = episode.get("plprogram$tvSeasonNumber")
if val is not None:
try:
return int(val)
except (ValueError, TypeError):
pass
if match := self.SEASON_RE.search(episode.get("title", "")):
return int(match.group(1))
return 0
def _show(self, title: str) -> Episode: def _show(self, title: str) -> Episode:
entry = self._request("/mpx/1uC-gC/rte-prd-prd-all-movies-series?byGuid={}".format(title))["entries"][0]["id"] series_search = self._request(f"/mpx/1uC-gC/rte-prd-prd-all-movies-series?byGuid={title}")
data = self._request("/mpx/1uC-gC/rte-prd-prd-all-programs?bySeriesId={}".format(entry.split("/")[-1]))["entries"] if not (entries := series_search.get("entries", [])):
raise ValueError("No entries for series found")
series_id = entries[0]["id"].split("/")[-1]
episode_data = self._request(
f"/mpx/1uC-gC/rte-prd-prd-all-programs?bySeriesId={series_id}&byProgramType=episode"
).get("entries", [])
return [ return [
Episode( Episode(
id_=episode.get("guid"), id_=episode.get("guid"),
title=episode.get("plprogram$longTitle"), title=episode.get("plprogram$longTitle"),
season=episode.get("plprogram$tvSeasonNumber") or 0, season=self._get_season(episode),
number=episode.get("plprogram$tvSeasonEpisodeNumber") or 0, number=episode.get("plprogram$tvSeasonEpisodeNumber") or 0,
name=episode.get("description"), name=episode.get("description"),
language=episode["plprogram$languages"][0] if episode.get("plprogram$languages") else "eng", language=(episode.get("plprogram$languages") or ["eng"])[0],
service=self.__class__, service=self.__class__,
year=episode.get("plprogram$year"),
data=episode, data=episode,
) )
for episode in data for episode in episode_data
if episode["plprogram$programType"] == "episode"
] ]
def _episode(self, title: str, guid: str) -> Episode: def _episode(self, title: str, guid: str) -> Episode:
title = title.split("?")[0] title = title.split("?")[0]
entry = self._request("/mpx/1uC-gC/rte-prd-prd-all-movies-series?byGuid={}".format(title))["entries"][0]["id"] series_search = self._request(f"/mpx/1uC-gC/rte-prd-prd-all-movies-series?byGuid={title}")
data = self._request("/mpx/1uC-gC/rte-prd-prd-all-programs?bySeriesId={}".format(entry.split("/")[-1]))["entries"] if not (entries := series_search.get("entries", [])):
raise ValueError("No entries for series found")
series_id = entries[0]["id"].split("/")[-1]
episode_data = self._request(
f"/mpx/1uC-gC/rte-prd-prd-all-programs?bySeriesId={series_id}&byProgramType=episode&byGuid={guid}"
).get("entries", [])
return [ return [
Episode( Episode(
id_=episode.get("guid"), id_=episode.get("guid"),
title=episode.get("plprogram$longTitle"), title=episode.get("plprogram$longTitle"),
season=episode.get("plprogram$tvSeasonNumber") or 0, season=self._get_season(episode),
number=episode.get("plprogram$tvSeasonEpisodeNumber") or 0, number=episode.get("plprogram$tvSeasonEpisodeNumber") or 0,
name=episode.get("description"), name=episode.get("description"),
language=episode["plprogram$languages"][0] if episode.get("plprogram$languages") else "eng", language=(episode.get("plprogram$languages") or ["eng"])[0],
service=self.__class__, service=self.__class__,
year=episode.get("plprogram$year"),
data=episode, data=episode,
) )
for episode in data for episode in episode_data
if episode["plprogram$programType"] == "episode" and episode.get("guid") == guid
] ]
def get_config(self): def get_config(self):
@@ -261,16 +289,10 @@ class RTE(Service):
"Content may be geo-restricted to IE" "Content may be geo-restricted to IE"
) )
def _request(self, api: str, params: dict = None, headers: dict = None) -> Any[dict | str]: def _request(self, api: str, **kwargs) -> Any[dict | str]:
url = urljoin(self.base_url, api) url = urljoin(self.base_url, api)
self.session.headers.update(self.config["headers"])
if params: prep = self.session.prepare_request(Request("GET", url, **kwargs))
self.session.params.update(params)
if headers:
self.session.headers.update(headers)
prep = self.session.prepare_request(Request("GET", url))
response = self.session.send(prep) response = self.session.send(prep)
if response.status_code != 200: if response.status_code != 200:
@@ -21,7 +21,7 @@ class STV(Service):
Service code for STV Player streaming service (https://player.stv.tv/). Service code for STV Player streaming service (https://player.stv.tv/).
\b \b
Version: 1.0.1 Version: 1.0.3
Author: stabbedbybrick Author: stabbedbybrick
Authorization: None Authorization: None
Robustness: Robustness:
@@ -108,36 +108,34 @@ class STV(Service):
if episode.get("playerSeries") and re.match(r"Series \d+", episode["playerSeries"]["name"]) if episode.get("playerSeries") and re.match(r"Series \d+", episode["playerSeries"]["name"])
else 0, else 0,
number=int(episode.get("number", 0)), number=int(episode.get("number", 0)),
name=episode.get("title", "").lstrip("0123456789. ").lstrip(), name=re.sub(r"^\d+\.\s+", "", episode.get("title", "")),
language="en", language="en",
data=episode, data=episode,
) )
] ]
elif kind == "summary": elif kind == "summary":
r = self.session.get(self.base + f"programmes/{slug}") r = self.session.get(self.base + f"episodes?programme.guid={slug}&limit=999")
r.raise_for_status() if not r.ok:
data = r.json() raise ConnectionError(f"Failed to find content for {slug}")
series = [series.get("guid") for series in data["results"]["series"]] data = r.json()
seasons = [self.session.get(self.base + f"episodes?series.guid={i}").json() for i in series]
episodes = [ episodes = [
Episode( Episode(
id_=episode["video"].get("id"), id_=episode["video"].get("id"),
service=self.__class__, service=self.__class__,
title=data["results"].get("name"), title=episode["programme"].get("name"),
season=int(episode["playerSeries"]["name"].split(" ")[1]) season=int(episode["playerSeries"]["name"].split(" ")[1])
if episode.get("playerSeries") if episode.get("playerSeries")
and re.match(r"Series \d+", episode["playerSeries"]["name"]) and re.match(r"Series \d+", episode["playerSeries"]["name"])
else 0, else 0,
number=int(episode.get("number", 0)), number=int(episode.get("number", 0)),
name=episode.get("title", "").lstrip("0123456789. ").lstrip(), name=re.sub(r"^\d+\.\s+", "", episode.get("title", "")),
language="en", language="en",
data=episode, data=episode,
) )
for season in seasons for episode in data["results"]
for episode in season["results"]
] ]
self.session.headers.pop("stv-drm") self.session.headers.pop("stv-drm")
@@ -1,7 +1,6 @@
from __future__ import annotations from __future__ import annotations
import hashlib import hashlib
import os
import re import re
import sys import sys
import uuid import uuid
@@ -13,12 +12,12 @@ import click
from langcodes import Language from langcodes import Language
from pyplayready.cdm import Cdm as PlayReadyCdm from pyplayready.cdm import Cdm as PlayReadyCdm
from envied.core.credential import Credential from envied.core.credential import Credential
from envied.core.downloaders import aria2c, requests from envied.core.downloaders import requests
from envied.core.manifests import DASH from envied.core.manifests import DASH
from envied.core.search_result import SearchResult from envied.core.search_result import SearchResult
from envied.core.service import Service from envied.core.service import Service
from envied.core.titles import Episode, Movie, Movies, Series, Title_T, Titles_T from envied.core.titles import Episode, Movie, Movies, Series, Title_T, Titles_T
from envied.core.tracks import Audio, Chapter, Chapters, Subtitle, Track, Tracks from envied.core.tracks import Audio, Chapter, Chapters, Subtitle, Tracks
class TUBI(Service): class TUBI(Service):
@@ -26,7 +25,7 @@ class TUBI(Service):
Service code for TubiTV streaming service (https://tubitv.com/) Service code for TubiTV streaming service (https://tubitv.com/)
\b \b
Version: 1.0.6 Version: 1.0.7
Author: stabbedbybrick Author: stabbedbybrick
Authorization: Cookies (Optional) Authorization: Cookies (Optional)
Geofence: Locked to whatever region the user is in (API only) Geofence: Locked to whatever region the user is in (API only)
@@ -35,8 +34,6 @@ class TUBI(Service):
L3: 1080p, AAC2.0 L3: 1080p, AAC2.0
PlayReady: PlayReady:
SL2000: 1080p, AAC2.0 SL2000: 1080p, AAC2.0
Clear:
1080p, AAC2.0
\b \b
Tips: Tips:
@@ -44,14 +41,11 @@ class TUBI(Service):
/series/300001423/gotham /series/300001423/gotham
/tv-shows/200024793/s01-e01-pilot /tv-shows/200024793/s01-e01-pilot
/movies/589279/the-outsiders /movies/589279/the-outsiders
- Use '-v H.265' to request HEVC tracks.
\b \b
Notes: Notes:
- Authentication is currently not required, but cookies are used if provided. - Authorization is currently only required for search, not for content.
- If 1080p exists, it's currently only available as H.265. - If 1080p exists, it's typically only available as HEVC/H.265.
- Unshackle fails to mux properly when n_m3u8dl_re is used, so aria2c is forced as downloader.
- Search is currently disabled.
""" """
TITLE_RE = r"^(?:https?://(?:www\.)?tubitv\.com?)?/(?:[a-z]{2}-[a-z]{2}/)?(?P<type>movies|series|tv-shows)/(?P<id>[a-z0-9-]+)" TITLE_RE = r"^(?:https?://(?:www\.)?tubitv\.com?)?/(?:[a-z]{2}-[a-z]{2}/)?(?P<type>movies|series|tv-shows)/(?P<id>[a-z0-9-]+)"
@@ -64,15 +58,12 @@ class TUBI(Service):
return TUBI(ctx, **kwargs) return TUBI(ctx, **kwargs)
def __init__(self, ctx, title): def __init__(self, ctx, title):
self.title = title
super().__init__(ctx) super().__init__(ctx)
self.title = title
cdm = ctx.obj.cdm cdm = ctx.obj.cdm
self.drm_system = "playready" if isinstance(cdm, PlayReadyCdm) else "widevine" self.drm_system = "playready" if isinstance(cdm, PlayReadyCdm) else "widevine"
vcodec = ctx.parent.params.get("vcodec")
self.vcodec = "H264" if vcodec is None else "H265"
def authenticate(self, cookies: Optional[MozillaCookieJar] = None, credential: Optional[Credential] = None) -> None: def authenticate(self, cookies: Optional[MozillaCookieJar] = None, credential: Optional[Credential] = None) -> None:
super().authenticate(cookies, credential) super().authenticate(cookies, credential)
self.auth_token = None self.auth_token = None
@@ -80,40 +71,43 @@ class TUBI(Service):
self.auth_token = next((cookie.value for cookie in cookies if cookie.name == "at"), None) self.auth_token = next((cookie.value for cookie in cookies if cookie.name == "at"), None)
self.session.headers.update({"Authorization": f"Bearer {self.auth_token}"}) self.session.headers.update({"Authorization": f"Bearer {self.auth_token}"})
# Disable search for now def search(self) -> Generator[SearchResult, None, None]:
# def search(self) -> Generator[SearchResult, None, None]: if not self.auth_token:
# params = { self.log.error(" - Error: Search requires authentication/cookies.")
# "search": self.title, return
# "include_linear": "true",
# "include_channels": "false",
# "is_kids_mode": "false",
# }
# r = self.session.get(self.config["endpoints"]["search"], params=params) params = {
# r.raise_for_status() "search": self.title,
# results = r.json() "include_linear": "false",
# from devine.core.console import console "include_channels": "false",
# console.print(results) "is_kids_mode": "false",
# exit() }
# for result in results: r = self.session.get(self.config["endpoints"]["search"], params=params)
# label = "series" if result["type"] == "s" else "movies" if result["type"] == "v" else result["type"] r.raise_for_status()
# title = ( results = r.json()
# result.get("title", "")
# .lower() search_results = [x for x in results["contents"].values()]
# .replace(" ", "-")
# .replace(":", "") for result in search_results:
# .replace("(", "") label = "series" if result["type"] == "s" else "movies" if result["type"] == "v" else result["type"]
# .replace(")", "") title = (
# .replace(".", "") result.get("title", "")
# ) .lower()
# yield SearchResult( .replace(" ", "-")
# id_=f"https://tubitv.com/{label}/{result.get('id')}/{title}", .replace(":", "")
# title=result.get("title"), .replace("(", "")
# description=result.get("description"), .replace(")", "")
# label=label, .replace(".", "")
# url=f"https://tubitv.com/{label}/{result.get('id')}/{title}", .replace("'", "-")
# ) )
yield SearchResult(
id_=f"https://tubitv.com/{label}/{result.get('id')}/{title}",
title=result.get("title"),
description=result.get("description"),
label=label,
url=f"https://tubitv.com/{label}/{result.get('id')}/{title}",
)
def get_titles(self) -> Titles_T: def get_titles(self) -> Titles_T:
try: try:
@@ -209,41 +203,27 @@ class TUBI(Service):
self.log.info(f"Title is available in: {title.data.get('country')}") self.log.info(f"Title is available in: {title.data.get('country')}")
sys.exit(1) sys.exit(1)
codecs = [x.get("codec") for x in resources] tracks = Tracks()
if not any(self.vcodec in x for x in codecs):
raise ValueError(f"Could not find a {self.vcodec} video resource for this title")
for codec in ["h264", "h265"]:
resource = next(( resource = next((
x for x in resources x for x in resources
if self.drm_system in x.get("type", "") and self.vcodec in x.get("codec", "") if self.drm_system in x.get("type", "") and codec in x.get("codec", "").lower()
), None) or next((
x for x in resources
if self.drm_system not in x.get("type", "") and
"dash" in x.get("type", "") and
self.vcodec in x.get("codec", "")
), None) ), None)
if not resource: if not resource:
raise ValueError("Could not find a video resource for this title") self.log.warning(f" - Could not find a {codec} video resource for this title")
continue
manifest = resource.get("manifest", {}).get("url") manifest = resource.get("manifest", {}).get("url")
if not manifest:
raise ValueError("Could not find a manifest for this title")
title.data["license_url"] = resource.get("license_server", {}).get("url") title.data["license_url"] = resource.get("license_server", {}).get("url")
tracks = DASH.from_url(url=manifest, session=self.session).to_tracks(language=title.language) tracks.add(DASH.from_url(url=manifest, session=self.session).to_tracks(language=title.language))
for track in tracks:
rep_base = track.data["dash"]["representation"].find("BaseURL")
if rep_base is not None:
base_url = os.path.dirname(track.url)
track_base = rep_base.text
track.url = f"{base_url}/{track_base}"
track.descriptor = Track.Descriptor.URL
track.downloader = aria2c
for track in tracks:
if isinstance(track, Audio): if isinstance(track, Audio):
role = track.data["dash"]["adaptation_set"].find("Role") role = track.data["dash"]["adaptation_set"].find("Role")
if role is not None and role.get("value") in ["description", "alternative", "alternate"]: if role is not None and role.get("value").lower() in ["description", "alternative", "alternate"]:
track.descriptive = True track.descriptive = True
if title.data.get("subtitles"): if title.data.get("subtitles"):
@@ -1,5 +1,5 @@
endpoints: endpoints:
content: https://uapi.adrise.tv/cms/content # https://content-cdn.production-public.tubi.io/api/v2/content content: https://uapi.adrise.tv/cms/content # https://content-cdn.production-public.tubi.io/api/v2/content
search: https://search.production-public.tubi.io/api/v1/search search: https://search.production-public.tubi.io/api/v2/search
@@ -31,7 +31,7 @@ class iP(Service):
Service code for the BBC iPlayer streaming service (https://www.bbc.co.uk/iplayer). Service code for the BBC iPlayer streaming service (https://www.bbc.co.uk/iplayer).
\b \b
Version: 1.0.2 Version: 1.0.3
Author: stabbedbybrick Author: stabbedbybrick
Authorization: None Authorization: None
Security: None Security: None
@@ -60,12 +60,12 @@ class iP(Service):
def __init__(self, ctx: Context, title: str): def __init__(self, ctx: Context, title: str):
super().__init__(ctx) super().__init__(ctx)
self.title = title self.title = title
self.vcodec = ctx.parent.params.get("vcodec") self.vcodec = next(iter(self.track_request.codecs or []), None)
self.range = ctx.parent.params.get("range_") self.range = next(iter(self.track_request.ranges or []), None)
self.session.headers.update({"user-agent": "BBCiPlayer/5.17.2.32046"}) self.session.headers.update({"user-agent": "BBCiPlayer/5.17.2.32046"})
if self.range and self.range[0].name == "HLG": if self.range and self.range.name == "HLG":
if not self.config.get("certificate"): if not self.config.get("certificate"):
raise CertificateMissingError("HLG/H.265 tracks cannot be requested without a TLS certificate.") raise CertificateMissingError("HLG/H.265 tracks cannot be requested without a TLS certificate.")