mirror of
https://github.com/vinefeeder/TwinVine.git
synced 2026-07-15 18:10:04 +02:00
service updates
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]))
|
||||
ch_map = {0: Chapter(timestamp=0)}
|
||||
|
||||
if not any(c.timestamp == "00:00:00.000" for c in chapters):
|
||||
chapters.append(Chapter(timestamp=0))
|
||||
for c in chapters:
|
||||
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:
|
||||
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}")
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"/(?P<type>movies|series)"
|
||||
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/).
|
||||
|
||||
\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"/(?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
|
||||
@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"/(?P<type>series|movie)"
|
||||
r"/(?P<slug>[a-zA-Z0-9%_.-]+)"
|
||||
r"/(?P<id>[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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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}/)?(?P<type>movies|series|tv-shows)/(?P<id>[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
|
||||
|
||||
# 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()
|
||||
params = {
|
||||
"search": self.title,
|
||||
"include_linear": "false",
|
||||
"include_channels": "false",
|
||||
"is_kids_mode": "false",
|
||||
}
|
||||
|
||||
# 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}",
|
||||
# )
|
||||
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 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()
|
||||
|
||||
for codec in ["h264", "h265"]:
|
||||
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", "")
|
||||
if self.drm_system in x.get("type", "") and codec in x.get("codec", "").lower()
|
||||
), None)
|
||||
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")
|
||||
if not manifest:
|
||||
raise ValueError("Could not find a manifest for this title")
|
||||
|
||||
title.data["license_url"] = resource.get("license_server", {}).get("url")
|
||||
|
||||
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
|
||||
tracks.add(DASH.from_url(url=manifest, session=self.session).to_tracks(language=title.language))
|
||||
|
||||
for track in tracks:
|
||||
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"):
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user