mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-06-10 11:12:13 +00:00
fix(dl): make a failed subtitle non-fatal under --skip-subtitle-errors
A single failing subtitle track previously aborted the whole download. Add an opt-in --skip-subtitle-errors flag: when set, a Subtitle failure is logged and the track dropped from the mux while the video/audio still complete (Video/Audio failures stay fatal; default behaviour is unchanged). Done at the right layer to avoid the shared-event race: a failed track sets the process-global DOWNLOAD_CANCELLED event, which makes other in-flight tracks early-return without raising — so a skipped subtitle could otherwise silently truncate the video/audio that still got muxed. The download is split into two passes (download_tracks_in_passes): the fatal tracks download concurrently first, then the skippable subtitles run in a separate sequential pass once nothing else is in flight, with the event reset before each and at the start of every title. Skipped languages are recorded on the dl instance (skipped_subtitles) for callers to surface. Adds tests for the cancel-event interaction (a failing subtitle no longer truncates the video/audio), the good/bad subtitle mix, the flag-off fatal path, and the per-title reset.
This commit is contained in:
141
tests/tracks/test_subtitle_skip.py
Normal file
141
tests/tracks/test_subtitle_skip.py
Normal file
@@ -0,0 +1,141 @@
|
||||
"""Tests for ``download_tracks_in_passes`` — the two-pass track download used by
|
||||
``dl.result()`` when ``--skip-subtitle-errors`` is set.
|
||||
|
||||
The behaviour under test is the cancel-event interaction: a failed track sets the
|
||||
process-global ``DOWNLOAD_CANCELLED`` event, which makes other in-flight tracks
|
||||
early-return without raising. Running the fatal video/audio tracks to completion
|
||||
*before* the skippable subtitles removes that race, so a subtitle failure can no
|
||||
longer truncate the video/audio that still gets muxed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from unshackle.commands.dl import download_tracks_in_passes
|
||||
from unshackle.core.constants import DOWNLOAD_CANCELLED
|
||||
from unshackle.core.tracks import Audio, Subtitle, Video
|
||||
|
||||
|
||||
def make_video(track_id: str = "v") -> Video:
|
||||
return Video(
|
||||
id_=track_id,
|
||||
url=f"https://example.test/{track_id}.m3u8",
|
||||
language="en",
|
||||
codec=Video.Codec.AVC,
|
||||
range_=Video.Range.SDR,
|
||||
width=1920,
|
||||
height=1080,
|
||||
bitrate=5_000_000,
|
||||
)
|
||||
|
||||
|
||||
def make_audio(track_id: str = "a") -> Audio:
|
||||
return Audio(
|
||||
id_=track_id,
|
||||
url=f"https://example.test/{track_id}.m3u8",
|
||||
language="en",
|
||||
codec=Audio.Codec.AAC,
|
||||
bitrate=128_000,
|
||||
)
|
||||
|
||||
|
||||
def make_subtitle(track_id: str, language: str) -> Subtitle:
|
||||
return Subtitle(
|
||||
id_=track_id,
|
||||
url=f"https://example.test/{track_id}.vtt",
|
||||
language=language,
|
||||
codec=Subtitle.Codec.WebVTT,
|
||||
)
|
||||
|
||||
|
||||
class Harness:
|
||||
"""Mimics how ``dl.result`` drives the helper, with a controllable downloader.
|
||||
|
||||
``run_one`` mirrors ``Track.download``: it early-returns (without recording a
|
||||
completion) when the cancel event is already set, and a track flagged to fail
|
||||
sets the event and raises — exactly what the real cancel sites do.
|
||||
"""
|
||||
|
||||
def __init__(self, fail_ids: set[str]):
|
||||
self.fail_ids = fail_ids
|
||||
self.completed: list[str] = []
|
||||
self.early_returned: list[str] = []
|
||||
self.skipped: list[Subtitle] = []
|
||||
|
||||
def run_one(self, track, _index):
|
||||
if DOWNLOAD_CANCELLED.is_set():
|
||||
self.early_returned.append(track.id)
|
||||
return
|
||||
if track.id in self.fail_ids:
|
||||
DOWNLOAD_CANCELLED.set()
|
||||
raise RuntimeError(f"{track.id} failed")
|
||||
self.completed.append(track.id)
|
||||
|
||||
def on_subtitle_skipped(self, track):
|
||||
self.skipped.append(track)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_event():
|
||||
DOWNLOAD_CANCELLED.clear()
|
||||
yield
|
||||
DOWNLOAD_CANCELLED.clear()
|
||||
|
||||
|
||||
def test_failed_subtitle_does_not_truncate_video_or_audio():
|
||||
"""A subtitle that fails *and sets the cancel event* must not stop the video/audio."""
|
||||
video, audio = make_video(), make_audio()
|
||||
sub = make_subtitle("s-he", "he")
|
||||
h = Harness(fail_ids={"s-he"})
|
||||
|
||||
download_tracks_in_passes(
|
||||
[video, audio, sub], 4, h.run_one,
|
||||
skip_subtitle_errors=True, on_subtitle_skipped=h.on_subtitle_skipped,
|
||||
)
|
||||
|
||||
assert set(h.completed) == {"v", "a"} # both fatal tracks fully downloaded
|
||||
assert h.early_returned == [] # nothing early-returned because of the subtitle
|
||||
assert h.skipped == [sub] # the subtitle was recorded as skipped
|
||||
|
||||
|
||||
def test_good_subtitle_kept_bad_subtitle_skipped():
|
||||
video = make_video()
|
||||
good, bad = make_subtitle("s-en", "en"), make_subtitle("s-fr", "fr")
|
||||
h = Harness(fail_ids={"s-fr"})
|
||||
|
||||
download_tracks_in_passes(
|
||||
[video, good, bad], 4, h.run_one,
|
||||
skip_subtitle_errors=True, on_subtitle_skipped=h.on_subtitle_skipped,
|
||||
)
|
||||
|
||||
assert "s-en" in h.completed # the available subtitle still downloaded
|
||||
assert h.skipped == [bad] # only the failing one was skipped
|
||||
|
||||
|
||||
def test_subtitle_failure_stays_fatal_without_flag():
|
||||
"""Default behaviour (flag off) is unchanged: a subtitle failure aborts the title."""
|
||||
video = make_video()
|
||||
sub = make_subtitle("s-he", "he")
|
||||
h = Harness(fail_ids={"s-he"})
|
||||
|
||||
with pytest.raises(RuntimeError):
|
||||
download_tracks_in_passes(
|
||||
[video, sub], 4, h.run_one,
|
||||
skip_subtitle_errors=False, on_subtitle_skipped=h.on_subtitle_skipped,
|
||||
)
|
||||
|
||||
|
||||
def test_cancel_event_is_reset_between_titles():
|
||||
"""A cancel left set by a previous title must not skip this title's tracks."""
|
||||
DOWNLOAD_CANCELLED.set()
|
||||
video, audio = make_video(), make_audio()
|
||||
h = Harness(fail_ids=set())
|
||||
|
||||
download_tracks_in_passes(
|
||||
[video, audio], 4, h.run_one,
|
||||
skip_subtitle_errors=True, on_subtitle_skipped=h.on_subtitle_skipped,
|
||||
)
|
||||
|
||||
assert set(h.completed) == {"v", "a"}
|
||||
assert h.early_returned == []
|
||||
Reference in New Issue
Block a user