diff --git a/src/alt_ani_cli/cli.py b/src/alt_ani_cli/cli.py index b97b0ab..358be7d 100644 --- a/src/alt_ani_cli/cli.py +++ b/src/alt_ani_cli/cli.py @@ -16,6 +16,7 @@ from alt_ani_cli.diagnostics import _host_of_url from alt_ani_cli.errors import ( AntiBotError, + DownloadFailedError, FilterMismatchError, NoStreamError, ParseError, @@ -683,7 +684,7 @@ def main() -> None: # noqa: C901 else: progress.error(str(exc)) sys.exit(1) - except (AntiBotError, NoStreamError, ParseError, FilterMismatchError) as exc: + except (AntiBotError, NoStreamError, ParseError, FilterMismatchError, DownloadFailedError) as exc: diagnostics.session_end("error", type(exc).__name__) progress.error(str(exc)) sys.exit(1) diff --git a/src/alt_ani_cli/content/exceptions_content_en-us.yaml b/src/alt_ani_cli/content/exceptions_content_en-us.yaml index 5b15124..2a747a2 100644 --- a/src/alt_ani_cli/content/exceptions_content_en-us.yaml +++ b/src/alt_ani_cli/content/exceptions_content_en-us.yaml @@ -72,3 +72,6 @@ player: VLC not found on PATH. Install via: winget install VideoLAN.VLC Or set env: ANI_CLI_PLAYER=C:\path\to\vlc.exe + +download: + failed: "Download failed." diff --git a/src/alt_ani_cli/content/exceptions_content_pl-pl.yaml b/src/alt_ani_cli/content/exceptions_content_pl-pl.yaml index 46e3fec..c6907ea 100644 --- a/src/alt_ani_cli/content/exceptions_content_pl-pl.yaml +++ b/src/alt_ani_cli/content/exceptions_content_pl-pl.yaml @@ -72,3 +72,6 @@ player: VLC nie znaleziony w PATH. Zainstaluj przez: winget install VideoLAN.VLC Lub wskaż ścieżkę: ANI_CLI_PLAYER=C:\path\to\vlc.exe + +download: + failed: "Pobieranie nie powiodło się." diff --git a/src/alt_ani_cli/content/ui_content_pl-pl.yaml b/src/alt_ani_cli/content/ui_content_pl-pl.yaml index b2200be..b6ac347 100644 --- a/src/alt_ani_cli/content/ui_content_pl-pl.yaml +++ b/src/alt_ani_cli/content/ui_content_pl-pl.yaml @@ -53,6 +53,7 @@ progress: player_failed_long: "Player {player} (ep {number:g}) nie zadziałał: {exc}" player_failed_short: "Player {player} nie zadziałał — wybierz inny:" no_player_worked: "Żaden player nie zadziałał dla odcinka {number:g} — pomijam." + download_failed: "Pobieranie z playera {player} nie powiodło się." host_deferred: "Host {host} jest chwilowo niedostępny w tej sesji — odkładam próbę." host_last_resort: "Pozostałe źródła zawiodły — próbuję odłożonego hosta {host}." host_dropped: "Host {host} nadal jest niedostępny po próbie last-resort — pomijam kolejnego playera na tym hoście." diff --git a/src/alt_ani_cli/diagnostics.py b/src/alt_ani_cli/diagnostics.py index da7dd78..e73b115 100644 --- a/src/alt_ani_cli/diagnostics.py +++ b/src/alt_ani_cli/diagnostics.py @@ -183,5 +183,9 @@ def playback_result(kind: str, rc: int, elapsed: float, confirmed: bool, mpv_log _emit("playback_result", kind=kind, rc=rc, elapsed=f"{elapsed:.3f}", confirmed=confirmed, mpv_log=mpv_log) +def download_result(host: str | None, ok: bool, exc: str | None) -> None: + _emit("download_result", host=host, ok=ok, exc=exc) + + def history_update(series_id: str, last_ep: float) -> None: _emit("history_update", id=series_id, last_ep=last_ep) diff --git a/src/alt_ani_cli/download.py b/src/alt_ani_cli/download.py index b575662..6f24a9a 100644 --- a/src/alt_ani_cli/download.py +++ b/src/alt_ani_cli/download.py @@ -1,8 +1,10 @@ +import gc import shutil from pathlib import Path from alt_ani_cli.config import DOWNLOADS, USER_AGENT -from alt_ani_cli.content import CONTENT +from alt_ani_cli.content import CONTENT, EXCEPTIONS_PL +from alt_ani_cli.errors import DownloadFailedError from alt_ani_cli.extract.common import Stream from alt_ani_cli.models import EpisodeRow, SeriesRef from alt_ani_cli.ui import progress @@ -69,6 +71,7 @@ def run( dest_dir: Path = DOWNLOADS, ) -> None: from yt_dlp import YoutubeDL + from yt_dlp.utils import DownloadError dest_dir.mkdir(parents=True, exist_ok=True) safe_title = "".join(c for c in series.title if c.isalnum() or c in " _-").strip() @@ -90,12 +93,21 @@ def run( } progress.info(CONTENT["download"]["starting"].format(title=safe_title, ep_label=ep_label, dir=dest_dir)) - with YoutubeDL(opts) as ydl: - info = ydl.extract_info(stream.url, download=False) - final_path = Path(ydl.prepare_filename(info)) - if str(info.get("protocol", "")).startswith("m3u8") and (backup := _quarantine_legacy_hls_part(final_path)): - progress.warn(CONTENT["download"]["legacy_part_moved"].format(path=backup)) - ydl.process_ie_result(info, download=True) + failed = False + try: + with YoutubeDL(opts) as ydl: + info = ydl.extract_info(stream.url, download=False) + final_path = Path(ydl.prepare_filename(info)) + if str(info.get("protocol", "")).startswith("m3u8") and (backup := _quarantine_legacy_hls_part(final_path)): + progress.warn(CONTENT["download"]["legacy_part_moved"].format(path=backup)) + ydl.process_ie_result(info, download=True) + except DownloadError: + failed = True + + if failed: + # Outside except on purpose: yt-dlp's leaked .part handle sits in a traceback cycle (WinError 32 on reuse). + gc.collect() + raise DownloadFailedError(EXCEPTIONS_PL["download"]["failed"]) if final_path.exists(): progress.success(CONTENT["download"]["saved"].format(path=final_path)) diff --git a/src/alt_ani_cli/errors.py b/src/alt_ani_cli/errors.py index e7e0e3d..25feda0 100644 --- a/src/alt_ani_cli/errors.py +++ b/src/alt_ani_cli/errors.py @@ -28,3 +28,7 @@ class ParseError(ShindenError): class FilterMismatchError(ShindenError): """--lang/--subs/--player-name matched no players and --allow-fallback was not set.""" + + +class DownloadFailedError(ShindenError): + """yt-dlp could not download the stream; carries no library exception or URL.""" diff --git a/src/alt_ani_cli/flow/handlers.py b/src/alt_ani_cli/flow/handlers.py index e678371..0a57b29 100644 --- a/src/alt_ani_cli/flow/handlers.py +++ b/src/alt_ani_cli/flow/handlers.py @@ -19,9 +19,9 @@ from alt_ani_cli import __version__, diagnostics, download, history from alt_ani_cli.content import CONTENT -from alt_ani_cli.errors import ShindenError +from alt_ani_cli.errors import DownloadFailedError, ShindenError from alt_ani_cli.flow.state import BACK, FlowState, Screen, ScreenResult -from alt_ani_cli.models import EmbedURL, PlayerSource, SeriesHit, SeriesMetadata, SeriesRef +from alt_ani_cli.models import EmbedURL, EpisodeRow, PlayerSource, SeriesHit, SeriesMetadata, SeriesRef from alt_ani_cli.shinden import api as shinden_api from alt_ani_cli.shinden import episode as shinden_episode from alt_ani_cli.shinden import search as shinden_search @@ -420,13 +420,16 @@ def handle_resolve_stream(state: FlowState) -> ScreenResult: return Screen.QUALITY_PICK return Screen.ACTION_PICK - # player failed online_id = state.chosen_player.online_id - state.failed_ids.add(online_id) failed_embed = state.player_embeds.get(online_id) if failed_embed is not None: # the embed resolved fine, only extraction failed downstream — the host is already known _record_player_source(state, online_id, failed_embed) + return _fail_chosen_player(state, ep) + + +def _fail_chosen_player(state: FlowState, ep: EpisodeRow) -> ScreenResult: + state.failed_ids.add(state.chosen_player.online_id) remaining = [p for p in state.players if p.online_id not in state.failed_ids] if remaining: return Screen.PLAYER_PICK # try another (no history push — stays in same UI level) @@ -487,7 +490,17 @@ def handle_run_action(state: FlowState) -> ScreenResult: completed = False if args.download or state.episode_action == "download": - download.run(stream, ep, state.ref) + source = state.player_sources.get(state.chosen_player.online_id) + host = source.host if source else None + try: + download.run(stream, ep, state.ref) + except DownloadFailedError as exc: + diagnostics.download_result(host, ok=False, exc=type(exc).__name__) + progress.error(_PROG["download_failed"].format(player=repr(state.chosen_player.player))) + state.stream = None + state.embed = None + return _fail_chosen_player(state, ep) + diagnostics.download_result(host, ok=True, exc=None) elif args.debug or state.episode_action == "debug": _print_debug(stream, state.embed) else: diff --git a/tests/cli/test_errors.py b/tests/cli/test_errors.py index d9f591b..08c36f2 100644 --- a/tests/cli/test_errors.py +++ b/tests/cli/test_errors.py @@ -6,7 +6,7 @@ from curl_cffi.requests.exceptions import HTTPError as CurlHTTPError from alt_ani_cli.cli import main -from alt_ani_cli.errors import FilterMismatchError +from alt_ani_cli.errors import DownloadFailedError, FilterMismatchError def _make_http_error(status_code: int, url: str = "https://shinden.pl/series/1-test") -> CurlHTTPError: @@ -90,3 +90,20 @@ def test_filter_mismatch_exits_1_with_message(self, capsys): assert exc_info.value.code == 1 captured = capsys.readouterr() assert "--lang=xx" in captured.err + captured.out + + +@pytest.mark.unit +class TestDownloadFailedErrorHandler: + def test_download_failure_exits_1_with_plain_message(self, capsys): + exc = DownloadFailedError("Pobieranie nie powiodło się.") + with ( + patch("sys.argv", ["alt-ani-cli", "--url", "https://shinden.pl/series/1-test", "-S", "1", "-d"]), + patch("alt_ani_cli.cli._run_noninteractive", side_effect=exc), + patch("alt_ani_cli.cli.shinden_http.make_client"), + pytest.raises(SystemExit) as exc_info, + ): + main() + assert exc_info.value.code == 1 + combined = "".join(capsys.readouterr()) + assert "Pobieranie nie powiodło się." in combined + assert "Błąd shinden" not in combined diff --git a/tests/flow/test_handlers.py b/tests/flow/test_handlers.py index 5d7000c..7e40737 100644 --- a/tests/flow/test_handlers.py +++ b/tests/flow/test_handlers.py @@ -11,7 +11,8 @@ import pytest from curl_cffi.requests.exceptions import RequestException as CurlRequestException -from alt_ani_cli.errors import AntiBotError, NoStreamError, ShindenError +from alt_ani_cli.content import CONTENT +from alt_ani_cli.errors import AntiBotError, DownloadFailedError, NoStreamError, ShindenError from alt_ani_cli.extract.common import Stream from alt_ani_cli.flow.handlers import ( HANDLERS, @@ -22,7 +23,7 @@ handle_run_action, ) from alt_ani_cli.flow.state import BACK, FlowState, Screen, _BackSentinel -from alt_ani_cli.models import EmbedURL, RelatedSeries, SeriesMetadata +from alt_ani_cli.models import EmbedURL, PlayerSource, RelatedSeries, SeriesMetadata from alt_ani_cli.player.runner import PlayResult from alt_ani_cli.shinden.models import EpisodeRow, PlayerEntry, SeriesHit, SeriesRef @@ -560,6 +561,8 @@ def _play_state(self, episode_action="play", **overrides): stream=Stream(url="https://cdn.example.com/v.m3u8", headers={}, ext="m3u8"), embed=EmbedURL(url="https://morencius.com/embed/abc", referer="https://shinden.pl/"), episode_action=episode_action, + players=[_PLAYER], + chosen_player=_PLAYER, **overrides, ) return state @@ -671,6 +674,8 @@ def _play_state(self, episode_action="play", **overrides): stream=Stream(url="https://cdn.example.com/v.m3u8", headers={}, ext="m3u8"), embed=EmbedURL(url="https://morencius.com/embed/abc", referer="https://shinden.pl/"), episode_action=episode_action, + players=[_PLAYER], + chosen_player=_PLAYER, **overrides, ) return state @@ -758,6 +763,164 @@ def test_ep_idx_advances_regardless_of_confirmation(self): assert state.ep_idx == 1 +_STREAM = Stream(url="https://cdn.example.com/v.m3u8", headers={}, ext="m3u8") +_EMBED = EmbedURL(url="https://vidara.to/e/abc", referer="https://shinden.pl/") + + +def _download_state(players=(_PLAYER, _PLAYER2), **overrides) -> FlowState: + state = _make_state( + ref=_SERIES_REF, + targets=[_EP1, _EP2], + ep_idx=0, + players=list(players), + chosen_player=players[0], + stream=_STREAM, + embed=_EMBED, + episode_action="download", + ) + for k, v in overrides.items(): + setattr(state, k, v) + return state + + +@pytest.mark.unit +class TestHandleRunActionDownloadFailure: + def test_success_advances_episode_without_history(self): + state = _download_state() + with ( + patch("alt_ani_cli.download.run") as mock_download, + patch("alt_ani_cli.history.upsert") as mock_upsert, + patch("alt_ani_cli.diagnostics.download_result") as mock_diag, + ): + result = handle_run_action(state) + mock_download.assert_called_once() + mock_upsert.assert_not_called() + mock_diag.assert_called_once_with(None, ok=True, exc=None) + assert result is Screen.EPISODE_DISPATCH + assert state.ep_idx == 1 + assert state.failed_ids == set() + + def test_failure_with_other_players_returns_to_player_pick(self): + state = _download_state() + with ( + patch("alt_ani_cli.download.run", side_effect=DownloadFailedError("x")), + patch("alt_ani_cli.history.upsert") as mock_upsert, + patch("alt_ani_cli.ui.progress.error") as mock_error, + ): + result = handle_run_action(state) + assert result is Screen.PLAYER_PICK + assert state.ep_idx == 0 + assert state.failed_ids == {_PLAYER.online_id} + assert state.stream is None + assert state.embed is None + assert state.completed_eps == set() + assert state.episode_action == "download" + mock_upsert.assert_not_called() + assert _PLAYER.player in mock_error.call_args.args[0] + + def test_failure_logs_redacted_diagnostics(self): + state = _download_state() + state.player_sources = {_PLAYER.online_id: PlayerSource(_PLAYER.online_id, "vidara.to", _EMBED.url)} + with ( + patch("alt_ani_cli.download.run", side_effect=DownloadFailedError("x")), + patch("alt_ani_cli.ui.progress.error"), + patch("alt_ani_cli.diagnostics.download_result") as mock_diag, + ): + handle_run_action(state) + mock_diag.assert_called_once_with("vidara.to", ok=False, exc="DownloadFailedError") + + def test_failure_on_last_remaining_player_skips_episode(self): + state = _download_state(failed_ids={_PLAYER.online_id}, chosen_player=_PLAYER2) + with ( + patch("alt_ani_cli.download.run", side_effect=DownloadFailedError("x")), + patch("alt_ani_cli.history.upsert") as mock_upsert, + patch("alt_ani_cli.ui.progress.error"), + patch("alt_ani_cli.ui.progress.warn") as mock_warn, + ): + result = handle_run_action(state) + assert result is Screen.EPISODE_DISPATCH + assert state.ep_idx == 1 + assert state.completed_eps == set() + mock_upsert.assert_not_called() + assert mock_warn.call_args.args[0] == CONTENT["progress"]["no_player_worked"].format(number=_EP1.number) + + def test_failure_with_single_player_skips_episode(self): + state = _download_state(players=(_PLAYER,)) + with ( + patch("alt_ani_cli.download.run", side_effect=DownloadFailedError("x")), + patch("alt_ani_cli.ui.progress.error"), + patch("alt_ani_cli.ui.progress.warn"), + ): + result = handle_run_action(state) + assert result is Screen.EPISODE_DISPATCH + assert state.ep_idx == 1 + + def test_args_download_failure_also_returns_to_player_pick(self): + state = _download_state(episode_action=None, args=_make_args(download=True)) + with ( + patch("alt_ani_cli.download.run", side_effect=DownloadFailedError("x")), + patch("alt_ani_cli.ui.progress.error"), + ): + result = handle_run_action(state) + assert result is Screen.PLAYER_PICK + assert state.ep_idx == 0 + + +@pytest.mark.unit +class TestDownloadRetryOnAnotherPlayer: + """Episode → player A → download failure → PLAYER_PICK → player B → download success → next episode.""" + + def _drive(self, state: FlowState, start: Screen) -> list[Screen]: + screens = [start] + screen = start + while not (screen is Screen.EPISODE_DISPATCH and state.ep_idx == 1): + screen = HANDLERS[screen](state) + screens.append(screen) + assert len(screens) < 20, screens + return screens + + def test_failed_download_retries_on_picked_player_without_asking_action(self): + state = _download_state(stream=None, embed=None) + stream_b = Stream(url="https://cdn.example.com/b.mp4", ext="mp4") + streams = {_PLAYER.online_id: _STREAM, _PLAYER2.online_id: stream_b} + downloaded: list[str] = [] + + def fake_resolve(client, players, chosen, **kwargs): + return streams[chosen.online_id], _EMBED + + def fake_download(stream, ep, ref): + downloaded.append(stream.url) + if stream is _STREAM: + raise DownloadFailedError("x") + + with ( + patch("alt_ani_cli.cli._resolve_with_fallback", side_effect=fake_resolve), + patch("alt_ani_cli.download.run", side_effect=fake_download), + patch("alt_ani_cli.ui.menus.select_player_once", return_value=("pick", _PLAYER2)) as mock_pick, + patch("alt_ani_cli.ui.menus.select_action") as mock_action, + patch("alt_ani_cli.history.upsert") as mock_upsert, + patch("alt_ani_cli.ui.progress.error"), + patch("alt_ani_cli.ui.progress.warn"), + ): + screens = self._drive(state, Screen.RESOLVE_STREAM) + + assert screens == [ + Screen.RESOLVE_STREAM, + Screen.ACTION_PICK, + Screen.RUN_ACTION, + Screen.PLAYER_PICK, + Screen.RESOLVE_STREAM, + Screen.ACTION_PICK, + Screen.RUN_ACTION, + Screen.EPISODE_DISPATCH, + ] + assert downloaded == [_STREAM.url, stream_b.url] + assert mock_pick.call_args.kwargs["failed"] == {_PLAYER.online_id} + mock_action.assert_not_called() + mock_upsert.assert_not_called() + assert state.episode_action == "download" + + @pytest.mark.unit class TestSortedByDateDesc: def test_sorts_by_year_descending(self): diff --git a/tests/test_diagnostics.py b/tests/test_diagnostics.py index acc9ca7..13974d2 100644 --- a/tests/test_diagnostics.py +++ b/tests/test_diagnostics.py @@ -175,6 +175,12 @@ def test_playback_result_includes_local_mpv_log_path_but_no_urls(self, log_path, assert str(mpv_log) in content assert "https://" not in content + def test_download_result_records_host_and_exception_class_only(self, log_path): + diagnostics.download_result("vidara.to", ok=False, exc="DownloadFailedError") + + content = log_path.read_text(encoding="utf-8") + assert "event=download_result host=vidara.to ok=False exc=DownloadFailedError" in content + def test_session_start_records_environment_without_urls(self, log_path): diagnostics.session_start("1.2.3", "3.14.0", "Windows-11-10.0.26200") diff --git a/tests/test_download.py b/tests/test_download.py index 4e62a9d..5f52e07 100644 --- a/tests/test_download.py +++ b/tests/test_download.py @@ -1,10 +1,13 @@ +import sys from pathlib import Path from unittest.mock import patch import pytest +from yt_dlp.utils import DownloadError from alt_ani_cli import download from alt_ani_cli.config import USER_AGENT +from alt_ani_cli.errors import DownloadFailedError from alt_ani_cli.extract.common import Stream from alt_ani_cli.models import EpisodeRow, SeriesRef @@ -17,21 +20,26 @@ class _FakeYDL: instances: list[_FakeYDL] = [] info: dict = {} + fail_in: str | None = None def __init__(self, opts: dict) -> None: self.opts = opts self.final_path: Path | None = None self.part_existed_at_download: bool | None = None + self.exited = False _FakeYDL.instances.append(self) def __enter__(self) -> _FakeYDL: return self def __exit__(self, *exc) -> None: + self.exited = True return None def extract_info(self, url: str, download: bool = True) -> dict: assert download is False + if self.fail_in == "extract_info": + raise DownloadError("ERROR: [generic] master: Unable to download webpage: timed out (https://h/m?token=SECRET)") return dict(self.info) def prepare_filename(self, info: dict) -> str: @@ -40,6 +48,8 @@ def prepare_filename(self, info: dict) -> str: def process_ie_result(self, info: dict, download: bool = True) -> dict: self.final_path = Path(self.prepare_filename(info)) self.part_existed_at_download = self.final_path.with_name(self.final_path.name + ".part").exists() + if self.fail_in == "process_ie_result": + raise DownloadError("ERROR: fragment 2 not found, unable to continue (https://h/s2.ts?token=SECRET)") return info @@ -47,6 +57,7 @@ def process_ie_result(self, info: dict, download: bool = True) -> dict: def fake_ydl(): _FakeYDL.instances = [] _FakeYDL.info = {"ext": "mp4", "protocol": "m3u8_native"} + _FakeYDL.fail_in = None with patch("yt_dlp.YoutubeDL", _FakeYDL): yield _FakeYDL @@ -158,3 +169,57 @@ def test_existing_backup_is_not_overwritten(self, tmp_path): assert backup.read_bytes() == b"newer legacy" assert Path(f"{final}.part.legacy-ffmpeg").read_bytes() == b"older legacy" assert not Path(f"{final}.part").exists() + + +@pytest.mark.unit +class TestDownloadFailure: + @pytest.mark.parametrize("stage", ["extract_info", "process_ie_result"]) + def test_ytdlp_error_becomes_download_failed_error(self, fake_ydl, tmp_path, stage): + fake_ydl.fail_in = stage + with pytest.raises(DownloadFailedError) as info: + _run(Stream(url="https://h/master.m3u8", ext="m3u8"), tmp_path) + assert "token" not in str(info.value) + assert "http" not in str(info.value) + + @pytest.mark.parametrize("stage", ["extract_info", "process_ie_result"]) + def test_failure_does_not_report_saved(self, fake_ydl, tmp_path, stage): + fake_ydl.fail_in = stage + (tmp_path / "Show - ep1.mp4").write_bytes(b"older complete file") + with patch.object(download.progress, "success") as success, pytest.raises(DownloadFailedError): + _run(Stream(url="https://h/master.m3u8", ext="m3u8"), tmp_path) + success.assert_not_called() + + def test_original_ytdlp_exception_is_not_chained(self, fake_ydl, tmp_path): + fake_ydl.fail_in = "process_ie_result" + with pytest.raises(DownloadFailedError) as info: + _run(Stream(url="https://h/master.m3u8", ext="m3u8"), tmp_path) + assert info.value.__cause__ is None + assert info.value.__context__ is None + + def test_gc_runs_after_context_exit_with_no_active_exception(self, fake_ydl, tmp_path): + fake_ydl.fail_in = "process_ie_result" + seen: list[tuple[bool, object]] = [] + + def collect() -> int: + seen.append((_FakeYDL.instances[-1].exited, sys.exception())) + return 0 + + with patch.object(download.gc, "collect", side_effect=collect), pytest.raises(DownloadFailedError): + _run(Stream(url="https://h/master.m3u8", ext="m3u8"), tmp_path) + assert seen == [(True, None)] + + def test_success_does_not_force_gc(self, fake_ydl, tmp_path): + with patch.object(download.gc, "collect") as collect: + _run(Stream(url="https://h/master.m3u8", ext="m3u8"), tmp_path) + collect.assert_not_called() + + def test_success_reports_saved_path(self, fake_ydl, tmp_path): + class _WritingYDL(_FakeYDL): + def process_ie_result(self, info: dict, download: bool = True) -> dict: + super().process_ie_result(info, download) + self.final_path.write_bytes(b"mp4") + return info + + with patch("yt_dlp.YoutubeDL", _WritingYDL), patch.object(download.progress, "success") as success: + download.run(Stream(url="https://h/master.m3u8", ext="m3u8"), _EP, _SERIES, dest_dir=tmp_path) + assert str(tmp_path / "Show - ep1.mp4") in success.call_args.args[0]