Skip to content

Commit 56b5c83

Browse files
fix(download): return to player pick on download failure instead of crashing (#22)
1 parent 00fbfc4 commit 56b5c83

12 files changed

Lines changed: 308 additions & 16 deletions

‎src/alt_ani_cli/cli.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from alt_ani_cli.diagnostics import _host_of_url
1717
from alt_ani_cli.errors import (
1818
AntiBotError,
19+
DownloadFailedError,
1920
FilterMismatchError,
2021
NoStreamError,
2122
ParseError,
@@ -683,7 +684,7 @@ def main() -> None: # noqa: C901
683684
else:
684685
progress.error(str(exc))
685686
sys.exit(1)
686-
except (AntiBotError, NoStreamError, ParseError, FilterMismatchError) as exc:
687+
except (AntiBotError, NoStreamError, ParseError, FilterMismatchError, DownloadFailedError) as exc:
687688
diagnostics.session_end("error", type(exc).__name__)
688689
progress.error(str(exc))
689690
sys.exit(1)

‎src/alt_ani_cli/content/exceptions_content_en-us.yaml‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,3 +72,6 @@ player:
7272
VLC not found on PATH.
7373
Install via: winget install VideoLAN.VLC
7474
Or set env: ANI_CLI_PLAYER=C:\path\to\vlc.exe
75+
76+
download:
77+
failed: "Download failed."

‎src/alt_ani_cli/content/exceptions_content_pl-pl.yaml‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,3 +72,6 @@ player:
7272
VLC nie znaleziony w PATH.
7373
Zainstaluj przez: winget install VideoLAN.VLC
7474
Lub wskaż ścieżkę: ANI_CLI_PLAYER=C:\path\to\vlc.exe
75+
76+
download:
77+
failed: "Pobieranie nie powiodło się."

‎src/alt_ani_cli/content/ui_content_pl-pl.yaml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ progress:
5353
player_failed_long: "Player {player} (ep {number:g}) nie zadziałał: {exc}"
5454
player_failed_short: "Player {player} nie zadziałał — wybierz inny:"
5555
no_player_worked: "Żaden player nie zadziałał dla odcinka {number:g} — pomijam."
56+
download_failed: "Pobieranie z playera {player} nie powiodło się."
5657
host_deferred: "Host {host} jest chwilowo niedostępny w tej sesji — odkładam próbę."
5758
host_last_resort: "Pozostałe źródła zawiodły — próbuję odłożonego hosta {host}."
5859
host_dropped: "Host {host} nadal jest niedostępny po próbie last-resort — pomijam kolejnego playera na tym hoście."

‎src/alt_ani_cli/diagnostics.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,5 +183,9 @@ def playback_result(kind: str, rc: int, elapsed: float, confirmed: bool, mpv_log
183183
_emit("playback_result", kind=kind, rc=rc, elapsed=f"{elapsed:.3f}", confirmed=confirmed, mpv_log=mpv_log)
184184

185185

186+
def download_result(host: str | None, ok: bool, exc: str | None) -> None:
187+
_emit("download_result", host=host, ok=ok, exc=exc)
188+
189+
186190
def history_update(series_id: str, last_ep: float) -> None:
187191
_emit("history_update", id=series_id, last_ep=last_ep)

‎src/alt_ani_cli/download.py‎

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1+
import gc
12
import shutil
23
from pathlib import Path
34

45
from alt_ani_cli.config import DOWNLOADS, USER_AGENT
5-
from alt_ani_cli.content import CONTENT
6+
from alt_ani_cli.content import CONTENT, EXCEPTIONS_PL
7+
from alt_ani_cli.errors import DownloadFailedError
68
from alt_ani_cli.extract.common import Stream
79
from alt_ani_cli.models import EpisodeRow, SeriesRef
810
from alt_ani_cli.ui import progress
@@ -69,6 +71,7 @@ def run(
6971
dest_dir: Path = DOWNLOADS,
7072
) -> None:
7173
from yt_dlp import YoutubeDL
74+
from yt_dlp.utils import DownloadError
7275

7376
dest_dir.mkdir(parents=True, exist_ok=True)
7477
safe_title = "".join(c for c in series.title if c.isalnum() or c in " _-").strip()
@@ -90,12 +93,21 @@ def run(
9093
}
9194

9295
progress.info(CONTENT["download"]["starting"].format(title=safe_title, ep_label=ep_label, dir=dest_dir))
93-
with YoutubeDL(opts) as ydl:
94-
info = ydl.extract_info(stream.url, download=False)
95-
final_path = Path(ydl.prepare_filename(info))
96-
if str(info.get("protocol", "")).startswith("m3u8") and (backup := _quarantine_legacy_hls_part(final_path)):
97-
progress.warn(CONTENT["download"]["legacy_part_moved"].format(path=backup))
98-
ydl.process_ie_result(info, download=True)
96+
failed = False
97+
try:
98+
with YoutubeDL(opts) as ydl:
99+
info = ydl.extract_info(stream.url, download=False)
100+
final_path = Path(ydl.prepare_filename(info))
101+
if str(info.get("protocol", "")).startswith("m3u8") and (backup := _quarantine_legacy_hls_part(final_path)):
102+
progress.warn(CONTENT["download"]["legacy_part_moved"].format(path=backup))
103+
ydl.process_ie_result(info, download=True)
104+
except DownloadError:
105+
failed = True
106+
107+
if failed:
108+
# Outside except on purpose: yt-dlp's leaked .part handle sits in a traceback cycle (WinError 32 on reuse).
109+
gc.collect()
110+
raise DownloadFailedError(EXCEPTIONS_PL["download"]["failed"])
99111

100112
if final_path.exists():
101113
progress.success(CONTENT["download"]["saved"].format(path=final_path))

‎src/alt_ani_cli/errors.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,3 +28,7 @@ class ParseError(ShindenError):
2828

2929
class FilterMismatchError(ShindenError):
3030
"""--lang/--subs/--player-name matched no players and --allow-fallback was not set."""
31+
32+
33+
class DownloadFailedError(ShindenError):
34+
"""yt-dlp could not download the stream; carries no library exception or URL."""

‎src/alt_ani_cli/flow/handlers.py‎

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,9 @@
1919

2020
from alt_ani_cli import __version__, diagnostics, download, history
2121
from alt_ani_cli.content import CONTENT
22-
from alt_ani_cli.errors import ShindenError
22+
from alt_ani_cli.errors import DownloadFailedError, ShindenError
2323
from alt_ani_cli.flow.state import BACK, FlowState, Screen, ScreenResult
24-
from alt_ani_cli.models import EmbedURL, PlayerSource, SeriesHit, SeriesMetadata, SeriesRef
24+
from alt_ani_cli.models import EmbedURL, EpisodeRow, PlayerSource, SeriesHit, SeriesMetadata, SeriesRef
2525
from alt_ani_cli.shinden import api as shinden_api
2626
from alt_ani_cli.shinden import episode as shinden_episode
2727
from alt_ani_cli.shinden import search as shinden_search
@@ -420,13 +420,16 @@ def handle_resolve_stream(state: FlowState) -> ScreenResult:
420420
return Screen.QUALITY_PICK
421421
return Screen.ACTION_PICK
422422

423-
# player failed
424423
online_id = state.chosen_player.online_id
425-
state.failed_ids.add(online_id)
426424
failed_embed = state.player_embeds.get(online_id)
427425
if failed_embed is not None:
428426
# the embed resolved fine, only extraction failed downstream — the host is already known
429427
_record_player_source(state, online_id, failed_embed)
428+
return _fail_chosen_player(state, ep)
429+
430+
431+
def _fail_chosen_player(state: FlowState, ep: EpisodeRow) -> ScreenResult:
432+
state.failed_ids.add(state.chosen_player.online_id)
430433
remaining = [p for p in state.players if p.online_id not in state.failed_ids]
431434
if remaining:
432435
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:
487490

488491
completed = False
489492
if args.download or state.episode_action == "download":
490-
download.run(stream, ep, state.ref)
493+
source = state.player_sources.get(state.chosen_player.online_id)
494+
host = source.host if source else None
495+
try:
496+
download.run(stream, ep, state.ref)
497+
except DownloadFailedError as exc:
498+
diagnostics.download_result(host, ok=False, exc=type(exc).__name__)
499+
progress.error(_PROG["download_failed"].format(player=repr(state.chosen_player.player)))
500+
state.stream = None
501+
state.embed = None
502+
return _fail_chosen_player(state, ep)
503+
diagnostics.download_result(host, ok=True, exc=None)
491504
elif args.debug or state.episode_action == "debug":
492505
_print_debug(stream, state.embed)
493506
else:

‎tests/cli/test_errors.py‎

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from curl_cffi.requests.exceptions import HTTPError as CurlHTTPError
77

88
from alt_ani_cli.cli import main
9-
from alt_ani_cli.errors import FilterMismatchError
9+
from alt_ani_cli.errors import DownloadFailedError, FilterMismatchError
1010

1111

1212
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):
9090
assert exc_info.value.code == 1
9191
captured = capsys.readouterr()
9292
assert "--lang=xx" in captured.err + captured.out
93+
94+
95+
@pytest.mark.unit
96+
class TestDownloadFailedErrorHandler:
97+
def test_download_failure_exits_1_with_plain_message(self, capsys):
98+
exc = DownloadFailedError("Pobieranie nie powiodło się.")
99+
with (
100+
patch("sys.argv", ["alt-ani-cli", "--url", "https://shinden.pl/series/1-test", "-S", "1", "-d"]),
101+
patch("alt_ani_cli.cli._run_noninteractive", side_effect=exc),
102+
patch("alt_ani_cli.cli.shinden_http.make_client"),
103+
pytest.raises(SystemExit) as exc_info,
104+
):
105+
main()
106+
assert exc_info.value.code == 1
107+
combined = "".join(capsys.readouterr())
108+
assert "Pobieranie nie powiodło się." in combined
109+
assert "Błąd shinden" not in combined

‎tests/flow/test_handlers.py‎

Lines changed: 165 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@
1111
import pytest
1212
from curl_cffi.requests.exceptions import RequestException as CurlRequestException
1313

14-
from alt_ani_cli.errors import AntiBotError, NoStreamError, ShindenError
14+
from alt_ani_cli.content import CONTENT
15+
from alt_ani_cli.errors import AntiBotError, DownloadFailedError, NoStreamError, ShindenError
1516
from alt_ani_cli.extract.common import Stream
1617
from alt_ani_cli.flow.handlers import (
1718
HANDLERS,
@@ -22,7 +23,7 @@
2223
handle_run_action,
2324
)
2425
from alt_ani_cli.flow.state import BACK, FlowState, Screen, _BackSentinel
25-
from alt_ani_cli.models import EmbedURL, RelatedSeries, SeriesMetadata
26+
from alt_ani_cli.models import EmbedURL, PlayerSource, RelatedSeries, SeriesMetadata
2627
from alt_ani_cli.player.runner import PlayResult
2728
from alt_ani_cli.shinden.models import EpisodeRow, PlayerEntry, SeriesHit, SeriesRef
2829

@@ -560,6 +561,8 @@ def _play_state(self, episode_action="play", **overrides):
560561
stream=Stream(url="https://cdn.example.com/v.m3u8", headers={}, ext="m3u8"),
561562
embed=EmbedURL(url="https://morencius.com/embed/abc", referer="https://shinden.pl/"),
562563
episode_action=episode_action,
564+
players=[_PLAYER],
565+
chosen_player=_PLAYER,
563566
**overrides,
564567
)
565568
return state
@@ -671,6 +674,8 @@ def _play_state(self, episode_action="play", **overrides):
671674
stream=Stream(url="https://cdn.example.com/v.m3u8", headers={}, ext="m3u8"),
672675
embed=EmbedURL(url="https://morencius.com/embed/abc", referer="https://shinden.pl/"),
673676
episode_action=episode_action,
677+
players=[_PLAYER],
678+
chosen_player=_PLAYER,
674679
**overrides,
675680
)
676681
return state
@@ -758,6 +763,164 @@ def test_ep_idx_advances_regardless_of_confirmation(self):
758763
assert state.ep_idx == 1
759764

