From 1486c4bd37e824b003b8214c6ba657ec518379a9 Mon Sep 17 00:00:00 2001 From: VineFeeder Date: Sat, 21 Mar 2026 10:56:22 +0000 Subject: [PATCH] service updates --- packages/envied/src/envied/CHANGELOG.md | 152 ------------------ .../src/envied/envied-working-example.yaml | 8 +- .../src/envied/services/ALL4/__init__.py | 14 +- .../src/envied/services/AUBC/__init__.py | 13 +- .../src/envied/services/CWTV/__init__.py | 12 +- .../src/envied/services/CWTV/config.yaml | 2 +- .../src/envied/services/DSCP/__init__.py | 47 +++--- .../src/envied/services/ITV/__init__.py | 4 +- .../src/envied/services/PLUTO/__init__.py | 4 +- .../src/envied/services/RTE/__init__.py | 86 ++++++---- .../src/envied/services/STV/__init__.py | 20 ++- .../src/envied/services/TUBI/__init__.py | 132 +++++++-------- .../src/envied/services/TUBI/config.yaml | 2 +- .../envied/src/envied/services/iP/__init__.py | 8 +- 14 files changed, 185 insertions(+), 319 deletions(-) diff --git a/packages/envied/src/envied/CHANGELOG.md b/packages/envied/src/envied/CHANGELOG.md index 0d7f5d8..954da95 100644 --- a/packages/envied/src/envied/CHANGELOG.md +++ b/packages/envied/src/envied/CHANGELOG.md @@ -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/), 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 ### Added diff --git a/packages/envied/src/envied/envied-working-example.yaml b/packages/envied/src/envied/envied-working-example.yaml index d8b2f43..c9eb64d 100644 --- a/packages/envied/src/envied/envied-working-example.yaml +++ b/packages/envied/src/envied/envied-working-example.yaml @@ -117,12 +117,12 @@ key_vaults: #- type: SQLite # name: Local vault # path: key_store.db -- type: HTTP - name: DRMLab +- type: HTTPAPI + name: drmlab host: http://api.drmlab.io/vault/ password: gEX75q7I5YVkvgF5SUkcNd41IbGrDtTT - api_mode: json - no_push: false + + # Choose what software to use to download data downloader: n_m3u8dl_re diff --git a/packages/envied/src/envied/services/ALL4/__init__.py b/packages/envied/src/envied/services/ALL4/__init__.py index da3e2c8..5ef72de 100644 --- a/packages/envied/src/envied/services/ALL4/__init__.py +++ b/packages/envied/src/envied/services/ALL4/__init__.py @@ -28,7 +28,7 @@ class ALL4(Service): Service code for Channel 4's All4 streaming service (https://channel4.com). \b - Version: 1.0.1 + Version: 1.0.2 Author: stabbedbybrick Authorization: Credentials Robustness: @@ -177,7 +177,7 @@ class ALL4(Service): title=data["brand"]["title"], season=episode["seriesNumber"], number=episode["episodeNumber"], - name=episode["originalTitle"], + name=self._clean_episode(episode.get("originalTitle", "")), language="en", data=episode["assetInfo"].get("streaming") or episode["assetInfo"].get("download"), ) @@ -194,7 +194,7 @@ class ALL4(Service): title=data["brand"]["title"], season=data["selectedEpisode"]["seriesNumber"] or 0, number=data["selectedEpisode"]["episodeNumber"] or 0, - name=data["selectedEpisode"]["originalTitle"], + name=self._clean_episode(data.get("selectedEpisode", {}).get("originalTitle", "")), language="en", data=data["selectedEpisode"], ) @@ -225,7 +225,7 @@ class ALL4(Service): title=data["brand"]["title"], season=episode["seriesNumber"], number=episode["episodeNumber"], - name=episode["originalTitle"], + name=self._clean_episode(episode.get("originalTitle", "")), language="en", data=episode["assetInfo"].get("streaming") or episode["assetInfo"].get("download"), ) @@ -308,7 +308,7 @@ class ALL4(Service): 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: android_heights = None @@ -417,3 +417,7 @@ class ALL4(Service): except Exception: self.log.error(f"Failed to get episode for {url}") 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) diff --git a/packages/envied/src/envied/services/AUBC/__init__.py b/packages/envied/src/envied/services/AUBC/__init__.py index d779e0c..8f5b44f 100644 --- a/packages/envied/src/envied/services/AUBC/__init__.py +++ b/packages/envied/src/envied/services/AUBC/__init__.py @@ -24,7 +24,7 @@ class AUBC(Service): Service code for ABC iView streaming service (https://iview.abc.net.au/). \b - Version: 1.0.3 + Version: 1.0.5 Author: stabbedbybrick Authorization: None Robustness: @@ -119,8 +119,8 @@ class AUBC(Service): captions = next((x.get("captions") for x in playlist if x["type"] == "program"), None) title.data["protected"] = streams.get("protected", False) - if "720" in streams: - streams["1080"] = streams["720"].replace("720", "1080") + if hd := streams.get("720"): + streams["1080"] = hd.replace("720.mpd", "1080.mpd") manifest = next( (url for key in ["1080", "720", "sd", "sd-low"] if key in streams @@ -130,7 +130,7 @@ class AUBC(Service): ) if not manifest: raise ValueError("Could not find a manifest for this title") - + tracks = DASH.from_url(manifest, self.session).to_tracks(title.language) for track in tracks.audio: @@ -180,8 +180,11 @@ class AUBC(Service): def _series(self, title: str) -> Episode: 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 = [ self.create_episode(episode) diff --git a/packages/envied/src/envied/services/CWTV/__init__.py b/packages/envied/src/envied/services/CWTV/__init__.py index e1c2474..7f5acc3 100644 --- a/packages/envied/src/envied/services/CWTV/__init__.py +++ b/packages/envied/src/envied/services/CWTV/__init__.py @@ -24,7 +24,7 @@ class CWTV(Service): Service code for CWTV streaming service (https://www.cwtv.com/). \b - Version: 1.0.1 + Version: 1.0.2 Author: stabbedbybrick Authorization: None Geofence: US (API and downloads) @@ -171,7 +171,7 @@ class CWTV(Service): # Service specific 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"): raise ValueError(f"Could not find any episodes with ID {guid}") @@ -193,7 +193,7 @@ class CWTV(Service): return episodes 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"): raise ValueError(f"Could not find any data for ID {guid}") @@ -212,7 +212,7 @@ class CWTV(Service): return movies 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"): raise ValueError(f"Could not find any data for ID {guid}") @@ -238,7 +238,9 @@ class CWTV(Service): response = self.session.send(prep) 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: return json.loads(response.content) diff --git a/packages/envied/src/envied/services/CWTV/config.yaml b/packages/envied/src/envied/services/CWTV/config.yaml index c37cf6c..901dd1d 100644 --- a/packages/envied/src/envied/services/CWTV/config.yaml +++ b/packages/envied/src/envied/services/CWTV/config.yaml @@ -2,7 +2,7 @@ headers: User-Agent: Mozilla/5.0 (Linux; Android 11; Smart TV Build/AR2101; wv) endpoints: - base_url: https://images.cwtv.com + base_url: https://data.cwtv.com playback: https://edge.api.brightcove.com/playback/v1/accounts/6415823816001/videos/{} policy_key: BCpkADawqM0t2qFXB_K2XdHv2JmeRgQjpP6De9_Fl7d4akhL5aeqYwErorzsAxa7dyOF2FdxuG5wWVOREHEwb0DI-M8CGBBDpqwvDBEPfDKQg7kYGnccdNDErkvEh2O28CrGR3sEG6MZBlZ03I0xH7EflYKooIhfwvNWWw diff --git a/packages/envied/src/envied/services/DSCP/__init__.py b/packages/envied/src/envied/services/DSCP/__init__.py index de98633..eb92c90 100644 --- a/packages/envied/src/envied/services/DSCP/__init__.py +++ b/packages/envied/src/envied/services/DSCP/__init__.py @@ -33,7 +33,7 @@ class DSCP(Service): Credit to @sp4rk.y for the subtitle fix. \b - Version: 1.0.1 + Version: 1.0.3 Author: stabbedbybrick Authorization: Cookies for subscription, none for freely available titles Robustness: @@ -291,26 +291,31 @@ class DSCP(Service): return tracks def get_chapters(self, title: Movie | Episode) -> Chapters: - if not title.data.get("chapters"): + if not (chapters := title.data.get("chapters")): return Chapters() - chapters = [] - 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): - chapters.append(Chapter(timestamp=0)) + ch_map = {0: Chapter(timestamp=0)} - return sorted(chapters, key=lambda x: x.timestamp) + for c in chapters: + s_type, c_type = c.get("secondaryType", "").lower(), c.get("type", "").lower() + + 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: if not (license_url := title.data.get("license_url")): @@ -515,13 +520,17 @@ class DSCP(Service): ] def _sport(self, title: str) -> Movie: + title_id = title + if len(title_id.split("/")) > 1: + title_id = title_id.split("/")[-1] + params = { "include": "default", "decorators": "viewingHistory,badges,isFavorite,contentAction", } 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: raise ValueError(f"Content not found for title: {title}") diff --git a/packages/envied/src/envied/services/ITV/__init__.py b/packages/envied/src/envied/services/ITV/__init__.py index 8b3b8a0..c7b1492 100644 --- a/packages/envied/src/envied/services/ITV/__init__.py +++ b/packages/envied/src/envied/services/ITV/__init__.py @@ -24,7 +24,7 @@ class ITV(Service): Service code for ITVx streaming service (https://www.itv.com/). \b - Version: 1.0.2 + Version: 1.0.3 Author: stabbedbybrick Authorization: Cookies (Optional for free content | Required for premium content) Robustness: @@ -97,7 +97,7 @@ class ITV(Service): headers = { "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-Language": "en-US,en;q=0.8", "Origin": "https://www.itv.com", diff --git a/packages/envied/src/envied/services/PLUTO/__init__.py b/packages/envied/src/envied/services/PLUTO/__init__.py index f17b9ad..8018cda 100644 --- a/packages/envied/src/envied/services/PLUTO/__init__.py +++ b/packages/envied/src/envied/services/PLUTO/__init__.py @@ -22,7 +22,7 @@ class PLUTO(Service): Credit to @wks_uwu for providing an alternative API, making the codebase much cleaner \b - Version: 1.0.2 + Version: 1.0.3 Author: stabbedbybrick Authorization: None Robustness: @@ -55,7 +55,7 @@ class PLUTO(Service): ALIASES = ("plu", "plutotv") TITLE_RE = ( r"^" - r"(?:https?://(?:www\.)?pluto\.tv(?:/[a-z]{2})?)?" + r"(?:https?://(?:www\.)?pluto\.tv(?:/[a-z]{2,})?)?" r"(?:/on-demand)?" r"/(?Pmovies|series)" r"/(?P[a-z0-9-]+)" diff --git a/packages/envied/src/envied/services/RTE/__init__.py b/packages/envied/src/envied/services/RTE/__init__.py index 4098bb1..462e819 100644 --- a/packages/envied/src/envied/services/RTE/__init__.py +++ b/packages/envied/src/envied/services/RTE/__init__.py @@ -24,7 +24,7 @@ class RTE(Service): Service code for RTE Player streaming service (https://www.rte.ie/player/). \b - Version: 1.0.3 + Version: 1.0.6 Author: stabbedbybrick Authorization: None Robustness: @@ -46,6 +46,13 @@ class RTE(Service): """ # GEOFENCE = ("ie",) + TITLE_RE = ( + r"https://www\.rte\.ie/player" + r"/(?Pseries|movie)" + r"/(?P[a-zA-Z0-9%_.-]+)" + r"/(?P[a-zA-Z0-9_\-=?]+)/?$" + ) + SEASON_RE = re.compile(r"S(\d+)\s*E\d+", re.IGNORECASE) @staticmethod @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.license = self.config["endpoints"]["license"] + self.session.headers.update(self.config["headers"]) + def search(self) -> Generator[SearchResult, None, None]: params = { "byProgramType": "Series|Movie", - "q": f"title:({self.title})", + "q": f"*{self.title}*", "range": "0-40", "schema": "2.15", "sort": "rte$rank|desc", @@ -94,14 +103,8 @@ class RTE(Service): ) def get_titles(self) -> Titles_T: - title_re = ( - r"https://www\.rte\.ie/player" - r"/(?Pseries|movie)" - r"/(?P[a-zA-Z0-9%_.-]+)" - r"/(?P[a-zA-Z0-9_\-=?]+)/?$" - ) 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: raise ValueError("- Could not parse ID from input") @@ -168,7 +171,7 @@ class RTE(Service): return r.json()["getWidevineLicenseResponse"]["license"] - # Service specific functions + # Service-specific functions def _movie(self, title: str) -> Movie: params = {"count": "true", "entries": "true", "byId": title} @@ -180,49 +183,74 @@ class RTE(Service): service=self.__class__, name=movie.get("plprogram$longTitle"), 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, ) 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: - entry = self._request("/mpx/1uC-gC/rte-prd-prd-all-movies-series?byGuid={}".format(title))["entries"][0]["id"] - data = self._request("/mpx/1uC-gC/rte-prd-prd-all-programs?bySeriesId={}".format(entry.split("/")[-1]))["entries"] + series_search = self._request(f"/mpx/1uC-gC/rte-prd-prd-all-movies-series?byGuid={title}") + 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 [ Episode( id_=episode.get("guid"), title=episode.get("plprogram$longTitle"), - season=episode.get("plprogram$tvSeasonNumber") or 0, + season=self._get_season(episode), number=episode.get("plprogram$tvSeasonEpisodeNumber") or 0, 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__, + year=episode.get("plprogram$year"), data=episode, ) - for episode in data - if episode["plprogram$programType"] == "episode" + for episode in episode_data ] def _episode(self, title: str, guid: str) -> Episode: title = title.split("?")[0] - entry = self._request("/mpx/1uC-gC/rte-prd-prd-all-movies-series?byGuid={}".format(title))["entries"][0]["id"] - data = self._request("/mpx/1uC-gC/rte-prd-prd-all-programs?bySeriesId={}".format(entry.split("/")[-1]))["entries"] + series_search = self._request(f"/mpx/1uC-gC/rte-prd-prd-all-movies-series?byGuid={title}") + 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 [ Episode( id_=episode.get("guid"), title=episode.get("plprogram$longTitle"), - season=episode.get("plprogram$tvSeasonNumber") or 0, + season=self._get_season(episode), number=episode.get("plprogram$tvSeasonEpisodeNumber") or 0, 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__, + year=episode.get("plprogram$year"), data=episode, ) - for episode in data - if episode["plprogram$programType"] == "episode" and episode.get("guid") == guid + for episode in episode_data ] def get_config(self): @@ -261,16 +289,10 @@ class RTE(Service): "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) - self.session.headers.update(self.config["headers"]) - if params: - self.session.params.update(params) - if headers: - self.session.headers.update(headers) - - prep = self.session.prepare_request(Request("GET", url)) + prep = self.session.prepare_request(Request("GET", url, **kwargs)) response = self.session.send(prep) if response.status_code != 200: @@ -282,4 +304,4 @@ class RTE(Service): try: return json.loads(response.content) except json.JSONDecodeError: - return response.text + return response.text \ No newline at end of file diff --git a/packages/envied/src/envied/services/STV/__init__.py b/packages/envied/src/envied/services/STV/__init__.py index c25bcb3..764b657 100644 --- a/packages/envied/src/envied/services/STV/__init__.py +++ b/packages/envied/src/envied/services/STV/__init__.py @@ -21,7 +21,7 @@ class STV(Service): Service code for STV Player streaming service (https://player.stv.tv/). \b - Version: 1.0.1 + Version: 1.0.3 Author: stabbedbybrick Authorization: None Robustness: @@ -108,36 +108,34 @@ class STV(Service): if episode.get("playerSeries") and re.match(r"Series \d+", episode["playerSeries"]["name"]) else 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", data=episode, ) ] elif kind == "summary": - r = self.session.get(self.base + f"programmes/{slug}") - r.raise_for_status() - data = r.json() + r = self.session.get(self.base + f"episodes?programme.guid={slug}&limit=999") + if not r.ok: + raise ConnectionError(f"Failed to find content for {slug}") - series = [series.get("guid") for series in data["results"]["series"]] - seasons = [self.session.get(self.base + f"episodes?series.guid={i}").json() for i in series] + data = r.json() episodes = [ Episode( id_=episode["video"].get("id"), service=self.__class__, - title=data["results"].get("name"), + title=episode["programme"].get("name"), season=int(episode["playerSeries"]["name"].split(" ")[1]) if episode.get("playerSeries") and re.match(r"Series \d+", episode["playerSeries"]["name"]) else 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", data=episode, ) - for season in seasons - for episode in season["results"] + for episode in data["results"] ] self.session.headers.pop("stv-drm") diff --git a/packages/envied/src/envied/services/TUBI/__init__.py b/packages/envied/src/envied/services/TUBI/__init__.py index 6576c83..3a2d50c 100644 --- a/packages/envied/src/envied/services/TUBI/__init__.py +++ b/packages/envied/src/envied/services/TUBI/__init__.py @@ -1,7 +1,6 @@ from __future__ import annotations import hashlib -import os import re import sys import uuid @@ -13,12 +12,12 @@ import click from langcodes import Language from pyplayready.cdm import Cdm as PlayReadyCdm 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.search_result import SearchResult from envied.core.service import Service 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): @@ -26,7 +25,7 @@ class TUBI(Service): Service code for TubiTV streaming service (https://tubitv.com/) \b - Version: 1.0.6 + Version: 1.0.7 Author: stabbedbybrick Authorization: Cookies (Optional) Geofence: Locked to whatever region the user is in (API only) @@ -35,8 +34,6 @@ class TUBI(Service): L3: 1080p, AAC2.0 PlayReady: SL2000: 1080p, AAC2.0 - Clear: - 1080p, AAC2.0 \b Tips: @@ -44,14 +41,11 @@ class TUBI(Service): /series/300001423/gotham /tv-shows/200024793/s01-e01-pilot /movies/589279/the-outsiders - - Use '-v H.265' to request HEVC tracks. \b Notes: - - Authentication is currently not required, but cookies are used if provided. - - If 1080p exists, it's currently only available as H.265. - - Unshackle fails to mux properly when n_m3u8dl_re is used, so aria2c is forced as downloader. - - Search is currently disabled. + - Authorization is currently only required for search, not for content. + - If 1080p exists, it's typically only available as HEVC/H.265. """ TITLE_RE = r"^(?:https?://(?:www\.)?tubitv\.com?)?/(?:[a-z]{2}-[a-z]{2}/)?(?Pmovies|series|tv-shows)/(?P[a-z0-9-]+)" @@ -64,15 +58,12 @@ class TUBI(Service): return TUBI(ctx, **kwargs) def __init__(self, ctx, title): - self.title = title super().__init__(ctx) + self.title = title cdm = ctx.obj.cdm 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: super().authenticate(cookies, credential) 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.session.headers.update({"Authorization": f"Bearer {self.auth_token}"}) - # Disable search for now - # def search(self) -> Generator[SearchResult, None, None]: - # params = { - # "search": self.title, - # "include_linear": "true", - # "include_channels": "false", - # "is_kids_mode": "false", - # } + def search(self) -> Generator[SearchResult, None, None]: + if not self.auth_token: + self.log.error(" - Error: Search requires authentication/cookies.") + return + + params = { + "search": self.title, + "include_linear": "false", + "include_channels": "false", + "is_kids_mode": "false", + } - # r = self.session.get(self.config["endpoints"]["search"], params=params) - # r.raise_for_status() - # results = r.json() - # from devine.core.console import console - # console.print(results) - # exit() + r = self.session.get(self.config["endpoints"]["search"], params=params) + r.raise_for_status() + results = r.json() + + search_results = [x for x in results["contents"].values()] - # for result in results: - # label = "series" if result["type"] == "s" else "movies" if result["type"] == "v" else result["type"] - # title = ( - # result.get("title", "") - # .lower() - # .replace(" ", "-") - # .replace(":", "") - # .replace("(", "") - # .replace(")", "") - # .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}", - # ) + for result in search_results: + label = "series" if result["type"] == "s" else "movies" if result["type"] == "v" else result["type"] + title = ( + result.get("title", "") + .lower() + .replace(" ", "-") + .replace(":", "") + .replace("(", "") + .replace(")", "") + .replace(".", "") + .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: try: @@ -209,41 +203,27 @@ class TUBI(Service): self.log.info(f"Title is available in: {title.data.get('country')}") sys.exit(1) - codecs = [x.get("codec") for x in resources] - 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") + tracks = Tracks() - resource = next(( - x for x in resources - if self.drm_system in x.get("type", "") and self.vcodec in x.get("codec", "") - ), 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) - if not resource: - raise ValueError("Could not find a video resource for this title") - - manifest = resource.get("manifest", {}).get("url") - if not manifest: - raise ValueError("Could not find a manifest for this title") + for codec in ["h264", "h265"]: + resource = next(( + x for x in resources + if self.drm_system in x.get("type", "") and codec in x.get("codec", "").lower() + ), None) + if not resource: + self.log.warning(f" - Could not find a {codec} video resource for this title") + continue - title.data["license_url"] = resource.get("license_server", {}).get("url") + manifest = resource.get("manifest", {}).get("url") + + title.data["license_url"] = resource.get("license_server", {}).get("url") + + tracks.add(DASH.from_url(url=manifest, session=self.session).to_tracks(language=title.language)) - tracks = 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 - if isinstance(track, Audio): 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 if title.data.get("subtitles"): diff --git a/packages/envied/src/envied/services/TUBI/config.yaml b/packages/envied/src/envied/services/TUBI/config.yaml index 38de312..6ff6227 100644 --- a/packages/envied/src/envied/services/TUBI/config.yaml +++ b/packages/envied/src/envied/services/TUBI/config.yaml @@ -1,5 +1,5 @@ endpoints: 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 \ No newline at end of file diff --git a/packages/envied/src/envied/services/iP/__init__.py b/packages/envied/src/envied/services/iP/__init__.py index 55c07ef..30c178e 100644 --- a/packages/envied/src/envied/services/iP/__init__.py +++ b/packages/envied/src/envied/services/iP/__init__.py @@ -31,7 +31,7 @@ class iP(Service): Service code for the BBC iPlayer streaming service (https://www.bbc.co.uk/iplayer). \b - Version: 1.0.2 + Version: 1.0.3 Author: stabbedbybrick Authorization: None Security: None @@ -60,12 +60,12 @@ class iP(Service): def __init__(self, ctx: Context, title: str): super().__init__(ctx) self.title = title - self.vcodec = ctx.parent.params.get("vcodec") - self.range = ctx.parent.params.get("range_") + self.vcodec = next(iter(self.track_request.codecs or []), None) + self.range = next(iter(self.track_request.ranges or []), None) 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"): raise CertificateMissingError("HLG/H.265 tracks cannot be requested without a TLS certificate.")