feat(import): reconstruct downloads from an --export sidecar

Add a shareable --export schema and a new `unshackle import` command that rebuilds tracks from the export, injects the licensed content keys, and runs the normal download/decrypt/mux pipeline with no re-authentication or licensing.
This commit is contained in:
imSp4rky
2026-05-24 21:17:31 -06:00
parent 13f924f825
commit 7017bd0460
11 changed files with 629 additions and 61 deletions
+82 -59
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
import html
import json
import logging
import math
import os
@@ -22,7 +23,6 @@ from typing import Any, Callable, Optional
from uuid import UUID
import click
import jsonpickle
import yaml
from langcodes import Language
from pymediainfo import MediaInfo
@@ -56,29 +56,14 @@ from unshackle.core.tracks import Audio, Subtitle, Tracks, Video
from unshackle.core.tracks.attachment import Attachment
from unshackle.core.tracks.dv_fixup import apply_dv_fixup
from unshackle.core.tracks.hybrid import Hybrid
from unshackle.core.utilities import (
find_font_with_fallbacks,
find_missing_langs,
get_debug_logger,
get_system_fonts,
init_debug_logger,
is_close_match,
suggest_font_packages,
time_elapsed_since,
)
from unshackle.core.utilities import (find_font_with_fallbacks, find_missing_langs, get_debug_logger,
get_system_fonts, init_debug_logger, is_close_match, suggest_font_packages,
time_elapsed_since)
from unshackle.core.utils import tags
from unshackle.core.utils.bitrate import apply_real_bitrates
from unshackle.core.utils.click_types import (
AUDIO_CODEC_LIST,
LANGUAGE_RANGE,
QUALITY_LIST,
SEASON_RANGE,
SLOW_DELAY_RANGE,
ContextData,
MultipleChoice,
MultipleVideoCodecChoice,
SubtitleCodecChoice,
)
from unshackle.core.utils.click_types import (AUDIO_CODEC_LIST, LANGUAGE_RANGE, QUALITY_LIST, SEASON_RANGE,
SLOW_DELAY_RANGE, ContextData, MultipleChoice, MultipleVideoCodecChoice,
SubtitleCodecChoice)
from unshackle.core.utils.collections import merge_dict
from unshackle.core.utils.selector import select_multiple
from unshackle.core.utils.subprocess import ffprobe
@@ -523,6 +508,14 @@ class dl:
default=False,
help="Export track info and decryption keys to a JSON file in the exports directory.",
)
@click.option(
"--import",
"import_file",
type=str,
default=None,
hidden=True,
help="Internal: path to an export JSON to reconstruct a download from (used by 'unshackle import').",
)
@click.option(
"--cdm-only/--vaults-only",
is_flag=True,
@@ -658,6 +651,7 @@ class dl:
)
self.profile = profile
self.proxy_requested = bool(proxy)
self.tmdb_id = tmdb_id
self.imdb_id = imdb_id
self.enrich = enrich
@@ -1100,6 +1094,7 @@ class dl:
if export:
config.directories.exports.mkdir(parents=True, exist_ok=True)
export_path = config.directories.exports / f"export_{self.service}_{int(time.time())}.json"
self.export_service = service
else:
export_path = None
@@ -2761,52 +2756,80 @@ class dl:
console.print(Padding(f"Processed all titles in [progress.elapsed]{dl_time}", (0, 5, 1, 5)))
def _write_export(self, export: Path, title: Title_T, track: AnyTrack, drm: Any) -> None:
"""Write decryption keys and track info to the export JSON file."""
@staticmethod
def title_to_meta(title: Title_T) -> dict[str, Any]:
"""Capture the title fields needed to rebuild a Title for output naming on import."""
from unshackle.core.titles import Episode, Movie
meta: dict[str, Any] = {
"id": str(title.id),
"language": str(title.language) if getattr(title, "language", None) else None,
}
if isinstance(title, Episode):
meta.update(
type="episode",
series_title=title.title,
season=title.season,
number=title.number,
name=title.name,
year=title.year,
)
elif isinstance(title, Movie):
meta.update(type="movie", name=title.name, year=title.year)
else:
meta.update(type="movie", name=str(title))
return meta
def write_export(self, export: Path, title: Title_T, track: AnyTrack, drm: Any) -> None:
"""Write a shareable v2 export usable by ``unshackle import``.
Carries no session/cookies/dl-flags. Region (country code) is stored only when the
export used ``--proxy``, as an import geofence. Each track records only the licensed
DRM system; content keys live once under the track's ``keys``.
"""
with self.EXPORT_LOCK:
keys = {}
doc: dict[str, Any] = {}
if export.is_file():
keys = jsonpickle.loads(export.read_text(encoding="utf8")) or {}
if str(title) not in keys:
keys[str(title)] = {}
doc = json.loads(export.read_text(encoding="utf8")) or {}
title_data = keys[str(title)]
doc.setdefault("version", 2)
doc.setdefault("service", self.service)
if "region" not in doc and getattr(self, "proxy_requested", False):
region = getattr(getattr(self, "export_service", None), "current_region", None)
if region:
doc["region"] = region
if title.tracks.manifest_url and "manifest" not in title_data:
title_data["manifest"] = title.tracks.manifest_url
titles = doc.setdefault("titles", {})
tinfo = titles.setdefault(str(title.id), {})
tinfo.setdefault("meta", self.title_to_meta(title))
if isinstance(track, Video):
section = "video"
elif isinstance(track, Audio):
section = "audio"
else:
section = "other"
if title.tracks.manifest_url:
tinfo.setdefault("manifest_url", title.tracks.manifest_url)
if section not in title_data:
title_data[section] = {}
if str(track) not in title_data[section]:
title_data[section][str(track)] = {}
all_tracks = [*title.tracks.videos, *title.tracks.audio, *title.tracks.subtitles]
if "manifest_type" not in tinfo:
tinfo["manifest_type"] = next(
(t.descriptor.name for t in all_tracks if t.descriptor != Video.Descriptor.URL), None
)
track_data = title_data[section][str(track)]
track_data["url"] = track.url
track_data["descriptor"] = track.descriptor.name
tracks_map = tinfo.setdefault("tracks", {})
if not tracks_map:
for t in all_tracks:
tracks_map[str(t.id)] = t.to_dict()
if "keys" not in track_data:
track_data["keys"] = {}
track_data = tracks_map.setdefault(str(track.id), track.to_dict())
if hasattr(drm, "to_dict"):
track_data["drm"] = [drm.to_dict()]
keys = track_data.setdefault("keys", {})
for kid, key in drm.content_keys.items():
track_data["keys"][kid.hex] = key
keys[kid.hex] = key
if "subtitles" not in title_data:
subs = {}
for sub in title.tracks.subtitles:
subs[str(sub)] = {"url": sub.url}
if subs:
title_data["subtitles"] = subs
if "chapters" not in tinfo:
tinfo["chapters"] = [
{"timestamp": chapter.timestamp, "name": chapter.name} for chapter in (title.tracks.chapters or [])
]
section_order = ["manifest", "video", "audio", "subtitles", "other"]
keys[str(title)] = {k: title_data[k] for k in section_order if k in title_data}
export.write_text(jsonpickle.dumps(keys, indent=4), encoding="utf8")
export.write_text(json.dumps(doc, indent=4, ensure_ascii=False), encoding="utf8")
def prepare_drm(
self,
@@ -3088,7 +3111,7 @@ class dl:
table.add_row(cek_tree)
if export:
self._write_export(export, title, track, drm)
self.write_export(export, title, track, drm)
elif isinstance(drm, PlayReady):
if self.debug_logger:
@@ -3259,7 +3282,7 @@ class dl:
table.add_row(cek_tree)
if export:
self._write_export(export, title, track, drm)
self.write_export(export, title, track, drm)
elif isinstance(drm, MonaLisa):
with self.DRM_TABLE_LOCK:
+54
View File
@@ -0,0 +1,54 @@
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
import click
from unshackle.commands.dl import dl
from unshackle.core.constants import context_settings
class ImportCommand:
@staticmethod
@click.command(
name="import",
short_help="Reconstruct a download (download, decrypt, mux) from an --export JSON file.",
context_settings={**context_settings, "ignore_unknown_options": True},
)
@click.argument("export_file", type=Path)
@click.argument("dl_args", nargs=-1, type=click.UNPROCESSED)
@click.pass_context
def cli(ctx: click.Context, export_file: Path, dl_args: tuple[str, ...]) -> None:
"""
Reconstruct an exported download without re-contacting the service.
Re-fetches the manifest, injects the stored keys, then downloads/decrypts/muxes as a
normal `dl` run. Any `dl` options after the file are forwarded verbatim:
unshackle import export.json -r HDR10 --proxy US
"""
if not export_file.is_file():
raise click.ClickException(f"Export file not found: {export_file}")
try:
data: dict[str, Any] = json.loads(export_file.read_text(encoding="utf8"))
except json.JSONDecodeError as e:
raise click.ClickException(f"Export file is not valid JSON: {e}")
if data.get("version") != 2:
raise click.ClickException(
f"Unsupported export version {data.get('version')!r}. "
"Re-create the export with a current build of unshackle."
)
service_tag = data.get("service")
if not service_tag:
raise click.ClickException("Export file is missing the 'service' tag.")
args = [*dl_args, "--import", str(export_file), service_tag]
dl.cli.main(args=args, prog_name="unshackle dl", standalone_mode=False)
globals()["import"] = ImportCommand
+35 -2
View File
@@ -1,4 +1,6 @@
from typing import Union
import base64
from typing import Any, Union
from uuid import UUID
from unshackle.core.drm.clearkey import ClearKey
from unshackle.core.drm.monalisa import MonaLisa
@@ -8,4 +10,35 @@ from unshackle.core.drm.widevine import Widevine
DRM_T = Union[ClearKey, Widevine, PlayReady, MonaLisa]
__all__ = ("ClearKey", "Widevine", "PlayReady", "MonaLisa", "DRM_T")
def drm_from_dict(data: dict[str, Any]) -> Union[Widevine, PlayReady]:
"""Reconstruct a Widevine/PlayReady DRM instance from its ``to_dict()`` form.
Rebuilds the PSSH from the stored base64 and re-injects any saved content keys
so the resulting object can decrypt without contacting a license server.
"""
system = data.get("system")
pssh_b64 = data.get("pssh_b64")
kids = data.get("kids") or []
content_keys = data.get("content_keys") or {}
if not pssh_b64:
raise ValueError("Cannot reconstruct DRM without a stored PSSH.")
if system == "PlayReady":
from pyplayready.system.pssh import PSSH as PlayReadyPSSH
drm: Union[Widevine, PlayReady] = PlayReady(pssh=PlayReadyPSSH(base64.b64decode(pssh_b64)), pssh_b64=pssh_b64)
elif system == "Widevine":
from pywidevine.pssh import PSSH as WidevinePSSH
drm = Widevine(pssh=WidevinePSSH(pssh_b64), kid=kids[0] if kids else None)
else:
raise ValueError(f"Unsupported DRM system for reconstruction: {system!r}")
for kid_hex, key in content_keys.items():
drm.content_keys[UUID(hex=kid_hex)] = key
return drm
__all__ = ("ClearKey", "Widevine", "PlayReady", "MonaLisa", "DRM_T", "drm_from_dict")
+11
View File
@@ -247,6 +247,17 @@ class PlayReady:
def kid(self) -> Optional[UUID]:
return next(iter(self.kids), None)
def to_dict(self) -> dict[str, Any]:
"""Serialise this DRM instance for export/import (PSSH + KIDs).
Content keys are stored once at the export's track level, not duplicated here.
"""
return {
"system": "PlayReady",
"pssh_b64": self.pssh_b64,
"kids": [kid.hex for kid in self.kids],
}
@property
def kids(self) -> list[UUID]:
return self._kids
+11
View File
@@ -180,6 +180,17 @@ class Widevine:
return [self._kid]
return []
def to_dict(self) -> dict[str, Any]:
"""Serialise this DRM instance for export/import (PSSH + KIDs).
Content keys are stored once at the export's track level, not duplicated here.
"""
return {
"system": "Widevine",
"pssh_b64": self.pssh.dumps(),
"kids": [kid.hex for kid in self.kids],
}
def get_content_keys(self, cdm: WidevineCdm, certificate: Callable, licence: Callable) -> None:
"""
Create a CDM Session and obtain Content Keys for this DRM Instance.
+284
View File
@@ -0,0 +1,284 @@
from __future__ import annotations
import json
import logging
from http.cookiejar import CookieJar
from pathlib import Path
from typing import Any, Optional, Union
from uuid import UUID
import click
import requests
from unshackle.core.config import config
from unshackle.core.constants import AnyTrack
from unshackle.core.credential import Credential
from unshackle.core.drm import drm_from_dict
from unshackle.core.manifests import DASH, HLS, ISM
from unshackle.core.remote_service import RemoteService, _build_title, _resolve_proxy
from unshackle.core.titles import Episode, Movies, Series, Title_T, Titles_T
from unshackle.core.tracks import Audio, Chapter, Chapters, Tracks, Video
from unshackle.core.tracks.track import Track
log = logging.getLogger("import")
PARSERS = {"DASH": DASH, "HLS": HLS, "ISM": ISM}
class ImportService:
"""Reconstructs a download from an export JSON.
Auth and licensing are skipped; tracks are rebuilt from the export and keys injected
directly. ``_server_cdm``/``_server_cdm_type`` keep their underscores: dl.py reads them
via getattr as the server-CDM contract that skips client licensing.
"""
ALIASES: tuple[str, ...] = ()
GEOFENCE: tuple[str, ...] = ()
NO_SUBTITLES: bool = False
def __init__(self, ctx: click.Context, service_tag: str, title: str, import_file: Optional[str]) -> None:
self.__class__.__name__ = service_tag
self.service_tag = service_tag
self.title_id = title
self.ctx = ctx
self.log = logging.getLogger(service_tag)
self.credential: Optional[Credential] = None
self.current_region: Optional[str] = None
self.title_cache = None
if not import_file:
raise click.ClickException("No export file was provided to import from.")
export_path = Path(import_file)
if not export_path.is_file():
raise click.ClickException(f"Export file not found: {export_path}")
self.data: dict[str, Any] = json.loads(export_path.read_text(encoding="utf8"))
version = self.data.get("version")
if version != 2:
raise click.ClickException(
f"Unsupported export version {version!r}. Re-create the export with a current build."
)
self.titles_data: dict[str, Any] = self.data.get("titles", {})
self.region: Optional[str] = self.data.get("region")
self.titles: Optional[Titles_T] = None
self.tracks_by_title: dict[str, Tracks] = {}
self._server_cdm = True
self._server_cdm_type = "widevine"
self.session = self.build_session(ctx, self.region)
@staticmethod
def build_session(ctx: click.Context, region: Optional[str] = None) -> requests.Session:
"""Session for re-fetching the manifest.
Honours the importer's ``--proxy``; otherwise falls back to the export region as a
geofence. An explicit proxy that fails to resolve raises; a region fallback warns.
"""
session = requests.Session()
session.headers.update(config.headers)
params = ctx.parent.params if ctx.parent else {}
if params.get("no_proxy"):
return session
explicit = params.get("proxy")
proxy_query = explicit or region
if not proxy_query:
return session
try:
proxy = _resolve_proxy(proxy_query)
except Exception as e:
if explicit:
raise click.ClickException(f"Failed to resolve proxy '{proxy_query}': {e}")
log.warning(f"Could not auto-select a proxy for export region '{region}': {e}. Continuing without proxy.")
proxy = None
if proxy:
session.proxies.update({"all": proxy})
if not explicit:
log.info(f"No --proxy given; using export region '{region}' via your proxy provider.")
return session
@property
def title(self) -> str:
return self.title_id
def authenticate(self, cookies: Optional[CookieJar] = None, credential: Optional[Credential] = None) -> None:
self.credential = credential
def get_titles(self) -> Titles_T:
if self.titles is not None:
return self.titles
titles_list = [
_build_title(entry.get("meta", {}), self.service_tag, fallback_id=title_id)
for title_id, entry in self.titles_data.items()
]
self.titles = (
Series(titles_list) if titles_list and isinstance(titles_list[0], Episode) else Movies(titles_list)
)
return self.titles
def get_titles_cached(self, title_id: Optional[str] = None) -> Titles_T:
return self.get_titles()
def get_tracks(self, title: Title_T) -> Tracks:
"""Reconstruct the title's tracks from the export.
DASH/ISM: re-fetch and re-parse the manifest and return the full ladder (the importer
picks quality with normal dl flags; keys are injected by KID later). HLS/URL: rebuild
from the stored per-track dicts, since the variant is re-fetched from track.url at
download time and ATV-style master playlists carry unstable per-fetch tokens.
"""
title_id = str(title.id)
if title_id in self.tracks_by_title:
return self.tracks_by_title[title_id]
entry = self.titles_data.get(title_id, {})
tracks_map: dict[str, Any] = entry.get("tracks") or {}
manifest_url = entry.get("manifest_url")
manifest_type = entry.get("manifest_type")
tracks = Tracks()
tracks.manifest_url = manifest_url
parser = PARSERS.get(manifest_type or "")
if manifest_url and parser is not None and manifest_type in ("DASH", "ISM"):
try:
parsed = parser.from_url(url=manifest_url, session=self.session).to_tracks(language=title.language)
except Exception as e:
raise click.ClickException(
f"Failed to re-fetch/parse the {manifest_type} manifest for '{title}'. "
f"The manifest URL may have expired since export. ({e})"
)
for track in parsed:
tracks.add(track)
else:
for track_dict in tracks_map.values():
track = Track.from_dict(track_dict)
drm = self.rebuild_drm(track_dict)
if drm:
track.drm = drm
tracks.add(track)
self.tracks_by_title[title_id] = tracks
return tracks
def key_pool(self) -> dict[UUID, str]:
"""All exported KID:KEY pairs across every title, as {UUID: key_hex}."""
pool: dict[UUID, str] = {}
for entry in self.titles_data.values():
for track_dict in (entry.get("tracks") or {}).values():
for kid_hex, key in (track_dict.get("keys") or {}).items():
pool[UUID(hex=kid_hex)] = key
return pool
def rebuild_drm(self, track_dict: dict[str, Any]) -> Optional[list[Any]]:
"""Rebuild a DRM object (from stored PSSH, falling back to a stub) with the exported keys."""
keys = track_dict.get("keys") or {}
drm_dicts = track_dict.get("drm") or []
if not drm_dicts and not keys:
return None
drm_obj = None
if drm_dicts:
try:
drm_obj = drm_from_dict(drm_dicts[0])
except Exception as e:
self.log.debug(f"Falling back to DRM stub (PSSH rebuild failed: {e})")
if drm_obj is None and keys:
drm_type = (drm_dicts[0].get("system", "Widevine").lower()) if drm_dicts else "widevine"
drm_obj = RemoteService._create_drm_stub(drm_type, list(keys.keys()))
if drm_obj is None:
return None
for kid_hex, key in keys.items():
drm_obj.content_keys[UUID(hex=kid_hex)] = key
return [drm_obj]
def resolve_server_keys(self, title: Title_T) -> None:
"""Inject exported keys into the selected encrypted tracks by KID (no network).
Called by dl.py after selection. Only encrypted video/audio are touched; encrypted
DASH tracks (no DRM at parse time) get a stub holding the keys, which
DASH.download_track preserves. decrypt() applies the key whose KID matches the media.
"""
pool = self.key_pool()
if not pool:
return
system = self.exported_drm_system()
kid_hexes = [kid.hex for kid in pool]
for track in title.tracks:
if not isinstance(track, (Video, Audio)) or not self.track_is_encrypted(track):
continue
drm_obj = track.drm[0] if track.drm else RemoteService._create_drm_stub(system, kid_hexes)
for kid, key in pool.items():
drm_obj.content_keys[kid] = key
track.drm = [drm_obj]
self._server_cdm_type = drm_obj.__class__.__name__.lower()
@staticmethod
def track_is_encrypted(track: Any) -> bool:
"""True if the track carries DRM or its DASH manifest declares ContentProtection."""
if track.drm:
return True
dash = track.data.get("dash") if getattr(track, "data", None) else None
if dash:
for element in (dash.get("representation"), dash.get("adaptation_set")):
if element is not None and element.findall("ContentProtection"):
return True
return False
def exported_drm_system(self) -> str:
"""The DRM system the exporter licensed (e.g. 'playready'), defaulting to widevine."""
for entry in self.titles_data.values():
for track_dict in (entry.get("tracks") or {}).values():
for drm_dict in track_dict.get("drm") or []:
if drm_dict.get("system"):
return drm_dict["system"].lower()
return "widevine"
def get_chapters(self, title: Title_T) -> Chapters:
entry = self.titles_data.get(str(title.id), {})
return Chapters(
[Chapter(ch["timestamp"], ch.get("name")) for ch in (entry.get("chapters") or []) if ch.get("timestamp")]
)
def get_widevine_service_certificate(self, **_: Any) -> Optional[str]:
return None
def get_widevine_license(self, *, challenge: bytes, title: Title_T, track: AnyTrack) -> Optional[Union[bytes, str]]:
raise RuntimeError("ImportService should not request a license; keys come from the export.")
def get_playready_license(
self, *, challenge: bytes, title: Title_T, track: AnyTrack
) -> Optional[Union[bytes, str]]:
raise RuntimeError("ImportService should not request a license; keys come from the export.")
def on_segment_downloaded(self, track: AnyTrack, segment: Any) -> None:
pass
def on_track_downloaded(self, track: AnyTrack) -> None:
pass
def on_track_decrypted(self, track: AnyTrack, drm: Any, segment: Any = None) -> None:
pass
def on_track_repacked(self, track: AnyTrack) -> None:
pass
def on_track_multiplex(self, track: AnyTrack) -> None:
pass
def close(self) -> None:
try:
self.session.close()
except Exception:
pass
+22
View File
@@ -70,6 +70,10 @@ class Services(click.MultiCommand):
"""Load the Service and return the Click CLI method."""
tag = Services.get_tag(name)
import_file = ctx.params.get("import_file") or (ctx.parent and ctx.parent.params.get("import_file"))
if import_file:
return Services._make_import_command(tag, ctx)
remote = ctx.params.get("remote") or (ctx.parent and ctx.parent.params.get("remote"))
if remote:
return Services._make_remote_command(tag, ctx)
@@ -141,6 +145,24 @@ class Services(click.MultiCommand):
return remote_cli
@staticmethod
def _make_import_command(tag: str, ctx: click.Context) -> click.Command:
"""Create a synthetic command that yields an ImportService from an export JSON.
Mirrors how remote services are wired so dl.py's result() runs unchanged.
"""
@click.command(name=tag, short_help="Reconstruct a download from an export JSON.")
@click.argument("title", type=str, required=False, default="")
@click.pass_context
def import_cli(ctx: click.Context, title: str, **kwargs: object) -> object:
from unshackle.core.import_service import ImportService
import_file = ctx.params.get("import_file") or (ctx.parent and ctx.parent.params.get("import_file"))
return ImportService(ctx, tag, title, import_file)
return import_cli
@staticmethod
def _fetch_remote_service_info(tag: str, ctx: click.Context) -> dict | None:
"""Fetch service info for a specific service from the remote server."""
+25
View File
@@ -126,6 +126,31 @@ class Audio(Track):
self.joc = joc
self.descriptive = bool(descriptive)
def to_dict(self) -> dict[str, Any]:
data = super().to_dict()
data.update(
{
"codec": self.codec.name if self.codec else None,
"bitrate": self.bitrate,
"channels": self.channels,
"joc": self.joc,
"descriptive": self.descriptive,
}
)
return data
@classmethod
def from_dict(cls, data: dict[str, Any]) -> Audio:
kwargs = Track.base_kwargs_from_dict(data)
return cls(
**kwargs,
codec=Audio.Codec[data["codec"]] if data.get("codec") else None,
bitrate=data.get("bitrate"),
channels=data.get("channels"),
joc=data.get("joc"),
descriptive=data.get("descriptive", False),
)
@property
def atmos(self) -> bool:
"""Return True if the audio track contains Dolby Atmos."""
+23
View File
@@ -195,6 +195,29 @@ class Subtitle(Track):
# Called after Track has been converted to another format
self.OnConverted: Optional[Callable[[Subtitle.Codec], None]] = None
def to_dict(self) -> dict[str, Any]:
data = super().to_dict()
data.update(
{
"codec": self.codec.name if self.codec else None,
"cc": self.cc,
"sdh": self.sdh,
"forced": self.forced,
}
)
return data
@classmethod
def from_dict(cls, data: dict[str, Any]) -> Subtitle:
kwargs = Track.base_kwargs_from_dict(data)
return cls(
**kwargs,
codec=Subtitle.Codec[data["codec"]] if data.get("codec") else None,
cc=data.get("cc", False),
sdh=data.get("sdh", False),
forced=data.get("forced", False),
)
def __str__(self) -> str:
return " | ".join(
filter(
+51
View File
@@ -439,6 +439,57 @@ class Track:
self.path = target
return target
def to_dict(self) -> dict[str, Any]:
"""Serialise the track for export/import (identity/URL/descriptor/language).
DRM is not serialised here; the export writer attaches the licensed DRM + keys.
Subclasses add their own codec/quality fields.
"""
data: dict[str, Any] = {
"type": self.__class__.__name__,
"id": self.id,
"url": self.url,
"language": str(self.language),
"is_original_lang": self.is_original_lang,
"descriptor": self.descriptor.name,
"needs_repack": self.needs_repack,
"name": self.name,
"edition": self.edition,
}
return data
@staticmethod
def base_kwargs_from_dict(data: dict[str, Any]) -> dict[str, Any]:
"""Build the shared Track constructor kwargs from a ``to_dict()`` payload.
DRM is not reconstructed here — ``to_dict`` does not serialise it, and the import
flow attaches the licensed DRM + content keys separately.
"""
return {
"url": data["url"],
"language": data.get("language") or "und",
"is_original_lang": data.get("is_original_lang", False),
"descriptor": Track.Descriptor[data.get("descriptor", "URL")],
"needs_repack": data.get("needs_repack", False),
"name": data.get("name"),
"edition": data.get("edition") or None,
"id_": data.get("id"),
}
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "Track":
"""Reconstruct the correct Track subclass from a ``to_dict()`` payload."""
from unshackle.core.tracks.audio import Audio
from unshackle.core.tracks.subtitle import Subtitle
from unshackle.core.tracks.video import Video
track_type = data.get("type")
builders = {"Video": Video, "Audio": Audio, "Subtitle": Subtitle}
builder = builders.get(track_type)
if builder is None:
raise ValueError(f"Cannot reconstruct unsupported track type: {track_type!r}")
return builder.from_dict(data)
def get_track_name(self) -> Optional[str]:
"""Get the Track Name."""
return self.name
+31
View File
@@ -297,6 +297,37 @@ class Video(Track):
self.needs_duration_fix = False
self.dv_compatible_bitstream = dv_compatible_bitstream
def to_dict(self) -> dict[str, Any]:
data = super().to_dict()
data.update(
{
"codec": self.codec.name if self.codec else None,
"range": self.range.name if self.range else None,
"bitrate": self.bitrate,
"width": self.width,
"height": self.height,
"fps": str(self.fps) if self.fps else None,
"scan_type": self.scan_type.name if self.scan_type else None,
"dv_compatible_bitstream": self.dv_compatible_bitstream,
}
)
return data
@classmethod
def from_dict(cls, data: dict[str, Any]) -> Video:
kwargs = Track.base_kwargs_from_dict(data)
return cls(
**kwargs,
codec=Video.Codec[data["codec"]] if data.get("codec") else None,
range_=Video.Range[data["range"]] if data.get("range") else None,
bitrate=data.get("bitrate"),
width=data.get("width"),
height=data.get("height"),
fps=data.get("fps"),
scan_type=Video.ScanType[data["scan_type"]] if data.get("scan_type") else None,
dv_compatible_bitstream=data.get("dv_compatible_bitstream", False),
)
def __str__(self) -> str:
return " | ".join(
filter(