forked from FairTrade/unshackle-services
Fixed MUBI with UHD and Added Search
This commit is contained in:
parent
2c09bce845
commit
3e0732987c
226
MUBI/__init__.py
226
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:
|
self.film_id = film_id
|
||||||
raise RuntimeError("film_id not set. Call get_titles() first.")
|
if not self.reels_data:
|
||||||
|
url_reels = self.config["endpoints"]["reels"].format(film_id=film_id)
|
||||||
|
r_reels = self.session.get(url_reels)
|
||||||
|
r_reels.raise_for_status()
|
||||||
|
self.reels_data = r_reels.json()
|
||||||
|
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])
|
||||||
|
|
||||||
if not self.reels_data:
|
# Get secure streaming URL
|
||||||
self.log.warning("Reels data not cached, fetching now.")
|
|
||||||
url_reels = self.config["endpoints"]["reels"].format(film_id=film_id)
|
|
||||||
r_reels = self.session.get(url_reels)
|
|
||||||
r_reels.raise_for_status()
|
|
||||||
reels = r_reels.json()
|
|
||||||
else:
|
|
||||||
reels = self.reels_data
|
|
||||||
|
|
||||||
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
|
|
||||||
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
|
||||||
|
|||||||
@ -1,12 +1,14 @@
|
|||||||
endpoints:
|
endpoints:
|
||||||
account: "https://api.mubi.com/v4/account"
|
account: "https://api.mubi.com/v4/account"
|
||||||
current_user: "https://api.mubi.com/v4/current_user"
|
current_user: "https://api.mubi.com/v4/current_user"
|
||||||
film_by_slug: "https://api.mubi.com/v4/films/{slug}"
|
film_by_slug: "https://api.mubi.com/v4/films/{slug}"
|
||||||
playback_languages: "https://api.mubi.com/v4/films/{film_id}/playback_languages"
|
playback_languages: "https://api.mubi.com/v4/films/{film_id}/playback_languages"
|
||||||
initiate_viewing: "https://api.mubi.com/v4/films/{film_id}/viewing?parental_lock_enabled=true"
|
initiate_viewing: "https://api.mubi.com/v4/films/{film_id}/viewing?parental_lock_enabled=true"
|
||||||
reels: "https://api.mubi.com/v4/films/{film_id}/reels"
|
reels: "https://api.mubi.com/v4/films/{film_id}/reels"
|
||||||
secure_url: "https://api.mubi.com/v4/films/{film_id}/viewing/secure_url"
|
secure_url: "https://api.mubi.com/v4/films/{film_id}/viewing/secure_url"
|
||||||
license: "https://lic.drmtoday.com/license-proxy-widevine/cenc/"
|
license: "https://lic.drmtoday.com/license-proxy-widevine/cenc/"
|
||||||
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"
|
||||||
|
|||||||
@ -19,7 +19,7 @@
|
|||||||
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:
|
||||||
@ -41,4 +41,5 @@
|
|||||||
- 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
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user