Added series support for Mubi
This commit is contained in:
parent
7007a2e2b0
commit
e4cf8a1d45
100
MUBI/__init__.py
100
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<series_slug>[^/]+)(?:/season/(?P<season_slug>[^/]+))?$"
|
||||
TITLE_RE = r"^(?:https?://(?:www\.)?mubi\.com)(?:/[^/]+)*?/films/(?P<slug>[^/?#]+)$"
|
||||
@ -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"])
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user