Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/alt_ani_cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
from alt_ani_cli.diagnostics import _host_of_url
from alt_ani_cli.errors import (
AntiBotError,
DownloadFailedError,
FilterMismatchError,
NoStreamError,
ParseError,
Expand Down Expand Up @@ -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)
Expand Down
3 changes: 3 additions & 0 deletions src/alt_ani_cli/content/exceptions_content_en-us.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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."
3 changes: 3 additions & 0 deletions src/alt_ani_cli/content/exceptions_content_pl-pl.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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ę."
1 change: 1 addition & 0 deletions src/alt_ani_cli/content/ui_content_pl-pl.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down
4 changes: 4 additions & 0 deletions src/alt_ani_cli/diagnostics.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
26 changes: 19 additions & 7 deletions src/alt_ani_cli/download.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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()
Expand All @@ -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))
4 changes: 4 additions & 0 deletions src/alt_ani_cli/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
23 changes: 18 additions & 5 deletions src/alt_ani_cli/flow/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand Down
19 changes: 18 additions & 1 deletion tests/cli/test_errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
167 changes: 165 additions & 2 deletions tests/flow/test_handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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):
Expand Down
6 changes: 6 additions & 0 deletions tests/test_diagnostics.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down
Loading
Loading