From e4cf8a1d45203c32306c312311f2c2ffc1e4e2a8 Mon Sep 17 00:00:00 2001 From: FairTrade Date: Wed, 12 Nov 2025 11:13:45 +0100 Subject: [PATCH] Added series support for Mubi --- MUBI/__init__.py | 100 +++++++++++++++++++++++++++++------------------ README.md | 2 +- 2 files changed, 62 insertions(+), 40 deletions(-) diff --git a/MUBI/__init__.py b/MUBI/__init__.py index 6a92d7b..4cd1e2f 100644 --- a/MUBI/__init__.py +++ b/MUBI/__init__.py @@ -17,15 +17,15 @@ from unshackle.core.tracks import Chapter, Tracks, Subtitle class MUBI(Service): """ Service code for MUBI (mubi.com) - Version: 1.3.0 + Version: 1.2.0 Authorization: Required cookies (lt token + session) Security: FHD @ L3 (Widevine) - Features: - - Uses /reels endpoint for definitive language code (per user suggestion). - - Dynamic country & userId from /v4/account. - - Anonymous User ID extracted from _snow_id.c006 cookie. + 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[^/]+)(?:/season/(?P[^/]+))?$" TITLE_RE = r"^(?:https?://(?:www\.)?mubi\.com)(?:/[^/]+)*?/films/(?P[^/?#]+)$" @@ -151,7 +151,7 @@ class MUBI(Service): headers={"Content-Type": "application/json"} ) if r.ok: - self.log.debug("🔗 Anonymous user ID successfully bound to account.") + self.log.debug("Anonymous user ID successfully bound to account.") else: self.log.warning(f"Failed to bind anonymous_user_uuid: {r.status_code}") except Exception as e: @@ -212,26 +212,58 @@ class MUBI(Service): r_series.raise_for_status() series_data = r_series.json() - # If no season specified, default to first season - if not self.season_slug: + 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, + season_slug=self.season_slug + ) + 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.") - self.season_slug = seasons[0]["slug"] - # Fetch episodes for selected season - eps_url = self.config["endpoints"]["season_episodes"].format( - series_slug=self.series_slug, - season_slug=self.season_slug - ) - r_eps = self.session.get(eps_url) - r_eps.raise_for_status() - episodes_data = r_eps.json().get("episodes", []) + 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 + ) - episodes = [] + 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 - # Fallback to English 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" @@ -241,7 +273,7 @@ class MUBI(Service): except: detected_lang = Language.get("en") - episodes.append(Episode( + episodes_list.append(Episode( id_=ep["id"], service=self.__class__, title=series_data["title"], # Series title @@ -253,26 +285,23 @@ class MUBI(Service): 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: film_id = getattr(title, "id", None) if not film_id: raise RuntimeError("Title ID not found.") # For series episodes, we don't have reels cached, so skip reel-based logic + 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: 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) - 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"] - - # Use reels data cached from get_titles() 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) @@ -284,7 +313,7 @@ class MUBI(Service): reel = next((r for r in reels if r["id"] == reel_id), reels[0]) else: - # For episodes, skip reel logic, go straight to secure_url + # For episodes, we don’t need reel-based logic — just proceed pass # Request secure streaming URL, works for both films and episodes @@ -329,14 +358,6 @@ class MUBI(Service): ) 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 def get_chapters(self, title: Title_T) -> list[Chapter]: @@ -372,3 +393,4 @@ class MUBI(Service): if license_data.get("status") != "OK": raise PermissionError(f"DRM license error: {license_data}") return base64.b64decode(license_data["license"]) + diff --git a/README.md b/README.md index 056e076..3815e5d 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ These services is new and in development. Please feel free to submit pull reques - 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) - MUBI - - Series is not fully operational (Only downloads the first episode only), movies does. + - Search Functionality