From 97c939630ca45a4d21e31c25ff3e4d2c05e41d08 Mon Sep 17 00:00:00 2001 From: imSp4rky Date: Wed, 26 Aug 2026 21:33:17 -0600 Subject: [PATCH] fix(remote): carry the full title and track state across the remote API Services set the original language, track names, repack flags, editions and track.data inside get_tracks; the client dropped them so --remote muxed with no default audio and could not select "orig". Also round-trip Song titles, subtitle DRM, PlayReady PSSH, exact bitrate and path-only attachments. --- docs/dev/rest-api/endpoints.md | 19 +- docs/dev/rest-api/remote-sessions.md | 7 + unshackle/core/api/handlers.py | 144 +++++++++++++--- unshackle/core/remote_service.py | 249 ++++++++++++++++++++++----- 4 files changed, 350 insertions(+), 69 deletions(-) diff --git a/docs/dev/rest-api/endpoints.md b/docs/dev/rest-api/endpoints.md index d9cf52de..8fe4c3de 100644 --- a/docs/dev/rest-api/endpoints.md +++ b/docs/dev/rest-api/endpoints.md @@ -284,21 +284,28 @@ When you give both `season` and `episode`, the server combines them into a `"{se "video": [ { "id": "v-1", "codec": "H265", "codec_display": "HEVC", - "bitrate": 8000, "width": 3840, "height": 2160, + "bitrate": 8000, "bitrate_bps": 8000000, "width": 3840, "height": 2160, "resolution": "2160p", "fps": "24", "range": "HDR10", - "range_display": "HDR10", "language": "en", "drm": [], "descriptor": "DASH" + "range_display": "HDR10", "scan_type": null, "closed_captions": [], + "dv_compatible_bitstream": false, "language": "en", "is_original_lang": true, + "name": null, "needs_repack": false, "edition": [], "data": null, + "drm": [], "descriptor": "DASH" } ], "audio": [ { "id": "a-1", "codec": "EC3", "codec_display": "DD+", - "bitrate": 640, "channels": "5.1", "language": "en", "is_original": true, - "atmos": false, "descriptive": false, "drm": [], "descriptor": "DASH" + "bitrate": 640, "bitrate_bps": 640000, "channels": "5.1", "language": "en", + "is_original": true, "is_original_lang": true, "atmos": false, "joc": 0, + "descriptive": false, "name": null, "needs_repack": false, "edition": [], + "data": null, "drm": [], "descriptor": "DASH" } ], "subtitles": [ { "id": "s-1", "codec": "vtt", "language": "en", - "forced": false, "sdh": false, "cc": false, "descriptor": "DASH" } + "forced": false, "sdh": false, "cc": false, "is_original_lang": false, + "name": null, "needs_repack": false, "edition": [], "data": null, + "drm": [], "descriptor": "DASH" } ] } ``` @@ -325,6 +332,8 @@ The server sorts tracks by bitrate, descending. Episodes the server cannot get g The `drm` array on a track is a list of `{type, pssh?, kids?, content_keys?, license_url?}` objects. +Every track carries the same fields as a local track: `is_original_lang`, `name`, `needs_repack`, `edition`, and `data`. `bitrate` is in kb/s, and `bitrate_bps` gives the exact bits/s. `data` holds the part of the service-set `track.data` that fits in JSON, without the `hls`, `dash`, and `ism` parser keys. Audio keeps `is_original` and `atmos` for clients that read them, and adds the exact `joc` object count. + | Status | Error code | Meaning | | --- | --- | --- | | `200` | - | Tracks returned. | diff --git a/docs/dev/rest-api/remote-sessions.md b/docs/dev/rest-api/remote-sessions.md index 2dc5e13a..de66c6d4 100644 --- a/docs/dev/rest-api/remote-sessions.md +++ b/docs/dev/rest-api/remote-sessions.md @@ -141,6 +141,13 @@ hood the client walks a remote session through its lifecycle. downloading. Any `session_headers` / `session_cookies` the server used are merged into the client's local HTTP session. + The tracks response repeats the title. A service can rewrite title fields inside + `get_tracks`, so the client takes this copy in place of the one from the title + list. Each track carries the full local field set. After the manifest re-parse the + client overlays the JSON-safe part of the service-set `track.data`, so a service + value wins over the parser's. An attachment that the service wrote to a file on the + server travels as base64, and the client writes it to its own temp directory. + === "4. Licensing" For a DRM-protected title the client either **proxies** its CDM challenge through diff --git a/unshackle/core/api/handlers.py b/unshackle/core/api/handlers.py index e489eb16..b159c25a 100644 --- a/unshackle/core/api/handlers.py +++ b/unshackle/core/api/handlers.py @@ -1,5 +1,7 @@ import asyncio +import base64 import enum +import json import logging import re from datetime import date as date_ @@ -391,6 +393,7 @@ def serialize_title(title: Title_T) -> Dict[str, Any]: cover_url = _data.get("cover_url") if isinstance(_data, dict) else None is_episode = isinstance(title, Episode) + is_song = isinstance(title, Song) episode_part = title.part if isinstance(title, Episode) else None if is_episode: # no part suffix here: remote_service.build_title rebuilds the Episode from this dict, so a @@ -400,7 +403,7 @@ def serialize_title(title: Title_T) -> Dict[str, Any]: name = str(title.name) if hasattr(title, "name") else str(title) result = { - "type": "episode" if is_episode else "movie" if isinstance(title, Movie) else "other", + "type": "episode" if is_episode else "movie" if isinstance(title, Movie) else "song" if is_song else "other", "name": name, "id": str(title.id) if hasattr(title, "id") else None, "language": title_language, @@ -408,8 +411,8 @@ def serialize_title(title: Title_T) -> Dict[str, Any]: "date": date, "cover_url": cover_url, } - # "other" titles carry no year; only Episode/Movie do. - if isinstance(title, (Episode, Movie)): + # "other" titles carry no year; only Episode/Movie/Song do. + if isinstance(title, (Episode, Movie, Song)): result["year"] = title.year if isinstance(title, Episode): result["series_title"] = str(title.title) @@ -426,6 +429,26 @@ def serialize_title(title: Title_T) -> Dict[str, Any]: result["absolute"] = title.absolute if getattr(title, "daily", None) is not None: result["daily"] = title.daily + if is_song: + for field in ( + "artist", + "album", + "track", + "disc", + "album_artist", + "release_type", + "total_tracks", + "total_discs", + "genre", + "explicit", + "isrc", + "upc", + "copyright", + "label", + "lyrics", + "artwork_url", + ): + result[field] = getattr(title, field, None) if isinstance(title, (Episode, Movie)) and getattr(title, "anime", None) is not None: result["anime"] = title.anime @@ -510,9 +533,11 @@ def serialize_drm(drm_list) -> Optional[List[Dict[str, Any]]]: drm_class = drm.__class__.__name__ drm_info["type"] = drm_class.lower() - # PSSH: pywidevine exposes dumps(); pyplayready's PSSH has no base64 method - # here, so PlayReady omits the field (unchanged from prior behaviour). + # pywidevine exposes dumps(); pyplayready's PSSH does not, so PlayReady falls back + # to the base64 it was built from. Without it the client drops every PlayReady + # entry and licenses the title with the wrong CDM. pssh_obj = getattr(drm, "_pssh", None) + pssh_b64 = (getattr(drm, "data", None) or {}).get("pssh_b64") if pssh_obj is not None and hasattr(pssh_obj, "dumps"): try: drm_info["pssh"] = pssh_obj.dumps() @@ -523,6 +548,8 @@ def serialize_drm(drm_list) -> Optional[List[Dict[str, Any]]]: type(pssh_obj).__name__, exc_info=True, ) + if not drm_info.get("pssh") and pssh_b64: + drm_info["pssh"] = pssh_b64 if hasattr(drm, "kids") and drm.kids: drm_info["kids"] = [str(kid) for kid in drm.kids] @@ -560,6 +587,46 @@ def drm_preference_name(track: Any) -> Optional[str]: return {"wv": "widevine", "pr": "playready"}.get(preference, preference) +MANIFEST_DATA_KEYS = ("hls", "dash", "ism") + + +def serialize_track_data(track: Any) -> Optional[Dict[str, Any]]: + """The JSON-safe part of a service-set ``track.data``. + + This drops the manifest parsers' own keys: the client rebuilds those from the + ``manifests`` payload, and they hold live lxml and m3u8 objects that JSON cannot carry. + """ + data = getattr(track, "data", None) + if not isinstance(data, dict): + return None + safe: Dict[str, Any] = {} + for key, value in data.items(): + if key in MANIFEST_DATA_KEYS: + continue + try: + json.dumps(value) + except (TypeError, ValueError): + continue + safe[key] = value + return safe or None + + +def serialize_track_common(track: Any) -> Dict[str, Any]: + """The Track fields every track type shares, a superset of ``Track.to_dict()``.""" + return { + "id": str(track.id), + "language": str(track.language) if track.language else None, + "is_original_lang": bool(getattr(track, "is_original_lang", False)), + "name": getattr(track, "name", None), + "needs_repack": bool(getattr(track, "needs_repack", False)), + "edition": list(getattr(track, "edition", None) or []), + "descriptor": descriptor_name(track), + "drm": serialize_drm(track.drm) if getattr(track, "drm", None) else None, + "drm_preference": getattr(track, "drm_preference", None), + "data": serialize_track_data(track), + } + + def serialize_video_track(track: Video, include_url: bool = False) -> Dict[str, Any]: """Convert video track to JSON-serializable dict.""" codec_name = enum_name(track.codec) @@ -570,17 +637,18 @@ def serialize_video_track(track: Video, include_url: bool = False) -> Dict[str, "codec": codec_name, "codec_display": VIDEO_CODEC_MAP.get(codec_name, codec_name), "bitrate": int(track.bitrate / 1000) if track.bitrate else None, + "bitrate_bps": track.bitrate or None, "width": track.width, "height": track.height, "resolution": f"{track.width}x{track.height}" if track.width and track.height else None, "fps": track.fps if track.fps else None, "range": range_name, "range_display": DYNAMIC_RANGE_MAP.get(range_name, range_name), - "language": str(track.language) if track.language else None, - "drm": serialize_drm(track.drm) if hasattr(track, "drm") and track.drm else None, - "drm_preference": getattr(track, "drm_preference", None), - "descriptor": descriptor_name(track), + "scan_type": enum_name(track.scan_type) if track.scan_type else None, + "closed_captions": list(getattr(track, "closed_captions", None) or []), + "dv_compatible_bitstream": bool(getattr(track, "dv_compatible_bitstream", False)), } + result.update(serialize_track_common(track)) if include_url and hasattr(track, "url") and track.url: result["url"] = str(track.url) return result @@ -618,15 +686,14 @@ def serialize_audio_track(track: Audio, include_url: bool = False, is_original: "codec": codec_name, "codec_display": AUDIO_CODEC_MAP.get(codec_name, codec_name), "bitrate": int(track.bitrate / 1000) if track.bitrate else None, + "bitrate_bps": track.bitrate or None, "channels": track.channels if track.channels else None, - "language": str(track.language) if track.language else None, "is_original": is_original, "atmos": track.atmos if hasattr(track, "atmos") else False, + "joc": getattr(track, "joc", 0), "descriptive": track.descriptive if hasattr(track, "descriptive") else False, - "drm": serialize_drm(track.drm) if hasattr(track, "drm") and track.drm else None, - "drm_preference": getattr(track, "drm_preference", None), - "descriptor": descriptor_name(track), } + result.update(serialize_track_common(track)) if include_url and hasattr(track, "url") and track.url: result["url"] = str(track.url) return result @@ -637,17 +704,56 @@ def serialize_subtitle_track(track: Subtitle, include_url: bool = False) -> Dict result = { "id": str(track.id), "codec": enum_name(track.codec), - "language": str(track.language) if track.language else None, "forced": track.forced if hasattr(track, "forced") else False, "sdh": track.sdh if hasattr(track, "sdh") else False, "cc": track.cc if hasattr(track, "cc") else False, - "descriptor": descriptor_name(track), } + result.update(serialize_track_common(track)) if include_url and hasattr(track, "url") and track.url: result["url"] = str(track.url) return result +ATTACHMENT_INLINE_LIMIT = 20 * 1024 * 1024 + + +def serialize_attachment(attachment: Any) -> Optional[Dict[str, Any]]: + """One attachment as JSON, with a path-only attachment carried as base64 file bytes. + + A service can attach a file it already wrote to disk (a font, a poster). The client + has no access to the server's filesystem, so those bytes travel inline. + """ + result: Dict[str, Any] = { + "url": attachment.url, + "name": attachment.name, + "mime_type": attachment.mime_type, + "description": attachment.description, + } + if attachment.url: + return result + + path = getattr(attachment, "path", None) + if not path: + return None + path = Path(path) + try: + size = path.stat().st_size + if size > ATTACHMENT_INLINE_LIMIT: + log.warning( + "Skipping attachment %s: %d bytes is over the %d byte inline limit", + sanitize_log(path.name), + size, + ATTACHMENT_INLINE_LIMIT, + ) + return None + result["content"] = base64.b64encode(path.read_bytes()).decode("ascii") + except OSError as e: + log.warning("Skipping attachment %s: %s", sanitize_log(path.name), e) + return None + result["file_name"] = path.name + return result + + async def search_handler(data: Dict[str, Any], request: Optional[web.Request] = None) -> web.Response: """Answer the request to find titles.""" from unshackle.commands.dl import dl @@ -2136,7 +2242,7 @@ async def session_tracks_handler( return web.json_response( { - "title": serialize_title(title), + "title": stamp_service_flags(serialize_title(title), service_instance), "video": [serialize_video_track(t, include_url=True) for t in video_tracks], "audio": [ serialize_audio_track(t, include_url=True, is_original=t.id in original_ids) for t in audio_tracks @@ -2146,11 +2252,7 @@ async def session_tracks_handler( {"timestamp": ch.timestamp, "name": ch.name} for ch in session.chapters_by_title.get(str(title_id), []) ], - "attachments": [ - {"url": a.url, "name": a.name, "mime_type": a.mime_type, "description": a.description} - for a in tracks.attachments - if hasattr(a, "url") and a.url - ], + "attachments": [a for a in map(serialize_attachment, tracks.attachments) if a], "manifests": manifests, "session_headers": session_headers, "session_cookies": session_cookies, diff --git a/unshackle/core/remote_service.py b/unshackle/core/remote_service.py index 78b3ca85..0c60155d 100644 --- a/unshackle/core/remote_service.py +++ b/unshackle/core/remote_service.py @@ -9,9 +9,12 @@ from __future__ import annotations import base64 import logging +import re import time +from datetime import date as date_ from enum import Enum from http.cookiejar import CookieJar +from pathlib import Path from typing import Any, Dict, Optional, Union import click @@ -28,6 +31,7 @@ from unshackle.core.credential import Credential from unshackle.core.titles import Title_T, Titles_T, remap_titles from unshackle.core.titles.episode import Episode, Series from unshackle.core.titles.movie import Movie, Movies +from unshackle.core.titles.music import Album, Song from unshackle.core.tracks import Audio, Chapter, Chapters, Subtitle, Tracks, Video from unshackle.core.tracks.attachment import Attachment from unshackle.core.tracks.track import Track @@ -121,47 +125,66 @@ def enum_get(enum_cls: type[Enum], name: Optional[str], default: Any = None) -> return default +def base_track_kwargs(data: Dict[str, Any]) -> Dict[str, Any]: + """The shared Track constructor kwargs a serialized track carries. + + An older server omits the newer keys, so each one falls back to the value a + locally-built track would have had. + """ + return { + "url": data.get("url") or "https://placeholder", + "language": Language.get(data.get("language") or "und"), + "is_original_lang": bool(data.get("is_original_lang", data.get("is_original", False))), + "descriptor": enum_get(Track.Descriptor, data.get("descriptor"), Track.Descriptor.URL), + "needs_repack": bool(data.get("needs_repack", False)), + "name": data.get("name"), + "edition": data.get("edition") or None, + "id_": data.get("id"), + } + + +def deserialize_bitrate(data: Dict[str, Any]) -> int: + """Exact bits/s when the server sent it, otherwise the kb/s figure scaled back up.""" + exact = data.get("bitrate_bps") + if exact: + return int(exact) + return data["bitrate"] * 1000 if data.get("bitrate") else 0 + + def deserialize_video(data: Dict[str, Any]) -> Video: - v = Video( - url=data.get("url") or "https://placeholder", - language=Language.get(data.get("language") or "und"), - descriptor=enum_get(Track.Descriptor, data.get("descriptor"), Track.Descriptor.URL), + return Video( + **base_track_kwargs(data), codec=enum_get(Video.Codec, data.get("codec")), range_=enum_get(Video.Range, data.get("range"), Video.Range.SDR), - bitrate=data["bitrate"] * 1000 if data.get("bitrate") else 0, + bitrate=deserialize_bitrate(data), width=data.get("width") or 0, height=data.get("height") or 0, fps=data.get("fps"), - id_=data.get("id"), + scan_type=enum_get(Video.ScanType, data.get("scan_type")), + closed_captions=data.get("closed_captions") or None, + dv_compatible_bitstream=bool(data.get("dv_compatible_bitstream", False)), ) - return v def deserialize_audio(data: Dict[str, Any]) -> Audio: - a = Audio( - url=data.get("url") or "https://placeholder", - language=Language.get(data.get("language") or "und"), - descriptor=enum_get(Track.Descriptor, data.get("descriptor"), Track.Descriptor.URL), + joc = data.get("joc") + return Audio( + **base_track_kwargs(data), codec=enum_get(Audio.Codec, data.get("codec")), - bitrate=data["bitrate"] * 1000 if data.get("bitrate") else 0, + bitrate=deserialize_bitrate(data), channels=data.get("channels"), - joc=1 if data.get("atmos") else 0, + joc=joc if joc is not None else (1 if data.get("atmos") else 0), descriptive=data.get("descriptive", False), - id_=data.get("id"), ) - return a def deserialize_subtitle(data: Dict[str, Any]) -> Subtitle: return Subtitle( - url=data.get("url") or "https://placeholder", - language=Language.get(data.get("language") or "und"), - descriptor=enum_get(Track.Descriptor, data.get("descriptor"), Track.Descriptor.URL), + **base_track_kwargs(data), codec=enum_get(Subtitle.Codec, data.get("codec")), cc=data.get("cc", False), sdh=data.get("sdh", False), forced=data.get("forced", False), - id_=data.get("id"), ) @@ -199,6 +222,30 @@ def reconstruct_drm(drm_list: Optional[list]) -> list: return result +def build_attachment(data: Dict[str, Any]) -> Optional[Attachment]: + """Rebuild one attachment. A path-only attachment arrives as base64, which this writes to temp.""" + kwargs = { + "name": data.get("name"), + "mime_type": data.get("mime_type"), + "description": data.get("description"), + } + if data.get("url"): + return Attachment(url=data["url"], **kwargs) + + content = data.get("content") + if not content: + return None + try: + file_name = Path(str(data.get("file_name") or data.get("name") or "attachment")).name + target = config.directories.temp / "remote_attachments" / file_name + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(base64.b64decode(content)) + return Attachment(path=target, **kwargs) + except (OSError, ValueError) as e: + log.warning(f"Skipping attachment {data.get('name')!r} sent by the server: {e}") + return None + + def build_tracks(data: Dict[str, Any]) -> Tracks: tracks = Tracks() tracks.videos = [deserialize_video(v) for v in data.get("video", [])] @@ -208,6 +255,7 @@ def build_tracks(data: Dict[str, Any]) -> Tracks: for track_data, track_obj in [ *zip(data.get("video", []), tracks.videos), *zip(data.get("audio", []), tracks.audio), + *zip(data.get("subtitles", []), tracks.subtitles), ]: drm_objs = reconstruct_drm(track_data.get("drm")) if drm_objs: @@ -218,13 +266,25 @@ def build_tracks(data: Dict[str, Any]) -> Tracks: track_obj.drm_preference = preference except ValueError: log.warning(f"Ignoring unknown drm_preference {preference!r} from the server for track {track_obj.id}") - tracks.attachments = [ - Attachment(url=a["url"], name=a.get("name"), mime_type=a.get("mime_type"), description=a.get("description")) - for a in data.get("attachments", []) - ] + tracks.attachments = [a for a in map(build_attachment, data.get("attachments", [])) if a] return tracks +def apply_service_track_data(tracks: Tracks, data: Dict[str, Any]) -> None: + """Overlay the service-set ``track.data`` the server sent. + + Runs after the manifest re-parse so a service value wins over the parser's own for + the same key, as it does on the server. + """ + by_id = {str(t.id): t for t in list(tracks.videos) + list(tracks.audio) + list(tracks.subtitles)} + for key in ("video", "audio", "subtitles"): + for entry in data.get(key, []): + track = by_id.get(str(entry.get("id"))) + extra = entry.get("data") + if track is not None and isinstance(extra, dict): + track.data.update(extra) + + def resolve_manifest_data(tracks: Tracks, manifests: list) -> None: """Re-parse serialized manifests and fill track.data for downloading. @@ -288,6 +348,13 @@ def resolve_manifest_data(tracks: Tracks, manifests: list) -> None: log_m.warning("Failed to re-parse %s manifest from %s: %s", m_type, m_url, e) +def same_bitrate(local: Optional[int], remote: Optional[int]) -> bool: + """Equal to the nearest kb/s. A server that predates ``bitrate_bps`` rounds to kb/s in transit.""" + if not local or not remote: + return not local and not remote + return round(local / 1000) == round(remote / 1000) + + def match_track(remote_track: Track, local_tracks: list) -> Optional[Track]: """Find the locally-parsed track that matches a remote track by ID or by attributes.""" remote_id = str(remote_track.id) @@ -304,7 +371,7 @@ def match_track(remote_track: Track, local_tracks: list) -> Optional[Track]: if lt.width == remote_track.width and lt.height == remote_track.height: return lt elif hasattr(lt, "channels") and hasattr(remote_track, "channels"): - if lt.bitrate == remote_track.bitrate: + if same_bitrate(lt.bitrate, remote_track.bitrate): return lt elif hasattr(lt, "forced"): if lt.forced == remote_track.forced and lt.sdh == remote_track.sdh: @@ -312,37 +379,124 @@ def match_track(remote_track: Track, local_tracks: list) -> Optional[Track]: return None -def build_title(info: Dict[str, Any], service_tag: str, fallback_id: str) -> Union[Episode, Movie]: +EPISODE_PLACEHOLDER_NAME = re.compile(r"Episode ?#?\d+", re.IGNORECASE) + + +def episode_name(name: Optional[str], series_title: Optional[str]) -> Optional[str]: + """Drop a placeholder episode name the way ``Episode.__init__`` does. + + The server sends "Episode 04" for an unnamed episode, so the same filter has to run + again here, or the placeholder reaches the output file name. + """ + if not name: + return None + name = name.strip() + if EPISODE_PLACEHOLDER_NAME.match(name): + return None + if series_title and name.lower() == series_title.lower(): + return None + return name + + +def parse_air_date(value: Any) -> Any: + """ISO date string back to a date, as ``Episode.__init__`` does; anything else passes through.""" + if isinstance(value, str): + try: + return date_.fromisoformat(value[:10]) + except ValueError: + return value + return value + + +def apply_title_fields(title: Title_T, info: Dict[str, Any]) -> None: + """Copy every serialized title field onto a local title. + + A service can change its title inside ``get_tracks`` (a corrected original language, + a real episode name, an air date), so the client re-applies the server's view of the + title after every call that returns one. + """ + if info.get("language"): + title.language = Language.get(info["language"]) + if info.get("description") is not None and isinstance(title, (Episode, Movie)): + title.description = info["description"] + if info.get("year") is not None: + title.year = info["year"] + + if isinstance(title, Episode): + if info.get("series_title"): + title.title = info["series_title"] + if "name" in info: + title.name = episode_name(info["name"], title.title) + for key in ("season", "number", "part", "absolute"): + if info.get(key) is not None: + setattr(title, key, info[key]) + if info.get("air_date") is not None: + title.air_date = parse_air_date(info["air_date"]) + elif info.get("name"): + title.name = info["name"] + + for key in ("anime", "daily"): + if info.get(key) is not None: + setattr(title, key, info[key]) + + extra = {k: info[k] for k in ("date", "cover_url") if info.get(k) is not None} + if extra: + if isinstance(title.data, dict): + title.data.update(extra) + else: + title.data = extra + + +def build_title(info: Dict[str, Any], service_tag: str, fallback_id: str) -> Title_T: svc_class = type(service_tag, (), {}) lang = Language.get(info["language"]) if info.get("language") else None - title: Union[Episode, Movie] - if info.get("type") == "episode": + title_type = info.get("type") + title: Title_T + if title_type == "episode": title = Episode( id_=info.get("id", fallback_id), service=svc_class, title=info.get("series_title", "Unknown"), season=info.get("season", 0), number=info.get("number", 0), - name=info.get("name"), - year=info.get("year"), language=lang, - air_date=info.get("air_date"), - part=info.get("part"), - absolute=info.get("absolute"), ) - if "daily" in info: - title.daily = info["daily"] - else: + elif title_type == "movie": title = Movie( id_=info.get("id", fallback_id), service=svc_class, - name=info.get("name", "Unknown"), - year=info.get("year"), + name=info.get("name") or "Unknown", language=lang, ) - # the synthetic service class carries no ANIME/DAILY, so the flags only survive per title - if "anime" in info: - title.anime = info["anime"] + elif title_type == "song": + title = Song( + id_=info.get("id", fallback_id), + service=svc_class, + name=info.get("name") or "Unknown", + artist=info.get("artist") or "Unknown", + album=info.get("album") or "Unknown", + track=info.get("track") or 1, + disc=info.get("disc") or 1, + language=lang, + album_artist=info.get("album_artist"), + release_type=info.get("release_type") or "album", + total_tracks=info.get("total_tracks"), + total_discs=info.get("total_discs"), + genre=info.get("genre"), + explicit=info.get("explicit"), + isrc=info.get("isrc"), + upc=info.get("upc"), + copyright=info.get("copyright"), + label=info.get("label"), + lyrics=info.get("lyrics"), + artwork_url=info.get("artwork_url"), + ) + else: + raise click.ClickException( + f"The remote server sent a title of type {title_type!r}, which this client does not support. " + "Update unshackle on both ends so they agree on the title types." + ) + apply_title_fields(title, info) return title @@ -645,9 +799,13 @@ class RemoteService: return self._titles result = self.client.get(f"/api/session/{self._session_id}/titles") titles_list = [build_title(t, self.service_tag, self.title_id) for t in result.get("titles", [])] - self._titles = ( - Series(titles_list) if titles_list and isinstance(titles_list[0], Episode) else Movies(titles_list) - ) + first = titles_list[0] if titles_list else None + if isinstance(first, Episode): + self._titles = Series(titles_list) + elif isinstance(first, Song): + self._titles = Album(titles_list) + else: + self._titles = Movies(titles_list) return self._titles def get_titles_cached(self, title_id: str = None) -> Titles_T: @@ -666,6 +824,10 @@ class RemoteService: result = self.client.post(f"/api/session/{self._session_id}/tracks", {"title_id": title_id}) tracks = build_tracks(result) + remote_title = result.get("title") or {} + if remote_title: + apply_title_fields(title, remote_title) + for k, v in result.get("session_headers", {}).items(): if k.lower() not in ("host", "content-length", "content-type"): self._session.headers[k] = v @@ -673,6 +835,7 @@ class RemoteService: self._session.cookies.set(k, v) resolve_manifest_data(tracks, result.get("manifests", [])) + apply_service_track_data(tracks, result) if self._server_cdm and not result.get("server_cdm", True): self._server_cdm = False