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": [ "video": [
{ {
"id": "v-1", "codec": "H265", "codec_display": "HEVC", "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", "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": [ "audio": [
{ {
"id": "a-1", "codec": "EC3", "codec_display": "DD+", "id": "a-1", "codec": "EC3", "codec_display": "DD+",
"bitrate": 640, "channels": "5.1", "language": "en", "is_original": true, "bitrate": 640, "bitrate_bps": 640000, "channels": "5.1", "language": "en",
"atmos": false, "descriptive": false, "drm": [], "descriptor": "DASH" "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": [ "subtitles": [
{ "id": "s-1", "codec": "vtt", "language": "en", { "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. 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 | | Status | Error code | Meaning |
| --- | --- | --- | | --- | --- | --- |
| `200` | - | Tracks returned. | | `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 downloading. Any `session_headers` / `session_cookies` the server used are
merged into the client's local HTTP session. 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" === "4. Licensing"
For a DRM-protected title the client either **proxies** its CDM challenge through For a DRM-protected title the client either **proxies** its CDM challenge through
+123 -21
View File
@@ -1,5 +1,7 @@
import asyncio import asyncio
import base64
import enum import enum
import json
import logging import logging
import re import re
from datetime import date as date_ 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 cover_url = _data.get("cover_url") if isinstance(_data, dict) else None
is_episode = isinstance(title, Episode) is_episode = isinstance(title, Episode)
is_song = isinstance(title, Song)
episode_part = title.part if isinstance(title, Episode) else None episode_part = title.part if isinstance(title, Episode) else None
if is_episode: if is_episode:
# no part suffix here: remote_service.build_title rebuilds the Episode from this dict, so a # 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) name = str(title.name) if hasattr(title, "name") else str(title)
result = { 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, "name": name,
"id": str(title.id) if hasattr(title, "id") else None, "id": str(title.id) if hasattr(title, "id") else None,
"language": title_language, "language": title_language,
@@ -408,8 +411,8 @@ def serialize_title(title: Title_T) -> Dict[str, Any]:
"date": date, "date": date,
"cover_url": cover_url, "cover_url": cover_url,
} }
# "other" titles carry no year; only Episode/Movie do. # "other" titles carry no year; only Episode/Movie/Song do.
if isinstance(title, (Episode, Movie)): if isinstance(title, (Episode, Movie, Song)):
result["year"] = title.year result["year"] = title.year
if isinstance(title, Episode): if isinstance(title, Episode):
result["series_title"] = str(title.title) result["series_title"] = str(title.title)
@@ -426,6 +429,26 @@ def serialize_title(title: Title_T) -> Dict[str, Any]:
result["absolute"] = title.absolute result["absolute"] = title.absolute
if getattr(title, "daily", None) is not None: if getattr(title, "daily", None) is not None:
result["daily"] = title.daily 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: if isinstance(title, (Episode, Movie)) and getattr(title, "anime", None) is not None:
result["anime"] = title.anime result["anime"] = title.anime
@@ -510,9 +533,11 @@ def serialize_drm(drm_list) -> Optional[List[Dict[str, Any]]]:
drm_class = drm.__class__.__name__ drm_class = drm.__class__.__name__
drm_info["type"] = drm_class.lower() drm_info["type"] = drm_class.lower()
# PSSH: pywidevine exposes dumps(); pyplayready's PSSH has no base64 method # pywidevine exposes dumps(); pyplayready's PSSH does not, so PlayReady falls back
# here, so PlayReady omits the field (unchanged from prior behaviour). # 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_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"): if pssh_obj is not None and hasattr(pssh_obj, "dumps"):
try: try:
drm_info["pssh"] = pssh_obj.dumps() drm_info["pssh"] = pssh_obj.dumps()
@@ -523,6 +548,8 @@ def serialize_drm(drm_list) -> Optional[List[Dict[str, Any]]]:
type(pssh_obj).__name__, type(pssh_obj).__name__,
exc_info=True, exc_info=True,
) )
if not drm_info.get("pssh") and pssh_b64:
drm_info["pssh"] = pssh_b64
if hasattr(drm, "kids") and drm.kids: if hasattr(drm, "kids") and drm.kids:
drm_info["kids"] = [str(kid) for kid in 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) 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]: def serialize_video_track(track: Video, include_url: bool = False) -> Dict[str, Any]:
"""Convert video track to JSON-serializable dict.""" """Convert video track to JSON-serializable dict."""
codec_name = enum_name(track.codec) 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": codec_name,
"codec_display": VIDEO_CODEC_MAP.get(codec_name, codec_name), "codec_display": VIDEO_CODEC_MAP.get(codec_name, codec_name),
"bitrate": int(track.bitrate / 1000) if track.bitrate else None, "bitrate": int(track.bitrate / 1000) if track.bitrate else None,
"bitrate_bps": track.bitrate or None,
"width": track.width, "width": track.width,
"height": track.height, "height": track.height,
"resolution": f"{track.width}x{track.height}" if track.width and track.height else None, "resolution": f"{track.width}x{track.height}" if track.width and track.height else None,
"fps": track.fps if track.fps else None, "fps": track.fps if track.fps else None,
"range": range_name, "range": range_name,
"range_display": DYNAMIC_RANGE_MAP.get(range_name, range_name), "range_display": DYNAMIC_RANGE_MAP.get(range_name, range_name),
"language": str(track.language) if track.language else None, "scan_type": enum_name(track.scan_type) if track.scan_type else None,
"drm": serialize_drm(track.drm) if hasattr(track, "drm") and track.drm else None, "closed_captions": list(getattr(track, "closed_captions", None) or []),
"drm_preference": getattr(track, "drm_preference", None), "dv_compatible_bitstream": bool(getattr(track, "dv_compatible_bitstream", False)),
"descriptor": descriptor_name(track),
} }
result.update(serialize_track_common(track))
if include_url and hasattr(track, "url") and track.url: if include_url and hasattr(track, "url") and track.url:
result["url"] = str(track.url) result["url"] = str(track.url)
return result return result
@@ -618,15 +686,14 @@ def serialize_audio_track(track: Audio, include_url: bool = False, is_original:
"codec": codec_name, "codec": codec_name,
"codec_display": AUDIO_CODEC_MAP.get(codec_name, codec_name), "codec_display": AUDIO_CODEC_MAP.get(codec_name, codec_name),
"bitrate": int(track.bitrate / 1000) if track.bitrate else None, "bitrate": int(track.bitrate / 1000) if track.bitrate else None,
"bitrate_bps": track.bitrate or None,
"channels": track.channels if track.channels else None, "channels": track.channels if track.channels else None,
"language": str(track.language) if track.language else None,
"is_original": is_original, "is_original": is_original,
"atmos": track.atmos if hasattr(track, "atmos") else False, "atmos": track.atmos if hasattr(track, "atmos") else False,
"joc": getattr(track, "joc", 0),
"descriptive": track.descriptive if hasattr(track, "descriptive") else False, "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: if include_url and hasattr(track, "url") and track.url:
result["url"] = str(track.url) result["url"] = str(track.url)
return result return result
@@ -637,17 +704,56 @@ def serialize_subtitle_track(track: Subtitle, include_url: bool = False) -> Dict
result = { result = {
"id": str(track.id), "id": str(track.id),
"codec": enum_name(track.codec), "codec": enum_name(track.codec),
"language": str(track.language) if track.language else None,
"forced": track.forced if hasattr(track, "forced") else False, "forced": track.forced if hasattr(track, "forced") else False,
"sdh": track.sdh if hasattr(track, "sdh") else False, "sdh": track.sdh if hasattr(track, "sdh") else False,
"cc": track.cc if hasattr(track, "cc") 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: if include_url and hasattr(track, "url") and track.url:
result["url"] = str(track.url) result["url"] = str(track.url)
return result 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: async def search_handler(data: Dict[str, Any], request: Optional[web.Request] = None) -> web.Response:
"""Answer the request to find titles.""" """Answer the request to find titles."""
from unshackle.commands.dl import dl from unshackle.commands.dl import dl
@@ -2136,7 +2242,7 @@ async def session_tracks_handler(
return web.json_response( 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], "video": [serialize_video_track(t, include_url=True) for t in video_tracks],
"audio": [ "audio": [
serialize_audio_track(t, include_url=True, is_original=t.id in original_ids) for t in audio_tracks 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} {"timestamp": ch.timestamp, "name": ch.name}
for ch in session.chapters_by_title.get(str(title_id), []) for ch in session.chapters_by_title.get(str(title_id), [])
], ],
"attachments": [ "attachments": [a for a in map(serialize_attachment, tracks.attachments) if a],
{"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
],
"manifests": manifests, "manifests": manifests,
"session_headers": session_headers, "session_headers": session_headers,
"session_cookies": session_cookies, "session_cookies": session_cookies,
+206 -43
View File
@@ -9,9 +9,12 @@ from __future__ import annotations
import base64 import base64
import logging import logging
import re
import time import time
from datetime import date as date_
from enum import Enum from enum import Enum
from http.cookiejar import CookieJar from http.cookiejar import CookieJar
from pathlib import Path
from typing import Any, Dict, Optional, Union from typing import Any, Dict, Optional, Union
import click 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 import Title_T, Titles_T, remap_titles
from unshackle.core.titles.episode import Episode, Series from unshackle.core.titles.episode import Episode, Series
from unshackle.core.titles.movie import Movie, Movies 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 import Audio, Chapter, Chapters, Subtitle, Tracks, Video
from unshackle.core.tracks.attachment import Attachment from unshackle.core.tracks.attachment import Attachment
from unshackle.core.tracks.track import Track 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 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: def deserialize_video(data: Dict[str, Any]) -> Video:
v = Video( return Video(
url=data.get("url") or "https://placeholder", **base_track_kwargs(data),
language=Language.get(data.get("language") or "und"),
descriptor=enum_get(Track.Descriptor, data.get("descriptor"), Track.Descriptor.URL),
codec=enum_get(Video.Codec, data.get("codec")), codec=enum_get(Video.Codec, data.get("codec")),
range_=enum_get(Video.Range, data.get("range"), Video.Range.SDR), 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, width=data.get("width") or 0,
height=data.get("height") or 0, height=data.get("height") or 0,
fps=data.get("fps"), 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: def deserialize_audio(data: Dict[str, Any]) -> Audio:
a = Audio( joc = data.get("joc")
url=data.get("url") or "https://placeholder", return Audio(
language=Language.get(data.get("language") or "und"), **base_track_kwargs(data),
descriptor=enum_get(Track.Descriptor, data.get("descriptor"), Track.Descriptor.URL),
codec=enum_get(Audio.Codec, data.get("codec")), 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"), 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), descriptive=data.get("descriptive", False),
id_=data.get("id"),
) )
return a
def deserialize_subtitle(data: Dict[str, Any]) -> Subtitle: def deserialize_subtitle(data: Dict[str, Any]) -> Subtitle:
return Subtitle( return Subtitle(
url=data.get("url") or "https://placeholder", **base_track_kwargs(data),
language=Language.get(data.get("language") or "und"),
descriptor=enum_get(Track.Descriptor, data.get("descriptor"), Track.Descriptor.URL),
codec=enum_get(Subtitle.Codec, data.get("codec")), codec=enum_get(Subtitle.Codec, data.get("codec")),
cc=data.get("cc", False), cc=data.get("cc", False),
sdh=data.get("sdh", False), sdh=data.get("sdh", False),
forced=data.get("forced", False), forced=data.get("forced", False),
id_=data.get("id"),
) )
@@ -199,6 +222,30 @@ def reconstruct_drm(drm_list: Optional[list]) -> list:
return result 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: def build_tracks(data: Dict[str, Any]) -> Tracks:
tracks = Tracks() tracks = Tracks()
tracks.videos = [deserialize_video(v) for v in data.get("video", [])] 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 [ for track_data, track_obj in [
*zip(data.get("video", []), tracks.videos), *zip(data.get("video", []), tracks.videos),
*zip(data.get("audio", []), tracks.audio), *zip(data.get("audio", []), tracks.audio),
*zip(data.get("subtitles", []), tracks.subtitles),
]: ]:
drm_objs = reconstruct_drm(track_data.get("drm")) drm_objs = reconstruct_drm(track_data.get("drm"))
if drm_objs: if drm_objs:
@@ -218,13 +266,25 @@ def build_tracks(data: Dict[str, Any]) -> Tracks:
track_obj.drm_preference = preference track_obj.drm_preference = preference
except ValueError: except ValueError:
log.warning(f"Ignoring unknown drm_preference {preference!r} from the server for track {track_obj.id}") log.warning(f"Ignoring unknown drm_preference {preference!r} from the server for track {track_obj.id}")
tracks.attachments = [ tracks.attachments = [a for a in map(build_attachment, data.get("attachments", [])) if a]
Attachment(url=a["url"], name=a.get("name"), mime_type=a.get("mime_type"), description=a.get("description"))
for a in data.get("attachments", [])
]
return tracks 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: def resolve_manifest_data(tracks: Tracks, manifests: list) -> None:
"""Re-parse serialized manifests and fill track.data for downloading. """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) 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]: 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.""" """Find the locally-parsed track that matches a remote track by ID or by attributes."""
remote_id = str(remote_track.id) 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: if lt.width == remote_track.width and lt.height == remote_track.height:
return lt return lt
elif hasattr(lt, "channels") and hasattr(remote_track, "channels"): 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 return lt
elif hasattr(lt, "forced"): elif hasattr(lt, "forced"):
if lt.forced == remote_track.forced and lt.sdh == remote_track.sdh: 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 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, (), {}) svc_class = type(service_tag, (), {})
lang = Language.get(info["language"]) if info.get("language") else None lang = Language.get(info["language"]) if info.get("language") else None
title: Union[Episode, Movie] title_type = info.get("type")
if info.get("type") == "episode": title: Title_T
if title_type == "episode":
title = Episode( title = Episode(
id_=info.get("id", fallback_id), id_=info.get("id", fallback_id),
service=svc_class, service=svc_class,
title=info.get("series_title", "Unknown"), title=info.get("series_title", "Unknown"),
season=info.get("season", 0), season=info.get("season", 0),
number=info.get("number", 0), number=info.get("number", 0),
name=info.get("name"),
year=info.get("year"),
language=lang, language=lang,
air_date=info.get("air_date"),
part=info.get("part"),
absolute=info.get("absolute"),
) )
if "daily" in info: elif title_type == "movie":
title.daily = info["daily"]
else:
title = Movie( title = Movie(
id_=info.get("id", fallback_id), id_=info.get("id", fallback_id),
service=svc_class, service=svc_class,
name=info.get("name", "Unknown"), name=info.get("name") or "Unknown",
year=info.get("year"),
language=lang, language=lang,
) )
# the synthetic service class carries no ANIME/DAILY, so the flags only survive per title elif title_type == "song":
if "anime" in info: title = Song(
title.anime = info["anime"] 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 return title
@@ -645,9 +799,13 @@ class RemoteService:
return self._titles return self._titles
result = self.client.get(f"/api/session/{self._session_id}/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", [])] titles_list = [build_title(t, self.service_tag, self.title_id) for t in result.get("titles", [])]
self._titles = ( first = titles_list[0] if titles_list else None
Series(titles_list) if titles_list and isinstance(titles_list[0], Episode) else Movies(titles_list) 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 return self._titles
def get_titles_cached(self, title_id: str = None) -> Titles_T: 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}) result = self.client.post(f"/api/session/{self._session_id}/tracks", {"title_id": title_id})
tracks = build_tracks(result) 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(): for k, v in result.get("session_headers", {}).items():
if k.lower() not in ("host", "content-length", "content-type"): if k.lower() not in ("host", "content-length", "content-type"):
self._session.headers[k] = v self._session.headers[k] = v
@@ -673,6 +835,7 @@ class RemoteService:
self._session.cookies.set(k, v) self._session.cookies.set(k, v)
resolve_manifest_data(tracks, result.get("manifests", [])) resolve_manifest_data(tracks, result.get("manifests", []))
apply_service_track_data(tracks, result)
if self._server_cdm and not result.get("server_cdm", True): if self._server_cdm and not result.get("server_cdm", True):
self._server_cdm = False self._server_cdm = False