mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-11-12 20:15:10 +00:00
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:
@@ -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. |
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user