Fixed MUBI with UHD and Added Search

This commit is contained in:
FairTrade 2026-02-06 17:02:24 +01:00
parent 2c09bce845
commit 3e0732987c
3 changed files with 184 additions and 125 deletions

View File

@ -7,25 +7,22 @@ from langcodes import Language
import base64
import click
from unshackle.core.constants import AnyTrack
from unshackle.core.credential import Credential
from unshackle.core.manifests import DASH
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.tracks import Chapter, Tracks, Subtitle
from unshackle.core.search_result import SearchResult
class MUBI(Service):
"""
Service code for MUBI (mubi.com)
Version: 1.2.0
Authorization: Required cookies (lt token + session)
Security: FHD @ L3 (Widevine)
Version: 1.2.1 (Cookie-only + Auto-UHD + Search)
Authorization: Cookies ONLY (lt token + _mubi_session)
Security: UHD @ L3/SL2K (Widevine/PlayReady)
Supports:
Series https://mubi.com/en/nl/series/twin-peaks
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>[^/]+))?$"
TITLE_RE = r"^(?:https?://(?:www\.)?mubi\.com)(?:/[^/]+)*?/films/(?P<slug>[^/?#]+)$"
@ -34,34 +31,39 @@ class MUBI(Service):
@staticmethod
@click.command(name="MUBI", short_help="https://mubi.com ")
@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
def cli(ctx, **kwargs):
return MUBI(ctx, **kwargs)
def __init__(self, ctx, title: str):
def __init__(self, ctx, title: str, country: str):
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_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.slug = m_film.group("slug") if m_film 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
# Core state
self.film_id: Optional[int] = None
self.lt_token: Optional[str] = None
self.session_token: Optional[str] = None
self.user_id: Optional[int] = None
self.country_code: Optional[str] = None
self.set_country_code: Optional[str] = country
self.anonymous_user_id: Optional[str] = None
self.default_country: Optional[str] = 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
if self.config is None:
@ -70,6 +72,10 @@ class MUBI(Service):
def authenticate(self, cookies: Optional[CookieJar] = None, credential: Optional[Credential] = None) -> None:
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:
r_ip = self.session.get(self.config["endpoints"]["ip_geolocation"], timeout=5)
r_ip.raise_for_status()
@ -82,10 +88,7 @@ class MUBI(Service):
except Exception as e:
raise ValueError(f"Failed to fetch IP geolocation: {e}")
if not cookies:
raise PermissionError("MUBI requires login cookies.")
# Extract essential tokens
# Extract essential tokens from cookies
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)
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.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:
self.anonymous_user_id = snow_id_cookie.value.split(".")[0]
else:
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}")
# Configure session headers for UHD access
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",
"Referer": "https://mubi.com/",
"CLIENT": "web",
"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}",
"ANONYMOUS_USER_ID": self.anonymous_user_id,
"Client-Country": self.default_country,
@ -121,9 +125,9 @@ class MUBI(Service):
"Pragma": "no-cache",
"Cache-Control": "no-cache",
}
self.session.headers.update(base_headers)
# Fetch account info
r_account = self.session.get(self.config["endpoints"]["account"])
if not r_account.ok:
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.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.GEOFENCE = (self.country_code,)
self._bind_anonymous_user()
self.log.info(
@ -168,24 +174,22 @@ class MUBI(Service):
r = self.session.get(url)
r.raise_for_status()
data = r.json()
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)
r_reels = self.session.get(url_reels)
r_reels.raise_for_status()
self.reels_data = r_reels.json()
# Extract original language from the first audio track of the first reel
original_language_code = "en" # Default fallback
# Detect original language from first audio track
original_language_code = "en"
if self.reels_data and self.reels_data[0].get("audio_tracks"):
first_audio_track = self.reels_data[0]["audio_tracks"][0]
if "language_code" in first_audio_track:
original_language_code = first_audio_track["language_code"]
self.log.debug(f"Detected original language from reels: '{original_language_code}'")
genres = ", ".join(data.get("genres", [])) or "Unknown"
description = (
data.get("default_editorial_html", "")
.replace("<p>", "").replace("</p>", "").replace("<em>", "").replace("</em>", "").strip()
@ -202,19 +206,15 @@ class MUBI(Service):
language=Language.get(original_language_code),
data=data,
)
return Movies([movie])
def _get_series_titles(self) -> Titles_T:
# Fetch series metadata
series_url = self.config["endpoints"]["series"].format(series_slug=self.series_slug)
r_series = self.session.get(series_url)
r_series.raise_for_status()
series_data = r_series.json()
episodes = []
# If season is explicitly specified, only fetch that season
if self.season_slug:
eps_url = self.config["endpoints"]["season_episodes"].format(
series_slug=self.series_slug,
@ -227,62 +227,48 @@ class MUBI(Service):
episodes_data = r_eps.json().get("episodes", [])
self._add_episodes_to_list(episodes, episodes_data, series_data)
else:
# No season specified fetch ALL seasons
seasons = series_data.get("seasons", [])
if not seasons:
raise ValueError("No seasons found for this series.")
for season in seasons:
season_slug = season["slug"]
eps_url = self.config["endpoints"]["season_episodes"].format(
series_slug=self.series_slug,
season_slug=season_slug
)
self.log.debug(f"Fetching episodes for season: {season_slug}")
r_eps = self.session.get(eps_url)
# Stop if season returns 404 or empty
if r_eps.status_code == 404:
self.log.info(f"Season '{season_slug}' not available, skipping.")
continue
r_eps.raise_for_status()
episodes_data = r_eps.json().get("episodes", [])
if not episodes_data:
self.log.info(f"No episodes found in season '{season_slug}'.")
continue
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)))
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:
# Use episode's own language detection via its consumable.playback_languages
playback_langs = ep.get("consumable", {}).get("playback_languages", {})
audio_langs = playback_langs.get("audio_options", ["English"])
lang_code = audio_langs[0].split()[0].lower() if audio_langs else "en"
try:
detected_lang = Language.get(lang_code)
except:
detected_lang = Language.get("en")
episodes_list.append(Episode(
id_=ep["id"],
service=self.__class__,
title=series_data["title"], # Series title
title=series_data["title"],
season=ep["episode"]["season_number"],
number=ep["episode"]["number"],
name=ep["title"], # Episode title
name=ep["title"],
description=ep.get("short_synopsis", ""),
language=detected_lang,
data=ep, # Full episode data for later use in get_tracks
data=ep,
))
def get_tracks(self, title: Title_T) -> Tracks:
@ -290,60 +276,77 @@ class MUBI(Service):
if not film_id:
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)
r_view = self.session.post(url_view, json={}, headers={"Content-Type": "application/json"})
r_view.raise_for_status()
view_data = r_view.json()
reel_id = view_data["reel_id"]
# For films, use reels data for language/audio mapping
if not self.is_series:
# Fetch reels data if not cached
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:
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:
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])
else:
# For episodes, we dont 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)
r_secure = self.session.get(url_secure)
r_secure.raise_for_status()
secure_data = r_secure.json()
# Find DASH manifest URL
manifest_url = None
for entry in secure_data.get("urls", []):
if entry.get("content_type") == "application/dash+xml":
manifest_url = entry["src"]
break
if not manifest_url:
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)
# Add subtitles
# Add enhanced subtitles (forced/SDH detection)
subtitles = []
for sub in secure_data.get("text_track_urls", []):
lang_code = sub.get("language_code", "und")
vtt_url = sub.get("url")
role = sub.get("role")
forced = False
sdh = False
if not vtt_url:
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
subtitles.append(
Subtitle(
id_=sub["id"],
@ -351,15 +354,43 @@ class MUBI(Service):
language=Language.get(lang_code),
is_original_lang=is_original,
codec=Subtitle.Codec.WebVTT,
name=sub.get("display_name", lang_code.upper()),
forced=False,
sdh=False,
name=disp_name,
forced=forced,
sdh=sdh,
)
)
tracks.subtitles = subtitles
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]:
return []
@ -367,22 +398,20 @@ class MUBI(Service):
if not self.user_id:
raise RuntimeError("user_id not set — authenticate first.")
# Cookie-based 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 (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": "*/*",
"Origin": "https://mubi.com",
"Referer": "https://mubi.com/",
"dt-custom-data": dt_custom_data_b64,
}
r = self.session.post(
self.config["endpoints"]["license"],
data=challenge,
@ -394,3 +423,30 @@ class MUBI(Service):
raise PermissionError(f"DRM license error: {license_data}")
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

View File

@ -10,3 +10,5 @@ endpoints:
ip_geolocation: "https://directory.cookieyes.com/api/v1/ip"
series: "https://api.mubi.com/v4/series/{series_slug}"
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"

View File

@ -19,7 +19,7 @@
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)
5. MUBI:
- Search Functionality
- Creds login
6. VIKI:
- CSRF Token is now scraped, would be from a api requests soon
7. VIDO:
@ -41,4 +41,5 @@
- Acknowledgment
Thanks to Adef for the NPO start downloader.
Thanks to UPS0 for fixing MUBI script