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):
|
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:
|
|
||||||
|
# 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", [])
|
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.")
|
||||||
self.season_slug = seasons[0]["slug"]
|
|
||||||
|
|
||||||
# Fetch episodes for selected season
|
for season in seasons:
|
||||||
eps_url = self.config["endpoints"]["season_episodes"].format(
|
season_slug = season["slug"]
|
||||||
series_slug=self.series_slug,
|
eps_url = self.config["endpoints"]["season_episodes"].format(
|
||||||
season_slug=self.season_slug
|
series_slug=self.series_slug,
|
||||||
)
|
season_slug=season_slug
|
||||||
r_eps = self.session.get(eps_url)
|
)
|
||||||
r_eps.raise_for_status()
|
|
||||||
episodes_data = r_eps.json().get("episodes", [])
|
|
||||||
|
|
||||||
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:
|
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
|
||||||
|
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.is_series:
|
||||||
if not self.film_id:
|
if not self.film_id:
|
||||||
raise RuntimeError("film_id not set. Call get_titles() first.")
|
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:
|
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 don’t 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"])
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user