760765

766+
_STREAM = Stream(url="https://cdn.example.com/v.m3u8", headers={}, ext="m3u8")
767+
_EMBED = EmbedURL(url="https://vidara.to/e/abc", referer="https://shinden.pl/")
768+
769+
770+
def _download_state(players=(_PLAYER, _PLAYER2), **overrides) -> FlowState:
771+
state = _make_state(
772+
ref=_SERIES_REF,
773+
targets=[_EP1, _EP2],
774+
ep_idx=0,
775+
players=list(players),
776+
chosen_player=players[0],
777+
stream=_STREAM,
778+
embed=_EMBED,
779+
episode_action="download",
780+
)
781+
for k, v in overrides.items():
782+
setattr(state, k, v)
783+
return state
784+
785+
786+
@pytest.mark.unit
787+
class TestHandleRunActionDownloadFailure:
788+
def test_success_advances_episode_without_history(self):
789+
state = _download_state()
790+
with (
791+
patch("alt_ani_cli.download.run") as mock_download,
792+
patch("alt_ani_cli.history.upsert") as mock_upsert,
793+
patch("alt_ani_cli.diagnostics.download_result") as mock_diag,
794+
):
795+
result = handle_run_action(state)
796+
mock_download.assert_called_once()
797+
mock_upsert.assert_not_called()
798+
mock_diag.assert_called_once_with(None, ok=True, exc=None)
799+
assert result is Screen.EPISODE_DISPATCH
800+
assert state.ep_idx == 1
801+
assert state.failed_ids == set()
802+
803+
def test_failure_with_other_players_returns_to_player_pick(self):
804+
state = _download_state()
805+
with (
806+
patch("alt_ani_cli.download.run", side_effect=DownloadFailedError("x")),
807+
patch("alt_ani_cli.history.upsert") as mock_upsert,
808+
patch("alt_ani_cli.ui.progress.error") as mock_error,
809+
):
810+
result = handle_run_action(state)
811+
assert result is Screen.PLAYER_PICK
812+
assert state.ep_idx == 0
813+
assert state.failed_ids == {_PLAYER.online_id}
814+
assert state.stream is None
815+
assert state.embed is None
816+
assert state.completed_eps == set()
817+
assert state.episode_action == "download"
818+
mock_upsert.assert_not_called()
819+
assert _PLAYER.player in mock_error.call_args.args[0]
820+
821+
def test_failure_logs_redacted_diagnostics(self):
822+
state = _download_state()
823+
state.player_sources = {_PLAYER.online_id: PlayerSource(_PLAYER.online_id, "vidara.to", _EMBED.url)}
824+
with (
825+
patch("alt_ani_cli.download.run", side_effect=DownloadFailedError("x")),
826+
patch("alt_ani_cli.ui.progress.error"),
827+
patch("alt_ani_cli.diagnostics.download_result") as mock_diag,
828+
):
829+
handle_run_action(state)
830+
mock_diag.assert_called_once_with("vidara.to", ok=False, exc="DownloadFailedError")
831+
832+
def test_failure_on_last_remaining_player_skips_episode(self):
833+
state = _download_state(failed_ids={_PLAYER.online_id}, chosen_player=_PLAYER2)
834+
with (
835+
patch("alt_ani_cli.download.run", side_effect=DownloadFailedError("x")),
836+
patch("alt_ani_cli.history.upsert") as mock_upsert,
837+
patch("alt_ani_cli.ui.progress.error"),
838+
patch("alt_ani_cli.ui.progress.warn") as mock_warn,
839+
):
840+
result = handle_run_action(state)
841+
assert result is Screen.EPISODE_DISPATCH
842+
assert state.ep_idx == 1
843+
assert state.completed_eps == set()
844+
mock_upsert.assert_not_called()
845+
assert mock_warn.call_args.args[0] == CONTENT["progress"]["no_player_worked"].format(number=_EP1.number)
846+
847+
def test_failure_with_single_player_skips_episode(self):
848+
state = _download_state(players=(_PLAYER,))
849+
with (
850+
patch("alt_ani_cli.download.run", side_effect=DownloadFailedError("x")),
851+
patch("alt_ani_cli.ui.progress.error"),
852+
patch("alt_ani_cli.ui.progress.warn"),
853+
):
854+
result = handle_run_action(state)
855+
assert result is Screen.EPISODE_DISPATCH
856+
assert state.ep_idx == 1
857+
858+
def test_args_download_failure_also_returns_to_player_pick(self):
859+
state = _download_state(episode_action=None, args=_make_args(download=True))
860+
with (
861+
patch("alt_ani_cli.download.run", side_effect=DownloadFailedError("x")),
862+
patch("alt_ani_cli.ui.progress.error"),
863+
):
864+
result = handle_run_action(state)
865+
assert result is Screen.PLAYER_PICK
866+
assert state.ep_idx == 0
867+
868+
869+
@pytest.mark.unit
870+
class TestDownloadRetryOnAnotherPlayer:
871+
"""Episode → player A → download failure → PLAYER_PICK → player B → download success → next episode."""
872+
873+
def _drive(self, state: FlowState, start: Screen) -> list[Screen]:
874+
screens = [start]
875+
screen = start
876+
while not (screen is Screen.EPISODE_DISPATCH and state.ep_idx == 1):
877+
screen = HANDLERS[screen](state)
878+
screens.append(screen)
879+
assert len(screens) < 20, screens
880+
return screens
881+
882+
def test_failed_download_retries_on_picked_player_without_asking_action(self):
883+
state = _download_state(stream=None, embed=None)
884+
stream_b = Stream(url="https://cdn.example.com/b.mp4", ext="mp4")
885+
streams = {_PLAYER.online_id: _STREAM, _PLAYER2.online_id: stream_b}
886+
downloaded: list[str] = []
887+
888+
def fake_resolve(client, players, chosen, **kwargs):
889+
return streams[chosen.online_id], _EMBED
890+
891+
def fake_download(stream, ep, ref):
892+
downloaded.append(stream.url)
893+
if stream is _STREAM:
894+
raise DownloadFailedError("x")
895+
896+
with (
897+
patch("alt_ani_cli.cli._resolve_with_fallback", side_effect=fake_resolve),
898+
patch("alt_ani_cli.download.run", side_effect=fake_download),
899+
patch("alt_ani_cli.ui.menus.select_player_once", return_value=("pick", _PLAYER2)) as mock_pick,
900+
patch("alt_ani_cli.ui.menus.select_action") as mock_action,
901+
patch("alt_ani_cli.history.upsert") as mock_upsert,
902+
patch("alt_ani_cli.ui.progress.error"),
903+
patch("alt_ani_cli.ui.progress.warn"),
904+
):
905+
screens = self._drive(state, Screen.RESOLVE_STREAM)
906+
907+
assert screens == [
908+
Screen.RESOLVE_STREAM,
909+
Screen.ACTION_PICK,
910+
Screen.RUN_ACTION,
911+
Screen.PLAYER_PICK,
912+
Screen.RESOLVE_STREAM,
913+
Screen.ACTION_PICK,
914+
Screen.RUN_ACTION,
915+
Screen.EPISODE_DISPATCH,
916+
]
917+
assert downloaded == [_STREAM.url, stream_b.url]
918+
assert mock_pick.call_args.kwargs["failed"] == {_PLAYER.online_id}
919+
mock_action.assert_not_called()
920+
mock_upsert.assert_not_called()
921+
assert state.episode_action == "download"
922+
923+
761924
@pytest.mark.unit
762925
class TestSortedByDateDesc:
763926
def test_sorts_by_year_descending(self):

0 commit comments

Comments
 (0)