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.
This commit is contained in:
imSp4rky
2026-08-26 21:33:17 -06:00
parent 89f09f8e84
commit 97c939630c
4 changed files with 350 additions and 69 deletions
+14 -5
View File
@@ -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. |
+7
View File
@@ -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
+123 -21
View File
@@ -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,
+206 -43
View File
@@ -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