forked from FairTrade/unshackle-services
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3e0732987c | |||
| 2c09bce845 | |||
| 22bb10cddf | |||
| 8289b3a709 | |||
| a5c68a0dcb |
280
HIDI/__init__.py
280
HIDI/__init__.py
@ -1,31 +1,37 @@
|
|||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import click
|
||||||
|
|
||||||
from http.cookiejar import CookieJar
|
from http.cookiejar import CookieJar
|
||||||
from typing import Optional, Iterable
|
from typing import Optional, Iterable
|
||||||
from langcodes import Language
|
from langcodes import Language
|
||||||
import base64
|
|
||||||
|
|
||||||
import click
|
|
||||||
|
|
||||||
from unshackle.core.constants import AnyTrack
|
from unshackle.core.constants import AnyTrack
|
||||||
from unshackle.core.credential import Credential
|
from unshackle.core.credential import Credential
|
||||||
from unshackle.core.manifests import DASH
|
from unshackle.core.manifests import DASH
|
||||||
from unshackle.core.service import Service
|
from unshackle.core.service import Service
|
||||||
from unshackle.core.titles import Episode, Series, Movie, Movies, Title_T, Titles_T
|
from unshackle.core.titles import Episode, Series, Movie, Movies, Title_T, Titles_T
|
||||||
from unshackle.core.tracks import Chapter, Tracks, Subtitle, Audio
|
from unshackle.core.tracks import Chapter, Tracks, Subtitle, Audio, Video
|
||||||
|
from unshackle.core.utilities import import_module_by_path
|
||||||
|
|
||||||
|
|
||||||
class HIDI(Service):
|
class HIDI(Service):
|
||||||
"""
|
"""
|
||||||
Service code for HiDive (hidive.com)
|
Service code for HiDive (hidive.com)
|
||||||
Version: 1.2.0
|
Version: 1.3.2
|
||||||
Authorization: Email + password login, with automatic token refresh.
|
Authorization: Email + password login, with automatic token refresh.
|
||||||
Security: FHD@L3
|
Security: FHD@L3
|
||||||
|
|
||||||
|
IMPORTANT: UPDATE YOUR UNSHACKLE TO 2.3.0 TO GET THE NECESSARY FIX FOR THIS SERVICE
|
||||||
|
Also when downloading a series, use the link from the first season of the series
|
||||||
"""
|
"""
|
||||||
|
|
||||||
TITLE_RE = r"^https?://(?:www\.)?hidive\.com/(?:season/(?P<season_id>\d+)|playlist/(?P<playlist_id>\d+))$"
|
TITLE_RE = r"^https?://(?:www\.)?hidive\.com/(?:season/(?P<season_id>\d+)|playlist/(?P<playlist_id>\d+))$"
|
||||||
GEOFENCE = ()
|
GEOFENCE = ()
|
||||||
NO_SUBTITLES = False
|
NO_SUBTITLES = False
|
||||||
|
API_BASE = "https://dce-frontoffice.imggaming.com/api/v4"
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@click.command(name="HIDI", short_help="https://hidive.com")
|
@click.command(name="HIDI", short_help="https://hidive.com")
|
||||||
@ -110,36 +116,160 @@ class HIDI(Service):
|
|||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
return resp
|
return resp
|
||||||
|
|
||||||
def get_titles(self) -> Titles_T:
|
def _fetch_season_data(self, season_id: int) -> dict:
|
||||||
# One endpoint for both season and playlist
|
"""Fetch season view data."""
|
||||||
resp = self._api_get(
|
return self._api_get(
|
||||||
self.config["endpoints"]["view"],
|
self.config["endpoints"]["view"],
|
||||||
params={"type": ("playlist" if self.kind == "movie" else "season"),
|
params={
|
||||||
"id": self.content_id,
|
"type": "season",
|
||||||
"timezone": "Europe/Amsterdam"}
|
"id": season_id,
|
||||||
)
|
"timezone": "Europe/Amsterdam"
|
||||||
data = resp.json()
|
}
|
||||||
|
).json()
|
||||||
|
|
||||||
|
def _fetch_adjacent_seasons(self, series_id: int, season_id: int) -> dict:
|
||||||
|
"""Fetch all seasons in a series using adjacentTo endpoint."""
|
||||||
|
url = f"{self.API_BASE}/series/{series_id}/adjacentTo/{season_id}"
|
||||||
|
return self._api_get(url, params={"size": 25}).json()
|
||||||
|
|
||||||
|
def _extract_series_info(self, season_data: dict) -> tuple[Optional[int], Optional[str]]:
|
||||||
|
"""
|
||||||
|
Extract series ID and title from season data.
|
||||||
|
Checks multiple locations in the JSON structure.
|
||||||
|
"""
|
||||||
|
series_id = None
|
||||||
|
series_title = None
|
||||||
|
|
||||||
|
# Method 1: Check metadata.series
|
||||||
|
metadata = season_data.get("metadata", {})
|
||||||
|
if metadata.get("series"):
|
||||||
|
series_id = metadata["series"].get("seriesId")
|
||||||
|
series_title = metadata["series"].get("title")
|
||||||
|
if series_id:
|
||||||
|
return series_id, series_title
|
||||||
|
|
||||||
|
# Method 2: Check elements for $type: "series"
|
||||||
|
for elem in season_data.get("elements", []):
|
||||||
|
if elem.get("$type") == "series":
|
||||||
|
attrs = elem.get("attributes", {})
|
||||||
|
series_id = attrs.get("id")
|
||||||
|
series_info = attrs.get("series", {})
|
||||||
|
series_title = series_info.get("title") or series_title
|
||||||
|
if series_id:
|
||||||
|
return series_id, series_title
|
||||||
|
|
||||||
|
# Method 3: Check bucket elements for seriesId
|
||||||
|
for elem in season_data.get("elements", []):
|
||||||
|
if elem.get("$type") == "bucket":
|
||||||
|
attrs = elem.get("attributes", {})
|
||||||
|
if attrs.get("seriesId"):
|
||||||
|
series_id = attrs["seriesId"]
|
||||||
|
return series_id, series_title
|
||||||
|
|
||||||
|
# Method 4: Check hero actions for seriesId
|
||||||
|
for elem in season_data.get("elements", []):
|
||||||
|
if elem.get("$type") == "hero":
|
||||||
|
for action in elem.get("attributes", {}).get("actions", []):
|
||||||
|
action_data = action.get("attributes", {}).get("action", {}).get("data", {})
|
||||||
|
if action_data.get("seriesId"):
|
||||||
|
series_id = action_data["seriesId"]
|
||||||
|
return series_id, series_title
|
||||||
|
|
||||||
|
return series_id, series_title
|
||||||
|
|
||||||
|
def _extract_season_number(self, season_data: dict) -> int:
|
||||||
|
"""Extract season number from season data."""
|
||||||
|
# Check metadata.currentSeason
|
||||||
|
metadata = season_data.get("metadata", {})
|
||||||
|
current_season = metadata.get("currentSeason", {})
|
||||||
|
if current_season.get("title"):
|
||||||
|
# Parse "Season 2" -> 2
|
||||||
|
title = current_season["title"]
|
||||||
|
if title.lower().startswith("season "):
|
||||||
|
try:
|
||||||
|
return int(title.split(" ")[1])
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Check elements for series type with seasons info
|
||||||
|
for elem in season_data.get("elements", []):
|
||||||
|
if elem.get("$type") == "series":
|
||||||
|
seasons_items = elem.get("attributes", {}).get("seasons", {}).get("items", [])
|
||||||
|
for item in seasons_items:
|
||||||
|
if item.get("seasonNumber"):
|
||||||
|
return item["seasonNumber"]
|
||||||
|
|
||||||
|
# Check bucket title
|
||||||
|
for elem in season_data.get("elements", []):
|
||||||
|
if elem.get("$type") == "bucket" and elem.get("attributes", {}).get("type") == "season":
|
||||||
|
bucket_title = elem.get("attributes", {}).get("bucketTitle", "")
|
||||||
|
if bucket_title.lower().startswith("season "):
|
||||||
|
try:
|
||||||
|
return int(bucket_title.split(" ")[1])
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
return 1
|
||||||
|
|
||||||
|
def _parse_episodes_from_season(self, season_data: dict, series_title: str, season_number: int) -> list[Episode]:
|
||||||
|
"""Parse episodes from season JSON data."""
|
||||||
|
episodes = []
|
||||||
|
|
||||||
|
for elem in season_data.get("elements", []):
|
||||||
|
if elem.get("$type") == "bucket" and elem.get("attributes", {}).get("type") == "season":
|
||||||
|
items = elem.get("attributes", {}).get("items", [])
|
||||||
|
|
||||||
|
for idx, item in enumerate(items):
|
||||||
|
if item.get("type") != "SEASON_VOD":
|
||||||
|
continue
|
||||||
|
|
||||||
|
ep_title = item.get("title", "")
|
||||||
|
ep_num = idx + 1
|
||||||
|
|
||||||
|
# Try to extract episode number from title "E1 - Title"
|
||||||
|
if ep_title.startswith("E") and " - " in ep_title:
|
||||||
|
try:
|
||||||
|
ep_num = int(ep_title.split(" - ")[0][1:])
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
episodes.append(Episode(
|
||||||
|
id_=item["id"],
|
||||||
|
service=self.__class__,
|
||||||
|
title=series_title,
|
||||||
|
season=season_number,
|
||||||
|
number=ep_num,
|
||||||
|
name=ep_title,
|
||||||
|
description=item.get("description", ""),
|
||||||
|
language=Language.get("ja"),
|
||||||
|
data=item,
|
||||||
|
))
|
||||||
|
break
|
||||||
|
|
||||||
|
return episodes
|
||||||
|
|
||||||
|
def get_titles(self) -> Titles_T:
|
||||||
|
anchor_data = self._fetch_season_data(self.content_id)
|
||||||
|
|
||||||
if self.kind == "movie":
|
if self.kind == "movie":
|
||||||
# Find the playlist bucket, then the single VOD
|
|
||||||
vod_id = None
|
vod_id = None
|
||||||
movie_title = None
|
movie_title = None
|
||||||
description = ""
|
description = ""
|
||||||
for elem in data.get("elements", []):
|
|
||||||
|
for elem in anchor_data.get("elements", []):
|
||||||
if elem.get("$type") == "hero":
|
if elem.get("$type") == "hero":
|
||||||
hdr = (elem.get("attributes", {}).get("header", {}) or {}).get("attributes", {})
|
hdr = (elem.get("attributes", {}).get("header", {}) or {}).get("attributes", {})
|
||||||
movie_title = hdr.get("text", movie_title)
|
movie_title = hdr.get("text", movie_title)
|
||||||
for c in elem.get("attributes", {}).get("content", []):
|
for c in elem.get("attributes", {}).get("content", []):
|
||||||
if c.get("$type") == "textblock":
|
if c.get("$type") == "textblock":
|
||||||
description = c.get("attributes", {}).get("text", description)
|
description = c.get("attributes", {}).get("text", description)
|
||||||
|
|
||||||
if elem.get("$type") == "bucket" and elem.get("attributes", {}).get("type") == "playlist":
|
if elem.get("$type") == "bucket" and elem.get("attributes", {}).get("type") == "playlist":
|
||||||
items = elem.get("attributes", {}).get("items", [])
|
items = elem.get("attributes", {}).get("items", [])
|
||||||
if items:
|
if items:
|
||||||
vod_id = items[0]["id"]
|
vod_id = items[0]["id"]
|
||||||
if not movie_title:
|
movie_title = movie_title or items[0].get("title")
|
||||||
movie_title = items[0].get("title")
|
description = description or items[0].get("description", "")
|
||||||
if not description:
|
|
||||||
description = items[0].get("description", "")
|
|
||||||
break
|
break
|
||||||
|
|
||||||
if not vod_id:
|
if not vod_id:
|
||||||
@ -157,37 +287,86 @@ class HIDI(Service):
|
|||||||
)
|
)
|
||||||
])
|
])
|
||||||
|
|
||||||
# Series
|
series_id, series_title = self._extract_series_info(anchor_data)
|
||||||
episodes = []
|
series_title = series_title or "HiDive Series"
|
||||||
series_title = None
|
anchor_season_num = self._extract_season_number(anchor_data)
|
||||||
for elem in data.get("elements", []):
|
|
||||||
if elem.get("$type") == "bucket" and elem["attributes"].get("type") == "season":
|
|
||||||
for item in elem["attributes"].get("items", []):
|
|
||||||
if item.get("type") != "SEASON_VOD":
|
|
||||||
continue
|
|
||||||
ep_title = item["title"]
|
|
||||||
ep_num = 1
|
|
||||||
if ep_title.startswith("E") and " - " in ep_title:
|
|
||||||
try:
|
|
||||||
ep_num = int(ep_title.split(" - ")[0][1:])
|
|
||||||
except:
|
|
||||||
pass
|
|
||||||
episodes.append(Episode(
|
|
||||||
id_=item["id"],
|
|
||||||
service=self.__class__,
|
|
||||||
title=data.get("metadata", {}).get("series", {}).get("title", "") or "HiDive",
|
|
||||||
season=1,
|
|
||||||
number=ep_num,
|
|
||||||
name=item["title"],
|
|
||||||
description=item.get("description", ""),
|
|
||||||
language=Language.get("en"),
|
|
||||||
data=item,
|
|
||||||
))
|
|
||||||
break
|
|
||||||
|
|
||||||
if not episodes:
|
if not series_id:
|
||||||
raise ValueError("No episodes found in season data.")
|
self.log.warning("Could not determine Series ID. Fetching single season only.")
|
||||||
return Series(sorted(episodes, key=lambda x: x.number))
|
episodes = self._parse_episodes_from_season(anchor_data, series_title, anchor_season_num)
|
||||||
|
return Series(episodes)
|
||||||
|
|
||||||
|
try:
|
||||||
|
adj_data = self._fetch_adjacent_seasons(series_id, self.content_id)
|
||||||
|
except Exception as e:
|
||||||
|
self.log.warning(f"Failed to fetch adjacent seasons: {e}. Falling back to single season.")
|
||||||
|
episodes = self._parse_episodes_from_season(anchor_data, series_title, anchor_season_num)
|
||||||
|
return Series(episodes)
|
||||||
|
|
||||||
|
# Build list of all seasons
|
||||||
|
all_seasons = []
|
||||||
|
|
||||||
|
# Preceding seasons (these come before current season)
|
||||||
|
for s in adj_data.get("precedingSeasons", []):
|
||||||
|
all_seasons.append({
|
||||||
|
"id": s["id"],
|
||||||
|
"seasonNumber": s.get("seasonNumber", 0),
|
||||||
|
"title": s.get("title", "")
|
||||||
|
})
|
||||||
|
|
||||||
|
# Current/Anchor season
|
||||||
|
all_seasons.append({
|
||||||
|
"id": self.content_id,
|
||||||
|
"seasonNumber": anchor_season_num,
|
||||||
|
"title": f"Season {anchor_season_num}",
|
||||||
|
"_data": anchor_data # Cache to avoid re-fetching
|
||||||
|
})
|
||||||
|
|
||||||
|
# Following seasons (these come after current season)
|
||||||
|
for s in adj_data.get("followingSeasons", []):
|
||||||
|
all_seasons.append({
|
||||||
|
"id": s["id"],
|
||||||
|
"seasonNumber": s.get("seasonNumber", 0),
|
||||||
|
"title": s.get("title", "")
|
||||||
|
})
|
||||||
|
|
||||||
|
# Deduplicate by ID and sort by season number
|
||||||
|
unique_seasons = {}
|
||||||
|
for s in all_seasons:
|
||||||
|
s_id = s["id"]
|
||||||
|
if s_id not in unique_seasons:
|
||||||
|
unique_seasons[s_id] = s
|
||||||
|
elif "_data" in s:
|
||||||
|
# Prefer the one with cached data
|
||||||
|
unique_seasons[s_id] = s
|
||||||
|
|
||||||
|
sorted_seasons = sorted(unique_seasons.values(), key=lambda x: x["seasonNumber"])
|
||||||
|
|
||||||
|
all_episodes = []
|
||||||
|
|
||||||
|
for season_info in sorted_seasons:
|
||||||
|
s_id = season_info["id"]
|
||||||
|
s_num = season_info["seasonNumber"]
|
||||||
|
|
||||||
|
if "_data" in season_info:
|
||||||
|
self.log.info(f"Processing Season {s_num} (ID: {s_id}) [cached]")
|
||||||
|
season_data = season_info["_data"]
|
||||||
|
else:
|
||||||
|
self.log.info(f"Fetching Season {s_num} (ID: {s_id})")
|
||||||
|
try:
|
||||||
|
season_data = self._fetch_season_data(s_id)
|
||||||
|
except Exception as e:
|
||||||
|
self.log.error(f"Failed to fetch Season {s_num}: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
episodes = self._parse_episodes_from_season(season_data, series_title, s_num)
|
||||||
|
self.log.info(f" Found {len(episodes)} episodes")
|
||||||
|
all_episodes.extend(episodes)
|
||||||
|
|
||||||
|
if not all_episodes:
|
||||||
|
raise ValueError("No episodes found across all seasons.")
|
||||||
|
|
||||||
|
return Series(all_episodes)
|
||||||
|
|
||||||
def _get_audio_for_langs(self, mpd_url: str, langs: Iterable[Language]) -> list[Audio]:
|
def _get_audio_for_langs(self, mpd_url: str, langs: Iterable[Language]) -> list[Audio]:
|
||||||
merged: list[Audio] = []
|
merged: list[Audio] = []
|
||||||
@ -300,11 +479,12 @@ class HIDI(Service):
|
|||||||
|
|
||||||
return base_tracks
|
return base_tracks
|
||||||
|
|
||||||
|
|
||||||
def _hidive_get_drm_info(self, title: Title_T) -> tuple[str, str]:
|
def _hidive_get_drm_info(self, title: Title_T) -> tuple[str, str]:
|
||||||
if title.id in self._drm_cache:
|
if title.id in self._drm_cache:
|
||||||
return self._drm_cache[title.id]
|
return self._drm_cache[title.id]
|
||||||
self.get_tracks(title)
|
self.get_tracks(title)
|
||||||
|
if title.id not in self._drm_cache:
|
||||||
|
raise ValueError("DRM information not found for this title.")
|
||||||
return self._drm_cache[title.id]
|
return self._drm_cache[title.id]
|
||||||
|
|
||||||
def _decode_hidive_license_payload(self, payload: bytes) -> bytes:
|
def _decode_hidive_license_payload(self, payload: bytes) -> bytes:
|
||||||
|
|||||||
509
HPLA/__init__.py
Normal file
509
HPLA/__init__.py
Normal file
@ -0,0 +1,509 @@
|
|||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from typing import Optional, Union, Generator
|
||||||
|
|
||||||
|
import click
|
||||||
|
from langcodes import Language
|
||||||
|
from lxml import etree
|
||||||
|
|
||||||
|
from unshackle.core.constants import AnyTrack
|
||||||
|
from unshackle.core.credential import Credential
|
||||||
|
from unshackle.core.manifests import DASH
|
||||||
|
from unshackle.core.search_result import SearchResult
|
||||||
|
from unshackle.core.service import Service
|
||||||
|
from unshackle.core.titles import Movie, Movies, Title_T, Titles_T, Song, Album
|
||||||
|
from unshackle.core.tracks import Chapter, Subtitle, Tracks, Audio
|
||||||
|
|
||||||
|
|
||||||
|
class HPLA(Service):
|
||||||
|
"""
|
||||||
|
Service code for Hoopla Digital (https://www.hoopladigital.com)
|
||||||
|
Version: 1.0.7
|
||||||
|
|
||||||
|
Authorization: Credentials (Email & Password)
|
||||||
|
|
||||||
|
Security:
|
||||||
|
- SL2K/SL3K/L1/L3: SD/360p
|
||||||
|
|
||||||
|
They are using the license server of DRMToday with encoded streams from CastLabs.
|
||||||
|
Supports movie and music (but kinda broken) at the moment
|
||||||
|
Television kinda sucks since you need to borrow it one by one, idk why people would want this shit quality series anyways
|
||||||
|
|
||||||
|
Use full URL (for example - https://www.hoopladigital.com/movie/title-name/10979706) or content ID.
|
||||||
|
"""
|
||||||
|
|
||||||
|
ALIASES = ("HPLA", "hoopla")
|
||||||
|
TITLE_RE = r"^(?:https?://(?:www\.)?hoopladigital\.com/[^/]*/[^/]*/)?(?P<title_id>\d+)"
|
||||||
|
GEOFENCE = ("US",)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
@click.command(name="HPLA", short_help="https://www.hoopladigital.com")
|
||||||
|
@click.argument("title", type=str)
|
||||||
|
@click.option("-m", "--movie", is_flag=True, default=False, help="Specify if it's a movie")
|
||||||
|
@click.pass_context
|
||||||
|
def cli(ctx, **kwargs):
|
||||||
|
return HPLA(ctx, **kwargs)
|
||||||
|
|
||||||
|
def __init__(self, ctx, title, movie):
|
||||||
|
super().__init__(ctx)
|
||||||
|
self.title = title
|
||||||
|
self.movie = movie
|
||||||
|
|
||||||
|
if self.config is None:
|
||||||
|
raise Exception("Config is missing!")
|
||||||
|
|
||||||
|
profile_name = ctx.parent.params.get("profile")
|
||||||
|
self.profile = profile_name if profile_name else "default"
|
||||||
|
|
||||||
|
self.platform = self.config["platform"]["amazon"]
|
||||||
|
|
||||||
|
def authenticate(self, cookies: Optional[any] = None, credential: Optional[Credential] = None) -> None:
|
||||||
|
super().authenticate(cookies, credential)
|
||||||
|
if not credential or not credential.username or not credential.password:
|
||||||
|
raise EnvironmentError("Service requires Credentials for Authentication.")
|
||||||
|
|
||||||
|
self.credential = credential
|
||||||
|
|
||||||
|
self.session.headers.update(self.platform["headers"])
|
||||||
|
|
||||||
|
cache_key = f"tokens_{self.profile}"
|
||||||
|
|
||||||
|
cache = self.cache.get(cache_key)
|
||||||
|
|
||||||
|
if cache and not cache.expired:
|
||||||
|
cached_data = cache.data
|
||||||
|
if isinstance(cached_data, dict) and cached_data.get("username") == credential.username:
|
||||||
|
self.log.info("Using cached tokens")
|
||||||
|
self._restore_from_cache(cached_data)
|
||||||
|
return
|
||||||
|
|
||||||
|
self.log.info("Logging in...")
|
||||||
|
self._do_login(credential)
|
||||||
|
|
||||||
|
self._cache_tokens(credential.username, cache_key)
|
||||||
|
|
||||||
|
def _restore_from_cache(self, cached_data: dict) -> None:
|
||||||
|
"""Restore authentication state from cached data."""
|
||||||
|
self.access_token = cached_data["access_token"]
|
||||||
|
self.patron_id = cached_data["patron_id"]
|
||||||
|
self.session.headers.update({
|
||||||
|
"Authorization": f"Bearer {self.access_token}",
|
||||||
|
"patron-id": self.patron_id,
|
||||||
|
})
|
||||||
|
|
||||||
|
def _cache_tokens(self, username: str, cache_key: str) -> None:
|
||||||
|
"""Cache the current authentication tokens."""
|
||||||
|
cache = self.cache.get(cache_key)
|
||||||
|
cache.set(
|
||||||
|
data={
|
||||||
|
"username": username,
|
||||||
|
"access_token": self.access_token,
|
||||||
|
"patron_id": self.patron_id,
|
||||||
|
},
|
||||||
|
expiration=3600
|
||||||
|
)
|
||||||
|
|
||||||
|
def _is_music_mpd(self, mpd: etree._Element) -> bool:
|
||||||
|
"""
|
||||||
|
Detect if MPD represents a single-file music asset.
|
||||||
|
"""
|
||||||
|
adaptation_sets = mpd.findall(".//AdaptationSet")
|
||||||
|
|
||||||
|
for aset in adaptation_sets:
|
||||||
|
if aset.get("contentType") == "video":
|
||||||
|
return False
|
||||||
|
|
||||||
|
audio_reps = mpd.findall(".//AdaptationSet[@contentType='audio']/Representation")
|
||||||
|
if len(audio_reps) != 1:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if mpd.find(".//SegmentTemplate") is not None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return mpd.find(".//BaseURL") is not None
|
||||||
|
|
||||||
|
def _extract_music_audio(self, mpd: etree._Element, manifest_url: str) -> str:
|
||||||
|
base = mpd.find(".//BaseURL")
|
||||||
|
if base is None or not base.text:
|
||||||
|
raise ValueError("Music MPD has no BaseURL")
|
||||||
|
|
||||||
|
return manifest_url.rsplit("/", 1)[0] + "/" + base.text
|
||||||
|
|
||||||
|
|
||||||
|
def _do_login(self, credential: Credential) -> None:
|
||||||
|
"""Perform full login flow."""
|
||||||
|
# Step 1: Get Bearer Token
|
||||||
|
login_response = self.session.post(
|
||||||
|
url=self.config["endpoints"]["login"],
|
||||||
|
data={
|
||||||
|
"username": credential.username,
|
||||||
|
"password": credential.password,
|
||||||
|
},
|
||||||
|
headers={"Content-Type": "application/x-www-form-urlencoded"}
|
||||||
|
).json()
|
||||||
|
|
||||||
|
if login_response.get("tokenStatus") != "SUCCESS":
|
||||||
|
raise EnvironmentError(f"Login failed: {login_response.get('tokenStatus', 'Unknown error')}")
|
||||||
|
|
||||||
|
self.access_token = login_response["token"]
|
||||||
|
self.session.headers.update({"Authorization": f"Bearer {self.access_token}"})
|
||||||
|
|
||||||
|
# Step 2: Get Patron ID
|
||||||
|
self.log.info("Fetching Patron ID...")
|
||||||
|
query = 'query { patron { id email } }'
|
||||||
|
patron_data = self.session.post(
|
||||||
|
url=self.config["endpoints"]["graphql"],
|
||||||
|
json={"query": query},
|
||||||
|
headers={"Content-Type": "application/json"}
|
||||||
|
).json()
|
||||||
|
|
||||||
|
self.patron_id = patron_data["data"]["patron"]["id"]
|
||||||
|
self.session.headers.update({"patron-id": self.patron_id})
|
||||||
|
self.log.debug(f"Logged in as Patron ID: {self.patron_id}")
|
||||||
|
|
||||||
|
def search(self) -> Generator[SearchResult, None, None]:
|
||||||
|
query = """
|
||||||
|
query GetFilterSearchQuery($criteria: SearchCriteria!, $sort: Sort) {
|
||||||
|
search(criteria: $criteria, sort: $sort) {
|
||||||
|
hits {
|
||||||
|
id
|
||||||
|
title
|
||||||
|
kind { name }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"operationName": "GetFilterSearchQuery",
|
||||||
|
"variables": {
|
||||||
|
"criteria": {
|
||||||
|
"q": self.title,
|
||||||
|
"availability": "ALL_TITLES",
|
||||||
|
"pagination": {
|
||||||
|
"page": 1,
|
||||||
|
"pageSize": 48,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"query": query,
|
||||||
|
}
|
||||||
|
|
||||||
|
resp = self.session.post(
|
||||||
|
self.config["endpoints"]["graphql"],
|
||||||
|
json=payload,
|
||||||
|
headers={"Content-Type": "application/json"},
|
||||||
|
).json()
|
||||||
|
|
||||||
|
hits = (
|
||||||
|
resp
|
||||||
|
.get("data", {})
|
||||||
|
.get("search", {})
|
||||||
|
.get("hits", [])
|
||||||
|
)
|
||||||
|
|
||||||
|
for hit in hits:
|
||||||
|
kind = hit["kind"]["name"]
|
||||||
|
|
||||||
|
label = {
|
||||||
|
"MOVIE": "MOVIE",
|
||||||
|
"TVSHOW": "SERIES",
|
||||||
|
"MUSIC": "ALBUM",
|
||||||
|
"AUDIOBOOK": "AUDIOBOOK",
|
||||||
|
"EBOOK": "BOOK",
|
||||||
|
"COMIC": "COMIC",
|
||||||
|
}.get(kind, kind)
|
||||||
|
|
||||||
|
yield SearchResult(
|
||||||
|
id_=hit["id"],
|
||||||
|
title=hit["title"],
|
||||||
|
label=label,
|
||||||
|
url=f"https://www.hoopladigital.com/title/{hit['id']}",
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_titles(self) -> Titles_T:
|
||||||
|
title_match = re.match(self.TITLE_RE, self.title)
|
||||||
|
if not title_match:
|
||||||
|
raise ValueError(f"Invalid title format: {self.title}")
|
||||||
|
|
||||||
|
content_id = title_match.group("title_id")
|
||||||
|
|
||||||
|
query = """
|
||||||
|
query {
|
||||||
|
contents(criteria:{contentIds:[%s]}) {
|
||||||
|
contents {
|
||||||
|
id
|
||||||
|
title
|
||||||
|
kind { id name }
|
||||||
|
mediaKey
|
||||||
|
circulation { id dueDate }
|
||||||
|
year
|
||||||
|
seconds
|
||||||
|
primaryArtist { name }
|
||||||
|
tracks {
|
||||||
|
id
|
||||||
|
mediaKey
|
||||||
|
name
|
||||||
|
seconds
|
||||||
|
segmentNumber
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""" % content_id
|
||||||
|
|
||||||
|
data = self.session.post(
|
||||||
|
url=self.config["endpoints"]["graphql"],
|
||||||
|
json={"query": query},
|
||||||
|
headers={"Content-Type": "application/json"}
|
||||||
|
).json()
|
||||||
|
|
||||||
|
contents = data.get("data", {}).get("contents", {}).get("contents", [])
|
||||||
|
if not contents:
|
||||||
|
raise ValueError("Content not found")
|
||||||
|
|
||||||
|
meta = contents[0]
|
||||||
|
kind_name = meta["kind"]["name"]
|
||||||
|
|
||||||
|
if not meta.get("circulation"):
|
||||||
|
raise ValueError("You must borrow this title on your Hoopla account before downloading.")
|
||||||
|
|
||||||
|
if kind_name == "MOVIE":
|
||||||
|
return Movies([
|
||||||
|
Movie(
|
||||||
|
id_=meta["id"],
|
||||||
|
service=self.__class__,
|
||||||
|
name=meta["title"],
|
||||||
|
year=int(meta["year"]) if meta.get("year") else None,
|
||||||
|
language=Language.get("en"),
|
||||||
|
data={
|
||||||
|
"mediaKey": meta["mediaKey"],
|
||||||
|
"circulationId": meta["circulation"]["id"],
|
||||||
|
"is_music": False,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
])
|
||||||
|
|
||||||
|
elif kind_name == "MUSIC":
|
||||||
|
if not meta.get("tracks"):
|
||||||
|
# Single-track album? Use main mediaKey
|
||||||
|
songs = [
|
||||||
|
Song(
|
||||||
|
id_=meta["id"],
|
||||||
|
service=self.__class__,
|
||||||
|
name=meta["title"],
|
||||||
|
artist=meta.get("primaryArtist", {}).get("name", "Unknown Artist"),
|
||||||
|
album=meta["title"],
|
||||||
|
track=1,
|
||||||
|
disc=1,
|
||||||
|
year=int(meta["year"]) if meta.get("year") else None,
|
||||||
|
data={
|
||||||
|
"mediaKey": meta["mediaKey"],
|
||||||
|
"circulationId": meta["circulation"]["id"],
|
||||||
|
"is_music": True,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
songs = []
|
||||||
|
for idx, track in enumerate(meta["tracks"], start=1):
|
||||||
|
songs.append(
|
||||||
|
Song(
|
||||||
|
id_=track["id"],
|
||||||
|
service=self.__class__,
|
||||||
|
name=track["name"],
|
||||||
|
artist=meta.get("primaryArtist", {}).get("name", "Unknown Artist"),
|
||||||
|
album=meta["title"],
|
||||||
|
track=track.get("segmentNumber", idx),
|
||||||
|
disc=1,
|
||||||
|
year=int(meta["year"]) if meta.get("year") else None,
|
||||||
|
data={
|
||||||
|
"mediaKey": track["mediaKey"], # ← Per-track mediaKey!
|
||||||
|
"circulationId": meta["circulation"]["id"],
|
||||||
|
"is_music": True,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return Album(songs)
|
||||||
|
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Unsupported content type: {kind_name}. Only MOVIE and MUSIC are supported.")
|
||||||
|
|
||||||
|
def get_tracks(self, title: Title_T) -> Tracks:
|
||||||
|
media_key = title.data["mediaKey"]
|
||||||
|
circulation_id = title.data["circulationId"]
|
||||||
|
|
||||||
|
# --- DRM bootstrap ---
|
||||||
|
self.asset_id = self.session.get(
|
||||||
|
self.config["endpoints"]["license_asset"].format(media_key=media_key)
|
||||||
|
).text.strip()
|
||||||
|
|
||||||
|
self.auth_token = self.session.get(
|
||||||
|
self.config["endpoints"]["license_token"].format(
|
||||||
|
media_key=media_key,
|
||||||
|
patron_id=self.patron_id,
|
||||||
|
circulation_id=circulation_id,
|
||||||
|
)
|
||||||
|
).text.strip()
|
||||||
|
|
||||||
|
self.custom_data = self._extract_custom_data(self.auth_token)
|
||||||
|
|
||||||
|
manifest_url = self.config["endpoints"]["manifest"].format(media_key=media_key)
|
||||||
|
mpd_xml = self.session.get(manifest_url).text
|
||||||
|
mpd_xml = self._strip_namespaces(mpd_xml)
|
||||||
|
mpd = etree.fromstring(mpd_xml.encode("utf-8"))
|
||||||
|
|
||||||
|
if self._is_music_mpd(mpd):
|
||||||
|
self.log.info("Detected Hoopla music MPD")
|
||||||
|
|
||||||
|
audio_url = self._extract_music_audio(mpd, manifest_url)
|
||||||
|
|
||||||
|
tracks = Tracks()
|
||||||
|
tracks.add(
|
||||||
|
Audio(
|
||||||
|
url=audio_url,
|
||||||
|
drm=[],
|
||||||
|
codec=Audio.Codec.AAC,
|
||||||
|
language=title.language or "en",
|
||||||
|
channels=2,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return tracks
|
||||||
|
|
||||||
|
self.log.info("Detected Hoopla movie MPD")
|
||||||
|
|
||||||
|
tracks = DASH(mpd, manifest_url).to_tracks(
|
||||||
|
language=title.language or Language.get("en")
|
||||||
|
)
|
||||||
|
|
||||||
|
self._add_subtitles(tracks, manifest_url, media_key)
|
||||||
|
return tracks
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_namespaces(self, xml_string: str) -> str:
|
||||||
|
"""
|
||||||
|
Strip namespace declarations and prefixes from XML string.
|
||||||
|
This is needed because unshackle's DASH parser expects plain 'MPD' tag,
|
||||||
|
not '{urn:mpeg:dash:schema:mpd:2011}MPD'.
|
||||||
|
"""
|
||||||
|
# Remove xmlns declarations (both default and prefixed)
|
||||||
|
xml_string = re.sub(r'\s+xmlns(:\w+)?="[^"]+"', '', xml_string)
|
||||||
|
|
||||||
|
# Remove namespace prefixes from element tags (e.g., <cenc:pssh> -> <pssh>)
|
||||||
|
xml_string = re.sub(r'<(/?)(\w+):', r'<\1', xml_string)
|
||||||
|
|
||||||
|
# Remove namespace prefixes from attributes (e.g., cenc:default_KID -> default_KID)
|
||||||
|
xml_string = re.sub(r'\s+\w+:(\w+)=', r' \1=', xml_string)
|
||||||
|
|
||||||
|
# Remove urn: prefixed attributes entirely (e.g., urn:assetId="...")
|
||||||
|
xml_string = re.sub(r'\s+urn:\w+="[^"]+"', '', xml_string)
|
||||||
|
|
||||||
|
return xml_string
|
||||||
|
|
||||||
|
def _extract_custom_data(self, jwt_token: str) -> str:
|
||||||
|
"""Extract and encode optData from JWT for dt-custom-data header."""
|
||||||
|
try:
|
||||||
|
jwt_parts = jwt_token.split(".")
|
||||||
|
padded_payload = jwt_parts[1] + "=" * (-len(jwt_parts[1]) % 4)
|
||||||
|
payload_json = json.loads(base64.urlsafe_b64decode(padded_payload))
|
||||||
|
|
||||||
|
opt_data_str = payload_json.get("optData")
|
||||||
|
if not opt_data_str:
|
||||||
|
raise ValueError("optData not found in JWT")
|
||||||
|
|
||||||
|
return base64.b64encode(opt_data_str.encode("utf-8")).decode("utf-8")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
raise ValueError(f"Failed to process license token: {e}")
|
||||||
|
|
||||||
|
def _add_subtitles(self, tracks: Tracks, manifest_url: str, media_key: str) -> None:
|
||||||
|
"""Add VTT subtitles from manifest if available."""
|
||||||
|
base_url = manifest_url.rsplit('/', 1)[0]
|
||||||
|
|
||||||
|
vtt_patterns = [
|
||||||
|
f"{base_url}/{media_key}-8784525650515056532-en/{media_key}-8784525650515056532-en.vtt",
|
||||||
|
]
|
||||||
|
|
||||||
|
for vtt_url in vtt_patterns:
|
||||||
|
try:
|
||||||
|
response = self.session.head(vtt_url)
|
||||||
|
if response.status_code == 200:
|
||||||
|
tracks.add(
|
||||||
|
Subtitle(
|
||||||
|
id_=hashlib.md5(vtt_url.encode()).hexdigest()[0:6],
|
||||||
|
url=vtt_url,
|
||||||
|
codec=Subtitle.Codec.WebVTT,
|
||||||
|
language=Language.get("en"),
|
||||||
|
sdh=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def get_chapters(self, title: Title_T) -> list[Chapter]:
|
||||||
|
return []
|
||||||
|
|
||||||
|
def get_widevine_service_certificate(self, **_) -> Optional[str]:
|
||||||
|
return self.config.get("certificate")
|
||||||
|
|
||||||
|
def get_widevine_license(self, *, challenge: bytes, title: Title_T, track: AnyTrack) -> Optional[Union[bytes, str]]:
|
||||||
|
response = self.session.post(
|
||||||
|
url=self.config["endpoints"]["license_wv"],
|
||||||
|
params={
|
||||||
|
"logRequestId": "unshackle",
|
||||||
|
"assetId": self.asset_id,
|
||||||
|
},
|
||||||
|
headers={
|
||||||
|
"dt-custom-data": self.custom_data,
|
||||||
|
"x-dt-auth-token": self.auth_token,
|
||||||
|
"Content-Type": "text/xml",
|
||||||
|
},
|
||||||
|
data=challenge,
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
self.log.error(f"License Error: {response.text}")
|
||||||
|
raise ValueError(f"Failed to get Widevine license: {response.status_code}")
|
||||||
|
|
||||||
|
return response.json().get("license")
|
||||||
|
|
||||||
|
def get_playready_license(self, *, challenge: bytes | str, title: Title_T, track: AnyTrack) -> bytes:
|
||||||
|
if not hasattr(self, 'auth_token') or not hasattr(self, 'custom_data'):
|
||||||
|
raise RuntimeError("Authentication tokens missing. Call get_tracks() first.")
|
||||||
|
|
||||||
|
if isinstance(challenge, str):
|
||||||
|
request_body = challenge.encode('utf-8')
|
||||||
|
else:
|
||||||
|
request_body = challenge
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"Accept": "*/*",
|
||||||
|
"Accept-Language": "nl",
|
||||||
|
"Cache-Control": "no-cache",
|
||||||
|
"Content-Type": "text/xml; charset=utf-8",
|
||||||
|
"dt-custom-data": self.custom_data,
|
||||||
|
"x-dt-auth-token": self.auth_token,
|
||||||
|
"soapaction": '"http://schemas.microsoft.com/DRM/2007/03/protocols/AcquireLicense"',
|
||||||
|
"Origin": "https://www.hoopladigital.com",
|
||||||
|
"Referer": "https://www.hoopladigital.com/",
|
||||||
|
"Pragma": "no-cache",
|
||||||
|
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36 Edg/144.0.0.0",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
response = self.session.post(
|
||||||
|
url=self.config["endpoints"]["license_pr"],
|
||||||
|
data=request_body,
|
||||||
|
headers=headers,
|
||||||
|
timeout=30
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
self.log.error(f"PlayReady license failed: {response.status_code}")
|
||||||
|
self.log.error(f"Response: {response.text[:1000]}")
|
||||||
|
raise ValueError(f"PlayReady license failed: HTTP {response.status_code}")
|
||||||
|
|
||||||
|
return response.content
|
||||||
22
HPLA/config.yaml
Normal file
22
HPLA/config.yaml
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
endpoints:
|
||||||
|
login: https://patron-api-gateway.hoopladigital.com/core/tokens
|
||||||
|
graphql: https://patron-api-gateway.hoopladigital.com/graphql
|
||||||
|
manifest: https://dash.hoopladigital.com/{media_key}/Manifest.mpd
|
||||||
|
license_asset: https://patron-api-gateway.hoopladigital.com/license/castlabs/asset-id/{media_key}
|
||||||
|
license_token: https://patron-api-gateway.hoopladigital.com/license/castlabs/upfront-auth-tokens/{media_key}/{patron_id}/{circulation_id}
|
||||||
|
license_wv: https://lic.drmtoday.com/license-proxy-widevine/cenc/
|
||||||
|
license_pr: https://lic.drmtoday.com/license-proxy-headerauth/drmtoday/RightsManager.asmx?persistent=false
|
||||||
|
|
||||||
|
platform:
|
||||||
|
amazon:
|
||||||
|
headers:
|
||||||
|
app: AMAZON
|
||||||
|
device-model: SM-A525F
|
||||||
|
os: AMAZON
|
||||||
|
User-Agent: Hoopla Amazon/4.84.1
|
||||||
|
app-version: "4.84.1"
|
||||||
|
os-version: "15"
|
||||||
|
ws-api: "2.1"
|
||||||
|
device-version: a52q
|
||||||
|
hoopla-version: "4.84.1"
|
||||||
|
Accept-Language: en-US
|
||||||
@ -14,8 +14,10 @@ from unshackle.core.search_result import SearchResult
|
|||||||
from unshackle.core.titles import Episode, Series, Title_T, Titles_T
|
from unshackle.core.titles import Episode, Series, Title_T, Titles_T
|
||||||
from unshackle.core.tracks import Subtitle, Tracks
|
from unshackle.core.tracks import Subtitle, Tracks
|
||||||
from unshackle.core.utilities import is_close_match
|
from unshackle.core.utilities import is_close_match
|
||||||
|
import uuid
|
||||||
|
import hashlib
|
||||||
|
|
||||||
class KOWP(Service):
|
class KOCW(Service):
|
||||||
"""
|
"""
|
||||||
Service code for Kocowa Plus (kocowa.com).
|
Service code for Kocowa Plus (kocowa.com).
|
||||||
Version: 1.0.0
|
Version: 1.0.0
|
||||||
@ -29,12 +31,12 @@ class KOWP(Service):
|
|||||||
NO_SUBTITLES = False
|
NO_SUBTITLES = False
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@click.command(name="kowp", short_help="https://www.kocowa.com")
|
@click.command(name="kocw", short_help="https://www.kocowa.com")
|
||||||
@click.argument("title", type=str)
|
@click.argument("title", type=str)
|
||||||
@click.option("--extras", is_flag=True, default=False, help="Include teasers/extras")
|
@click.option("--extras", is_flag=True, default=False, help="Include teasers/extras")
|
||||||
@click.pass_context
|
@click.pass_context
|
||||||
def cli(ctx, **kwargs):
|
def cli(ctx, **kwargs):
|
||||||
return KOWP(ctx, **kwargs)
|
return KOCW(ctx, **kwargs)
|
||||||
|
|
||||||
def __init__(self, ctx, title: str, extras: bool = False):
|
def __init__(self, ctx, title: str, extras: bool = False):
|
||||||
super().__init__(ctx)
|
super().__init__(ctx)
|
||||||
@ -52,16 +54,27 @@ class KOWP(Service):
|
|||||||
if not credential:
|
if not credential:
|
||||||
raise ValueError("KOWP requires username and password")
|
raise ValueError("KOWP requires username and password")
|
||||||
|
|
||||||
|
|
||||||
|
email = credential.username.lower().strip()
|
||||||
|
uuid_seed = hashlib.md5(email.encode()).digest()
|
||||||
|
fake_uuid = str(uuid.UUID(bytes=uuid_seed[:16]))
|
||||||
|
|
||||||
|
device_id = f"a_{fake_uuid}_{email}"
|
||||||
|
push_token = "fkiTs_a0SAaMYx957n-qA-:APA91bFb39IjJd_iA5bVmh-fjvaUKonvKDWw1PfKKcdpkSXanj0Jlevv_QlMPPD5ZykAQE4ELa3bs6p-Gnmz0R54U-B1o1ukBPLQEDLDdM3hU2ozZIRiy9I"
|
||||||
|
|
||||||
payload = {
|
payload = {
|
||||||
"username": credential.username,
|
"username": credential.username,
|
||||||
"password": credential.password,
|
"password": credential.password,
|
||||||
"device_id": f"{credential.username}_browser",
|
"device_id": device_id,
|
||||||
"device_type": "browser",
|
"device_type": "mobile",
|
||||||
"device_model": "Firefox",
|
"device_model": "SM-A525F",
|
||||||
"device_version": "firefox/143.0",
|
"device_version": "Android 15",
|
||||||
"push_token": None,
|
"push_token": None,
|
||||||
"app_version": "v4.0.16",
|
"app_version": "v4.0.11",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
self.log.debug(f"Authenticating with device_id: {device_id}")
|
||||||
|
|
||||||
r = self.session.post(
|
r = self.session.post(
|
||||||
self.config["endpoints"]["login"],
|
self.config["endpoints"]["login"],
|
||||||
json=payload,
|
json=payload,
|
||||||
@ -294,4 +307,3 @@ class KOWP(Service):
|
|||||||
|
|
||||||
def get_chapters(self, title: Title_T) -> list:
|
def get_chapters(self, title: Title_T) -> list:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
206
MUBI/__init__.py
206
MUBI/__init__.py
@ -7,61 +7,63 @@ from langcodes import Language
|
|||||||
import base64
|
import base64
|
||||||
import click
|
import click
|
||||||
from unshackle.core.constants import AnyTrack
|
from unshackle.core.constants import AnyTrack
|
||||||
from unshackle.core.credential import Credential
|
|
||||||
from unshackle.core.manifests import DASH
|
from unshackle.core.manifests import DASH
|
||||||
from unshackle.core.service import Service
|
from unshackle.core.service import Service
|
||||||
|
from unshackle.core.credential import Credential
|
||||||
from unshackle.core.titles import Episode, Movie, Movies, Title_T, Titles_T, Series
|
from unshackle.core.titles import Episode, Movie, Movies, Title_T, Titles_T, Series
|
||||||
from unshackle.core.tracks import Chapter, Tracks, Subtitle
|
from unshackle.core.tracks import Chapter, Tracks, Subtitle
|
||||||
|
from unshackle.core.search_result import SearchResult
|
||||||
|
|
||||||
class MUBI(Service):
|
class MUBI(Service):
|
||||||
"""
|
"""
|
||||||
Service code for MUBI (mubi.com)
|
Service code for MUBI (mubi.com)
|
||||||
Version: 1.2.0
|
Version: 1.2.1 (Cookie-only + Auto-UHD + Search)
|
||||||
|
Authorization: Cookies ONLY (lt token + _mubi_session)
|
||||||
Authorization: Required cookies (lt token + session)
|
Security: UHD @ L3/SL2K (Widevine/PlayReady)
|
||||||
Security: FHD @ L3 (Widevine)
|
|
||||||
|
|
||||||
Supports:
|
Supports:
|
||||||
• Series ↦ https://mubi.com/en/nl/series/twin-peaks
|
• Series ↦ https://mubi.com/en/nl/series/twin-peaks
|
||||||
• Movies ↦ https://mubi.com/en/nl/films/the-substance
|
• Movies ↦ https://mubi.com/en/nl/films/the-substance
|
||||||
|
|
||||||
"""
|
"""
|
||||||
SERIES_TITLE_RE = r"^https?://(?:www\.)?mubi\.com(?:/[^/]+)*?/series/(?P<series_slug>[^/]+)(?:/season/(?P<season_slug>[^/]+))?$"
|
SERIES_TITLE_RE = r"^https?://(?:www\.)?mubi\.com(?:/[^/]+)*?/series/(?P<series_slug>[^/]+)(?:/season/(?P<season_slug>[^/]+))?$"
|
||||||
TITLE_RE = r"^(?:https?://(?:www\.)?mubi\.com)(?:/[^/]+)*?/films/(?P<slug>[^/?#]+)$"
|
TITLE_RE = r"^(?:https?://(?:www\.)?mubi\.com)(?:/[^/]+)*?/films/(?P<slug>[^/?#]+)$"
|
||||||
NO_SUBTITLES = False
|
NO_SUBTITLES = False
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@click.command(name="MUBI", short_help="https://mubi.com")
|
@click.command(name="MUBI", short_help="https://mubi.com ")
|
||||||
@click.argument("title", type=str)
|
@click.argument("title", type=str)
|
||||||
|
@click.option("-c", "--country", default=None, type=str,
|
||||||
|
help="With VPN set country code other than the one assigned to the account.")
|
||||||
@click.pass_context
|
@click.pass_context
|
||||||
def cli(ctx, **kwargs):
|
def cli(ctx, **kwargs):
|
||||||
return MUBI(ctx, **kwargs)
|
return MUBI(ctx, **kwargs)
|
||||||
|
|
||||||
def __init__(self, ctx, title: str):
|
def __init__(self, ctx, title: str, country: str):
|
||||||
super().__init__(ctx)
|
super().__init__(ctx)
|
||||||
|
self.raw_title = title # Store raw input for search mode
|
||||||
|
self.country = country
|
||||||
|
|
||||||
|
# Only parse as URL if it matches MUBI patterns
|
||||||
m_film = re.match(self.TITLE_RE, title)
|
m_film = re.match(self.TITLE_RE, title)
|
||||||
m_series = re.match(self.SERIES_TITLE_RE, title)
|
m_series = re.match(self.SERIES_TITLE_RE, title)
|
||||||
|
|
||||||
if not m_film and not m_series:
|
|
||||||
raise ValueError(f"Invalid MUBI URL: {title}")
|
|
||||||
|
|
||||||
self.is_series = bool(m_series)
|
self.is_series = bool(m_series)
|
||||||
self.slug = m_film.group("slug") if m_film else None
|
self.slug = m_film.group("slug") if m_film else None
|
||||||
self.series_slug = m_series.group("series_slug") if m_series else None
|
self.series_slug = m_series.group("series_slug") if m_series else None
|
||||||
self.season_slug = m_series.group("season_slug") if m_series else None
|
self.season_slug = m_series.group("season_slug") if m_series else None
|
||||||
|
|
||||||
|
# Core state
|
||||||
self.film_id: Optional[int] = None
|
self.film_id: Optional[int] = None
|
||||||
self.lt_token: Optional[str] = None
|
self.lt_token: Optional[str] = None
|
||||||
self.session_token: Optional[str] = None
|
self.session_token: Optional[str] = None
|
||||||
self.user_id: Optional[int] = None
|
self.user_id: Optional[int] = None
|
||||||
self.country_code: Optional[str] = None
|
self.country_code: Optional[str] = None
|
||||||
|
self.set_country_code: Optional[str] = country
|
||||||
self.anonymous_user_id: Optional[str] = None
|
self.anonymous_user_id: Optional[str] = None
|
||||||
self.default_country: Optional[str] = None
|
self.default_country: Optional[str] = None
|
||||||
self.reels_data: Optional[list] = None
|
self.reels_data: Optional[list] = None
|
||||||
|
|
||||||
# Store CDM reference
|
# ALWAYS enable UHD/HEVC path - no user flag required
|
||||||
|
self.uhd = True
|
||||||
self.cdm = ctx.obj.cdm
|
self.cdm = ctx.obj.cdm
|
||||||
|
|
||||||
if self.config is None:
|
if self.config is None:
|
||||||
@ -70,6 +72,10 @@ class MUBI(Service):
|
|||||||
def authenticate(self, cookies: Optional[CookieJar] = None, credential: Optional[Credential] = None) -> None:
|
def authenticate(self, cookies: Optional[CookieJar] = None, credential: Optional[Credential] = None) -> None:
|
||||||
super().authenticate(cookies, credential)
|
super().authenticate(cookies, credential)
|
||||||
|
|
||||||
|
if not cookies:
|
||||||
|
raise PermissionError("MUBI requires login cookies (lt + _mubi_session). Credentials login is not supported.")
|
||||||
|
|
||||||
|
# IP geolocation for country detection
|
||||||
try:
|
try:
|
||||||
r_ip = self.session.get(self.config["endpoints"]["ip_geolocation"], timeout=5)
|
r_ip = self.session.get(self.config["endpoints"]["ip_geolocation"], timeout=5)
|
||||||
r_ip.raise_for_status()
|
r_ip.raise_for_status()
|
||||||
@ -82,10 +88,7 @@ class MUBI(Service):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise ValueError(f"Failed to fetch IP geolocation: {e}")
|
raise ValueError(f"Failed to fetch IP geolocation: {e}")
|
||||||
|
|
||||||
if not cookies:
|
# Extract essential tokens from cookies
|
||||||
raise PermissionError("MUBI requires login cookies.")
|
|
||||||
|
|
||||||
# Extract essential tokens
|
|
||||||
lt_cookie = next((c for c in cookies if c.name == "lt"), None)
|
lt_cookie = next((c for c in cookies if c.name == "lt"), None)
|
||||||
session_cookie = next((c for c in cookies if c.name == "_mubi_session"), None)
|
session_cookie = next((c for c in cookies if c.name == "_mubi_session"), None)
|
||||||
snow_id_cookie = next((c for c in cookies if c.name == "_snow_id.c006"), None)
|
snow_id_cookie = next((c for c in cookies if c.name == "_snow_id.c006"), None)
|
||||||
@ -98,20 +101,21 @@ class MUBI(Service):
|
|||||||
self.lt_token = lt_cookie.value
|
self.lt_token = lt_cookie.value
|
||||||
self.session_token = session_cookie.value
|
self.session_token = session_cookie.value
|
||||||
|
|
||||||
# Extract anonymous_user_id from _snow_id.c006
|
# Extract or generate anonymous_user_id
|
||||||
if snow_id_cookie and "." in snow_id_cookie.value:
|
if snow_id_cookie and "." in snow_id_cookie.value:
|
||||||
self.anonymous_user_id = snow_id_cookie.value.split(".")[0]
|
self.anonymous_user_id = snow_id_cookie.value.split(".")[0]
|
||||||
else:
|
else:
|
||||||
self.anonymous_user_id = str(uuid.uuid4())
|
self.anonymous_user_id = str(uuid.uuid4())
|
||||||
self.log.warning(f"No _snow_id.c006 cookie found — generated new anonymous_user_id: {self.anonymous_user_id}")
|
self.log.warning(f"No _snow_id.c006 cookie found — generated new anonymous_user_id: {self.anonymous_user_id}")
|
||||||
|
|
||||||
|
# Configure session headers for UHD access
|
||||||
base_headers = {
|
base_headers = {
|
||||||
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) Firefox/143.0",
|
"User-Agent": "Mozilla/5.0 (Linux; Android 13; SM-G975F) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36",
|
||||||
"Origin": "https://mubi.com",
|
"Origin": "https://mubi.com",
|
||||||
"Referer": "https://mubi.com/",
|
"Referer": "https://mubi.com/",
|
||||||
"CLIENT": "web",
|
"CLIENT": "web",
|
||||||
"Client-Accept-Video-Codecs": "h265,vp9,h264",
|
"Client-Accept-Video-Codecs": "h265,vp9,h264",
|
||||||
"Client-Accept-Audio-Codecs": "aac",
|
"Client-Accept-Audio-Codecs": "eac3,ac3,aac",
|
||||||
"Authorization": f"Bearer {self.lt_token}",
|
"Authorization": f"Bearer {self.lt_token}",
|
||||||
"ANONYMOUS_USER_ID": self.anonymous_user_id,
|
"ANONYMOUS_USER_ID": self.anonymous_user_id,
|
||||||
"Client-Country": self.default_country,
|
"Client-Country": self.default_country,
|
||||||
@ -121,9 +125,9 @@ class MUBI(Service):
|
|||||||
"Pragma": "no-cache",
|
"Pragma": "no-cache",
|
||||||
"Cache-Control": "no-cache",
|
"Cache-Control": "no-cache",
|
||||||
}
|
}
|
||||||
|
|
||||||
self.session.headers.update(base_headers)
|
self.session.headers.update(base_headers)
|
||||||
|
|
||||||
|
# Fetch account info
|
||||||
r_account = self.session.get(self.config["endpoints"]["account"])
|
r_account = self.session.get(self.config["endpoints"]["account"])
|
||||||
if not r_account.ok:
|
if not r_account.ok:
|
||||||
raise PermissionError(f"Failed to fetch MUBI account: {r_account.status_code} {r_account.text}")
|
raise PermissionError(f"Failed to fetch MUBI account: {r_account.status_code} {r_account.text}")
|
||||||
@ -132,9 +136,11 @@ class MUBI(Service):
|
|||||||
self.user_id = account_data.get("id")
|
self.user_id = account_data.get("id")
|
||||||
self.country_code = (account_data.get("country") or {}).get("code", "NL")
|
self.country_code = (account_data.get("country") or {}).get("code", "NL")
|
||||||
|
|
||||||
|
if self.set_country_code is not None:
|
||||||
|
self.country_code = self.set_country_code.upper()
|
||||||
|
|
||||||
self.session.headers["Client-Country"] = self.country_code
|
self.session.headers["Client-Country"] = self.country_code
|
||||||
self.GEOFENCE = (self.country_code,)
|
self.GEOFENCE = (self.country_code,)
|
||||||
|
|
||||||
self._bind_anonymous_user()
|
self._bind_anonymous_user()
|
||||||
|
|
||||||
self.log.info(
|
self.log.info(
|
||||||
@ -168,24 +174,22 @@ class MUBI(Service):
|
|||||||
r = self.session.get(url)
|
r = self.session.get(url)
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
data = r.json()
|
data = r.json()
|
||||||
|
|
||||||
self.film_id = data["id"]
|
self.film_id = data["id"]
|
||||||
|
|
||||||
# Fetch reels to get definitive language code and cache the response
|
# Fetch reels for language detection and subtitle names
|
||||||
url_reels = self.config["endpoints"]["reels"].format(film_id=self.film_id)
|
url_reels = self.config["endpoints"]["reels"].format(film_id=self.film_id)
|
||||||
r_reels = self.session.get(url_reels)
|
r_reels = self.session.get(url_reels)
|
||||||
r_reels.raise_for_status()
|
r_reels.raise_for_status()
|
||||||
self.reels_data = r_reels.json()
|
self.reels_data = r_reels.json()
|
||||||
|
|
||||||
# Extract original language from the first audio track of the first reel
|
# Detect original language from first audio track
|
||||||
original_language_code = "en" # Default fallback
|
original_language_code = "en"
|
||||||
if self.reels_data and self.reels_data[0].get("audio_tracks"):
|
if self.reels_data and self.reels_data[0].get("audio_tracks"):
|
||||||
first_audio_track = self.reels_data[0]["audio_tracks"][0]
|
first_audio_track = self.reels_data[0]["audio_tracks"][0]
|
||||||
if "language_code" in first_audio_track:
|
if "language_code" in first_audio_track:
|
||||||
original_language_code = first_audio_track["language_code"]
|
original_language_code = first_audio_track["language_code"]
|
||||||
self.log.debug(f"Detected original language from reels: '{original_language_code}'")
|
self.log.debug(f"Detected original language from reels: '{original_language_code}'")
|
||||||
|
|
||||||
genres = ", ".join(data.get("genres", [])) or "Unknown"
|
|
||||||
description = (
|
description = (
|
||||||
data.get("default_editorial_html", "")
|
data.get("default_editorial_html", "")
|
||||||
.replace("<p>", "").replace("</p>", "").replace("<em>", "").replace("</em>", "").strip()
|
.replace("<p>", "").replace("</p>", "").replace("<em>", "").replace("</em>", "").strip()
|
||||||
@ -202,19 +206,15 @@ class MUBI(Service):
|
|||||||
language=Language.get(original_language_code),
|
language=Language.get(original_language_code),
|
||||||
data=data,
|
data=data,
|
||||||
)
|
)
|
||||||
|
|
||||||
return Movies([movie])
|
return Movies([movie])
|
||||||
|
|
||||||
def _get_series_titles(self) -> Titles_T:
|
def _get_series_titles(self) -> Titles_T:
|
||||||
# Fetch series metadata
|
|
||||||
series_url = self.config["endpoints"]["series"].format(series_slug=self.series_slug)
|
series_url = self.config["endpoints"]["series"].format(series_slug=self.series_slug)
|
||||||
r_series = self.session.get(series_url)
|
r_series = self.session.get(series_url)
|
||||||
r_series.raise_for_status()
|
r_series.raise_for_status()
|
||||||
series_data = r_series.json()
|
series_data = r_series.json()
|
||||||
|
|
||||||
episodes = []
|
episodes = []
|
||||||
|
|
||||||
# If season is explicitly specified, only fetch that season
|
|
||||||
if self.season_slug:
|
if self.season_slug:
|
||||||
eps_url = self.config["endpoints"]["season_episodes"].format(
|
eps_url = self.config["endpoints"]["season_episodes"].format(
|
||||||
series_slug=self.series_slug,
|
series_slug=self.series_slug,
|
||||||
@ -227,62 +227,48 @@ class MUBI(Service):
|
|||||||
episodes_data = r_eps.json().get("episodes", [])
|
episodes_data = r_eps.json().get("episodes", [])
|
||||||
self._add_episodes_to_list(episodes, episodes_data, series_data)
|
self._add_episodes_to_list(episodes, episodes_data, series_data)
|
||||||
else:
|
else:
|
||||||
# No season specified fetch ALL seasons
|
|
||||||
seasons = series_data.get("seasons", [])
|
seasons = series_data.get("seasons", [])
|
||||||
if not seasons:
|
if not seasons:
|
||||||
raise ValueError("No seasons found for this series.")
|
raise ValueError("No seasons found for this series.")
|
||||||
|
|
||||||
for season in seasons:
|
for season in seasons:
|
||||||
season_slug = season["slug"]
|
season_slug = season["slug"]
|
||||||
eps_url = self.config["endpoints"]["season_episodes"].format(
|
eps_url = self.config["endpoints"]["season_episodes"].format(
|
||||||
series_slug=self.series_slug,
|
series_slug=self.series_slug,
|
||||||
season_slug=season_slug
|
season_slug=season_slug
|
||||||
)
|
)
|
||||||
|
|
||||||
self.log.debug(f"Fetching episodes for season: {season_slug}")
|
self.log.debug(f"Fetching episodes for season: {season_slug}")
|
||||||
|
|
||||||
r_eps = self.session.get(eps_url)
|
r_eps = self.session.get(eps_url)
|
||||||
|
|
||||||
# Stop if season returns 404 or empty
|
|
||||||
if r_eps.status_code == 404:
|
if r_eps.status_code == 404:
|
||||||
self.log.info(f"Season '{season_slug}' not available, skipping.")
|
self.log.info(f"Season '{season_slug}' not available, skipping.")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
r_eps.raise_for_status()
|
r_eps.raise_for_status()
|
||||||
episodes_data = r_eps.json().get("episodes", [])
|
episodes_data = r_eps.json().get("episodes", [])
|
||||||
|
|
||||||
if not episodes_data:
|
if not episodes_data:
|
||||||
self.log.info(f"No episodes found in season '{season_slug}'.")
|
self.log.info(f"No episodes found in season '{season_slug}'.")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
self._add_episodes_to_list(episodes, episodes_data, series_data)
|
self._add_episodes_to_list(episodes, episodes_data, series_data)
|
||||||
|
|
||||||
from unshackle.core.titles import Series
|
|
||||||
return Series(sorted(episodes, key=lambda x: (x.season, x.number)))
|
return Series(sorted(episodes, key=lambda x: (x.season, x.number)))
|
||||||
|
|
||||||
def _add_episodes_to_list(self, episodes_list: list, episodes_data: list, series_data: dict):
|
def _add_episodes_to_list(self, episodes_list: list, episodes_data: list, series_data: dict):
|
||||||
"""Helper to avoid code duplication when adding episodes."""
|
|
||||||
for ep in episodes_data:
|
for ep in episodes_data:
|
||||||
# Use episode's own language detection via its consumable.playback_languages
|
|
||||||
playback_langs = ep.get("consumable", {}).get("playback_languages", {})
|
playback_langs = ep.get("consumable", {}).get("playback_languages", {})
|
||||||
audio_langs = playback_langs.get("audio_options", ["English"])
|
audio_langs = playback_langs.get("audio_options", ["English"])
|
||||||
lang_code = audio_langs[0].split()[0].lower() if audio_langs else "en"
|
lang_code = audio_langs[0].split()[0].lower() if audio_langs else "en"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
detected_lang = Language.get(lang_code)
|
detected_lang = Language.get(lang_code)
|
||||||
except:
|
except:
|
||||||
detected_lang = Language.get("en")
|
detected_lang = Language.get("en")
|
||||||
|
|
||||||
episodes_list.append(Episode(
|
episodes_list.append(Episode(
|
||||||
id_=ep["id"],
|
id_=ep["id"],
|
||||||
service=self.__class__,
|
service=self.__class__,
|
||||||
title=series_data["title"], # Series title
|
title=series_data["title"],
|
||||||
season=ep["episode"]["season_number"],
|
season=ep["episode"]["season_number"],
|
||||||
number=ep["episode"]["number"],
|
number=ep["episode"]["number"],
|
||||||
name=ep["title"], # Episode title
|
name=ep["title"],
|
||||||
description=ep.get("short_synopsis", ""),
|
description=ep.get("short_synopsis", ""),
|
||||||
language=detected_lang,
|
language=detected_lang,
|
||||||
data=ep, # Full episode data for later use in get_tracks
|
data=ep,
|
||||||
))
|
))
|
||||||
|
|
||||||
def get_tracks(self, title: Title_T) -> Tracks:
|
def get_tracks(self, title: Title_T) -> Tracks:
|
||||||
@ -290,60 +276,77 @@ class MUBI(Service):
|
|||||||
if not film_id:
|
if not film_id:
|
||||||
raise RuntimeError("Title ID not found.")
|
raise RuntimeError("Title ID not found.")
|
||||||
|
|
||||||
# For series episodes, we don't have reels cached, so skip reel-based logic
|
# Initiate viewing session
|
||||||
url_view = self.config["endpoints"]["initiate_viewing"].format(film_id=film_id)
|
url_view = self.config["endpoints"]["initiate_viewing"].format(film_id=film_id)
|
||||||
r_view = self.session.post(url_view, json={}, headers={"Content-Type": "application/json"})
|
r_view = self.session.post(url_view, json={}, headers={"Content-Type": "application/json"})
|
||||||
r_view.raise_for_status()
|
r_view.raise_for_status()
|
||||||
view_data = r_view.json()
|
view_data = r_view.json()
|
||||||
reel_id = view_data["reel_id"]
|
reel_id = view_data["reel_id"]
|
||||||
|
|
||||||
# For films, use reels data for language/audio mapping
|
# Fetch reels data if not cached
|
||||||
if not self.is_series:
|
|
||||||
if not self.film_id:
|
if not self.film_id:
|
||||||
raise RuntimeError("film_id not set. Call get_titles() first.")
|
self.film_id = film_id
|
||||||
|
|
||||||
if not self.reels_data:
|
if not self.reels_data:
|
||||||
self.log.warning("Reels data not cached, fetching now.")
|
|
||||||
url_reels = self.config["endpoints"]["reels"].format(film_id=film_id)
|
url_reels = self.config["endpoints"]["reels"].format(film_id=film_id)
|
||||||
r_reels = self.session.get(url_reels)
|
r_reels = self.session.get(url_reels)
|
||||||
r_reels.raise_for_status()
|
r_reels.raise_for_status()
|
||||||
reels = r_reels.json()
|
self.reels_data = r_reels.json()
|
||||||
else:
|
|
||||||
reels = self.reels_data
|
reels = self.reels_data
|
||||||
|
text_tracks_reel = reels[0]["text_tracks"]
|
||||||
reel = next((r for r in reels if r["id"] == reel_id), reels[0])
|
reel = next((r for r in reels if r["id"] == reel_id), reels[0])
|
||||||
else:
|
|
||||||
# For episodes, we don’t need reel-based logic — just proceed
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Request secure streaming URL, works for both films and episodes
|
# Get secure streaming URL
|
||||||
url_secure = self.config["endpoints"]["secure_url"].format(film_id=film_id)
|
url_secure = self.config["endpoints"]["secure_url"].format(film_id=film_id)
|
||||||
r_secure = self.session.get(url_secure)
|
r_secure = self.session.get(url_secure)
|
||||||
r_secure.raise_for_status()
|
r_secure.raise_for_status()
|
||||||
secure_data = r_secure.json()
|
secure_data = r_secure.json()
|
||||||
|
|
||||||
|
# Find DASH manifest URL
|
||||||
manifest_url = None
|
manifest_url = None
|
||||||
for entry in secure_data.get("urls", []):
|
for entry in secure_data.get("urls", []):
|
||||||
if entry.get("content_type") == "application/dash+xml":
|
if entry.get("content_type") == "application/dash+xml":
|
||||||
manifest_url = entry["src"]
|
manifest_url = entry["src"]
|
||||||
break
|
break
|
||||||
|
|
||||||
if not manifest_url:
|
if not manifest_url:
|
||||||
raise ValueError("No DASH manifest URL found.")
|
raise ValueError("No DASH manifest URL found.")
|
||||||
|
|
||||||
# Parse DASH, use title.language as fallback
|
manifest_url = re.sub(
|
||||||
|
r'/default/ver1\.AVC1\.[^/]*\.mpd',
|
||||||
|
'/default/ver1.hevc.ex-vtt.mpd',
|
||||||
|
manifest_url
|
||||||
|
)
|
||||||
|
# Fallback for non-AVC URLs
|
||||||
|
if '/default/ver1.hevc.ex-vtt.mpd' not in manifest_url:
|
||||||
|
manifest_url = re.sub(
|
||||||
|
r'/default/[^/]*\.mpd',
|
||||||
|
'/default/ver1.hevc.ex-vtt.mpd',
|
||||||
|
manifest_url
|
||||||
|
)
|
||||||
|
|
||||||
|
# Parse DASH manifest
|
||||||
tracks = DASH.from_url(manifest_url, session=self.session).to_tracks(language=title.language)
|
tracks = DASH.from_url(manifest_url, session=self.session).to_tracks(language=title.language)
|
||||||
|
|
||||||
# Add subtitles
|
# Add enhanced subtitles (forced/SDH detection)
|
||||||
subtitles = []
|
subtitles = []
|
||||||
for sub in secure_data.get("text_track_urls", []):
|
for sub in secure_data.get("text_track_urls", []):
|
||||||
lang_code = sub.get("language_code", "und")
|
lang_code = sub.get("language_code", "und")
|
||||||
vtt_url = sub.get("url")
|
vtt_url = sub.get("url")
|
||||||
|
role = sub.get("role")
|
||||||
|
forced = False
|
||||||
|
sdh = False
|
||||||
if not vtt_url:
|
if not vtt_url:
|
||||||
continue
|
continue
|
||||||
|
try:
|
||||||
|
disp_name = (next(filter(lambda x: x['id'] == sub["id"], text_tracks_reel), None))["display_name"]
|
||||||
|
except:
|
||||||
|
disp_name = sub.get("role", "") + " " + lang_code.upper()
|
||||||
|
if role == "forced-subtitle":
|
||||||
|
forced = True
|
||||||
|
if role == "caption":
|
||||||
|
sdh = True
|
||||||
|
if "(SDH)" in disp_name:
|
||||||
|
disp_name = disp_name.replace("(SDH)", "").strip()
|
||||||
is_original = lang_code == title.language.language
|
is_original = lang_code == title.language.language
|
||||||
|
|
||||||
subtitles.append(
|
subtitles.append(
|
||||||
Subtitle(
|
Subtitle(
|
||||||
id_=sub["id"],
|
id_=sub["id"],
|
||||||
@ -351,15 +354,43 @@ class MUBI(Service):
|
|||||||
language=Language.get(lang_code),
|
language=Language.get(lang_code),
|
||||||
is_original_lang=is_original,
|
is_original_lang=is_original,
|
||||||
codec=Subtitle.Codec.WebVTT,
|
codec=Subtitle.Codec.WebVTT,
|
||||||
name=sub.get("display_name", lang_code.upper()),
|
name=disp_name,
|
||||||
forced=False,
|
forced=forced,
|
||||||
sdh=False,
|
sdh=sdh,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
tracks.subtitles = subtitles
|
tracks.subtitles = subtitles
|
||||||
|
|
||||||
return tracks
|
return tracks
|
||||||
|
|
||||||
|
def search(self) -> Generator[SearchResult, None, None]:
|
||||||
|
"""
|
||||||
|
Search MUBI films using official API endpoint.
|
||||||
|
Returns only playable films with proper metadata formatting.
|
||||||
|
"""
|
||||||
|
params = {
|
||||||
|
"query": self.raw_title,
|
||||||
|
"page": 1,
|
||||||
|
"per_page": 24,
|
||||||
|
"playable": "true",
|
||||||
|
"all_films_on_zero_hits": "true"
|
||||||
|
}
|
||||||
|
|
||||||
|
response = self.session.get(
|
||||||
|
url=self.config["endpoints"]["search"],
|
||||||
|
params=params
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
results = response.json()
|
||||||
|
|
||||||
|
for film in results.get("films", []):
|
||||||
|
display_title = f"{film['title']} ({film['year']})"
|
||||||
|
yield SearchResult(
|
||||||
|
id_=film["id"],
|
||||||
|
title=display_title,
|
||||||
|
label="MOVIE",
|
||||||
|
url=film["web_url"].rstrip() # Clean trailing spaces
|
||||||
|
)
|
||||||
|
|
||||||
def get_chapters(self, title: Title_T) -> list[Chapter]:
|
def get_chapters(self, title: Title_T) -> list[Chapter]:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
@ -367,22 +398,20 @@ class MUBI(Service):
|
|||||||
if not self.user_id:
|
if not self.user_id:
|
||||||
raise RuntimeError("user_id not set — authenticate first.")
|
raise RuntimeError("user_id not set — authenticate first.")
|
||||||
|
|
||||||
|
# Cookie-based license request (NO dtinfo - credentials removed)
|
||||||
dt_custom_data = {
|
dt_custom_data = {
|
||||||
"userId": self.user_id,
|
"userId": self.user_id,
|
||||||
"sessionId": self.lt_token,
|
"sessionId": self.lt_token,
|
||||||
"merchant": "mubi"
|
"merchant": "mubi"
|
||||||
}
|
}
|
||||||
|
|
||||||
dt_custom_data_b64 = base64.b64encode(json.dumps(dt_custom_data).encode()).decode()
|
dt_custom_data_b64 = base64.b64encode(json.dumps(dt_custom_data).encode()).decode()
|
||||||
|
|
||||||
headers = {
|
headers = {
|
||||||
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:143.0) Gecko/20100101 Firefox/143.0",
|
"User-Agent": "Mozilla/5.0 (Linux; Android 13; SM-G975F) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36",
|
||||||
"Accept": "*/*",
|
"Accept": "*/*",
|
||||||
"Origin": "https://mubi.com",
|
"Origin": "https://mubi.com",
|
||||||
"Referer": "https://mubi.com/",
|
"Referer": "https://mubi.com/",
|
||||||
"dt-custom-data": dt_custom_data_b64,
|
"dt-custom-data": dt_custom_data_b64,
|
||||||
}
|
}
|
||||||
|
|
||||||
r = self.session.post(
|
r = self.session.post(
|
||||||
self.config["endpoints"]["license"],
|
self.config["endpoints"]["license"],
|
||||||
data=challenge,
|
data=challenge,
|
||||||
@ -394,3 +423,30 @@ class MUBI(Service):
|
|||||||
raise PermissionError(f"DRM license error: {license_data}")
|
raise PermissionError(f"DRM license error: {license_data}")
|
||||||
return base64.b64decode(license_data["license"])
|
return base64.b64decode(license_data["license"])
|
||||||
|
|
||||||
|
def get_playready_license(self, challenge: bytes, title: Title_T, track: AnyTrack) -> bytes:
|
||||||
|
if not self.user_id:
|
||||||
|
raise RuntimeError("user_id not set — authenticate first.")
|
||||||
|
|
||||||
|
# Cookie-based PlayReady license request (NO dtinfo - credentials removed)
|
||||||
|
dt_custom_data = {
|
||||||
|
"userId": self.user_id,
|
||||||
|
"sessionId": self.lt_token,
|
||||||
|
"merchant": "mubi"
|
||||||
|
}
|
||||||
|
dt_custom_data_b64 = base64.b64encode(json.dumps(dt_custom_data).encode()).decode()
|
||||||
|
headers = {
|
||||||
|
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36 Edg/144.0.0.0",
|
||||||
|
"Accept": "*/*",
|
||||||
|
"Origin": "https://mubi.com",
|
||||||
|
"Referer": "https://mubi.com/",
|
||||||
|
"dt-custom-data": dt_custom_data_b64,
|
||||||
|
}
|
||||||
|
r = self.session.post(
|
||||||
|
self.config["endpoints"]["license_pr"],
|
||||||
|
data=challenge,
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
r.raise_for_status()
|
||||||
|
if r.status_code != 200:
|
||||||
|
raise PermissionError(f"DRM license error")
|
||||||
|
return r.content
|
||||||
|
|||||||
@ -10,3 +10,5 @@ endpoints:
|
|||||||
ip_geolocation: "https://directory.cookieyes.com/api/v1/ip"
|
ip_geolocation: "https://directory.cookieyes.com/api/v1/ip"
|
||||||
series: "https://api.mubi.com/v4/series/{series_slug}"
|
series: "https://api.mubi.com/v4/series/{series_slug}"
|
||||||
season_episodes: "https://api.mubi.com/v4/series/{series_slug}/seasons/{season_slug}/episodes/available"
|
season_episodes: "https://api.mubi.com/v4/series/{series_slug}/seasons/{season_slug}/episodes/available"
|
||||||
|
license_pr: "https://lic.drmtoday.com/license-proxy-headerauth/drmtoday/RightsManager.asmx?persistent=false"
|
||||||
|
search: "https://api.mubi.com/v4/search/films"
|
||||||
|
|||||||
@ -16,17 +16,19 @@ from unshackle.core.tracks import Tracks
|
|||||||
class PTHS(Service):
|
class PTHS(Service):
|
||||||
"""
|
"""
|
||||||
Service code for Pathé Thuis (pathe-thuis.nl)
|
Service code for Pathé Thuis (pathe-thuis.nl)
|
||||||
Version: 1.0.0
|
Version: 1.1.0 (PlayReady Support Added)
|
||||||
|
|
||||||
Security: SD @ L3 (Widevine)
|
Security: SD/FHD @ L1/L3 (Widevine)
|
||||||
FHD @ L1
|
SD/FHD @ SL2K/SL3K (Playready)
|
||||||
Authorization: Cookies or authentication token
|
Authorization: Cookies with authenticationToken + XSRF-TOKEN
|
||||||
|
|
||||||
Supported:
|
Supported:
|
||||||
• Movies → https://www.pathe-thuis.nl/film/{id}
|
• Movies → https://www.pathe-thuis.nl/film/{id}
|
||||||
|
|
||||||
Note:
|
Note:
|
||||||
Pathé Thuis does not have episodic content, only movies.
|
Pathé Thuis does not have episodic content, only movies.
|
||||||
|
Subtitles are hardcoded here so yeah I can't do anything about it
|
||||||
|
The quality is depend on what you rented for, is it SD or HD?
|
||||||
"""
|
"""
|
||||||
|
|
||||||
TITLE_RE = (
|
TITLE_RE = (
|
||||||
@ -44,17 +46,15 @@ class PTHS(Service):
|
|||||||
|
|
||||||
def __init__(self, ctx, title: str):
|
def __init__(self, ctx, title: str):
|
||||||
super().__init__(ctx)
|
super().__init__(ctx)
|
||||||
|
|
||||||
m = re.match(self.TITLE_RE, title)
|
m = re.match(self.TITLE_RE, title)
|
||||||
if not m:
|
if not m:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"Unsupported Pathé Thuis URL or ID: {title}\n"
|
f"Unsupported Pathé Thuis URL or ID: {title}\n"
|
||||||
"Use e.g. https://www.pathe-thuis.nl/film/30591"
|
"Use e.g. https://www.pathe-thuis.nl/film/30591"
|
||||||
)
|
)
|
||||||
|
|
||||||
self.movie_id = m.group("id")
|
self.movie_id = m.group("id")
|
||||||
self.drm_token = None
|
self.drm_token = None
|
||||||
|
self.license_url = None
|
||||||
if self.config is None:
|
if self.config is None:
|
||||||
raise EnvironmentError("Missing service config for Pathé Thuis.")
|
raise EnvironmentError("Missing service config for Pathé Thuis.")
|
||||||
|
|
||||||
@ -65,18 +65,27 @@ class PTHS(Service):
|
|||||||
self.log.warning("No cookies provided, proceeding unauthenticated.")
|
self.log.warning("No cookies provided, proceeding unauthenticated.")
|
||||||
return
|
return
|
||||||
|
|
||||||
token = next((c.value for c in cookies if c.name == "authenticationToken"), None)
|
# Extract critical cookies
|
||||||
if not token:
|
auth_token = next((c.value for c in cookies if c.name == "authenticationToken"), None)
|
||||||
|
xsrf_token = next((c.value for c in cookies if c.name == "XSRF-TOKEN"), None)
|
||||||
|
|
||||||
|
if not auth_token:
|
||||||
self.log.info("No authenticationToken cookie found, unauthenticated mode.")
|
self.log.info("No authenticationToken cookie found, unauthenticated mode.")
|
||||||
return
|
return
|
||||||
|
|
||||||
self.session.headers.update({
|
headers = {
|
||||||
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:143.0) Gecko/20100101 Firefox/143.0",
|
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36 Edg/144.0.0.0",
|
||||||
"X-Pathe-Device-Identifier": "web-widevine-1",
|
"X-Pathe-Device-Identifier": "web-1",
|
||||||
"X-Pathe-Auth-Session-Token": token,
|
"X-Pathe-Auth-Session-Token": auth_token,
|
||||||
})
|
}
|
||||||
self.log.info("Authentication token successfully attached to session.")
|
|
||||||
|
|
||||||
|
if xsrf_token:
|
||||||
|
headers["X-XSRF-TOKEN"] = xsrf_token
|
||||||
|
self.log.debug(f"XSRF-TOKEN header set: {xsrf_token[:10]}...")
|
||||||
|
|
||||||
|
self.session.headers.update(headers)
|
||||||
|
auth_status = "with XSRF" if xsrf_token else "without XSRF"
|
||||||
|
self.log.info(f"Authentication token attached ({auth_status}).")
|
||||||
|
|
||||||
def get_titles(self) -> Titles_T:
|
def get_titles(self) -> Titles_T:
|
||||||
url = self.config["endpoints"]["metadata"].format(movie_id=self.movie_id)
|
url = self.config["endpoints"]["metadata"].format(movie_id=self.movie_id)
|
||||||
@ -90,15 +99,15 @@ class PTHS(Service):
|
|||||||
name=data["name"],
|
name=data["name"],
|
||||||
description=data.get("intro", ""),
|
description=data.get("intro", ""),
|
||||||
year=data.get("year"),
|
year=data.get("year"),
|
||||||
language=Language.get(data.get("language", "en")),
|
language=Language.get(data.get("language", "nl")), # Default to Dutch
|
||||||
data=data,
|
data=data,
|
||||||
)
|
)
|
||||||
return Movies([movie])
|
return Movies([movie])
|
||||||
|
|
||||||
|
|
||||||
def get_tracks(self, title: Title_T) -> Tracks:
|
def get_tracks(self, title: Title_T) -> Tracks:
|
||||||
ticket_id = self._get_ticket_id(title)
|
ticket_id = self._get_ticket_id(title)
|
||||||
url = self.config["endpoints"]["ticket"].format(ticket_id=ticket_id)
|
base_url = self.config["endpoints"]["ticket"].format(ticket_id=ticket_id)
|
||||||
|
url = f"{base_url}?drmType=dash-widevine"
|
||||||
|
|
||||||
r = self.session.get(url)
|
r = self.session.get(url)
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
@ -107,16 +116,17 @@ class PTHS(Service):
|
|||||||
|
|
||||||
manifest_url = stream.get("url") or stream.get("drmurl")
|
manifest_url = stream.get("url") or stream.get("drmurl")
|
||||||
if not manifest_url:
|
if not manifest_url:
|
||||||
raise ValueError("No stream manifest URL found.")
|
raise ValueError("No stream manifest URL found in ticket response.")
|
||||||
|
|
||||||
|
# Store DRM context for license acquisition
|
||||||
self.drm_token = stream["token"]
|
self.drm_token = stream["token"]
|
||||||
self.license_url = stream["rawData"]["licenseserver"]
|
self.license_url = stream["rawData"]["licenseserver"]
|
||||||
|
drm_type = stream["rawData"].get("type", "unknown")
|
||||||
|
self.log.info(f"Acquired {drm_type.upper()} stream manifest. License URL set.")
|
||||||
|
|
||||||
tracks = DASH.from_url(manifest_url, session=self.session).to_tracks(language=title.language)
|
tracks = DASH.from_url(manifest_url, session=self.session).to_tracks(language=title.language)
|
||||||
|
|
||||||
return tracks
|
return tracks
|
||||||
|
|
||||||
|
|
||||||
def _get_ticket_id(self, title: Title_T) -> str:
|
def _get_ticket_id(self, title: Title_T) -> str:
|
||||||
"""Fetch the user's owned ticket ID if present."""
|
"""Fetch the user's owned ticket ID if present."""
|
||||||
data = title.data
|
data = title.data
|
||||||
@ -125,12 +135,45 @@ class PTHS(Service):
|
|||||||
return str(t["id"])
|
return str(t["id"])
|
||||||
raise ValueError("No valid ticket found for this movie. Ensure purchase or login.")
|
raise ValueError("No valid ticket found for this movie. Ensure purchase or login.")
|
||||||
|
|
||||||
|
|
||||||
def get_chapters(self, title: Title_T):
|
def get_chapters(self, title: Title_T):
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
def get_playready_license(self, challenge: bytes, title: Title_T, track: AnyTrack) -> bytes:
|
||||||
|
"""
|
||||||
|
Acquire PlayReady license using the authentication token.
|
||||||
|
Matches the license request pattern observed in browser traffic.
|
||||||
|
"""
|
||||||
|
if not self.license_url or not self.drm_token:
|
||||||
|
raise ValueError("Missing license URL or DRM token. Call get_tracks() first.")
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"Content-Type": "application/octet-stream",
|
||||||
|
"Authorization": f"Bearer {self.drm_token}",
|
||||||
|
|
||||||
|
}
|
||||||
|
params = {"custom_data": self.drm_token}
|
||||||
|
|
||||||
|
self.log.debug(f"Requesting PlayReady license from {self.license_url}")
|
||||||
|
r = self.session.post(
|
||||||
|
self.license_url,
|
||||||
|
params=params,
|
||||||
|
data=challenge,
|
||||||
|
headers=headers,
|
||||||
|
timeout=10
|
||||||
|
)
|
||||||
|
r.raise_for_status()
|
||||||
|
|
||||||
|
if not r.content or len(r.content) < 10:
|
||||||
|
raise ValueError(
|
||||||
|
"Invalid PlayReady license response. "
|
||||||
|
"Check: 1) Valid session 2) XSRF token 3) Active rental/purchase"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.log.info(f"Successfully acquired PlayReady license ({len(r.content)} bytes)")
|
||||||
|
return r.content
|
||||||
|
|
||||||
def get_widevine_license(self, challenge: bytes, title: Title_T, track: AnyTrack) -> bytes:
|
def get_widevine_license(self, challenge: bytes, title: Title_T, track: AnyTrack) -> bytes:
|
||||||
|
"""Widevine license acquisition . """
|
||||||
if not self.license_url or not self.drm_token:
|
if not self.license_url or not self.drm_token:
|
||||||
raise ValueError("Missing license URL or token.")
|
raise ValueError("Missing license URL or token.")
|
||||||
|
|
||||||
@ -138,7 +181,6 @@ class PTHS(Service):
|
|||||||
"Content-Type": "application/octet-stream",
|
"Content-Type": "application/octet-stream",
|
||||||
"Authorization": f"Bearer {self.drm_token}",
|
"Authorization": f"Bearer {self.drm_token}",
|
||||||
}
|
}
|
||||||
|
|
||||||
params = {"custom_data": self.drm_token}
|
params = {"custom_data": self.drm_token}
|
||||||
|
|
||||||
r = self.session.post(self.license_url, params=params, data=challenge, headers=headers)
|
r = self.session.post(self.license_url, params=params, data=challenge, headers=headers)
|
||||||
|
|||||||
11
README.md
11
README.md
@ -14,13 +14,12 @@
|
|||||||
- Audio mislabel as English
|
- Audio mislabel as English
|
||||||
- To add Playready Support
|
- To add Playready Support
|
||||||
3. PTHS:
|
3. PTHS:
|
||||||
- To add Playready Support (is needed since L3 is just 480p)
|
|
||||||
- Search Functionality
|
- Search Functionality
|
||||||
- Account login if possible
|
- Account login if possible
|
||||||
4. HIDI:
|
4. HIDI:
|
||||||
- Subtitle is a bit misplace if second sentences came up making the last sentence on the first order and vice versa (needs to be fixed)
|
- Subtitle is a bit misplace if second sentences came up making the last sentence on the first order and vice versa (needs to be fixed)
|
||||||
5. MUBI:
|
5. MUBI:
|
||||||
- Search Functionality
|
- Creds login
|
||||||
6. VIKI:
|
6. VIKI:
|
||||||
- CSRF Token is now scraped, would be from a api requests soon
|
- CSRF Token is now scraped, would be from a api requests soon
|
||||||
7. VIDO:
|
7. VIDO:
|
||||||
@ -32,9 +31,15 @@
|
|||||||
- Search functionality
|
- Search functionality
|
||||||
- Fixing few hickups
|
- Fixing few hickups
|
||||||
10. SKST (the hardest service I ever dealt upon now):
|
10. SKST (the hardest service I ever dealt upon now):
|
||||||
- Subtitles is a litte bit hit or miss for movies and for series there's still no subtitles
|
- Subtitle has been fixed, hopefully no issue
|
||||||
|
11. VLD:
|
||||||
|
- Token isn't cached so that's a major problem with series
|
||||||
|
12. HPLA:
|
||||||
|
- No support for Television yet
|
||||||
|
- Music needs to be fixed since the output is a mp4 instead of m4a
|
||||||
|
|
||||||
- Acknowledgment
|
- Acknowledgment
|
||||||
|
|
||||||
Thanks to Adef for the NPO start downloader.
|
Thanks to Adef for the NPO start downloader.
|
||||||
|
Thanks to UPS0 for fixing MUBI script
|
||||||
|
|
||||||
|
|||||||
@ -699,15 +699,11 @@ class SKST(Service):
|
|||||||
self.drm_license_url = protection.get("licenceAcquisitionUrl")
|
self.drm_license_url = protection.get("licenceAcquisitionUrl")
|
||||||
self.license_token = protection.get("licenceToken")
|
self.license_token = protection.get("licenceToken")
|
||||||
|
|
||||||
|
manifest_url = manifest_url + "&audio=all&subtitle=all"
|
||||||
|
|
||||||
dash = DASH.from_url(manifest_url, session=self.session)
|
dash = DASH.from_url(manifest_url, session=self.session)
|
||||||
tracks = dash.to_tracks(language=title.language)
|
tracks = dash.to_tracks(language=title.language)
|
||||||
|
|
||||||
# Remove default subtitle tracks and add properly processed ones
|
|
||||||
for track in list(tracks.subtitles):
|
|
||||||
tracks.subtitles.remove(track)
|
|
||||||
|
|
||||||
subtitles = self._process_subtitles(dash, str(title.language))
|
|
||||||
tracks.add(subtitles)
|
|
||||||
|
|
||||||
return tracks
|
return tracks
|
||||||
|
|
||||||
|
|||||||
465
VLD/__init__.py
Normal file
465
VLD/__init__.py
Normal file
@ -0,0 +1,465 @@
|
|||||||
|
import re
|
||||||
|
import uuid
|
||||||
|
from collections.abc import Generator
|
||||||
|
from http.cookiejar import CookieJar
|
||||||
|
from typing import Optional, Union
|
||||||
|
|
||||||
|
import click
|
||||||
|
from langcodes import Language
|
||||||
|
|
||||||
|
from unshackle.core.constants import AnyTrack
|
||||||
|
from unshackle.core.credential import Credential
|
||||||
|
from unshackle.core.manifests import DASH
|
||||||
|
from unshackle.core.search_result import SearchResult
|
||||||
|
from unshackle.core.service import Service
|
||||||
|
from unshackle.core.titles import Episode, Movie, Movies, Series, Title_T, Titles_T
|
||||||
|
from unshackle.core.tracks import Chapter, Subtitle, Tracks
|
||||||
|
|
||||||
|
|
||||||
|
class VLD(Service):
|
||||||
|
"""
|
||||||
|
Service code for RTL's Dutch streaming service Videoland (https://v2.videoland.com)
|
||||||
|
Version: 1.0.0
|
||||||
|
|
||||||
|
Authorization: Credentials
|
||||||
|
|
||||||
|
Security:
|
||||||
|
- L1: >= 720p
|
||||||
|
- L3: <= 576p
|
||||||
|
|
||||||
|
They are using the license server of DRMToday with encoded streams from CastLabs.
|
||||||
|
It accepts Non-Whitelisted CDMs so every unrevoked L1 CDM should work.
|
||||||
|
|
||||||
|
Use full URL (for example - https://v2.videoland.com/title-p_12345) or title slug.
|
||||||
|
"""
|
||||||
|
|
||||||
|
ALIASES = ("VLD", "videoland")
|
||||||
|
TITLE_RE = r"^(?:https?://(?:www\.)?v2\.videoland\.com/)?(?P<title_id>[a-zA-Z0-9_-]+)"
|
||||||
|
GEOFENCE = ("NL",)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
@click.command(name="Videoland", short_help="https://v2.videoland.com")
|
||||||
|
@click.argument("title", type=str)
|
||||||
|
@click.option("-m", "--movie", is_flag=True, default=False, help="Specify if it's a movie")
|
||||||
|
@click.pass_context
|
||||||
|
def cli(ctx, **kwargs):
|
||||||
|
return VLD(ctx, **kwargs)
|
||||||
|
|
||||||
|
def __init__(self, ctx, title, movie):
|
||||||
|
super().__init__(ctx)
|
||||||
|
|
||||||
|
self.title = title
|
||||||
|
self.movie = movie
|
||||||
|
self.cdm = ctx.obj.cdm
|
||||||
|
self.device_id = str(uuid.uuid1().int)
|
||||||
|
|
||||||
|
if self.config is None:
|
||||||
|
raise Exception("Config is missing!")
|
||||||
|
|
||||||
|
profile_name = ctx.parent.params.get("profile")
|
||||||
|
self.profile = profile_name if profile_name else "default"
|
||||||
|
|
||||||
|
self.platform = self.config["platform"]["android_tv"]
|
||||||
|
self.platform_token = "token-androidtv-3"
|
||||||
|
|
||||||
|
def authenticate(self, cookies: Optional[CookieJar] = None, credential: Optional[Credential] = None) -> None:
|
||||||
|
super().authenticate(cookies, credential)
|
||||||
|
if not credential or not credential.username or not credential.password:
|
||||||
|
raise EnvironmentError("Service requires Credentials for Authentication.")
|
||||||
|
|
||||||
|
self.credential = credential # Store for potential re-auth
|
||||||
|
|
||||||
|
self.session.headers.update({
|
||||||
|
"origin": "https://v2.videoland.com",
|
||||||
|
"x-client-release": self.config["sdk"]["version"],
|
||||||
|
"x-customer-name": "rtlnl",
|
||||||
|
})
|
||||||
|
|
||||||
|
# Build cache key
|
||||||
|
cache_key = f"tokens_{self.profile}"
|
||||||
|
|
||||||
|
# Check cache first
|
||||||
|
cache = self.cache.get(cache_key)
|
||||||
|
|
||||||
|
if cache and not cache.expired:
|
||||||
|
cached_data = cache.data
|
||||||
|
if isinstance(cached_data, dict) and cached_data.get("username") == credential.username:
|
||||||
|
self.log.info("Using cached tokens")
|
||||||
|
self._restore_from_cache(cached_data)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Perform fresh login
|
||||||
|
self.log.info("Retrieving new tokens")
|
||||||
|
self._do_login(credential)
|
||||||
|
|
||||||
|
# Cache the tokens
|
||||||
|
self._cache_tokens(credential.username, cache_key)
|
||||||
|
|
||||||
|
def _restore_from_cache(self, cached_data: dict) -> None:
|
||||||
|
"""Restore authentication state from cached data."""
|
||||||
|
self.access_token = cached_data["access_token"]
|
||||||
|
self.gigya_uid = cached_data["gigya_uid"]
|
||||||
|
self.profile_id = cached_data["profile_id"]
|
||||||
|
self.session.headers.update({"Authorization": f"Bearer {self.access_token}"})
|
||||||
|
|
||||||
|
def _cache_tokens(self, username: str, cache_key: str) -> None:
|
||||||
|
"""Cache the current authentication tokens."""
|
||||||
|
cache = self.cache.get(cache_key)
|
||||||
|
cache.set(
|
||||||
|
data={
|
||||||
|
"username": username,
|
||||||
|
"access_token": self.access_token,
|
||||||
|
"gigya_uid": self.gigya_uid,
|
||||||
|
"profile_id": self.profile_id,
|
||||||
|
},
|
||||||
|
expiration=3600 # 1 hour expiration, adjust as needed
|
||||||
|
)
|
||||||
|
|
||||||
|
def _do_login(self, credential: Credential) -> None:
|
||||||
|
"""Perform full login flow."""
|
||||||
|
# Step 1: Authorize with Gigya
|
||||||
|
auth_response = self.session.post(
|
||||||
|
url=self.config["endpoints"]["authorization"],
|
||||||
|
data={
|
||||||
|
"loginID": credential.username,
|
||||||
|
"password": credential.password,
|
||||||
|
"sessionExpiration": "0",
|
||||||
|
"targetEnv": "jssdk",
|
||||||
|
"include": "profile,data",
|
||||||
|
"includeUserInfo": "true",
|
||||||
|
"lang": "nl",
|
||||||
|
"ApiKey": self.config["sdk"]["apikey"],
|
||||||
|
"authMode": "cookie",
|
||||||
|
"pageURL": "https://v2.videoland.com/",
|
||||||
|
"sdkBuild": self.config["sdk"]["build"],
|
||||||
|
"format": "json",
|
||||||
|
},
|
||||||
|
).json()
|
||||||
|
|
||||||
|
if auth_response.get("errorMessage"):
|
||||||
|
raise EnvironmentError(f"Could not authorize Videoland account: {auth_response['errorMessage']!r}")
|
||||||
|
|
||||||
|
self.gigya_uid = auth_response["UID"]
|
||||||
|
uid_signature = auth_response["UIDSignature"]
|
||||||
|
signature_timestamp = auth_response["signatureTimestamp"]
|
||||||
|
|
||||||
|
# Step 2: Get initial JWT token
|
||||||
|
jwt_headers = {
|
||||||
|
"x-auth-device-id": self.device_id,
|
||||||
|
"x-auth-device-player-size-height": "3840",
|
||||||
|
"x-auth-device-player-size-width": "2160",
|
||||||
|
"X-Auth-gigya-signature": uid_signature,
|
||||||
|
"X-Auth-gigya-signature-timestamp": signature_timestamp,
|
||||||
|
"X-Auth-gigya-uid": self.gigya_uid,
|
||||||
|
"X-Client-Release": self.config["sdk"]["version"],
|
||||||
|
"X-Customer-Name": "rtlnl",
|
||||||
|
}
|
||||||
|
|
||||||
|
jwt_response = self.session.get(
|
||||||
|
url=self.config["endpoints"]["jwt_tokens"].format(platform=self.platform),
|
||||||
|
headers=jwt_headers,
|
||||||
|
).json()
|
||||||
|
|
||||||
|
if jwt_response.get("error"):
|
||||||
|
raise EnvironmentError(f"Could not get Access Token: {jwt_response['error']['message']!r}")
|
||||||
|
|
||||||
|
initial_token = jwt_response["token"]
|
||||||
|
|
||||||
|
# Step 3: Get profiles
|
||||||
|
profiles_response = self.session.get(
|
||||||
|
url=self.config["endpoints"]["profiles"].format(
|
||||||
|
platform=self.platform,
|
||||||
|
gigya=self.gigya_uid,
|
||||||
|
),
|
||||||
|
headers={"Authorization": f"Bearer {initial_token}"},
|
||||||
|
).json()
|
||||||
|
|
||||||
|
if isinstance(profiles_response, dict) and profiles_response.get("error"):
|
||||||
|
raise EnvironmentError(f"Could not get profiles: {profiles_response['error']['message']!r}")
|
||||||
|
|
||||||
|
self.profile_id = profiles_response[0]["uid"]
|
||||||
|
|
||||||
|
# Step 4: Get final JWT token with profile
|
||||||
|
jwt_headers["X-Auth-profile-id"] = self.profile_id
|
||||||
|
|
||||||
|
final_jwt_response = self.session.get(
|
||||||
|
url=self.config["endpoints"]["jwt_tokens"].format(platform=self.platform),
|
||||||
|
headers=jwt_headers,
|
||||||
|
).json()
|
||||||
|
|
||||||
|
if final_jwt_response.get("error"):
|
||||||
|
raise EnvironmentError(f"Could not get final Access Token: {final_jwt_response['error']['message']!r}")
|
||||||
|
|
||||||
|
self.access_token = final_jwt_response["token"]
|
||||||
|
self.session.headers.update({"Authorization": f"Bearer {self.access_token}"})
|
||||||
|
|
||||||
|
def search(self) -> Generator[SearchResult, None, None]:
|
||||||
|
# Videoland doesn't have a documented search endpoint in the original code
|
||||||
|
# This is a placeholder - you may need to implement based on actual API
|
||||||
|
raise NotImplementedError("Search is not implemented for Videoland")
|
||||||
|
|
||||||
|
def get_titles(self) -> Titles_T:
|
||||||
|
title_match = re.match(self.TITLE_RE, self.title)
|
||||||
|
if not title_match:
|
||||||
|
raise ValueError(f"Invalid title format: {self.title}")
|
||||||
|
|
||||||
|
title_slug = title_match.group("title_id")
|
||||||
|
|
||||||
|
# Handle folder URLs (e.g., title-f_12345)
|
||||||
|
if re.match(r".+?-f_[0-9]+", title_slug):
|
||||||
|
title_slug = self._get_program_title(title_slug)
|
||||||
|
|
||||||
|
# Extract title ID from slug (e.g., "show-name-p_12345" -> "12345")
|
||||||
|
title_id = title_slug.split("-p_")[-1] if "-p_" in title_slug else title_slug
|
||||||
|
|
||||||
|
metadata = self.session.get(
|
||||||
|
url=self.config["endpoints"]["layout"].format(
|
||||||
|
platform=self.platform,
|
||||||
|
token=self.platform_token,
|
||||||
|
endpoint=f"program/{title_id}",
|
||||||
|
),
|
||||||
|
params={"nbPages": "10"},
|
||||||
|
).json()
|
||||||
|
|
||||||
|
# Check for API errors
|
||||||
|
if isinstance(metadata, dict) and metadata.get("error"):
|
||||||
|
raise ValueError(f"API Error: {metadata.get('message', 'Unknown error')}")
|
||||||
|
|
||||||
|
# Determine if it's a movie based on metadata
|
||||||
|
is_movie = "Seizoen" not in str(metadata)
|
||||||
|
|
||||||
|
if is_movie:
|
||||||
|
movie_info = metadata["blocks"][0]["content"]["items"][0]
|
||||||
|
viewable_id = movie_info["itemContent"]["action"]["target"]["value_layout"]["id"]
|
||||||
|
|
||||||
|
return Movies([
|
||||||
|
Movie(
|
||||||
|
id_=movie_info["ucid"],
|
||||||
|
service=self.__class__,
|
||||||
|
name=metadata["entity"]["metadata"]["title"],
|
||||||
|
year=None,
|
||||||
|
language=Language.get("nl"),
|
||||||
|
data={
|
||||||
|
"viewable": viewable_id,
|
||||||
|
"metadata": metadata,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
])
|
||||||
|
else:
|
||||||
|
seasons = [
|
||||||
|
block
|
||||||
|
for block in metadata["blocks"]
|
||||||
|
if block["featureId"] == "videos_by_season_by_program"
|
||||||
|
]
|
||||||
|
|
||||||
|
# Fetch all episodes from all seasons with pagination
|
||||||
|
for season in seasons:
|
||||||
|
while len(season["content"]["items"]) != season["content"]["pagination"]["totalItems"]:
|
||||||
|
season_data = self.session.get(
|
||||||
|
url=self.config["endpoints"]["seasoning"].format(
|
||||||
|
platform=self.platform,
|
||||||
|
token=self.platform_token,
|
||||||
|
program=title_id,
|
||||||
|
season_id=season["id"],
|
||||||
|
),
|
||||||
|
params={
|
||||||
|
"nbPages": "10",
|
||||||
|
"page": season["content"]["pagination"]["nextPage"],
|
||||||
|
},
|
||||||
|
).json()
|
||||||
|
|
||||||
|
for episode in season_data["content"]["items"]:
|
||||||
|
if episode not in season["content"]["items"]:
|
||||||
|
season["content"]["items"].append(episode)
|
||||||
|
|
||||||
|
season["content"]["pagination"]["nextPage"] = season_data["content"]["pagination"]["nextPage"]
|
||||||
|
|
||||||
|
episodes = []
|
||||||
|
for season in seasons:
|
||||||
|
# Extract season number from title like "Seizoen 1" or "Season 1"
|
||||||
|
season_title = season.get("title", {}).get("long", "")
|
||||||
|
season_match = re.search(r"(\d+)", season_title)
|
||||||
|
season_number = int(season_match.group(1)) if season_match else 1
|
||||||
|
|
||||||
|
for idx, episode_data in enumerate(season["content"]["items"]):
|
||||||
|
# Get the extra title which contains episode info
|
||||||
|
extra_title = episode_data["itemContent"].get("extraTitle", "")
|
||||||
|
|
||||||
|
# Extract episode number from extraTitle like "1. Hondenadoptiedag" or "14. Een Draak Op School (Deel 1)"
|
||||||
|
episode_number = None
|
||||||
|
episode_name = extra_title
|
||||||
|
|
||||||
|
ep_match = re.match(r"^(\d+)\.\s*(.*)$", extra_title)
|
||||||
|
if ep_match:
|
||||||
|
episode_number = int(ep_match.group(1))
|
||||||
|
episode_name = ep_match.group(2)
|
||||||
|
else:
|
||||||
|
# Fallback to index + 1
|
||||||
|
episode_number = idx + 1
|
||||||
|
|
||||||
|
viewable_id = episode_data["itemContent"]["action"]["target"]["value_layout"]["id"]
|
||||||
|
|
||||||
|
episodes.append(
|
||||||
|
Episode(
|
||||||
|
id_=episode_data["ucid"],
|
||||||
|
service=self.__class__,
|
||||||
|
title=metadata["entity"]["metadata"]["title"],
|
||||||
|
season=season_number,
|
||||||
|
number=episode_number,
|
||||||
|
name=episode_name,
|
||||||
|
year=None,
|
||||||
|
language=Language.get("nl"),
|
||||||
|
data={
|
||||||
|
"viewable": viewable_id,
|
||||||
|
"episode_data": episode_data,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Sort episodes by season and episode number
|
||||||
|
episodes = sorted(episodes, key=lambda ep: (ep.season, ep.number))
|
||||||
|
|
||||||
|
return Series(episodes)
|
||||||
|
|
||||||
|
def get_tracks(self, title: Title_T) -> Tracks:
|
||||||
|
viewable_id = title.data["viewable"]
|
||||||
|
|
||||||
|
manifest_response = self.session.get(
|
||||||
|
url=self.config["endpoints"]["layout"].format(
|
||||||
|
platform=self.platform,
|
||||||
|
token=self.platform_token,
|
||||||
|
endpoint=f"video/{viewable_id}",
|
||||||
|
),
|
||||||
|
params={"nbPages": "2"},
|
||||||
|
).json()
|
||||||
|
|
||||||
|
player_block = next(
|
||||||
|
(block for block in manifest_response["blocks"] if block["templateId"] == "Player"),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not player_block:
|
||||||
|
raise ValueError("Could not find player block in manifest")
|
||||||
|
|
||||||
|
assets = player_block["content"]["items"][0]["itemContent"]["video"]["assets"]
|
||||||
|
|
||||||
|
if not assets:
|
||||||
|
raise ValueError("Failed to load content manifest - no assets found")
|
||||||
|
|
||||||
|
# Prefer HD quality
|
||||||
|
mpd_asset = next((asset for asset in assets if asset["quality"] == "hd"), None)
|
||||||
|
if not mpd_asset:
|
||||||
|
mpd_asset = next((asset for asset in assets if asset["quality"] == "sd"), None)
|
||||||
|
|
||||||
|
if not mpd_asset:
|
||||||
|
raise ValueError("No suitable quality stream found")
|
||||||
|
|
||||||
|
mpd_url = mpd_asset["path"]
|
||||||
|
|
||||||
|
# Extract PlayReady PSSH from manifest
|
||||||
|
manifest_content = self.session.get(mpd_url).text
|
||||||
|
pssh_matches = re.findall(r'<cenc:pssh>(.+?)</cenc:pssh>', manifest_content)
|
||||||
|
|
||||||
|
self.pssh_playready = None
|
||||||
|
for pssh in pssh_matches:
|
||||||
|
if len(pssh) > 200:
|
||||||
|
self.pssh_playready = pssh
|
||||||
|
break
|
||||||
|
|
||||||
|
# Store viewable ID for license request
|
||||||
|
self.current_viewable = viewable_id
|
||||||
|
|
||||||
|
tracks = DASH.from_url(url=mpd_url, session=self.session).to_tracks(language=title.language)
|
||||||
|
|
||||||
|
# Fix track URLs - replace CDN hostname
|
||||||
|
for track in tracks:
|
||||||
|
if hasattr(track, 'url') and track.url:
|
||||||
|
if isinstance(track.url, list):
|
||||||
|
track.url = [
|
||||||
|
re.sub(
|
||||||
|
r"https://.+?\.videoland\.bedrock\.tech",
|
||||||
|
"https://origin.vod.videoland.bedrock.tech",
|
||||||
|
uri.split("?")[0],
|
||||||
|
)
|
||||||
|
for uri in track.url
|
||||||
|
]
|
||||||
|
elif isinstance(track.url, str):
|
||||||
|
track.url = re.sub(
|
||||||
|
r"https://.+?\.videoland\.bedrock\.tech",
|
||||||
|
"https://origin.vod.videoland.bedrock.tech",
|
||||||
|
track.url.split("?")[0],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Handle subtitles
|
||||||
|
for subtitle in tracks.subtitles:
|
||||||
|
if isinstance(subtitle.url, list) or (isinstance(subtitle.url, str) and "dash" in subtitle.url):
|
||||||
|
subtitle.codec = Subtitle.Codec.SubRip
|
||||||
|
else:
|
||||||
|
self.log.warning("Unknown subtitle codec detected")
|
||||||
|
|
||||||
|
return tracks
|
||||||
|
|
||||||
|
def get_chapters(self, title: Title_T) -> list[Chapter]:
|
||||||
|
return []
|
||||||
|
|
||||||
|
def get_widevine_service_certificate(self, **_) -> Optional[str]:
|
||||||
|
return self.config.get("certificate")
|
||||||
|
|
||||||
|
def get_widevine_license(self, *, challenge: bytes, title: Title_T, track: AnyTrack) -> Optional[Union[bytes, str]]:
|
||||||
|
license_token = self._get_license_token(title)
|
||||||
|
|
||||||
|
response = self.session.post(
|
||||||
|
url=self.config["endpoints"]["license_wv"],
|
||||||
|
data=challenge,
|
||||||
|
headers={"x-dt-auth-token": license_token},
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
raise ValueError(f"Failed to get Widevine license: {response.status_code}")
|
||||||
|
|
||||||
|
return response.json().get("license")
|
||||||
|
|
||||||
|
def get_playready_license(self, *, challenge: bytes, title: Title_T, track: AnyTrack) -> Optional[bytes]:
|
||||||
|
license_token = self._get_license_token(title)
|
||||||
|
|
||||||
|
response = self.session.post(
|
||||||
|
url=self.config["endpoints"]["license_pr"],
|
||||||
|
data=challenge,
|
||||||
|
headers={"x-dt-auth-token": license_token},
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
raise ValueError(f"Failed to get PlayReady license: {response.status_code}")
|
||||||
|
|
||||||
|
return response.content
|
||||||
|
|
||||||
|
def _get_license_token(self, title: Title_T) -> str:
|
||||||
|
viewable_id = title.data["viewable"]
|
||||||
|
|
||||||
|
response = self.session.get(
|
||||||
|
url=self.config["endpoints"]["license_token"].format(
|
||||||
|
platform=self.platform,
|
||||||
|
gigya=self.gigya_uid,
|
||||||
|
clip=viewable_id,
|
||||||
|
),
|
||||||
|
).json()
|
||||||
|
|
||||||
|
return response["token"]
|
||||||
|
|
||||||
|
def _get_program_title(self, folder_title: str) -> str:
|
||||||
|
folder_id = folder_title.split("-f_")[1]
|
||||||
|
|
||||||
|
response = self.session.get(
|
||||||
|
url=self.config["endpoints"]["layout"].format(
|
||||||
|
platform=self.platform,
|
||||||
|
token=self.platform_token,
|
||||||
|
endpoint=f"folder/{folder_id}",
|
||||||
|
),
|
||||||
|
params={"nbPages": "2"},
|
||||||
|
).json()
|
||||||
|
|
||||||
|
target = response["blocks"][0]["content"]["items"][0]["itemContent"]["action"]["target"]["value_layout"]
|
||||||
|
parent_seo = target["parent"]["seo"]
|
||||||
|
parent_id = target["parent"]["id"]
|
||||||
|
|
||||||
|
return f"{parent_seo}-p_{parent_id}"
|
||||||
29
VLD/config.yaml
Normal file
29
VLD/config.yaml
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
certificate: |
|
||||||
|
CsECCAMSEBcFuRfMEgSGiwYzOi93KowYgrSCkgUijgIwggEKAoIBAQCZ7Vs7Mn2rXiTvw7YqlbWYUgrVvMs3UD4GRbgU2Ha430BRBEGtjOOtsRu4jE5yWl5
|
||||||
|
KngeVKR1YWEAjp+GvDjipEnk5MAhhC28VjIeMfiG/+/7qd+EBnh5XgeikX0YmPRTmDoBYqGB63OBPrIRXsTeo1nzN6zNwXZg6IftO7L1KEMpHSQykfqpdQ4
|
||||||
|
IY3brxyt4zkvE9b/tkQv0x4b9AsMYE0cS6TJUgpL+X7r1gkpr87vVbuvVk4tDnbNfFXHOggrmWEguDWe3OJHBwgmgNb2fG2CxKxfMTRJCnTuw3r0svAQxZ6
|
||||||
|
ChD4lgvC2ufXbD8Xm7fZPvTCLRxG88SUAGcn1oJAgMBAAE6FGxpY2Vuc2Uud2lkZXZpbmUuY29tEoADrjRzFLWoNSl/JxOI+3u4y1J30kmCPN3R2jC5MzlR
|
||||||
|
HrPMveoEuUS5J8EhNG79verJ1BORfm7BdqEEOEYKUDvBlSubpOTOD8S/wgqYCKqvS/zRnB3PzfV0zKwo0bQQQWz53ogEMBy9szTK/NDUCXhCOmQuVGE98K/
|
||||||
|
PlspKkknYVeQrOnA+8XZ/apvTbWv4K+drvwy6T95Z0qvMdv62Qke4XEMfvKUiZrYZ/DaXlUP8qcu9u/r6DhpV51Wjx7zmVflkb1gquc9wqgi5efhn9joLK3
|
||||||
|
/bNixbxOzVVdhbyqnFk8ODyFfUnaq3fkC3hR3f0kmYgI41sljnXXjqwMoW9wRzBMINk+3k6P8cbxfmJD4/Paj8FwmHDsRfuoI6Jj8M76H3CTsZCZKDJjM3B
|
||||||
|
QQ6Kb2m+bQ0LMjfVDyxoRgvfF//M/EEkPrKWyU2C3YBXpxaBquO4C8A0ujVmGEEqsxN1HX9lu6c5OMm8huDxwWFd7OHMs3avGpr7RP7DUnTikXrh6X0
|
||||||
|
|
||||||
|
endpoints:
|
||||||
|
layout: https://layout.videoland.bedrock.tech/front/v1/rtlnl/{platform}/main/{token}/{endpoint}/layout
|
||||||
|
seasoning: https://layout.videoland.bedrock.tech/front/v1/rtlnl/{platform}/main/{token}/program/{program}/block/{season_id}
|
||||||
|
license_pr: https://lic.drmtoday.com/license-proxy-headerauth/drmtoday/RightsManager.asmx
|
||||||
|
license_wv: https://lic.drmtoday.com/license-proxy-widevine/cenc/
|
||||||
|
license_token: https://drm.videoland.bedrock.tech/v1/customers/rtlnl/platforms/{platform}/services/videoland/users/{gigya}/videos/{clip}/upfront-token
|
||||||
|
authorization: https://accounts.eu1.gigya.com/accounts.login
|
||||||
|
jwt_tokens: https://front-auth.videoland.bedrock.tech/v2/platforms/{platform}/getJwt
|
||||||
|
profiles: https://users.videoland.bedrock.tech/v2/platforms/{platform}/users/{gigya}/profiles
|
||||||
|
|
||||||
|
platform:
|
||||||
|
web: m6group_web
|
||||||
|
android_mob: m6group_android_mob
|
||||||
|
android_tv: m6group_android_tv
|
||||||
|
|
||||||
|
sdk:
|
||||||
|
apikey: 3_W6BPwMz2FGQEfH4_nVRaj4Ak1F1XDp33an_8y8nXULn8nk43FHvPIpb0TLOYIaUI
|
||||||
|
build: "13414"
|
||||||
|
version: 5.47.2
|
||||||
Loading…
x
Reference in New Issue
Block a user