feat(dl): add --skip-missing to skip titles that miss a required language or track

A season run skips such a title and continues; one title, or a run that skips every title, still exits 1 (now also for -ado/-fso). REST jobs report skipped_titles.
This commit is contained in:
imSp4rky
2026-11-13 18:30:01 -07:00
parent 94dfb5d677
commit 6964f58271
13 changed files with 403 additions and 83 deletions
+171 -10
View File
@@ -12,9 +12,11 @@ import logging
from typing import Any, Optional, cast
import pytest
import requests
from unshackle.commands.dl import dl
from unshackle.core.api.handlers import DEFAULT_DOWNLOAD_PARAMS
from unshackle.core.constants import DOWNLOAD_LICENCE_ONLY
from unshackle.core.service import Service
from unshackle.core.titles import Episode, Series
from unshackle.core.tracks import Audio, Subtitle, Tracks, Video
@@ -38,7 +40,7 @@ class Harness(dl):
class FakeService:
"""One episode per entry; ``ad`` adds a descriptive track, ``forced`` a forced subtitle."""
"""One episode per entry; ``ad`` adds a descriptive track, ``forced`` a forced subtitle, ``ko`` Korean audio."""
def __init__(self, episodes: dict[int, dict[str, bool]]) -> None:
self.episodes = episodes
@@ -62,6 +64,8 @@ class FakeService:
tracks.add(
Audio(id_="ad", url="https://x/ad", language="en", codec=Audio.Codec.AAC, bitrate=1, descriptive=True)
)
if kinds.get("ko"):
tracks.add(Audio(id_="ko", url="https://x/ko", language="ko", codec=Audio.Codec.AAC, bitrate=128_000))
tracks.add(Subtitle(id_="s", url="https://x/s", language="en", codec=Subtitle.Codec.WebVTT))
if kinds.get("forced"):
tracks.add(Subtitle(id_="sf", url="https://x/sf", language="en", codec=Subtitle.Codec.WebVTT, forced=True))
@@ -71,8 +75,8 @@ class FakeService:
return []
def __getattr__(self, name: str) -> Any:
if name.startswith("on_"):
# the event hooks (on_track_downloaded, ...) the loop subscribes
if name.startswith(("on_", "get_")):
# the event hooks (on_track_downloaded, ...) and the DRM callbacks a run past selection probes
return lambda *a, **k: None
raise AttributeError(name)
@@ -82,16 +86,22 @@ def run(
caplog: pytest.LogCaptureFixture,
episodes: dict,
service: Optional[FakeService] = None,
to_end: bool = False,
**flags: Any,
) -> Any:
"""Return the Selected ids, the exit code, or None for a run that completes."""
monkeypatch.setattr(Tracks, "tree", lambda self, *a, **k: (_ for _ in ()).throw(Selected(self)))
"""Return the Selected ids, the exit code, or None for a run that completes; to_end runs past selection."""
if not to_end:
monkeypatch.setattr(Tracks, "tree", lambda self, *a, **k: (_ for _ in ()).throw(Selected(self)))
else:
monkeypatch.setattr("unshackle.commands.dl.download_tracks_in_passes", lambda *a, **k: None)
monkeypatch.setattr("unshackle.core.providers.search_metadata", lambda *a, **k: None)
cmd = Harness.__new__(Harness)
cmd.log = logging.getLogger("test_kind_only")
cmd.service = "FAKE"
cmd.tmdb_searched = True
cmd.completed_files = []
cmd.skipped_titles = []
cmd.skipped_subtitles = []
kwargs: dict[str, Any] = {}
for name, param in inspect.signature(dl.result).parameters.items():
if name in DEFAULT_DOWNLOAD_PARAMS:
@@ -101,12 +111,17 @@ def run(
kwargs.update(quality=[], vcodec=[], acodec=[], range_=[], wanted=[], cdm_only=None, downloads=1)
kwargs.update(flags)
caplog.set_level(logging.WARNING, logger="test_kind_only")
service = service or FakeService(episodes)
if to_end:
service.session = requests.Session()
try:
cmd.result(cast(Service, service or FakeService(episodes)), **kwargs)
cmd.result(cast(Service, service), **kwargs)
except Selected as sel:
return sel.ids
except SystemExit as exc:
return exc.code
finally:
DOWNLOAD_LICENCE_ONLY.clear()
return None
@@ -145,8 +160,9 @@ def test_subs_only_with_no_matching_subtitle_exits_a_single_title_and_skips_in_a
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
assert run(monkeypatch, caplog, {1: {}}, subs_only=True, s_lang=["fr"]) == 1
assert run(monkeypatch, caplog, {1: {}, 2: {}}, subs_only=True, s_lang=["fr"]) is None
assert caplog.text.count("There's no track left for the selected options. Skipping Show S01E") == 2
assert run(monkeypatch, caplog, {1: {}, 2: {}}, subs_only=True, s_lang=["fr"]) == 1
assert caplog.text.count("Skipping Show S01E") == 2
assert "Skipping Show S01E02: There's no track left for the selected options." in caplog.text
def test_a_multi_title_run_skips_the_title_and_continues(
@@ -157,13 +173,158 @@ def test_a_multi_title_run_skips_the_title_and_continues(
assert "Skipping Show S01E01" in caplog.text
def test_a_multi_title_run_with_every_title_skipped_completes(
def test_a_multi_title_run_with_every_title_skipped_fails(
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
assert run(monkeypatch, caplog, {1: {}, 2: {}}, forced_subs_only=True) is None
assert run(monkeypatch, caplog, {1: {}, 2: {}}, forced_subs_only=True) == 1
assert caplog.text.count("Skipping Show S01E") == 2
def test_a_required_language_miss_stops_the_run_by_default(
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
sink: list[dict] = []
assert run(monkeypatch, caplog, {1: {}, 2: {"ko": True}}, require_audio=["ko"], progress_sink=sink.append) == 1
assert "Skipping" not in caplog.text
assert "Show S01E01: Required audio language(s) not found: ko" in caplog.text
assert not any("skipped_titles" in event for event in sink)
def test_skip_missing_skips_the_title_and_reports_it(
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
sink: list[dict] = []
ids = run(
monkeypatch,
caplog,
{1: {}, 2: {"ko": True}},
require_audio=["ko"],
skip_missing=True,
progress_sink=sink.append,
)
assert ids == {"video": ["v"], "audio": ["a"], "subs": ["s"]}
assert "Skipping Show S01E01: Required audio language(s) not found: ko" in caplog.text
assert {"skipped_titles": [{"title": "Show S01E01", "reason": "Required audio language(s) not found: ko"}]} in sink
def test_require_audio_video_and_subs_work_together(
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
ids = run(monkeypatch, caplog, {1: {}}, require_audio=["en"], require_video=["en"], require_subs=["en"])
assert ids == {"video": ["v"], "audio": ["a"], "subs": ["s"]}
assert run(monkeypatch, caplog, {1: {}}, require_audio=["ko"], require_video=["ko"], require_subs=["ko"]) == 1
assert (
"Required audio language(s) not found: ko; Required video language(s) not found: ko; "
"Required subtitle language(s) not found: ko"
) in caplog.text
assert run(monkeypatch, caplog, {1: {}}, require_audio=["en"], require_video=["ko"], require_subs=["en"]) == 1
assert "Show S01E01: Required video language(s) not found: ko" in caplog.text
def test_skip_missing_skips_a_filter_miss_and_continues(
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
ids = run(monkeypatch, caplog, {1: {}, 2: {"ko": True}}, a_lang=["ko"], skip_missing=True)
assert ids == {"video": ["v"], "audio": ["ko"], "subs": ["s"]}
assert "Skipping Show S01E01" in caplog.text
def test_skip_missing_still_fails_a_run_that_skips_every_title(
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
assert run(monkeypatch, caplog, {1: {}, 2: {}}, require_audio=["ko"], skip_missing=True) == 1
assert "Skipped all 2 titles" in caplog.text
assert run(monkeypatch, caplog, {1: {}}, require_audio=["ko"], skip_missing=True) == 1
class LangService(FakeService):
"""Each episode names its original language and its audio languages; the video is in the original language."""
def get_titles_cached(self) -> Series:
return Series(
Episode(id_=f"show-s01-e{n:03}", service=FakeService, title="Show", season=1, number=n, language=ep["orig"])
for n, ep in self.episodes.items()
)
def get_tracks(self, title: Episode) -> Tracks:
ep = self.episodes[title.number]
tracks = Tracks()
tracks.add(Video(id_="v", url="https://x/v", language=ep["orig"] or "en", codec=Video.Codec.AVC))
for lang in ep["audio"]:
tracks.add(Audio(id_=lang, url=f"https://x/{lang}", language=lang, codec=Audio.Codec.AAC, bitrate=128_000))
return tracks
JA_EN = {"orig": "ja", "audio": ["ja", "en"]}
FR = {"orig": "ja", "audio": ["fr"]}
EVERY = {"orig": "ja", "audio": ["ja", "ko", "en"]}
ORIG_KO_EN = ["orig", "ko", "en"]
@pytest.mark.parametrize(
"first,flags,outcome,logged",
[
(JA_EN, {}, 1, "Show S01E01: ko not found in audio tracks"),
(JA_EN, {"skip_missing": True}, ["ja", "ko", "en"], "Skipping Show S01E01: ko not found in audio tracks"),
({"orig": "ja", "audio": ["ko", "en"]}, {"skip_missing": True}, ["ja", "ko", "en"], "S01E01: orig not found"),
(JA_EN, {"best_available": True}, ["ja", "en"], "ko not found in audio tracks, continuing with: orig, en"),
(FR, {"best_available": True}, 1, "orig, ko, en not found in audio tracks and no fallback available"),
(FR, {"best_available": True, "skip_missing": True}, ["ja", "ko", "en"], "Skipping Show S01E01: orig, ko, en"),
(JA_EN, {"best_available": True, "require_audio": ["ko"]}, 1, "Required audio language(s) not found: ko"),
(
JA_EN,
{"best_available": True, "require_audio": ["ko"], "skip_missing": True},
["ja", "ko", "en"],
"Skipping Show S01E01: Required audio language(s) not found: ko",
),
],
)
def test_several_audio_languages_skip_an_episode_that_misses_one(
monkeypatch: pytest.MonkeyPatch,
caplog: pytest.LogCaptureFixture,
first: dict,
flags: dict[str, Any],
outcome: Any,
logged: str,
) -> None:
"""-al orig,ko,en on a season where only the second episode has all three."""
episodes = {1: first, 2: EVERY}
result = run(monkeypatch, caplog, episodes, service=LangService(episodes), a_lang=ORIG_KO_EN, **flags)
assert (result if outcome == 1 else result["audio"]) == outcome
assert logged in caplog.text
def test_several_audio_languages_fail_a_season_that_misses_one_everywhere(
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
episodes = {1: JA_EN, 2: JA_EN}
assert run(monkeypatch, caplog, episodes, service=LangService(episodes), a_lang=ORIG_KO_EN, skip_missing=True) == 1
assert caplog.text.count("ko not found in audio tracks") == 2
assert "Skipped all 2 titles" in caplog.text
def test_a_partly_skipped_run_completes_and_lists_the_skips(
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
episodes = {1: {}, 2: {"ko": True}}
assert run(monkeypatch, caplog, episodes, to_end=True, a_lang=["ko"], skip_missing=True, skip_dl=True) is None
assert "Skipped 1 of 2 titles: Show S01E01" in caplog.text
@pytest.mark.parametrize("no_mux,settled", [(False, 2), (True, 1)])
def test_a_skipped_title_settles_its_post_script_group(
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture, no_mux: bool, settled: int
) -> None:
"""The season event waits on every title of a group; a skipped title counts as settled, once at count time."""
import unshackle.commands.dl as dl_module
seen: list[str] = []
group = dl_module.post_script_group
monkeypatch.setattr(dl_module, "post_script_group", lambda t: seen.append(str(t)) or group(t))
run(monkeypatch, caplog, {1: {}, 2: {"ko": True}}, a_lang=["ko"], skip_missing=True, no_mux=no_mux)
assert seen.count("Show S01E01") == settled
class LcevcService(FakeService):
"""A 540p base video and the 1080p LCEVC enhancement layer that decodes on top of it."""
@@ -626,3 +626,86 @@ def test_client_job_view_hides_debug_fields_without_debug_api() -> None:
assert client_job_view(job, off) == {"job_id": "j"}
assert client_job_view(job, None) == {"job_id": "j"}
assert client_job_view(job, on) == job
FAKE_SKIPPING_WORKER = """
import sys
from unshackle.core.api import download_worker
def skip_then_stop(*args, progress_callback, **kwargs):
progress_callback({"skipped_titles": [{"title": "Show S01E02", "reason": "missing ko"}]})
raise RuntimeError("Download failed: Skipped all 2 titles")
download_worker.perform_download = skip_then_stop
sys.exit(download_worker.main(["worker"] + sys.argv[1:]))
"""
async def test_a_skip_written_just_before_the_worker_exits_reaches_the_job(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, manager: DownloadQueueManager
) -> None:
import asyncio
from unshackle.core.api import download_manager
worker = tmp_path / "worker.py"
worker.write_text(FAKE_SKIPPING_WORKER, encoding="utf-8")
spawn = asyncio.create_subprocess_exec
async def spawn_fake_worker(executable: str, *args: str, **kwargs: Any) -> Any:
return await spawn(executable, str(worker), *args[2:], **kwargs)
monkeypatch.setattr(download_manager.asyncio, "create_subprocess_exec", spawn_fake_worker)
job = manager.create_job("EXAMPLE", "t")
with pytest.raises(Exception, match="Skipped all 2 titles"):
await asyncio.wait_for(manager.run_download_async(job), timeout=20)
assert job.skipped_titles == [{"title": "Show S01E02", "reason": "missing ko"}]
assert job.to_dict()["skipped_titles"] == job.skipped_titles
def test_a_stopped_run_names_its_reason_in_the_job_error(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
import sys
import click
import unshackle.commands.dl as dl_module
from unshackle.core.api import download_manager, handlers
from unshackle.core.api.errors import APIError
from unshackle.core.services import Services
class FakeDl:
cli = dl_module.dl.cli
def __init__(self, ctx: click.Context, **kwargs: object) -> None:
self.stop_reason = None
def result(self, **kwargs: object) -> None:
self.stop_reason = "Show S01E01: Required audio language(s) not found: ko"
sys.exit(1)
class FakeService:
def __init__(self, ctx: click.Context) -> None:
pass
monkeypatch.setattr(dl_module, "dl", FakeDl)
monkeypatch.setattr(Services, "get_path", staticmethod(lambda s: tmp_path))
monkeypatch.setattr(Services, "load", staticmethod(lambda s: FakeService))
monkeypatch.setattr(handlers, "load_full_cdm", lambda *a: None)
with pytest.raises(APIError, match=r"^Download failed: Show S01E01: Required audio language\(s\) not found: ko$"):
download_manager.perform_download("job-1", "EXAMPLE1", "t1", {})
def test_job_history_keeps_the_skipped_titles(tmp_path: Path, manager: DownloadQueueManager) -> None:
import json
from unshackle.core.api.download_manager import record_job_history
job = manager.create_job("EXAMPLE", "t")
job.status = JobStatus.COMPLETED
job.skipped_titles = [{"title": "Show S01E02", "reason": "missing ko"}]
record_job_history(job)
entry = json.loads((tmp_path / "api_history.jsonl").read_text(encoding="utf-8").splitlines()[-1])
assert entry["skipped_titles"] == job.skipped_titles