Added series support for Mubi

This commit is contained in:
FairTrade 2025-11-12 11:13:45 +01:00
parent 7007a2e2b0
commit e4cf8a1d45
2 changed files with 62 additions and 40 deletions

View File

@ -17,15 +17,15 @@ from unshackle.core.tracks import Chapter, Tracks, Subtitle
class MUBI(Service): class MUBI(Service):
""" """
Service code for MUBI (mubi.com) Service code for MUBI (mubi.com)
Version: 1.3.0 Version: 1.2.0
Authorization: Required cookies (lt token + session) Authorization: Required cookies (lt token + session)
Security: FHD @ L3 (Widevine) Security: FHD @ L3 (Widevine)
Features: Supports:
- Uses /reels endpoint for definitive language code (per user suggestion). Series https://mubi.com/en/nl/series/twin-peaks
- Dynamic country & userId from /v4/account. Movies https://mubi.com/en/nl/films/the-substance
- Anonymous User ID extracted from _snow_id.c006 cookie.
""" """
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>[^/?#]+)$"
@ -151,7 +151,7 @@ class MUBI(Service):
headers={"Content-Type": "application/json"} headers={"Content-Type": "application/json"}
) )
if r.ok: if r.ok:
self.log.debug("🔗 Anonymous user ID successfully bound to account.") self.log.debug("Anonymous user ID successfully bound to account.")
else: else:
self.log.warning(f"Failed to bind anonymous_user_uuid: {r.status_code}") self.log.warning(f"Failed to bind anonymous_user_uuid: {r.status_code}")
except Exception as e: except Exception as e:
@ -212,26 +212,58 @@ class MUBI(Service):
r_series.raise_for_status() r_series.raise_for_status()
series_data = r_series.json() series_data = r_series.json()
# If no season specified, default to first season episodes = []
if not self.season_slug:
seasons = series_data.get("seasons", [])
if not seasons:
raise ValueError("No seasons found for this series.")
self.season_slug = seasons[0]["slug"]
# Fetch episodes for selected season # If season is explicitly specified, only fetch that season
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,
season_slug=self.season_slug season_slug=self.season_slug
) )
r_eps = self.session.get(eps_url) r_eps = self.session.get(eps_url)
if r_eps.status_code == 404:
raise ValueError(f"Season '{self.season_slug}' not found.")
r_eps.raise_for_status()
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() r_eps.raise_for_status()
episodes_data = r_eps.json().get("episodes", []) episodes_data = r_eps.json().get("episodes", [])
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: for ep in episodes_data:
# Use episode's own language detection via its consumable.playback_languages # Use episode's own language detection via its consumable.playback_languages
# Fallback to English
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"
@ -241,7 +273,7 @@ class MUBI(Service):
except: except:
detected_lang = Language.get("en") detected_lang = Language.get("en")
episodes.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"], # Series title
@ -253,26 +285,23 @@ class MUBI(Service):
data=ep, # Full episode data for later use in get_tracks data=ep, # Full episode data for later use in get_tracks
)) ))
return Series(sorted(episodes, key=lambda x: (x.season, x.number)))
def get_tracks(self, title: Title_T) -> Tracks: def get_tracks(self, title: Title_T) -> Tracks:
film_id = getattr(title, "id", None) film_id = getattr(title, "id", None)
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 # For series episodes, we don't have reels cached, so skip reel-based logic
if not self.is_series:
if not self.film_id:
raise RuntimeError("film_id not set. Call get_titles() first.")
# Initiate viewing session (only for films)
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"]
# Use reels data cached from get_titles() # For films, use reels data for language/audio mapping
if not self.is_series:
if not self.film_id:
raise RuntimeError("film_id not set. Call get_titles() first.")
if not self.reels_data: if not self.reels_data:
self.log.warning("Reels data not cached, fetching now.") 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)
@ -284,7 +313,7 @@ class MUBI(Service):
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: else:
# For episodes, skip reel logic, go straight to secure_url # For episodes, we dont need reel-based logic — just proceed
pass pass
# Request secure streaming URL, works for both films and episodes # Request secure streaming URL, works for both films and episodes
@ -329,14 +358,6 @@ class MUBI(Service):
) )
tracks.subtitles = subtitles tracks.subtitles = subtitles
# Attach Widevine DRM
drm_info = secure_data.get("drm", {})
for tr in tracks.videos + tracks.audio:
if hasattr(tr, "drm") and tr.drm:
tr.drm.license = lambda challenge, **kw: self.get_widevine_license(
challenge=challenge, title=title, track=tr
)
return tracks return tracks
def get_chapters(self, title: Title_T) -> list[Chapter]: def get_chapters(self, title: Title_T) -> list[Chapter]:
@ -372,3 +393,4 @@ class MUBI(Service):
if license_data.get("status") != "OK": if license_data.get("status") != "OK":
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"])

View File

@ -17,7 +17,7 @@ These services is new and in development. Please feel free to submit pull reques
- HIDI - 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)
- MUBI - MUBI
- Series is not fully operational (Only downloads the first episode only), movies does. - Search Functionality