From e6a627118fc93c658720cbf2776592ef9ca82351 Mon Sep 17 00:00:00 2001 From: imSp4rky Date: Fri, 30 Oct 2026 16:17:36 -0600 Subject: [PATCH] feat(tracks): add a display-only note shown in --list and over --remote --- docs/dev/rest-api/endpoints.md | 8 ++++---- .../unit/test_remote_service_helpers.py | 20 ++++++++++++++++++- unshackle/core/api/handlers.py | 1 + unshackle/core/remote_service.py | 1 + unshackle/core/tracks/audio.py | 1 + unshackle/core/tracks/subtitle.py | 8 +++++++- unshackle/core/tracks/track.py | 6 ++++++ unshackle/core/tracks/video.py | 1 + 8 files changed, 40 insertions(+), 6 deletions(-) diff --git a/docs/dev/rest-api/endpoints.md b/docs/dev/rest-api/endpoints.md index 3d8f2dad..30e3e4d7 100644 --- a/docs/dev/rest-api/endpoints.md +++ b/docs/dev/rest-api/endpoints.md @@ -340,7 +340,7 @@ When you give both `season` and `episode`, the server combines them into a `"{se "range_display": "HDR", "scan_type": null, "closed_captions": [], "dv_compatible_bitstream": false, "hdr10plus": false, "hdr10_base": false, "language": "en", "is_original_lang": true, - "name": null, "track_name": "English", "needs_repack": false, "edition": [], "data": null, + "name": null, "track_name": "English", "needs_repack": false, "edition": [], "note": null, "data": null, "drm": [], "descriptor": "DASH" } ], @@ -350,13 +350,13 @@ When you give both `season` and `episode`, the server combines them into a `"{se "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, "track_name": "English", "needs_repack": false, "edition": [], - "data": null, "drm": [], "descriptor": "DASH" + "note": null, "data": null, "drm": [], "descriptor": "DASH" } ], "subtitles": [ { "id": "s-1", "codec": "vtt", "language": "en", "forced": false, "sdh": false, "cc": false, "is_original_lang": false, - "name": null, "track_name": "English", "needs_repack": false, "edition": [], "data": null, + "name": null, "track_name": "English", "needs_repack": false, "edition": [], "note": null, "data": null, "drm": [], "descriptor": "DASH" } ] } @@ -384,7 +384,7 @@ 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`. `name` is the name the service set, or null. `track_name` is the name `--list` shows and the mux writes to an audio or subtitle track: `name` when the service set one, else the name of the language (English, or in the language itself when the server sets `muxing.track_names: native`, or no name when it sets `none`), then the server's `muxing.original_label` on an original-language track, then the SDH, CC, Forced or Descriptive label. `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. +Every track carries the same fields as a local track: `is_original_lang`, `name`, `needs_repack`, `edition`, `note`, and `data`. `name` is the name the service set, or null. `note` is display text the service set, such as an encoding profile, or null: `--list` shows it, but the filename and the mux do not use it. `track_name` is the name `--list` shows and the mux writes to an audio or subtitle track: `name` when the service set one, else the name of the language (English, or in the language itself when the server sets `muxing.track_names: native`, or no name when it sets `none`), then the server's `muxing.original_label` on an original-language track, then the SDH, CC, Forced or Descriptive label. `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 | | --- | --- | --- | diff --git a/tests/remote/unit/test_remote_service_helpers.py b/tests/remote/unit/test_remote_service_helpers.py index a18d9a22..c8d176b8 100644 --- a/tests/remote/unit/test_remote_service_helpers.py +++ b/tests/remote/unit/test_remote_service_helpers.py @@ -6,7 +6,7 @@ from enum import Enum import pytest -from unshackle.core.api.handlers import serialize_video_track +from unshackle.core.api.handlers import serialize_audio_track, serialize_subtitle_track, serialize_video_track from unshackle.core.remote_service import ( RemoteService, build_title, @@ -58,6 +58,24 @@ def test_a_dv_track_keeps_its_hdr10_base_label_over_remote() -> None: assert deserialize_video(serialize_video_track(v)).range_label == "DV+HDR10" +def test_a_track_note_shows_over_remote_and_export_and_is_not_an_edition() -> None: + from unshackle.core.tracks.track import Track + + v = Video(url="https://x/v.mp4", language="ja", codec=Video.Codec.AVC, note="h264mpl40-dash") + a = Audio(url="https://x/a.mp4", language="ja", codec=Audio.Codec.AAC, note="heaac-2-dash") + s = Subtitle(url="https://x/s.vtt", language="en", codec=Subtitle.Codec.WebVTT, note="webvtt-lssdh") + copies = [deserialize_video(serialize_video_track(v)), deserialize_audio(serialize_audio_track(a))] + copies += [deserialize_subtitle(serialize_subtitle_track(s))] + copies += [Track.from_dict(t.to_dict()) for t in (v, a, s)] + for copy in copies: + assert copy.note and str(copy).endswith(f"| {copy.note}") + assert copy.edition == [] + + +def test_a_note_that_is_not_text_is_dropped_not_fatal() -> None: + assert deserialize_video({"id": "v1", "codec": "AVC", "width": 1, "height": 1, "note": {"x": 1}}).note is None + + def test_deserialize_video_unknown_codec_falls_back_to_none() -> None: v = deserialize_video({"id": "v2", "codec": "MADE_UP", "width": 0, "height": 0}) assert v.codec is None diff --git a/unshackle/core/api/handlers.py b/unshackle/core/api/handlers.py index bbbaac52..b80c3984 100644 --- a/unshackle/core/api/handlers.py +++ b/unshackle/core/api/handlers.py @@ -1722,6 +1722,7 @@ def serialize_track_common(track: Any) -> Dict[str, Any]: "track_name": track.get_track_name(), "needs_repack": bool(getattr(track, "needs_repack", False)), "edition": list(getattr(track, "edition", None) or []), + "note": getattr(track, "note", None), "descriptor": descriptor_name(track), "drm": serialize_drm(track.drm) if getattr(track, "drm", None) else None, "drm_preference": getattr(track, "drm_preference", None), diff --git a/unshackle/core/remote_service.py b/unshackle/core/remote_service.py index 46189c19..7c317864 100644 --- a/unshackle/core/remote_service.py +++ b/unshackle/core/remote_service.py @@ -288,6 +288,7 @@ def base_track_kwargs(data: Dict[str, Any]) -> Dict[str, Any]: "needs_repack": bool(data.get("needs_repack", False)), "name": data.get("name"), "edition": data.get("edition") or None, + "note": data["note"] if isinstance(data.get("note"), str) else None, "id_": data.get("id"), } diff --git a/unshackle/core/tracks/audio.py b/unshackle/core/tracks/audio.py index 5b152bdc..78348569 100644 --- a/unshackle/core/tracks/audio.py +++ b/unshackle/core/tracks/audio.py @@ -214,6 +214,7 @@ class Audio(Track): f"{self.bitrate // 1000} kb/s" if self.bitrate else None, self.get_track_name(), ", ".join(self.edition) if self.edition else None, + self.note, ], ) ) diff --git a/unshackle/core/tracks/subtitle.py b/unshackle/core/tracks/subtitle.py index b27e1992..9cfde2e5 100644 --- a/unshackle/core/tracks/subtitle.py +++ b/unshackle/core/tracks/subtitle.py @@ -231,7 +231,13 @@ class Subtitle(Track): return " | ".join( filter( bool, - ["SUB", f"[{self.codec.value}]" if self.codec else None, str(self.language), self.get_track_name()], + [ + "SUB", + f"[{self.codec.value}]" if self.codec else None, + str(self.language), + self.get_track_name(), + self.note, + ], ) ) diff --git a/unshackle/core/tracks/track.py b/unshackle/core/tracks/track.py index b4b7b8a1..3fd6536c 100644 --- a/unshackle/core/tracks/track.py +++ b/unshackle/core/tracks/track.py @@ -427,6 +427,7 @@ class Track: name: Optional[str] = None, drm: Optional[Iterable[DRM_T]] = None, edition: Optional[str] = None, + note: Optional[str] = None, session: Optional[Union[Session, "RnetSession"]] = None, downloader: Optional[Callable] = None, downloader_args: Optional[dict] = None, @@ -451,6 +452,8 @@ class Track: raise TypeError(f"Expected id_ to be a {str}, not {type(id_)}") if not isinstance(edition, (str, list, type(None))): raise TypeError(f"Expected edition to be a {str}, {list}, or None, not {type(edition)}") + if not isinstance(note, (str, type(None))): + raise TypeError(f"Expected note to be a {str}, not {type(note)}") if not isinstance(downloader, (Callable, type(None))): raise TypeError(f"Expected downloader to be a {Callable}, not {type(downloader)}") if not isinstance(downloader_args, (dict, type(None))): @@ -483,6 +486,7 @@ class Track: self.drm = drm self._drm_preference: Optional[str] = None self.edition: list[str] = [edition] if isinstance(edition, str) else (edition or []) + self.note = note self.session = session self.downloader = downloader self.downloader_args = downloader_args @@ -981,6 +985,7 @@ class Track: "needs_repack": self.needs_repack, "name": self.name, "edition": self.edition, + "note": self.note, } return data @@ -999,6 +1004,7 @@ class Track: "needs_repack": data.get("needs_repack", False), "name": data.get("name"), "edition": data.get("edition") or None, + "note": data.get("note"), "id_": data.get("id"), } diff --git a/unshackle/core/tracks/video.py b/unshackle/core/tracks/video.py index 022b0eac..1b9441de 100644 --- a/unshackle/core/tracks/video.py +++ b/unshackle/core/tracks/video.py @@ -412,6 +412,7 @@ class Video(Track): ) ), ", ".join(self.edition) if self.edition else None, + self.note, ], ) )