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/),
|
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.")
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user