mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-06-17 06:27:35 +00:00
feat(api): aggregate REST download progress with weighting, track labels and mux stage
Replace the class-level Track.download monkeypatch with a per-job progress sink threaded through dl.result(). The API now reports a single aggregate signal instead of each track's bouncing 0-100%:
- bitrate-weighted completion so video/audio dominate subtitles
- completed_tracks/total_tracks counts and active_tracks labels (e.g. "video 2160p DV", "audio en-US 5.1")
- downloads fill 0-90%; repackaging (when needed) and a "muxing" stage carry it to 100% so post-download work is no longer frozen at 100%
- monotonic throughout (handles the download->decrypt callable reuse)
Also:
- accept "HDR10P" as the canonical API range value ("HDR10+" still works)
- declare AUTH_METHODS opt-in on the Service base
- raise typed APIError (WORKER_ERROR/DOWNLOAD_ERROR) from the worker path
- move the progress helpers to unshackle/core/api/progress.py
This commit is contained in:
@@ -9,13 +9,8 @@ import pytest
|
|||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
|
|
||||||
from unshackle.core.api import handlers
|
from unshackle.core.api import handlers
|
||||||
from unshackle.core.api.download_manager import (
|
from unshackle.core.api.download_manager import (DownloadJob, JobStatus, _redact_parameters, _redact_text,
|
||||||
DownloadJob,
|
_secret_values)
|
||||||
JobStatus,
|
|
||||||
_redact_parameters,
|
|
||||||
_redact_text,
|
|
||||||
_secret_values,
|
|
||||||
)
|
|
||||||
from unshackle.core.api.errors import APIError, APIErrorCode
|
from unshackle.core.api.errors import APIError, APIErrorCode
|
||||||
|
|
||||||
pytestmark = pytest.mark.unit
|
pytestmark = pytest.mark.unit
|
||||||
@@ -140,3 +135,18 @@ async def test_credential_allowed_when_enabled(stub_handler):
|
|||||||
stub_handler.setattr(handlers.config, "serve", {"allow_job_credentials": True})
|
stub_handler.setattr(handlers.config, "serve", {"allow_job_credentials": True})
|
||||||
resp = await handlers.download_handler({"service": "ATV", "title_id": "t", "credential": "u:p"})
|
resp = await handlers.download_handler({"service": "ATV", "title_id": "t", "credential": "u:p"})
|
||||||
assert isinstance(resp, web.Response)
|
assert isinstance(resp, web.Response)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- range validation ----------
|
||||||
|
|
||||||
|
|
||||||
|
def test_range_validation_accepts_hdr10p_and_alias():
|
||||||
|
# canonical "HDR10P" and back-compat "HDR10+" both pass; mixed casing too
|
||||||
|
assert handlers.validate_download_parameters({"range": ["HDR10P", "DV", "SDR"]}) is None
|
||||||
|
assert handlers.validate_download_parameters({"range": ["hdr10+"]}) is None
|
||||||
|
assert handlers.validate_download_parameters({"range": "HYBRID"}) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_range_validation_rejects_unknown_and_lists_hdr10p():
|
||||||
|
err = handlers.validate_download_parameters({"range": ["HDR99"]})
|
||||||
|
assert err and "HDR10P" in err and "HDR99" in err
|
||||||
|
|||||||
155
tests/remote/unit/test_progress_sink.py
Normal file
155
tests/remote/unit/test_progress_sink.py
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
"""Unit tests for the aggregate per-job download progress sink.
|
||||||
|
|
||||||
|
``build_job_progress_callables`` wraps the per-track progress callables so the API job sees one
|
||||||
|
aggregate signal - a bitrate-weighted completion percentage, track counts, and the labels of the
|
||||||
|
tracks downloading now - instead of each track's own bouncing 0-100%. These tests pin that
|
||||||
|
contract."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from unshackle.core.api.progress import (DOWNLOAD_PROGRESS_CEILING, build_job_progress_callables,
|
||||||
|
track_progress_label, track_progress_weight)
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.unit
|
||||||
|
|
||||||
|
|
||||||
|
# --- lightweight track stand-ins (label/weight key off class name + attributes) ---
|
||||||
|
class _Range:
|
||||||
|
def __init__(self, value):
|
||||||
|
self.value = value
|
||||||
|
|
||||||
|
|
||||||
|
class Video:
|
||||||
|
def __init__(self, height=1080, range_value="SDR", bitrate=4_000_000):
|
||||||
|
self.height = height
|
||||||
|
self.range = _Range(range_value)
|
||||||
|
self.bitrate = bitrate
|
||||||
|
|
||||||
|
|
||||||
|
class Audio:
|
||||||
|
def __init__(self, language="en-US", channels="2.0", bitrate=200_000):
|
||||||
|
self.language = language
|
||||||
|
self.channels = channels
|
||||||
|
self.bitrate = bitrate
|
||||||
|
|
||||||
|
|
||||||
|
class Subtitle:
|
||||||
|
def __init__(self, language="fr"):
|
||||||
|
self.language = language
|
||||||
|
self.bitrate = None
|
||||||
|
|
||||||
|
|
||||||
|
def _noop(**kwargs):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def test_track_progress_label():
|
||||||
|
assert track_progress_label(Video(2160, "DV")) == "video 2160p DV"
|
||||||
|
assert track_progress_label(Video(1080, "HDR10+")) == "video 1080p HDR10+"
|
||||||
|
assert track_progress_label(Audio("en-US", "5.1")) == "audio en-US 5.1"
|
||||||
|
assert track_progress_label(Subtitle("ro")) == "subtitle ro"
|
||||||
|
|
||||||
|
|
||||||
|
def test_weight_video_over_audio_over_subtitle():
|
||||||
|
assert track_progress_weight(Video(bitrate=4_000_000)) == 4_000_000
|
||||||
|
assert track_progress_weight(Audio(bitrate=200_000)) == 200_000
|
||||||
|
# subtitle has no bitrate -> small fixed weight, far below media
|
||||||
|
assert track_progress_weight(Subtitle()) < track_progress_weight(Audio(bitrate=200_000))
|
||||||
|
|
||||||
|
|
||||||
|
def test_weighting_makes_video_dominate_progress():
|
||||||
|
updates: list[dict] = []
|
||||||
|
video, sub = Video(bitrate=4_000_000), Subtitle()
|
||||||
|
cbs = build_job_progress_callables([video, sub], [_noop, _noop], updates.append)
|
||||||
|
|
||||||
|
# subtitle fully done, video untouched -> progress is tiny (subtitle barely weighted)
|
||||||
|
cbs[1](downloaded="Downloaded")
|
||||||
|
assert updates[-1]["completed_tracks"] == 1
|
||||||
|
assert updates[-1]["progress"] < 5.0
|
||||||
|
|
||||||
|
# video half done -> progress is dominated by video (scaled into the 0..ceiling download band)
|
||||||
|
cbs[0](total=100, completed=50)
|
||||||
|
assert updates[-1]["progress"] > 40.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_active_tracks_labels_reported_and_cleared_on_done():
|
||||||
|
updates: list[dict] = []
|
||||||
|
cbs = build_job_progress_callables(
|
||||||
|
[Video(2160, "DV"), Audio("en-US", "2.0")], [_noop, _noop], updates.append
|
||||||
|
)
|
||||||
|
|
||||||
|
cbs[0](total=100, completed=10) # video downloading
|
||||||
|
assert updates[-1]["active_tracks"] == ["video 2160p DV"]
|
||||||
|
assert updates[-1]["phase"] == "downloading video 2160p DV"
|
||||||
|
|
||||||
|
cbs[1](total=100, completed=10) # audio also downloading
|
||||||
|
assert updates[-1]["active_tracks"] == ["video 2160p DV", "audio en-US 2.0"]
|
||||||
|
|
||||||
|
cbs[0](downloaded="Downloaded") # video done -> drops out of active
|
||||||
|
assert updates[-1]["active_tracks"] == ["audio en-US 2.0"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_aggregate_progress_is_monotonic_with_counts():
|
||||||
|
updates: list[dict] = []
|
||||||
|
inner_calls = [0, 0, 0]
|
||||||
|
|
||||||
|
def make_inner(i):
|
||||||
|
def inner(**kwargs):
|
||||||
|
inner_calls[i] += 1
|
||||||
|
|
||||||
|
return inner
|
||||||
|
|
||||||
|
tracks = [Video(bitrate=1000), Audio(bitrate=1000), Subtitle()]
|
||||||
|
cbs = build_job_progress_callables(tracks, [make_inner(0), make_inner(1), make_inner(2)], updates.append)
|
||||||
|
assert len(cbs) == 3
|
||||||
|
|
||||||
|
cbs[0](total=100, completed=50)
|
||||||
|
cbs[0](downloaded="Downloaded")
|
||||||
|
cbs[1](total=100, completed=50)
|
||||||
|
|
||||||
|
progresses = [u["progress"] for u in updates]
|
||||||
|
assert progresses == sorted(progresses)
|
||||||
|
assert updates[-1]["completed_tracks"] == 1
|
||||||
|
assert updates[-1]["total_tracks"] == 3
|
||||||
|
assert inner_calls == [2, 1, 0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_all_tracks_done_reaches_download_ceiling():
|
||||||
|
# Downloads fill up to the ceiling; dl.result drives muxing the rest of the way to 100.
|
||||||
|
updates: list[dict] = []
|
||||||
|
cbs = build_job_progress_callables([Audio(bitrate=1000), Audio(bitrate=1000)], [_noop, _noop], updates.append)
|
||||||
|
|
||||||
|
cbs[0](total=10, completed=10, downloaded="Downloaded")
|
||||||
|
assert updates[-1]["progress"] < DOWNLOAD_PROGRESS_CEILING
|
||||||
|
assert updates[-1]["completed_tracks"] == 1
|
||||||
|
|
||||||
|
cbs[1](total=10, completed=10, downloaded="Decrypted")
|
||||||
|
assert updates[-1]["progress"] == pytest.approx(DOWNLOAD_PROGRESS_CEILING)
|
||||||
|
assert updates[-1]["completed_tracks"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_finished_track_does_not_dip_when_callable_reused_for_decrypt():
|
||||||
|
"""A track hits 100% (then decrypt reuses the callable with completed=0); the aggregate must
|
||||||
|
hold, never dip - even before the terminal 'Downloaded'/'Decrypted' string arrives."""
|
||||||
|
updates: list[dict] = []
|
||||||
|
cbs = build_job_progress_callables([Video(bitrate=1000), Video(bitrate=1000)], [_noop, _noop], updates.append)
|
||||||
|
|
||||||
|
cbs[0](total=100, completed=100) # download hits 100% BEFORE any terminal string -> 50%
|
||||||
|
cbs[0](total=200, completed=0) # decrypt phase resets counts, still no terminal string
|
||||||
|
cbs[0](total=200, completed=100) # decrypt mid-way
|
||||||
|
cbs[0](total=200, completed=200, downloaded="Decrypted") # terminal
|
||||||
|
|
||||||
|
progresses = [u["progress"] for u in updates]
|
||||||
|
assert progresses == sorted(progresses) # monotonic, no dip
|
||||||
|
assert updates[-1]["progress"] == pytest.approx(DOWNLOAD_PROGRESS_CEILING / 2)
|
||||||
|
assert updates[-1]["completed_tracks"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_skipped_subtitle_counts_as_done():
|
||||||
|
updates: list[dict] = []
|
||||||
|
cbs = build_job_progress_callables([Subtitle()], [_noop], updates.append)
|
||||||
|
cbs[0](downloaded="[yellow]SKIPPED")
|
||||||
|
assert updates[-1]["completed_tracks"] == 1
|
||||||
|
assert updates[-1]["progress"] == pytest.approx(DOWNLOAD_PROGRESS_CEILING)
|
||||||
@@ -1143,6 +1143,7 @@ class dl:
|
|||||||
split_audio: Optional[bool] = None,
|
split_audio: Optional[bool] = None,
|
||||||
real_video_bitrate: bool = False,
|
real_video_bitrate: bool = False,
|
||||||
real_audio_bitrate: bool = False,
|
real_audio_bitrate: bool = False,
|
||||||
|
progress_sink: Optional[Callable[[dict[str, Any]], None]] = None,
|
||||||
*_: Any,
|
*_: Any,
|
||||||
**__: Any,
|
**__: Any,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -2214,6 +2215,13 @@ class dl:
|
|||||||
|
|
||||||
selected_tracks, tracks_progress_callables = title.tracks.tree(add_progress=True)
|
selected_tracks, tracks_progress_callables = title.tracks.tree(add_progress=True)
|
||||||
|
|
||||||
|
if progress_sink is not None:
|
||||||
|
from unshackle.core.api.progress import build_job_progress_callables
|
||||||
|
|
||||||
|
tracks_progress_callables = build_job_progress_callables(
|
||||||
|
list(title.tracks), tracks_progress_callables, progress_sink
|
||||||
|
)
|
||||||
|
|
||||||
for track in title.tracks:
|
for track in title.tracks:
|
||||||
if hasattr(track, "needs_drm_loading") and track.needs_drm_loading:
|
if hasattr(track, "needs_drm_loading") and track.needs_drm_loading:
|
||||||
track.load_drm_if_needed(service)
|
track.load_drm_if_needed(service)
|
||||||
@@ -2470,6 +2478,8 @@ class dl:
|
|||||||
break
|
break
|
||||||
|
|
||||||
# Now repack the decrypted tracks
|
# Now repack the decrypted tracks
|
||||||
|
if progress_sink and any(getattr(t, "needs_repack", False) for t in title.tracks):
|
||||||
|
progress_sink({"phase": "repackaging", "progress": 92.0, "status": "downloading", "active_tracks": []})
|
||||||
with console.status("Repackaging tracks with FFMPEG..."):
|
with console.status("Repackaging tracks with FFMPEG..."):
|
||||||
has_repacked = False
|
has_repacked = False
|
||||||
for track in title.tracks:
|
for track in title.tracks:
|
||||||
@@ -2636,6 +2646,8 @@ class dl:
|
|||||||
for video_track in title.tracks.videos or [None]:
|
for video_track in title.tracks.videos or [None]:
|
||||||
mux_video_standalone(video_track)
|
mux_video_standalone(video_track)
|
||||||
|
|
||||||
|
if progress_sink:
|
||||||
|
progress_sink({"phase": "muxing", "progress": 96.0, "status": "downloading", "active_tracks": []})
|
||||||
try:
|
try:
|
||||||
with Live(Padding(progress, (0, 5, 1, 5)), console=console):
|
with Live(Padding(progress, (0, 5, 1, 5)), console=console):
|
||||||
mux_index = 0
|
mux_index = 0
|
||||||
|
|||||||
@@ -108,8 +108,11 @@ class DownloadJob:
|
|||||||
error_traceback: Optional[str] = None
|
error_traceback: Optional[str] = None
|
||||||
worker_stderr: Optional[str] = None
|
worker_stderr: Optional[str] = None
|
||||||
|
|
||||||
# Human-readable current phase (e.g. "downloading video 1080p")
|
# Current phase, track counts, and labels of the tracks downloading now.
|
||||||
phase: Optional[str] = None
|
phase: Optional[str] = None
|
||||||
|
completed_tracks: int = 0
|
||||||
|
total_tracks: int = 0
|
||||||
|
active_tracks: List[str] = field(default_factory=list)
|
||||||
|
|
||||||
# Subtitles skipped under skip_subtitle_errors (non-fatal). Each entry is a dl.SkippedSubtitle
|
# Subtitles skipped under skip_subtitle_errors (non-fatal). Each entry is a dl.SkippedSubtitle
|
||||||
# dict (id / language / title) so a client can report which weren't available.
|
# dict (id / language / title) so a client can report which weren't available.
|
||||||
@@ -128,6 +131,9 @@ class DownloadJob:
|
|||||||
"title_id": self.title_id,
|
"title_id": self.title_id,
|
||||||
"progress": self.progress,
|
"progress": self.progress,
|
||||||
"phase": self.phase,
|
"phase": self.phase,
|
||||||
|
"completed_tracks": self.completed_tracks,
|
||||||
|
"total_tracks": self.total_tracks,
|
||||||
|
"active_tracks": self.active_tracks,
|
||||||
"skipped_subtitles": self.skipped_subtitles,
|
"skipped_subtitles": self.skipped_subtitles,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,6 +181,7 @@ def _perform_download(
|
|||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
from unshackle.commands.dl import dl
|
from unshackle.commands.dl import dl
|
||||||
|
from unshackle.core.api.errors import APIError, APIErrorCode
|
||||||
from unshackle.core.config import config
|
from unshackle.core.config import config
|
||||||
from unshackle.core.services import Services
|
from unshackle.core.services import Services
|
||||||
from unshackle.core.tracks import Subtitle, Video
|
from unshackle.core.tracks import Subtitle, Video
|
||||||
@@ -359,69 +366,16 @@ def _perform_download(
|
|||||||
stdout_capture = StringIO()
|
stdout_capture = StringIO()
|
||||||
stderr_capture = StringIO()
|
stderr_capture = StringIO()
|
||||||
|
|
||||||
# Simple progress tracking if callback provided
|
# The progress_sink (dl.build_job_progress_callables) owns the percentage; status changes
|
||||||
|
# are emitted here.
|
||||||
if progress_callback:
|
if progress_callback:
|
||||||
# Report initial progress
|
|
||||||
progress_callback({"progress": 0.0, "status": "starting"})
|
progress_callback({"progress": 0.0, "status": "starting"})
|
||||||
|
|
||||||
# Tee each Track.download's progress callable so the downloader's live percentage
|
|
||||||
# is forwarded to the API job (not just 5%/100%), and expose which track is being
|
|
||||||
# downloaded now as a human-readable phase.
|
|
||||||
from unshackle.core.tracks.track import Track as _Track
|
|
||||||
|
|
||||||
if not getattr(_Track, "_api_progress_patched", False):
|
|
||||||
_orig_track_download = _Track.download
|
|
||||||
|
|
||||||
def _download_with_progress(self, *args, **kwargs):
|
|
||||||
inner_progress = kwargs.get("progress")
|
|
||||||
track_type = type(self).__name__
|
|
||||||
phase = {
|
|
||||||
"Video": "downloading video",
|
|
||||||
"Audio": "downloading audio",
|
|
||||||
"Subtitle": "downloading subtitle",
|
|
||||||
}.get(track_type, f"downloading {track_type.lower()}")
|
|
||||||
height = getattr(self, "height", None)
|
|
||||||
language = getattr(self, "language", None)
|
|
||||||
if height:
|
|
||||||
phase += f" {height}p"
|
|
||||||
elif track_type in ("Audio", "Subtitle") and language:
|
|
||||||
phase += f" {language}"
|
|
||||||
progress_callback({"phase": phase, "status": "downloading"})
|
|
||||||
|
|
||||||
if callable(inner_progress):
|
|
||||||
counts = {"completed": 0.0, "total": 0.0}
|
|
||||||
|
|
||||||
def tee(*tee_args, **tee_kwargs):
|
|
||||||
if tee_kwargs.get("total"):
|
|
||||||
counts["total"] = tee_kwargs["total"]
|
|
||||||
if tee_kwargs.get("completed") is not None:
|
|
||||||
counts["completed"] = tee_kwargs["completed"]
|
|
||||||
if "advance" in tee_kwargs:
|
|
||||||
counts["completed"] += tee_kwargs["advance"]
|
|
||||||
pct = counts["completed"] * 100.0 / counts["total"] if counts["total"] else 0
|
|
||||||
if pct:
|
|
||||||
progress_callback(
|
|
||||||
{"progress": min(99.0, float(pct)), "phase": phase, "status": "downloading"}
|
|
||||||
)
|
|
||||||
return inner_progress(*tee_args, **tee_kwargs)
|
|
||||||
|
|
||||||
kwargs["progress"] = tee
|
|
||||||
return _orig_track_download(self, *args, **kwargs)
|
|
||||||
|
|
||||||
_Track.download = _download_with_progress
|
|
||||||
_Track._api_progress_patched = True
|
|
||||||
|
|
||||||
original_result = dl_instance.result
|
original_result = dl_instance.result
|
||||||
|
|
||||||
def result_with_progress(*args, **kwargs):
|
def result_with_progress(*args, **kwargs):
|
||||||
try:
|
try:
|
||||||
# Report that download started
|
progress_callback({"status": "downloading"})
|
||||||
progress_callback({"progress": 5.0, "status": "downloading"})
|
|
||||||
|
|
||||||
# Call original method
|
|
||||||
result = original_result(*args, **kwargs)
|
result = original_result(*args, **kwargs)
|
||||||
|
|
||||||
# Report completion
|
|
||||||
progress_callback({"progress": 100.0, "status": "completed"})
|
progress_callback({"progress": 100.0, "status": "completed"})
|
||||||
return result
|
return result
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -481,6 +435,7 @@ def _perform_download(
|
|||||||
worst=params.get("worst", False),
|
worst=params.get("worst", False),
|
||||||
best_available=params.get("best_available", False),
|
best_available=params.get("best_available", False),
|
||||||
split_audio=params.get("split_audio"),
|
split_audio=params.get("split_audio"),
|
||||||
|
progress_sink=progress_callback,
|
||||||
)
|
)
|
||||||
|
|
||||||
except SystemExit as exc:
|
except SystemExit as exc:
|
||||||
@@ -490,7 +445,7 @@ def _perform_download(
|
|||||||
log.error(f"Download exited with code {exc.code}")
|
log.error(f"Download exited with code {exc.code}")
|
||||||
log.error(f"Stdout: {stdout_str}")
|
log.error(f"Stdout: {stdout_str}")
|
||||||
log.error(f"Stderr: {stderr_str}")
|
log.error(f"Stderr: {stderr_str}")
|
||||||
raise Exception(f"Download failed with exit code {exc.code}")
|
raise APIError(APIErrorCode.DOWNLOAD_ERROR, f"Download failed with exit code {exc.code}")
|
||||||
|
|
||||||
except Exception as exc: # noqa: BLE001 - propagate to caller
|
except Exception as exc: # noqa: BLE001 - propagate to caller
|
||||||
stdout_str = stdout_capture.getvalue()
|
stdout_str = stdout_capture.getvalue()
|
||||||
@@ -504,7 +459,7 @@ def _perform_download(
|
|||||||
# It sets download_failed in that case, so the job isn't reported as completed with no output.
|
# It sets download_failed in that case, so the job isn't reported as completed with no output.
|
||||||
if getattr(dl_instance, "download_failed", False):
|
if getattr(dl_instance, "download_failed", False):
|
||||||
detail = (stdout_capture.getvalue() + stderr_capture.getvalue())[-200:].strip()
|
detail = (stdout_capture.getvalue() + stderr_capture.getvalue())[-200:].strip()
|
||||||
raise Exception("download worker failed: " + (detail or "see logs"))
|
raise APIError(APIErrorCode.WORKER_ERROR, "download worker failed: " + (detail or "see logs"))
|
||||||
|
|
||||||
# Surface any subtitles that were skipped (non-fatal failures) so the client can report them.
|
# Surface any subtitles that were skipped (non-fatal failures) so the client can report them.
|
||||||
if progress_callback:
|
if progress_callback:
|
||||||
@@ -796,6 +751,12 @@ class DownloadQueueManager:
|
|||||||
progress_data = json.load(handle)
|
progress_data = json.load(handle)
|
||||||
if progress_data.get("phase") and progress_data["phase"] != job.phase:
|
if progress_data.get("phase") and progress_data["phase"] != job.phase:
|
||||||
job.phase = progress_data["phase"]
|
job.phase = progress_data["phase"]
|
||||||
|
if progress_data.get("total_tracks"):
|
||||||
|
job.total_tracks = int(progress_data["total_tracks"])
|
||||||
|
if progress_data.get("completed_tracks") is not None:
|
||||||
|
job.completed_tracks = int(progress_data["completed_tracks"])
|
||||||
|
if "active_tracks" in progress_data:
|
||||||
|
job.active_tracks = list(progress_data["active_tracks"])
|
||||||
if progress_data.get("skipped_subtitles"):
|
if progress_data.get("skipped_subtitles"):
|
||||||
job.skipped_subtitles = progress_data["skipped_subtitles"]
|
job.skipped_subtitles = progress_data["skipped_subtitles"]
|
||||||
if "progress" in progress_data:
|
if "progress" in progress_data:
|
||||||
|
|||||||
@@ -957,13 +957,13 @@ def validate_download_parameters(data: Dict[str, Any]) -> Optional[str]:
|
|||||||
return "Cannot use both s_lang and require_subs"
|
return "Cannot use both s_lang and require_subs"
|
||||||
|
|
||||||
if "range" in data and data["range"]:
|
if "range" in data and data["range"]:
|
||||||
valid_ranges = ["SDR", "HDR10", "HDR10+", "DV", "HLG", "HYBRID"]
|
# "HDR10P" is the canonical range value ("+" is awkward in scripts); "HDR10+" stays valid.
|
||||||
if isinstance(data["range"], list):
|
valid_ranges = ["SDR", "HDR10", "HDR10P", "DV", "HLG", "HYBRID"]
|
||||||
for r in data["range"]:
|
accepted = {*valid_ranges, "HDR10+"}
|
||||||
if r.upper() not in valid_ranges:
|
values = data["range"] if isinstance(data["range"], list) else [data["range"]]
|
||||||
return f"Invalid range value: {r}. Must be one of: {', '.join(valid_ranges)}"
|
for r in values:
|
||||||
elif data["range"].upper() not in valid_ranges:
|
if r.upper() not in accepted:
|
||||||
return f"Invalid range value: {data['range']}. Must be one of: {', '.join(valid_ranges)}"
|
return f"Invalid range value: {r}. Must be one of: {', '.join(valid_ranges)}"
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|||||||
118
unshackle/core/api/progress.py
Normal file
118
unshackle/core/api/progress.py
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
"""Aggregate job-level download progress for the REST API.
|
||||||
|
|
||||||
|
Turns the per-track progress callables from ``Tracks.tree`` into one signal a job can report:
|
||||||
|
a bitrate-weighted percentage, track counts, and the labels of the tracks downloading now.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Callable
|
||||||
|
|
||||||
|
from unshackle.core.constants import AnyTrack
|
||||||
|
|
||||||
|
JOB_PROGRESS_TERMINAL_STATES = {"Downloaded", "Decrypted", "[yellow]SKIPPED"}
|
||||||
|
|
||||||
|
# Weight for a track with no bitrate (subtitles); small vs media bitrates so subs barely move the bar.
|
||||||
|
SUBTITLE_PROGRESS_WEIGHT = 50_000.0
|
||||||
|
|
||||||
|
# Downloads fill 0..this; dl.result drives the remainder (repackaging, muxing) up to 100.
|
||||||
|
DOWNLOAD_PROGRESS_CEILING = 90.0
|
||||||
|
|
||||||
|
|
||||||
|
def track_progress_label(track: AnyTrack) -> str:
|
||||||
|
"""Short label for a track, e.g. "video 2160p DV", "audio en-US 5.1", "subtitle fr"."""
|
||||||
|
track_type = type(track).__name__
|
||||||
|
if track_type == "Video":
|
||||||
|
parts = ["video"]
|
||||||
|
height = getattr(track, "height", None)
|
||||||
|
if height:
|
||||||
|
parts.append(f"{height}p")
|
||||||
|
track_range = getattr(track, "range", None)
|
||||||
|
if track_range is not None:
|
||||||
|
parts.append(track_range.value)
|
||||||
|
return " ".join(parts)
|
||||||
|
if track_type == "Audio":
|
||||||
|
parts = ["audio"]
|
||||||
|
language = getattr(track, "language", None)
|
||||||
|
if language:
|
||||||
|
parts.append(str(language))
|
||||||
|
channels = getattr(track, "channels", None)
|
||||||
|
if channels:
|
||||||
|
parts.append(str(channels))
|
||||||
|
return " ".join(parts)
|
||||||
|
if track_type == "Subtitle":
|
||||||
|
language = getattr(track, "language", None)
|
||||||
|
return f"subtitle {language}" if language else "subtitle"
|
||||||
|
return track_type.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def track_progress_weight(track: AnyTrack) -> float:
|
||||||
|
"""Track weight in the aggregate (its bitrate in bits/s), so video/audio dominate subtitles."""
|
||||||
|
bitrate = getattr(track, "bitrate", None)
|
||||||
|
return float(bitrate) if bitrate else SUBTITLE_PROGRESS_WEIGHT
|
||||||
|
|
||||||
|
|
||||||
|
def build_job_progress_callables(
|
||||||
|
tracks: list[AnyTrack],
|
||||||
|
inner_callables: list[Callable[..., None]],
|
||||||
|
sink: Callable[[dict[str, Any]], None],
|
||||||
|
) -> list[Callable[..., None]]:
|
||||||
|
"""Wrap each track's progress callable so ``sink`` receives aggregate job progress.
|
||||||
|
|
||||||
|
The sink gets a bitrate-weighted mean completion across all tracks, ``completed_tracks`` /
|
||||||
|
``total_tracks`` counts, and ``active_tracks`` labels. Each track's fraction is monotonic, so
|
||||||
|
the percentage only climbs. The original ``inner`` callable is always invoked.
|
||||||
|
"""
|
||||||
|
total = len(inner_callables)
|
||||||
|
weights = [track_progress_weight(t) for t in tracks]
|
||||||
|
labels = [track_progress_label(t) for t in tracks]
|
||||||
|
total_weight = sum(weights) or 1.0
|
||||||
|
fractions = [0.0] * total
|
||||||
|
done = [False] * total
|
||||||
|
started = [False] * total
|
||||||
|
|
||||||
|
def emit() -> None:
|
||||||
|
completed = sum(done)
|
||||||
|
# Downloads fill 0..DOWNLOAD_PROGRESS_CEILING; dl.result drives muxing up to 100.
|
||||||
|
progress = sum(w * f for w, f in zip(weights, fractions)) * DOWNLOAD_PROGRESS_CEILING / total_weight
|
||||||
|
active = [labels[i] for i in range(total) if started[i] and not done[i]]
|
||||||
|
if active:
|
||||||
|
phase = "downloading " + ", ".join(active[:3])
|
||||||
|
if len(active) > 3:
|
||||||
|
phase += f" (+{len(active) - 3} more)"
|
||||||
|
else:
|
||||||
|
phase = f"downloading {completed}/{total} tracks"
|
||||||
|
sink(
|
||||||
|
{
|
||||||
|
"progress": progress,
|
||||||
|
"phase": phase,
|
||||||
|
"completed_tracks": completed,
|
||||||
|
"total_tracks": total,
|
||||||
|
"active_tracks": active,
|
||||||
|
"status": "downloading",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
def wrap(index: int, inner: Callable[..., None]) -> Callable[..., None]:
|
||||||
|
counts = {"completed": 0.0, "total": 0.0}
|
||||||
|
|
||||||
|
def tee(*args: Any, **kwargs: Any) -> None:
|
||||||
|
started[index] = True
|
||||||
|
if kwargs.get("total"):
|
||||||
|
counts["total"] = kwargs["total"]
|
||||||
|
if kwargs.get("completed") is not None:
|
||||||
|
counts["completed"] = kwargs["completed"]
|
||||||
|
if "advance" in kwargs:
|
||||||
|
counts["completed"] += kwargs["advance"]
|
||||||
|
if kwargs.get("downloaded") in JOB_PROGRESS_TERMINAL_STATES:
|
||||||
|
done[index] = True
|
||||||
|
fractions[index] = 1.0
|
||||||
|
elif counts["total"]:
|
||||||
|
# max() keeps the fraction monotonic across the download->decrypt callable reuse.
|
||||||
|
fractions[index] = max(fractions[index], min(1.0, counts["completed"] / counts["total"]))
|
||||||
|
emit()
|
||||||
|
return inner(*args, **kwargs)
|
||||||
|
|
||||||
|
return tee
|
||||||
|
|
||||||
|
return [wrap(i, inner) for i, inner in enumerate(inner_callables)]
|
||||||
@@ -233,9 +233,7 @@ async def services(request: web.Request) -> web.Response:
|
|||||||
or getattr(service_module, "get_playready_license", None) is not _BaseService.get_playready_license
|
or getattr(service_module, "get_playready_license", None) is not _BaseService.get_playready_license
|
||||||
)
|
)
|
||||||
|
|
||||||
# Auth methods the service accepts. Prefer an explicit `AUTH_METHODS` class var
|
# Prefer the service's explicit AUTH_METHODS; otherwise infer from authenticate().
|
||||||
# (reliable); otherwise fall back to inferring from what authenticate() references
|
|
||||||
# - that mostly returns both because services call super().authenticate(...).
|
|
||||||
methods = []
|
methods = []
|
||||||
if service_data["needs_auth"]:
|
if service_data["needs_auth"]:
|
||||||
declared = getattr(service_module, "AUTH_METHODS", None)
|
declared = getattr(service_module, "AUTH_METHODS", None)
|
||||||
|
|||||||
@@ -95,6 +95,9 @@ class Service(metaclass=ABCMeta):
|
|||||||
GEOFENCE: tuple[str, ...] = () # list of ip regions required to use the service. empty list == no specific region.
|
GEOFENCE: tuple[str, ...] = () # list of ip regions required to use the service. empty list == no specific region.
|
||||||
# vault namespace override; when set, key vault read/write uses this tag instead of the service's own.
|
# vault namespace override; when set, key vault read/write uses this tag instead of the service's own.
|
||||||
VAULT_TAG: Optional[str] = None
|
VAULT_TAG: Optional[str] = None
|
||||||
|
# Auth methods the service accepts ("cookies"/"credentials"); when None the REST /services
|
||||||
|
# endpoint infers them from authenticate().
|
||||||
|
AUTH_METHODS: Optional[tuple[str, ...]] = None
|
||||||
|
|
||||||
def __init__(self, ctx: click.Context):
|
def __init__(self, ctx: click.Context):
|
||||||
console.print(Padding(Rule(f"[rule.text]Service: {self.__class__.__name__}"), (1, 2)))
|
console.print(Padding(Rule(f"[rule.text]Service: {self.__class__.__name__}"), (1, 2)))
|
||||||
|
|||||||
Reference in New Issue
Block a user