From b80edd8940db99daa8b7b8411391bce852c5d3dd Mon Sep 17 00:00:00 2001 From: Aleksander <71835339+Cybernetic-Ransomware@users.noreply.github.com> Date: Sun, 4 Oct 2026 11:49:58 +0200 Subject: [PATCH 01/11] feat(health): add session-aware resolver health and fallback ordering (#16) Adds session-scoped resolver health tracking, unavailable-host deferral, and health-aware tie-breaking within exact preference groups. --- src/alt_ani_cli/cli.py | 161 ++++- src/alt_ani_cli/content/ui_content_pl-pl.yaml | 3 + src/alt_ani_cli/diagnostics.py | 57 +- src/alt_ani_cli/extract/__init__.py | 14 + src/alt_ani_cli/flow/handlers.py | 1 + src/alt_ani_cli/flow/state.py | 5 + src/alt_ani_cli/health.py | 274 +++++++ tests/cli/test_noninteractive.py | 298 ++++++++ tests/cli/test_resolve_with_fallback.py | 518 +++++++++++++- tests/conftest.py | 14 + tests/content/test_ui_labels.py | 3 + tests/extract/test_dispatch.py | 37 +- tests/flow/test_handlers.py | 23 + tests/flow/test_state.py | 89 +++ tests/test_diagnostics.py | 153 +++- tests/test_health.py | 666 ++++++++++++++++++ tests/test_replay_failed_resolvers.py | 198 ++++++ 17 files changed, 2497 insertions(+), 17 deletions(-) create mode 100644 src/alt_ani_cli/health.py create mode 100644 tests/cli/test_noninteractive.py create mode 100644 tests/flow/test_state.py create mode 100644 tests/test_health.py diff --git a/src/alt_ani_cli/cli.py b/src/alt_ani_cli/cli.py index 5a86347..b97b0ab 100644 --- a/src/alt_ani_cli/cli.py +++ b/src/alt_ani_cli/cli.py @@ -5,6 +5,7 @@ import platform import sys import time +from dataclasses import dataclass from urllib.parse import urlparse from curl_cffi.requests.exceptions import HTTPError as CurlHTTPError @@ -22,6 +23,7 @@ ShindenError, ) from alt_ani_cli.extract.common import Stream +from alt_ani_cli.health import HealthTransition, ResolverHealth, Signal, classify_failure from alt_ani_cli.models import EmbedURL, EpisodeRow, PlayerEntry, SeriesRef from alt_ani_cli.player import runner as player_runner from alt_ani_cli.shinden import api as shinden_api @@ -214,6 +216,70 @@ def _extract_stream(embed: EmbedURL, cookies_file: str | None, cookies_browser: ) +def _emit_health_transition(transition: HealthTransition | None, *, category: str | None, http_status: int | None) -> None: + if transition is None: + return + diagnostics.host_health( + host=transition.host, + from_state=transition.before.value, + to_state=transition.after.value, + signal=transition.signal.value, + online_id=transition.online_id, + category=category, + http_status=http_status, + resolver=extract.resolver_family(transition.host), + evidence_expired=transition.evidence_expired, + ) + + +def _record_extraction_success(host: str | None, candidate: PlayerEntry, health: ResolverHealth | None, start: float) -> None: + if health is not None and host is not None: + transition = health.record(host, candidate.online_id, Signal.SUCCESS) + _emit_health_transition(transition, category=None, http_status=None) + diagnostics.resolve_result(host, True, None, time.monotonic() - start) + + +def _record_extraction_failure( + host: str | None, + candidate: PlayerEntry, + exc: Exception, + health: ResolverHealth | None, + start: float, + ep_number: float, +) -> None: + fields = {name: getattr(exc, name, None) for name in _RESOLVE_DIAG_FIELDS} + if isinstance(exc, AntiBotError): + fields.update(layer="shinden_api", category="anti_bot") + if health is not None and host is not None: + signal = classify_failure( + host, + layer=fields.get("layer") or "", + category=fields.get("category"), + http_status=fields.get("http_status"), + used_fallback=bool(fields.get("used_fallback")), + fallback_category=fields.get("fallback_category"), + fallback_http_status=fields.get("fallback_http_status"), + ) + transition = health.record(host, candidate.online_id, signal) + _emit_health_transition(transition, category=fields.get("category"), http_status=fields.get("http_status")) + diagnostics.resolve_result(host, False, type(exc).__name__, time.monotonic() - start, **fields) + progress.warn(_PROG["player_failed_long"].format(player=repr(candidate.player), number=ep_number, exc=exc)) + + +@dataclass +class _DeferredCandidate: + """A candidate whose embed is already resolved but whose extraction was postponed. + + Carries the resolved ``embed`` so last-resort can retry extraction without a second + resolve_embed call — the host is only known after resolving, so deferral can never + happen earlier than this. + """ + + candidate: PlayerEntry + embed: EmbedURL + host: str + + def _resolve_with_fallback( client, players: list[PlayerEntry], @@ -224,6 +290,7 @@ def _resolve_with_fallback( cookies_file: str | None = None, cookies_browser: str | None = None, embed_cache: dict[str, EmbedURL] | None = None, + health: ResolverHealth | None = None, ): """Try chosen player; if it fails and auto-mode is active, walk down the sorted list. @@ -231,6 +298,11 @@ def _resolve_with_fallback( For user-chosen players (auto=False) only the selected player is attempted. A candidate found in embed_cache skips the 5 s resolve; if extraction on the cached embed fails, one fresh resolve is attempted before giving up on it. + + ``health``, when given, records outcomes for diagnostics and — in auto mode only — + gates *when* a candidate's extraction runs: a host already known UNAVAILABLE in this + session is deferred to the end of the candidate list, then given at least one real + attempt in a last-resort pass if nothing else worked. Player order itself never changes. """ candidates = players if auto else [chosen] # Always start with the explicitly chosen player @@ -238,8 +310,11 @@ def _resolve_with_fallback( candidates = [chosen] + [p for p in candidates if p is not chosen] cache = embed_cache if embed_cache is not None else {} + deferred: list[_DeferredCandidate] = [] for candidate in candidates: + if auto: + diagnostics.player_selected(candidate.online_id, candidate.player, None) start = time.monotonic() host_hint: str | None = None try: @@ -250,6 +325,23 @@ def _resolve_with_fallback( embed = _resolve_embed_with_spinner(client, candidate.online_id) cache[candidate.online_id] = embed host_hint = _host_of_url(embed.url) + if health is not None: + health.learn_label(candidate.player, host_hint) + + if auto and health is not None and health.should_defer(host_hint): + deferred.append(_DeferredCandidate(candidate=candidate, embed=embed, host=host_hint)) + diagnostics.health_defer( + action="deferred", + host=host_hint, + online_id=candidate.online_id, + player=candidate.player, + state=health.state(host_hint).value, + category=None, + http_status=None, + ) + progress.warn(_PROG["host_deferred"].format(host=host_hint)) + continue + try: stream = _extract_stream(embed, cookies_file, cookies_browser) except NoStreamError: @@ -258,15 +350,51 @@ def _resolve_with_fallback( embed = _resolve_embed_with_spinner(client, candidate.online_id) cache[candidate.online_id] = embed host_hint = _host_of_url(embed.url) + if health is not None: + health.learn_label(candidate.player, host_hint) stream = _extract_stream(embed, cookies_file, cookies_browser) - diagnostics.resolve_result(host_hint, True, None, time.monotonic() - start) + _record_extraction_success(host_hint, candidate, health, start) return stream, embed except (NoStreamError, AntiBotError) as exc: - fields = {name: getattr(exc, name, None) for name in _RESOLVE_DIAG_FIELDS} - if isinstance(exc, AntiBotError): - fields.update(layer="shinden_api", category="anti_bot") - diagnostics.resolve_result(host_hint, False, type(exc).__name__, time.monotonic() - start, **fields) - progress.warn(_PROG["player_failed_long"].format(player=repr(candidate.player), number=ep_number, exc=exc)) + _record_extraction_failure(host_hint, candidate, exc, health, start, ep_number) + + if deferred and health is not None: + retried_hosts: set[str] = set() + for entry in deferred: + if entry.host in retried_hosts and health.should_defer(entry.host): + diagnostics.health_defer( + action="dropped", + host=entry.host, + online_id=entry.candidate.online_id, + player=entry.candidate.player, + state=health.state(entry.host).value, + category=None, + http_status=None, + ) + progress.warn(_PROG["host_dropped"].format(host=entry.host)) + continue + + diagnostics.player_selected(entry.candidate.online_id, entry.candidate.player, None) + diagnostics.health_defer( + action="retry", + host=entry.host, + online_id=entry.candidate.online_id, + player=entry.candidate.player, + state=health.state(entry.host).value, + category=None, + http_status=None, + ) + progress.warn(_PROG["host_last_resort"].format(host=entry.host)) + retried_hosts.add(entry.host) + + retry_start = time.monotonic() + try: + stream = _extract_stream(entry.embed, cookies_file, cookies_browser) + except NoStreamError as exc: + _record_extraction_failure(entry.host, entry.candidate, exc, health, retry_start, ep_number) + continue + _record_extraction_success(entry.host, entry.candidate, health, retry_start) + return stream, entry.embed return None, None @@ -405,9 +533,11 @@ def _run_noninteractive(args, client) -> None: # noqa: C901 player_kind = "vlc" if args.vlc else "mpv" _episode_action: str | None = None + health = ResolverHealth() for ep in targets: progress.info(_PROG["episode"].format(number=ep.number, title=ep.title)) + diagnostics.episode_selected(ep.number, ep.title) ep_resp = client.get(ep.url) ep_resp.raise_for_status() @@ -434,6 +564,16 @@ def _run_noninteractive(args, client) -> None: # noqa: C901 else: players = filtered + ordered = health.order(players) + if [p.online_id for p in ordered] != [p.online_id for p in players]: + diagnostics.health_reorder( + number=ep.number, + before=",".join(p.online_id for p in players), + after=",".join(p.online_id for p in ordered), + states=",".join(health.predict(p.player).value for p in ordered), + ) + players = ordered + chosen = players[0] stream, embed = _resolve_with_fallback( client, @@ -443,6 +583,7 @@ def _run_noninteractive(args, client) -> None: # noqa: C901 ep_number=ep.number, cookies_file=args.cookies_file, cookies_browser=args.cookies_browser, + health=health, ) if stream is None: @@ -513,11 +654,11 @@ def main() -> None: # noqa: C901 client = shinden_http.make_client() interactive = sys.stdin.isatty() and not args.select_nth + mode = "interactive" if interactive else "noninteractive" - if interactive: - diag_path = diagnostics.configure() - diagnostics.session_start(__version__, platform.python_version(), platform.platform()) - progress.info(_PROG["diagnostics_log_hint"].format(path=diag_path)) + diag_path = diagnostics.configure() + diagnostics.session_start(__version__, platform.python_version(), platform.platform(), mode=mode) + progress.info(_PROG["diagnostics_log_hint"].format(path=diag_path)) try: if interactive: 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 3beb774..8492d4f 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,9 @@ 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." + 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." history_empty: "Historia jest pusta." history_cleared: "Historia wyczyszczona." interrupted: "Przerwano przez użytkownika." diff --git a/src/alt_ani_cli/diagnostics.py b/src/alt_ani_cli/diagnostics.py index 81fc3ca..da7dd78 100644 --- a/src/alt_ani_cli/diagnostics.py +++ b/src/alt_ani_cli/diagnostics.py @@ -1,4 +1,4 @@ -"""Local-only session diagnostics log for interactive soak testing. +"""Local-only session diagnostics log for interactive and noninteractive runs. Never sent over the network — written to a plain-text file under ``CACHE_DIR/diagnostics``. Every public function here accepts only @@ -73,8 +73,8 @@ def _emit(event: str, **fields: object) -> None: _logger.info(" ".join(parts)) -def session_start(version: str, python_version: str, platform: str) -> None: - _emit("session_start", version=version, python=python_version, platform=platform) +def session_start(version: str, python_version: str, platform: str, *, mode: str | None = None) -> None: + _emit("session_start", version=version, python=python_version, platform=platform, mode=mode) def session_end(outcome: str, exc: str | None) -> None: @@ -128,6 +128,57 @@ def resolve_result( ) +def host_health( + host: str, + from_state: str, + to_state: str, + signal: str, + online_id: str | None, + category: str | None, + http_status: int | None, + resolver: str | None, + evidence_expired: bool, +) -> None: + _emit( + "host_health", + host=host, + from_state=from_state, + to_state=to_state, + signal=signal, + online_id=online_id, + category=category, + http_status=http_status, + resolver=resolver, + evidence_expired=evidence_expired, + ) + + +def health_defer( + action: str, + host: str, + online_id: str | None, + player: str | None, + state: str, + category: str | None, + http_status: int | None, +) -> None: + _emit( + "health_defer", + action=action, + host=host, + online_id=online_id, + player=player, + state=state, + category=category, + http_status=http_status, + ) + + +def health_reorder(number: float, before: str, after: str, states: str) -> None: + """``before``/``after`` are comma-joined online_id sequences; ``states`` aligns with ``after``.""" + _emit("health_reorder", number=number, before=before, after=after, states=states) + + def playback_result(kind: str, rc: int, elapsed: float, confirmed: bool, mpv_log: str | None) -> None: _emit("playback_result", kind=kind, rc=rc, elapsed=f"{elapsed:.3f}", confirmed=confirmed, mpv_log=mpv_log) diff --git a/src/alt_ani_cli/extract/__init__.py b/src/alt_ani_cli/extract/__init__.py index d66947c..7d7c7d2 100644 --- a/src/alt_ani_cli/extract/__init__.py +++ b/src/alt_ani_cli/extract/__init__.py @@ -136,6 +136,20 @@ class HostRule: } +def resolver_family(host: str) -> str: + """Diagnostics-only family label for a hostname — never influences health decisions.""" + rule = HOST_RULES.get(host) or HOST_RULES.get(f"www.{host}") + if rule is None: + return "generic" + if rule.mode == "unsupported": + return "unsupported" + if rule.mode == "ytdlp": + return "ytdlp" + if rule.resolver is None: + return "jwplayer" + return rule.resolver.__module__.rsplit(".", 1)[-1] + + def _exc_text(exc: Exception, embed_url: str, host: str) -> str: """Format an exception for user-facing messages, replacing the embed URL with its host.""" return f"{type(exc).__name__}: {exc}".replace(repr(embed_url), host).replace(embed_url, host) diff --git a/src/alt_ani_cli/flow/handlers.py b/src/alt_ani_cli/flow/handlers.py index b15318f..e678371 100644 --- a/src/alt_ani_cli/flow/handlers.py +++ b/src/alt_ani_cli/flow/handlers.py @@ -408,6 +408,7 @@ def handle_resolve_stream(state: FlowState) -> ScreenResult: cookies_file=state.args.cookies_file, cookies_browser=state.args.cookies_browser, embed_cache=state.player_embeds, + health=state.health, ) if stream is not None: diff --git a/src/alt_ani_cli/flow/state.py b/src/alt_ani_cli/flow/state.py index d2781b4..ca4b268 100644 --- a/src/alt_ani_cli/flow/state.py +++ b/src/alt_ani_cli/flow/state.py @@ -4,6 +4,8 @@ from enum import Enum, auto from typing import Any +from alt_ani_cli.health import ResolverHealth + class Screen(Enum): START_MODE = auto() @@ -77,6 +79,9 @@ class FlowState: quality: str | None = None episode_action: str | None = None + # observability only — never gates player choice, order, or skipping + health: ResolverHealth = field(default_factory=ResolverHealth) + @property def current_ep(self): return self.targets[self.ep_idx] if self.ep_idx < len(self.targets) else None diff --git a/src/alt_ani_cli/health.py b/src/alt_ani_cli/health.py new file mode 100644 index 0000000..f0ac88a --- /dev/null +++ b/src/alt_ani_cli/health.py @@ -0,0 +1,274 @@ +"""Session-scoped resolver health model and failure classifier. + +Pure domain logic — no network, no persistence, no singleton. A ``ResolverHealth`` instance +is created explicitly per session/run by its caller; this module only defines the model. +""" + +import time +from collections.abc import Callable +from dataclasses import dataclass, field +from enum import IntEnum, StrEnum + +from alt_ani_cli.models import PlayerEntry + +EVIDENCE_TTL_SEC = 600.0 + + +class HostState(StrEnum): + UNKNOWN = "unknown" + HEALTHY = "healthy" + DEGRADED = "degraded" + UNAVAILABLE = "unavailable" + + +# Order() tie-break rank, best first. Never used by should_defer() — ordering only. +_ORDER_RANK: dict[HostState, int] = { + HostState.HEALTHY: 0, + HostState.UNKNOWN: 1, + HostState.DEGRADED: 2, + HostState.UNAVAILABLE: 3, +} + + +class Signal(StrEnum): + SUCCESS = "success" + FILE = "file" + SOFT = "soft" + TRANSIENT = "transient" + HARD = "hard" + UNSUPPORTED = "unsupported" + IGNORE = "ignore" + + +class _LayerEffect(IntEnum): + """Internal severity of a single resolver layer's failure — never exposed outside classify_failure().""" + + FILE = 0 + SOFT = 1 + TRANSIENT = 2 + NETWORK = 3 + + +@dataclass(frozen=True) +class HealthTransition: + host: str + before: HostState + after: HostState + signal: Signal + online_id: str + evidence_expired: bool + + +# Mirrors extract/common.py's CATEGORY_* constants; duplicated (not imported) so health.py stays extract-independent. +_CATEGORY_EFFECTS: dict[str, _LayerEffect] = { + "no_stream_url": _LayerEffect.FILE, + "parser_drift": _LayerEffect.SOFT, + "unknown": _LayerEffect.SOFT, + "unsupported_host": _LayerEffect.SOFT, + "timeout": _LayerEffect.TRANSIENT, + "network_error": _LayerEffect.NETWORK, +} + + +def _http_error_effect(http_status: int | None) -> _LayerEffect: + if http_status is None: + return _LayerEffect.SOFT + if http_status in (404, 410): + return _LayerEffect.FILE + if http_status == 429: + return _LayerEffect.TRANSIENT + if 500 <= http_status <= 599: + return _LayerEffect.TRANSIENT + if 400 <= http_status <= 499: + return _LayerEffect.SOFT + return _LayerEffect.SOFT + + +def _layer_effect(category: str, http_status: int | None) -> _LayerEffect: + if category == "http_error": + return _http_error_effect(http_status) + # Unrecognized categories default to SOFT — never assume a strong signal we weren't told about. + return _CATEGORY_EFFECTS.get(category, _LayerEffect.SOFT) + + +def classify_failure( + host: str | None, + *, + layer: str, + category: str | None, + http_status: int | None, + used_fallback: bool, + fallback_category: str | None, + fallback_http_status: int | None, +) -> Signal: + if host is None or host == "unknown-host": + return Signal.IGNORE + if layer == "shinden_api": + return Signal.IGNORE + if category is None: + return Signal.IGNORE + if layer == "unsupported": + return Signal.UNSUPPORTED + + effects = [_layer_effect(category, http_status)] + if used_fallback and fallback_category is not None: + effects.append(_layer_effect(fallback_category, fallback_http_status)) + + worst = min(effects) + if worst is _LayerEffect.FILE: + return Signal.FILE + if worst is _LayerEffect.SOFT: + return Signal.SOFT + if worst is _LayerEffect.TRANSIENT: + return Signal.TRANSIENT + return Signal.HARD if len(effects) >= 2 else Signal.TRANSIENT + + +@dataclass +class _HostRecord: + successes: int = 0 + soft_ids: set[str] = field(default_factory=set) + transport_ids: set[str] = field(default_factory=set) + hard: bool = False + unsupported: bool = False + last_failure_at: float | None = None + last_state: HostState = HostState.UNKNOWN + + +class ResolverHealth: + """Session-scoped resolver health store. Not a singleton — create one per session/run.""" + + def __init__(self, clock: Callable[[], float] = time.monotonic, ttl: float = EVIDENCE_TTL_SEC) -> None: + self._clock = clock + self._ttl = ttl + self._hosts: dict[str, _HostRecord] = {} + self._labels: dict[str, set[str]] = {} + + @staticmethod + def _normalize_label(label: str) -> str: + return label.strip().casefold() + + def _evidence_active(self, record: _HostRecord, now: float) -> bool: + return record.last_failure_at is not None and (now - record.last_failure_at) <= self._ttl + + def _compute_state(self, record: _HostRecord, now: float) -> HostState: + if record.unsupported: + return HostState.UNAVAILABLE + evidence_active = self._evidence_active(record, now) + if evidence_active and (record.hard or len(record.transport_ids) >= 2): + return HostState.UNAVAILABLE + if evidence_active and (record.soft_ids or record.transport_ids): + return HostState.DEGRADED + if record.successes > 0: + return HostState.HEALTHY + return HostState.UNKNOWN + + def state(self, host: str) -> HostState: + record = self._hosts.get(host) + if record is None: + return HostState.UNKNOWN + return self._compute_state(record, self._clock()) + + def should_defer(self, host: str) -> bool: + record = self._hosts.get(host) + if record is None or record.unsupported: + return False + return self._compute_state(record, self._clock()) == HostState.UNAVAILABLE + + def learn_label(self, label: str, host: str | None) -> None: + """Feeds predict()/order() only — never should_defer(), which always gates on the exact host.""" + if host is None or host == "unknown-host": + return + self._labels.setdefault(self._normalize_label(label), set()).add(host) + + def predict(self, label: str) -> HostState: + """Ordering hint only, never gates extraction; re-queries state() per host so TTL applies live.""" + hosts = self._labels.get(self._normalize_label(label)) + if not hosts: + return HostState.UNKNOWN + states = {self.state(host) for host in hosts} + if HostState.HEALTHY in states: + return HostState.HEALTHY + if states == {HostState.UNAVAILABLE}: + return HostState.UNAVAILABLE + if HostState.DEGRADED in states or HostState.UNAVAILABLE in states: + return HostState.DEGRADED + return HostState.UNKNOWN + + @staticmethod + def _group_key(player: PlayerEntry) -> tuple[str, str, str]: + return ( + player.lang_audio.strip().casefold(), + player.lang_subs.strip().casefold(), + (player.max_res or "").strip().casefold(), + ) + + def order(self, players: list[PlayerEntry]) -> list[PlayerEntry]: + """Stable tie-break within contiguous (lang_audio, lang_subs, max_res) runs — never across them.""" + if not players: + return [] + groups: list[list[PlayerEntry]] = [[players[0]]] + for player in players[1:]: + if self._group_key(player) == self._group_key(groups[-1][-1]): + groups[-1].append(player) + else: + groups.append([player]) + ordered: list[PlayerEntry] = [] + for group in groups: + ordered.extend(sorted(group, key=lambda p: _ORDER_RANK[self.predict(p.player)])) + return ordered + + def _apply(self, record: _HostRecord, signal: Signal, online_id: str, now: float) -> None: + if signal is Signal.SUCCESS: + record.soft_ids.clear() + record.transport_ids.clear() + record.hard = False + record.last_failure_at = None + record.successes += 1 + elif signal is Signal.FILE: + record.transport_ids.clear() + record.hard = False + elif signal is Signal.SOFT: + record.soft_ids.add(online_id) + record.last_failure_at = now + elif signal is Signal.TRANSIENT: + record.transport_ids.add(online_id) + record.last_failure_at = now + elif signal is Signal.HARD: + record.transport_ids.add(online_id) + record.hard = True + record.last_failure_at = now + elif signal is Signal.UNSUPPORTED: + record.unsupported = True + + def record(self, host: str | None, online_id: str, signal: Signal) -> HealthTransition | None: + if host is None or signal is Signal.IGNORE: + return None + + record = self._hosts.setdefault(host, _HostRecord()) + now = self._clock() + + evidence_expired = False + if record.last_failure_at is not None and (now - record.last_failure_at) > self._ttl: + record.soft_ids.clear() + record.transport_ids.clear() + record.hard = False + record.last_failure_at = None + evidence_expired = True + + self._apply(record, signal, online_id, now) + + before = record.last_state + after = self._compute_state(record, now) + record.last_state = after + + if after != before or evidence_expired: + return HealthTransition( + host=host, + before=before, + after=after, + signal=signal, + online_id=online_id, + evidence_expired=evidence_expired, + ) + return None diff --git a/tests/cli/test_noninteractive.py b/tests/cli/test_noninteractive.py new file mode 100644 index 0000000..eb6f74c --- /dev/null +++ b/tests/cli/test_noninteractive.py @@ -0,0 +1,298 @@ +"""Tests for noninteractive diagnostics wiring — session lifecycle, episode/player context.""" + +import argparse +from unittest.mock import MagicMock, patch + +import pytest + +from alt_ani_cli import diagnostics +from alt_ani_cli.cli import _run_noninteractive, main +from alt_ani_cli.errors import AntiBotError, NoStreamError +from alt_ani_cli.models import EmbedURL, EpisodeRow, PlayerEntry, SeriesRef + +_REF = SeriesRef(id="1", slug="fate", title="Fate", url="http://shinden.pl/series/1-fate") +_EP1 = EpisodeRow(number=1.0, title="Ep 1", url="http://shinden.pl/ep/1") +_PLAYER_A = PlayerEntry(online_id="a1", player="PlayerA", lang_audio="jp", lang_subs="pl") +_PLAYER_B = PlayerEntry(online_id="b2", player="PlayerB", lang_audio="jp", lang_subs="pl") + + +def _make_args(**overrides): + defaults = dict( + query=[], + url="http://shinden.pl/series/1-fate", + resume=False, + download=False, + delete_history=False, + episode=None, + quality=None, + select_nth=None, + vlc=False, + no_detach=False, + debug=False, + player_name=None, + lang=None, + subs=None, + allow_fallback=False, + show_sources=False, + cookies_file=None, + cookies_browser=None, + ) + defaults.update(overrides) + return argparse.Namespace(**defaults) + + +def _events(content: str) -> list[str]: + return [next(tok for tok in line.split() if tok.startswith("event=")) for line in content.splitlines()] + + +@pytest.mark.unit +class TestNoninteractiveDiagnosticsContext: + """Runs _run_noninteractive against a real, tmp_path-isolated diagnostics log.""" + + def test_episode_and_player_context_precede_each_resolve_result(self): + diagnostics.configure() + args = _make_args() + client = MagicMock() + client.get.return_value = MagicMock(raise_for_status=MagicMock(), text="") + + with ( + patch("alt_ani_cli.cli.shinden_series.parse_series_url", return_value=_REF), + patch("alt_ani_cli.cli.shinden_series.list_episodes", return_value=(_REF, [_EP1])), + patch("alt_ani_cli.cli.shinden_episode.parse_players", return_value=[_PLAYER_A, _PLAYER_B]), + patch("alt_ani_cli.cli.shinden_episode.sort_players", return_value=[_PLAYER_A, _PLAYER_B]), + patch("alt_ani_cli.cli.shinden_api.resolve_embed", side_effect=AntiBotError("blocked")), + ): + _run_noninteractive(args, client) + + (log_file,) = diagnostics.DIAG_DIR.glob("session_*.log") + content = log_file.read_text(encoding="utf-8") + + assert _events(content) == [ + "event=series_selected", + "event=episode_selected", + "event=player_selected", + "event=resolve_result", + "event=player_selected", + "event=resolve_result", + ] + assert f"online_id={_PLAYER_A.online_id}" in content.splitlines()[2] + assert f"online_id={_PLAYER_B.online_id}" in content.splitlines()[4] + + def test_episode_selected_uses_final_episode_number_and_title(self): + diagnostics.configure() + args = _make_args() + client = MagicMock() + client.get.return_value = MagicMock(raise_for_status=MagicMock(), text="") + + with ( + patch("alt_ani_cli.cli.shinden_series.parse_series_url", return_value=_REF), + patch("alt_ani_cli.cli.shinden_series.list_episodes", return_value=(_REF, [_EP1])), + patch("alt_ani_cli.cli.shinden_episode.parse_players", return_value=[]), + ): + _run_noninteractive(args, client) + + (log_file,) = diagnostics.DIAG_DIR.glob("session_*.log") + content = log_file.read_text(encoding="utf-8") + assert f"number={_EP1.number}" in content + assert _EP1.title in content + + +@pytest.mark.unit +class TestMainConfiguresDiagnosticsInBothModes: + def test_noninteractive_run_records_mode_and_session_end(self): + with ( + patch("sys.argv", ["alt-ani-cli", "--url", "http://shinden.pl/series/1-fate"]), + patch("alt_ani_cli.cli.shinden_http.make_client"), + patch("alt_ani_cli.cli._run_noninteractive"), + ): + main() + + (log_file,) = diagnostics.DIAG_DIR.glob("session_*.log") + content = log_file.read_text(encoding="utf-8") + assert "event=session_start" in content + assert "mode=noninteractive" in content + assert "event=session_end" in content + assert "outcome=ok" in content + + +@pytest.mark.unit +class TestNoninteractiveHealthDeferAcrossEpisodes: + """Health persists across the episode loop and defers a known-unavailable host — order never changes.""" + + def test_host_marked_unavailable_in_episode_one_is_deferred_in_episode_two(self): + embeds = { + "p1": EmbedURL(url="https://hostx.example/e/p1", referer="https://shinden.pl/"), + "p2": EmbedURL(url="https://hostx.example/e/p2", referer="https://shinden.pl/"), + "p3": EmbedURL(url="https://hosty.example/e/p3", referer="https://shinden.pl/"), + "p4": EmbedURL(url="https://hostx.example/e/p4", referer="https://shinden.pl/"), + "p5": EmbedURL(url="https://hosty.example/e/p5", referer="https://shinden.pl/"), + } + players_ep1 = [ + PlayerEntry(online_id="p1", player="P1", lang_audio="pl", lang_subs="pl"), + PlayerEntry(online_id="p2", player="P2", lang_audio="pl", lang_subs="pl"), + PlayerEntry(online_id="p3", player="P3", lang_audio="pl", lang_subs="pl"), + ] + players_ep2 = [ + PlayerEntry(online_id="p4", player="P4", lang_audio="pl", lang_subs="pl"), + PlayerEntry(online_id="p5", player="P5", lang_audio="pl", lang_subs="pl"), + ] + ep2 = EpisodeRow(number=2.0, title="Ep 2", url="http://shinden.pl/ep/2") + + stream_success = MagicMock(qualities={}) + + def _extract_side_effect(url, _referer, **_kwargs): + if "hostx" in url: + exc = NoStreamError("transient") + exc.layer = "custom" + exc.category = "network_error" + exc.http_status = None + exc.used_fallback = False + exc.fallback_category = None + exc.fallback_http_status = None + raise exc + return stream_success + + resolve_embed_ids: list[str] = [] + + def _resolve_embed_side_effect(_client, online_id): + resolve_embed_ids.append(online_id) + return embeds[online_id] + + client = MagicMock() + client.get.return_value = MagicMock(raise_for_status=MagicMock(), text="") + + with ( + patch("alt_ani_cli.cli.shinden_series.parse_series_url", return_value=_REF), + patch("alt_ani_cli.cli.shinden_series.list_episodes", return_value=(_REF, [_EP1, ep2])), + patch("alt_ani_cli.cli.shinden_episode.parse_players", side_effect=[players_ep1, players_ep2]), + patch("alt_ani_cli.cli.shinden_episode.sort_players", side_effect=lambda players, download: players), + patch("alt_ani_cli.cli.shinden_api.resolve_embed", side_effect=_resolve_embed_side_effect), + patch("alt_ani_cli.cli.extract.resolve", side_effect=_extract_side_effect) as mock_extract, + patch("alt_ani_cli.cli.diagnostics.health_defer") as mock_defer, + patch("alt_ani_cli.cli.player_runner.play", return_value=MagicMock(rc=0, elapsed=5.0)), + patch("alt_ani_cli.cli.history.upsert"), + ): + args = _make_args(episode="1-2") + _run_noninteractive(args, client) + + # Only p4 (episode 2) is deferred — host X only reaches UNAVAILABLE after p2's failure in episode 1. + mock_defer.assert_called_once() + _, kwargs = mock_defer.call_args + assert kwargs["action"] == "deferred" + assert kwargs["host"] == "hostx.example" + assert kwargs["online_id"] == "p4" + + # resolve_embed still runs for the deferred candidate — only its extraction is skipped + assert resolve_embed_ids.count("p4") == 1 + + extracted_urls = [c.args[0] for c in mock_extract.call_args_list] + assert extracted_urls == [ + embeds["p1"].url, + embeds["p2"].url, + embeds["p3"].url, + embeds["p5"].url, + ] + + +@pytest.mark.unit +class TestNoninteractiveReorder: + """health.order() tie-breaks auto-mode candidates within a (lang_audio, lang_subs, max_res) group.""" + + def test_no_reorder_event_on_first_episode_without_prior_evidence(self): + client = MagicMock() + client.get.return_value = MagicMock(raise_for_status=MagicMock(), text="") + + with ( + patch("alt_ani_cli.cli.shinden_series.parse_series_url", return_value=_REF), + patch("alt_ani_cli.cli.shinden_series.list_episodes", return_value=(_REF, [_EP1])), + patch("alt_ani_cli.cli.shinden_episode.parse_players", return_value=[_PLAYER_A, _PLAYER_B]), + patch("alt_ani_cli.cli.shinden_episode.sort_players", return_value=[_PLAYER_A, _PLAYER_B]), + patch("alt_ani_cli.cli.shinden_api.resolve_embed", side_effect=AntiBotError("blocked")), + patch("alt_ani_cli.cli.diagnostics.health_reorder") as mock_reorder, + ): + _run_noninteractive(_make_args(), client) + + mock_reorder.assert_not_called() + + def test_reorder_from_prior_episode_evidence_respects_group_boundary(self): + embeds = { + "pa1": EmbedURL(url="https://a-host.example/e/pa1", referer="https://shinden.pl/"), + "pb1": EmbedURL(url="https://b-host.example/e/pb1", referer="https://shinden.pl/"), + "qa2": EmbedURL(url="https://a-host.example/e/qa2", referer="https://shinden.pl/"), + "qb2": EmbedURL(url="https://b-host.example/e/qb2", referer="https://shinden.pl/"), + "qc2": EmbedURL(url="https://c-host.example/e/qc2", referer="https://shinden.pl/"), + } + # Episode 1: A (1080p) soft-fails -> DEGRADED, B (same group) succeeds -> HEALTHY, C (720p) is never tried. + pa1 = PlayerEntry(online_id="pa1", player="A", lang_audio="jp", lang_subs="pl", max_res="1080p") + pb1 = PlayerEntry(online_id="pb1", player="B", lang_audio="jp", lang_subs="pl", max_res="1080p") + pc1 = PlayerEntry(online_id="pc1", player="C", lang_audio="jp", lang_subs="pl", max_res="720p") + players_ep1 = [pa1, pb1, pc1] + + qa2 = PlayerEntry(online_id="qa2", player="A", lang_audio="jp", lang_subs="pl", max_res="1080p") + qb2 = PlayerEntry(online_id="qb2", player="B", lang_audio="jp", lang_subs="pl", max_res="1080p") + qc2 = PlayerEntry(online_id="qc2", player="C", lang_audio="jp", lang_subs="pl", max_res="720p") + players_ep2 = [qa2, qb2, qc2] + ep2 = EpisodeRow(number=2.0, title="Ep 2", url="http://shinden.pl/ep/2") + + stream_success = MagicMock(qualities={}) + + def _extract_side_effect(url, _referer, **_kwargs): + if "a-host" in url and "qa2" not in url: + exc = NoStreamError("forbidden") + exc.layer = "custom" + exc.category = "http_error" + exc.http_status = 403 + exc.used_fallback = False + exc.fallback_category = None + exc.fallback_http_status = None + raise exc + return stream_success + + resolve_embed_ids: list[str] = [] + + def _resolve_embed_side_effect(_client, online_id): + resolve_embed_ids.append(online_id) + return embeds[online_id] + + client = MagicMock() + client.get.return_value = MagicMock(raise_for_status=MagicMock(), text="") + + with ( + patch("alt_ani_cli.cli.shinden_series.parse_series_url", return_value=_REF), + patch("alt_ani_cli.cli.shinden_series.list_episodes", return_value=(_REF, [_EP1, ep2])), + patch("alt_ani_cli.cli.shinden_episode.parse_players", side_effect=[players_ep1, players_ep2]), + patch("alt_ani_cli.cli.shinden_episode.sort_players", side_effect=lambda players, download: players), + patch("alt_ani_cli.cli.shinden_api.resolve_embed", side_effect=_resolve_embed_side_effect), + patch("alt_ani_cli.cli.extract.resolve", side_effect=_extract_side_effect), + patch("alt_ani_cli.cli.diagnostics.health_reorder") as mock_reorder, + patch("alt_ani_cli.cli.player_runner.play", return_value=MagicMock(rc=0, elapsed=5.0)), + patch("alt_ani_cli.cli.history.upsert"), + ): + args = _make_args(episode="1-2") + _run_noninteractive(args, client) + + # No reorder in episode 1 — no evidence existed yet when it started. + mock_reorder.assert_called_once() + _, kwargs = mock_reorder.call_args + assert kwargs["number"] == ep2.number + + before_ids = kwargs["before"].split(",") + after_ids = kwargs["after"].split(",") + states = kwargs["states"].split(",") + + assert set(before_ids) == set(after_ids) == {"qa2", "qb2", "qc2"} + assert before_ids == ["qa2", "qb2", "qc2"] + + # B (healthy) is promoted ahead of A (degraded) within their shared 1080p group... + assert after_ids == ["qb2", "qa2", "qc2"] + # ...but C (a separate, 720p group) never moves — the group boundary is preserved. + assert after_ids[-1] == "qc2" + + # states is positionally aligned with after. + assert states == ["healthy", "degraded", "unknown"] + + # chosen == ordered[0]: episode 2's first extraction attempt is for B, not A (original order). + # Episode 1 only ever resolves pa1 (fails) then pb1 (succeeds and returns) — pc1 is never reached. + assert resolve_embed_ids[:2] == ["pa1", "pb1"] + second_episode_resolve_ids = resolve_embed_ids[2:] + assert second_episode_resolve_ids[0] == "qb2" diff --git a/tests/cli/test_resolve_with_fallback.py b/tests/cli/test_resolve_with_fallback.py index b9b17ea..ef44917 100644 --- a/tests/cli/test_resolve_with_fallback.py +++ b/tests/cli/test_resolve_with_fallback.py @@ -1,11 +1,12 @@ """Tests for cli._resolve_with_fallback — diagnostics fields passed through on failure.""" -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock, call, patch import pytest from alt_ani_cli.cli import _resolve_with_fallback from alt_ani_cli.errors import AntiBotError, NoStreamError +from alt_ani_cli.health import HostState, ResolverHealth, Signal from alt_ani_cli.models import EmbedURL, PlayerEntry _PLAYER = PlayerEntry(online_id="123", player="Default", lang_audio="pl", lang_subs="pl") @@ -96,3 +97,518 @@ def test_antibot_error_from_embed_resolution_tags_shinden_layer(self): assert args[2] == "AntiBotError" assert kwargs["layer"] == "shinden_api" assert kwargs["category"] == "anti_bot" + + +@pytest.mark.unit +class TestResolveWithFallbackHealthObservationOnly: + """health=None keeps today's behavior; health=ResolverHealth() only ever records, never decides.""" + + def test_health_none_preserves_existing_behavior(self): + stream = MagicMock() + with ( + patch("alt_ani_cli.cli.shinden_api.resolve_embed", return_value=_EMBED), + patch("alt_ani_cli.cli.extract.resolve", return_value=stream), + patch("alt_ani_cli.cli.diagnostics.host_health") as mock_health_diag, + ): + result, embed = _resolve_with_fallback(MagicMock(), [_PLAYER], _PLAYER, False, 1.0, health=None) + assert result is stream + assert embed is _EMBED + mock_health_diag.assert_not_called() + + def test_success_records_health_and_emits_host_health(self): + stream = MagicMock() + health = ResolverHealth() + with ( + patch("alt_ani_cli.cli.shinden_api.resolve_embed", return_value=_EMBED), + patch("alt_ani_cli.cli.extract.resolve", return_value=stream), + patch("alt_ani_cli.cli.diagnostics.resolve_result") as mock_result, + patch("alt_ani_cli.cli.diagnostics.host_health") as mock_health_diag, + ): + result, embed = _resolve_with_fallback(MagicMock(), [_PLAYER], _PLAYER, False, 1.0, health=health) + + assert result is stream + assert health.state("vidawra.cc") == HostState.HEALTHY + mock_result.assert_called_once() + mock_health_diag.assert_called_once() + _, kwargs = mock_health_diag.call_args + assert kwargs["host"] == "vidawra.cc" + assert kwargs["from_state"] == "unknown" + assert kwargs["to_state"] == "healthy" + assert kwargs["signal"] == "success" + assert kwargs["online_id"] == _PLAYER.online_id + assert kwargs["category"] is None + assert kwargs["http_status"] is None + assert kwargs["resolver"] == "vidara" + assert kwargs["evidence_expired"] is False + + def test_no_stream_error_classifies_and_emits_host_health(self): + exc = _tagged_no_stream_error( + layer="jwplayer", + category="http_error", + http_status=403, + used_fallback=False, + fallback_layer=None, + fallback_category=None, + fallback_http_status=None, + ) + health = ResolverHealth() + with ( + patch("alt_ani_cli.cli.shinden_api.resolve_embed", return_value=_EMBED), + patch("alt_ani_cli.cli.extract.resolve", side_effect=exc), + patch("alt_ani_cli.cli.diagnostics.resolve_result") as mock_result, + patch("alt_ani_cli.cli.diagnostics.host_health") as mock_health_diag, + ): + result, embed = _resolve_with_fallback(MagicMock(), [_PLAYER], _PLAYER, False, 1.0, health=health) + + assert result is None + assert health.state("vidawra.cc") == HostState.DEGRADED + mock_result.assert_called_once() + mock_health_diag.assert_called_once() + _, kwargs = mock_health_diag.call_args + assert kwargs["to_state"] == "degraded" + assert kwargs["signal"] == "soft" + assert kwargs["category"] == "http_error" + assert kwargs["http_status"] == 403 + assert kwargs["resolver"] == "vidara" + + def test_antibot_error_does_not_mutate_or_emit_health(self): + health = ResolverHealth() + with ( + patch("alt_ani_cli.cli.shinden_api.resolve_embed", side_effect=AntiBotError("guest token expired")), + patch("alt_ani_cli.cli.diagnostics.resolve_result") as mock_result, + patch("alt_ani_cli.cli.diagnostics.host_health") as mock_health_diag, + ): + result, embed = _resolve_with_fallback(MagicMock(), [_PLAYER], _PLAYER, False, 1.0, health=health) + + assert result is None + mock_health_diag.assert_not_called() + assert health.state("vidawra.cc") == HostState.UNKNOWN + _, kwargs = mock_result.call_args + assert kwargs["layer"] == "shinden_api" + assert kwargs["category"] == "anti_bot" + + def test_auto_false_still_attempts_when_host_already_marked_unavailable(self): + """The single most important contract of this stage: health only observes, never gates.""" + health = ResolverHealth() + health.record("vidawra.cc", "other-online-id", Signal.HARD) + assert health.state("vidawra.cc") == HostState.UNAVAILABLE + + stream = MagicMock() + with ( + patch("alt_ani_cli.cli.shinden_api.resolve_embed", return_value=_EMBED), + patch("alt_ani_cli.cli.extract.resolve", return_value=stream) as mock_resolve, + patch("alt_ani_cli.cli.diagnostics.resolve_result"), + patch("alt_ani_cli.cli.diagnostics.host_health"), + ): + result, embed = _resolve_with_fallback(MagicMock(), [_PLAYER], _PLAYER, False, 1.0, health=health) + + mock_resolve.assert_called_once() + assert result is stream + assert embed is _EMBED + + def test_auto_mode_emits_player_selected_before_each_candidate(self): + player2 = PlayerEntry(online_id="456", player="Backup", lang_audio="pl", lang_subs="pl") + with ( + patch("alt_ani_cli.cli.shinden_api.resolve_embed", side_effect=AntiBotError("blocked")), + patch("alt_ani_cli.cli.diagnostics.resolve_result"), + patch("alt_ani_cli.cli.diagnostics.player_selected") as mock_player_selected, + ): + _resolve_with_fallback(MagicMock(), [_PLAYER, player2], _PLAYER, True, 1.0) + + assert mock_player_selected.call_args_list == [ + call(_PLAYER.online_id, _PLAYER.player, None), + call(player2.online_id, player2.player, None), + ] + + def test_auto_false_does_not_emit_player_selected(self): + with ( + patch("alt_ani_cli.cli.shinden_api.resolve_embed", side_effect=AntiBotError("blocked")), + patch("alt_ani_cli.cli.diagnostics.resolve_result"), + patch("alt_ani_cli.cli.diagnostics.player_selected") as mock_player_selected, + ): + _resolve_with_fallback(MagicMock(), [_PLAYER], _PLAYER, False, 1.0) + + mock_player_selected.assert_not_called() + + +_PLAYER_A = PlayerEntry(online_id="a1", player="PlayerA", lang_audio="pl", lang_subs="pl") +_PLAYER_B = PlayerEntry(online_id="b1", player="PlayerB", lang_audio="pl", lang_subs="pl") +_EMBED_HOST_A = EmbedURL(url="https://hosta.example/e/a1", referer="https://shinden.pl/") +_EMBED_HOST_B = EmbedURL(url="https://hostb.example/e/b1", referer="https://shinden.pl/") + + +def _resolve_embed_from(embeds: dict[str, EmbedURL], calls: list[str] | None = None): + def _side_effect(_client, online_id): + if calls is not None: + calls.append(online_id) + return embeds[online_id] + + return _side_effect + + +def _extract_by_url(outcomes: dict[str, object]): + """outcomes maps embed url -> Stream (success) or Exception instance (failure).""" + + def _side_effect(url, _referer, **_kwargs): + outcome = outcomes[url] + if isinstance(outcome, Exception): + raise outcome + return outcome + + return _side_effect + + +@pytest.mark.unit +class TestResolveWithFallbackDeferAndLastResort: + """auto=True defers a candidate whose host is already UNAVAILABLE; last-resort retries it once.""" + + def test_deferred_host_skips_extraction_while_alternative_succeeds(self): + health = ResolverHealth() + health.record("hosta.example", "seed", Signal.HARD) + assert health.should_defer("hosta.example") is True + + stream_b = MagicMock() + resolve_embed_calls: list[str] = [] + with ( + patch( + "alt_ani_cli.cli.shinden_api.resolve_embed", + side_effect=_resolve_embed_from({"a1": _EMBED_HOST_A, "b1": _EMBED_HOST_B}, resolve_embed_calls), + ), + patch( + "alt_ani_cli.cli.extract.resolve", + side_effect=_extract_by_url({_EMBED_HOST_B.url: stream_b}), + ) as mock_extract, + patch("alt_ani_cli.cli.diagnostics.resolve_result") as mock_result, + patch("alt_ani_cli.cli.diagnostics.health_defer") as mock_defer, + ): + result, embed = _resolve_with_fallback(MagicMock(), [_PLAYER_A, _PLAYER_B], _PLAYER_A, True, 1.0, health=health) + + assert result is stream_b + assert embed is _EMBED_HOST_B + assert resolve_embed_calls == ["a1", "b1"] # host A's embed IS resolved — only extraction is skipped + mock_extract.assert_called_once() + assert mock_extract.call_args.args[0] == _EMBED_HOST_B.url + mock_defer.assert_called_once() + _, kwargs = mock_defer.call_args + assert kwargs["action"] == "deferred" + assert kwargs["host"] == "hosta.example" + assert kwargs["online_id"] == "a1" + hosts_recorded = [c.args[0] for c in mock_result.call_args_list] + assert hosts_recorded == ["hostb.example"] # no resolve_result for the deferred host + + def test_last_resort_retries_deferred_candidate_on_the_same_embed(self): + health = ResolverHealth() + health.record("hosta.example", "seed", Signal.HARD) + + stream_a = MagicMock() + exc_b = NoStreamError("b failed") + for name, value in dict( + layer="custom", + category="http_error", + http_status=403, + used_fallback=False, + fallback_category=None, + fallback_http_status=None, + ).items(): + setattr(exc_b, name, value) + + resolve_embed_calls: list[str] = [] + with ( + patch( + "alt_ani_cli.cli.shinden_api.resolve_embed", + side_effect=_resolve_embed_from({"a1": _EMBED_HOST_A, "b1": _EMBED_HOST_B}, resolve_embed_calls), + ), + patch( + "alt_ani_cli.cli.extract.resolve", + side_effect=_extract_by_url({_EMBED_HOST_B.url: exc_b, _EMBED_HOST_A.url: stream_a}), + ), + patch("alt_ani_cli.cli.diagnostics.resolve_result") as mock_result, + patch("alt_ani_cli.cli.diagnostics.health_defer") as mock_defer, + patch("alt_ani_cli.cli.diagnostics.player_selected") as mock_player_selected, + ): + result, embed = _resolve_with_fallback(MagicMock(), [_PLAYER_A, _PLAYER_B], _PLAYER_A, True, 1.0, health=health) + + assert result is stream_a + assert embed is _EMBED_HOST_A + assert resolve_embed_calls.count("a1") == 1 # no second resolve_embed for the last-resort retry + actions = [c.kwargs["action"] for c in mock_defer.call_args_list] + assert actions == ["deferred", "retry"] + player_ids_selected = [c.args[0] for c in mock_player_selected.call_args_list] + assert player_ids_selected == ["a1", "b1", "a1"] # A re-selected before its last-resort retry + hosts_recorded = [c.args[0] for c in mock_result.call_args_list] + assert hosts_recorded == ["hostb.example", "hosta.example"] + + def test_second_deferred_candidate_dropped_when_host_still_unavailable_after_retry(self): + embed_a = EmbedURL(url="https://hostx.example/e/a1", referer="https://shinden.pl/") + embed_b = EmbedURL(url="https://hostx.example/e/b1", referer="https://shinden.pl/") + health = ResolverHealth() + health.record("hostx.example", "seed", Signal.HARD) + + exc = NoStreamError("still down") + for name, value in dict( + layer="custom", + category="network_error", + http_status=None, + used_fallback=True, + fallback_category="network_error", + fallback_http_status=None, + ).items(): + setattr(exc, name, value) + + with ( + patch( + "alt_ani_cli.cli.shinden_api.resolve_embed", + side_effect=_resolve_embed_from({"a1": embed_a, "b1": embed_b}), + ), + patch("alt_ani_cli.cli.extract.resolve", side_effect=exc), + patch("alt_ani_cli.cli.diagnostics.resolve_result") as mock_result, + patch("alt_ani_cli.cli.diagnostics.health_defer") as mock_defer, + ): + result, embed = _resolve_with_fallback(MagicMock(), [_PLAYER_A, _PLAYER_B], _PLAYER_A, True, 1.0, health=health) + + assert result is None + actions = [c.kwargs["action"] for c in mock_defer.call_args_list] + assert actions == ["deferred", "deferred", "retry", "dropped"] + assert mock_result.call_count == 1 # only the real retry attempt on A — B was dropped, not extracted + + def test_second_deferred_candidate_attempted_normally_once_host_recovers(self): + embed_a = EmbedURL(url="https://hostx.example/e/a1", referer="https://shinden.pl/") + embed_b = EmbedURL(url="https://hostx.example/e/b1", referer="https://shinden.pl/") + health = ResolverHealth() + health.record("hostx.example", "seed", Signal.HARD) + + exc_a = NoStreamError("gone") + for name, value in dict( + layer="custom", + category="http_error", + http_status=404, + used_fallback=False, + fallback_category=None, + fallback_http_status=None, + ).items(): + setattr(exc_a, name, value) + stream_b = MagicMock() + + with ( + patch( + "alt_ani_cli.cli.shinden_api.resolve_embed", + side_effect=_resolve_embed_from({"a1": embed_a, "b1": embed_b}), + ), + patch( + "alt_ani_cli.cli.extract.resolve", + side_effect=_extract_by_url({embed_a.url: exc_a, embed_b.url: stream_b}), + ), + patch("alt_ani_cli.cli.diagnostics.resolve_result") as mock_result, + patch("alt_ani_cli.cli.diagnostics.health_defer") as mock_defer, + ): + result, embed = _resolve_with_fallback(MagicMock(), [_PLAYER_A, _PLAYER_B], _PLAYER_A, True, 1.0, health=health) + + assert result is stream_b + assert embed is embed_b + actions = [c.kwargs["action"] for c in mock_defer.call_args_list] + assert actions == ["deferred", "deferred", "retry", "retry"] # B not dropped — host recovered after A's retry + assert mock_result.call_count == 2 + + def test_auto_false_never_defers_even_when_host_unavailable(self): + health = ResolverHealth() + health.record("hosta.example", "seed", Signal.HARD) + + stream = MagicMock() + with ( + patch("alt_ani_cli.cli.shinden_api.resolve_embed", return_value=_EMBED_HOST_A), + patch("alt_ani_cli.cli.extract.resolve", return_value=stream) as mock_extract, + patch("alt_ani_cli.cli.diagnostics.resolve_result"), + patch("alt_ani_cli.cli.diagnostics.health_defer") as mock_defer, + ): + result, embed = _resolve_with_fallback(MagicMock(), [_PLAYER_A], _PLAYER_A, False, 1.0, health=health) + + mock_extract.assert_called_once() + mock_defer.assert_not_called() + assert result is stream + + def test_health_none_never_defers(self): + stream = MagicMock() + with ( + patch("alt_ani_cli.cli.shinden_api.resolve_embed", return_value=_EMBED_HOST_A), + patch("alt_ani_cli.cli.extract.resolve", return_value=stream) as mock_extract, + patch("alt_ani_cli.cli.diagnostics.resolve_result"), + patch("alt_ani_cli.cli.diagnostics.health_defer") as mock_defer, + ): + result, embed = _resolve_with_fallback(MagicMock(), [_PLAYER_A, _PLAYER_B], _PLAYER_A, True, 1.0, health=None) + + mock_extract.assert_called_once() + mock_defer.assert_not_called() + assert result is stream + + def test_unsupported_host_is_never_deferred(self): + health = ResolverHealth() + health.record("unsupported.example", "seed", Signal.UNSUPPORTED) + assert health.should_defer("unsupported.example") is False + + embed = EmbedURL(url="https://unsupported.example/e/a1", referer="https://shinden.pl/") + exc = NoStreamError("unsupported host") + for name, value in dict( + layer="unsupported", + category="unsupported_host", + http_status=None, + used_fallback=False, + fallback_category=None, + fallback_http_status=None, + ).items(): + setattr(exc, name, value) + + with ( + patch("alt_ani_cli.cli.shinden_api.resolve_embed", return_value=embed), + patch("alt_ani_cli.cli.extract.resolve", side_effect=exc) as mock_extract, + patch("alt_ani_cli.cli.diagnostics.resolve_result") as mock_result, + patch("alt_ani_cli.cli.diagnostics.health_defer") as mock_defer, + ): + result, _embed = _resolve_with_fallback(MagicMock(), [_PLAYER_A], _PLAYER_A, True, 1.0, health=health) + + assert result is None + mock_extract.assert_called_once() + mock_defer.assert_not_called() + mock_result.assert_called_once() + + +def _spy_learn_label(health: ResolverHealth) -> MagicMock: + health.learn_label = MagicMock(wraps=health.learn_label) + return health.learn_label + + +@pytest.mark.unit +class TestResolveWithFallbackLearnLabel: + """learn_label is called on every real host discovery — before should_defer, never in last-resort.""" + + def test_learn_label_called_with_host_before_extraction(self): + health = ResolverHealth() + spy = _spy_learn_label(health) + with ( + patch("alt_ani_cli.cli.shinden_api.resolve_embed", return_value=_EMBED), + patch("alt_ani_cli.cli.extract.resolve", return_value=MagicMock()), + patch("alt_ani_cli.cli.diagnostics.resolve_result"), + patch("alt_ani_cli.cli.diagnostics.host_health"), + ): + _resolve_with_fallback(MagicMock(), [_PLAYER], _PLAYER, False, 1.0, health=health) + + spy.assert_called_once_with(_PLAYER.player, "vidawra.cc") + + def test_learn_label_called_on_cache_hit(self): + health = ResolverHealth() + spy = _spy_learn_label(health) + with ( + patch("alt_ani_cli.cli.shinden_api.resolve_embed") as mock_resolve_embed, + patch("alt_ani_cli.cli.extract.resolve", return_value=MagicMock()), + patch("alt_ani_cli.cli.diagnostics.resolve_result"), + patch("alt_ani_cli.cli.diagnostics.host_health"), + ): + _resolve_with_fallback( + MagicMock(), + [_PLAYER], + _PLAYER, + False, + 1.0, + embed_cache={_PLAYER.online_id: _EMBED}, + health=health, + ) + + mock_resolve_embed.assert_not_called() + spy.assert_called_once_with(_PLAYER.player, "vidawra.cc") + + def test_learn_label_called_before_should_defer_for_deferred_candidate(self): + health = ResolverHealth() + health.record("hosta.example", "seed", Signal.HARD) + spy = _spy_learn_label(health) + stream_b = MagicMock() + with ( + patch( + "alt_ani_cli.cli.shinden_api.resolve_embed", + side_effect=_resolve_embed_from({"a1": _EMBED_HOST_A, "b1": _EMBED_HOST_B}), + ), + patch( + "alt_ani_cli.cli.extract.resolve", + side_effect=_extract_by_url({_EMBED_HOST_B.url: stream_b}), + ), + patch("alt_ani_cli.cli.diagnostics.resolve_result"), + patch("alt_ani_cli.cli.diagnostics.health_defer"), + ): + _resolve_with_fallback(MagicMock(), [_PLAYER_A, _PLAYER_B], _PLAYER_A, True, 1.0, health=health) + + assert call(_PLAYER_A.player, "hosta.example") in spy.call_args_list + + def test_learn_label_not_called_again_in_last_resort_retry(self): + health = ResolverHealth() + health.record("hosta.example", "seed", Signal.HARD) + spy = _spy_learn_label(health) + stream_a = MagicMock() + exc_b = _tagged_no_stream_error( + layer="custom", category="http_error", http_status=403, used_fallback=False, + fallback_category=None, fallback_http_status=None, + ) + with ( + patch( + "alt_ani_cli.cli.shinden_api.resolve_embed", + side_effect=_resolve_embed_from({"a1": _EMBED_HOST_A, "b1": _EMBED_HOST_B}), + ), + patch( + "alt_ani_cli.cli.extract.resolve", + side_effect=_extract_by_url({_EMBED_HOST_B.url: exc_b, _EMBED_HOST_A.url: stream_a}), + ), + patch("alt_ani_cli.cli.diagnostics.resolve_result"), + patch("alt_ani_cli.cli.diagnostics.health_defer"), + patch("alt_ani_cli.cli.diagnostics.player_selected"), + ): + _resolve_with_fallback(MagicMock(), [_PLAYER_A, _PLAYER_B], _PLAYER_A, True, 1.0, health=health) + + # exactly one learn_label per candidate across the initial pass + last-resort retry + assert spy.call_args_list == [ + call(_PLAYER_A.player, "hosta.example"), + call(_PLAYER_B.player, "hostb.example"), + ] + + def test_stale_cache_refresh_relearns_new_host_when_it_differs(self): + health = ResolverHealth() + spy = _spy_learn_label(health) + refreshed_embed = EmbedURL(url="https://otherhost.example/e/123", referer="https://shinden.pl/") + stream = MagicMock() + + extract_calls = {"n": 0} + + def _extract_side_effect(url, _referer, **_kwargs): + extract_calls["n"] += 1 + if extract_calls["n"] == 1: + raise NoStreamError("stale token") + return stream + + with ( + patch("alt_ani_cli.cli.shinden_api.resolve_embed", return_value=refreshed_embed), + patch("alt_ani_cli.cli.extract.resolve", side_effect=_extract_side_effect), + patch("alt_ani_cli.cli.diagnostics.resolve_result"), + patch("alt_ani_cli.cli.diagnostics.host_health"), + ): + _resolve_with_fallback( + MagicMock(), + [_PLAYER], + _PLAYER, + False, + 1.0, + embed_cache={_PLAYER.online_id: _EMBED}, + health=health, + ) + + assert spy.call_args_list == [ + call(_PLAYER.player, "vidawra.cc"), + call(_PLAYER.player, "otherhost.example"), + ] + + def test_health_none_never_calls_learn_label(self): + # trivially true, but guards against an accidental unconditional learn_label call. + with ( + patch("alt_ani_cli.cli.shinden_api.resolve_embed", return_value=_EMBED), + patch("alt_ani_cli.cli.extract.resolve", return_value=MagicMock()), + patch("alt_ani_cli.cli.diagnostics.resolve_result"), + ): + result, embed = _resolve_with_fallback(MagicMock(), [_PLAYER], _PLAYER, False, 1.0, health=None) + + assert result is not None + assert embed is _EMBED diff --git a/tests/conftest.py b/tests/conftest.py index 475ad46..a13e8aa 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,6 +1,20 @@ +import logging + import pytest +@pytest.fixture(autouse=True) +def _isolate_diagnostics_dir(tmp_path, monkeypatch): + """Every test gets its own DIAG_DIR — main() now configures diagnostics in noninteractive runs too.""" + from alt_ani_cli import diagnostics + + monkeypatch.setattr(diagnostics, "DIAG_DIR", tmp_path / "diagnostics") + yield + for handler in [h for h in diagnostics._logger.handlers if not isinstance(h, logging.NullHandler)]: + diagnostics._logger.removeHandler(handler) + handler.close() + + @pytest.fixture def player_show_html(): return '
' diff --git a/tests/content/test_ui_labels.py b/tests/content/test_ui_labels.py index a474576..66009b9 100644 --- a/tests/content/test_ui_labels.py +++ b/tests/content/test_ui_labels.py @@ -129,6 +129,9 @@ class TestTemplatePlaceholders: (("progress", "extractor_fallback"), ("{host}", "{exc}")), (("progress", "jwplayer_fallback"), ("{host}", "{exc}")), (("progress", "prefetch_sources"), ("{count}",)), + (("progress", "host_deferred"), ("{host}",)), + (("progress", "host_last_resort"), ("{host}",)), + (("progress", "host_dropped"), ("{host}",)), ], ) def test_template_has_required_placeholders(self, path, placeholders): diff --git a/tests/extract/test_dispatch.py b/tests/extract/test_dispatch.py index 0a3e947..62abe95 100644 --- a/tests/extract/test_dispatch.py +++ b/tests/extract/test_dispatch.py @@ -11,7 +11,7 @@ from yt_dlp.networking.exceptions import TransportError from alt_ani_cli.errors import JavaScriptRequiredError, NoStreamError, UnsupportedHostError -from alt_ani_cli.extract import HOST_RULES, HostRule, _normalize_url, resolve +from alt_ani_cli.extract import HOST_RULES, HostRule, _normalize_url, mp4upload, resolve, resolver_family from alt_ani_cli.extract.common import CATEGORY_NO_STREAM_URL, CATEGORY_PARSER_DRIFT, ExtractError, Stream _REFERER = "https://shinden.pl/" @@ -399,3 +399,38 @@ def test_unknown_host_fallback_http_status_is_kept(self): assert exc_info.value.category == CATEGORY_PARSER_DRIFT assert exc_info.value.fallback_category == "http_error" assert exc_info.value.fallback_http_status == 503 + + +@pytest.mark.unit +class TestResolverFamily: + @pytest.mark.parametrize( + ("host", "family"), + [ + ("dood.la", "dood"), + ("dood.yt", "dood"), + ("vidawra.cc", "vidara"), + ("morningmarkets.art", "vidara"), + ("streamtape.to", "streamtape"), + ("mp4upload.com", "mp4upload"), + ("lycoris.cafe", "lycoris"), + ("playmate.to", "playmate"), + ("uqload.is", "uqload"), + ("flyf.lat", "flyf"), + ("streamwish.com", "jwplayer"), + ("filemoon.sx", "jwplayer"), + ("cda.pl", "ytdlp"), + ("sibnet.ru", "ytdlp"), + ("voe.sx", "unsupported"), + ("mega.nz", "unsupported"), + ("completely-unknown-host.example", "generic"), + ], + ) + def test_known_and_unknown_hosts(self, host, family): + assert resolver_family(host) == family + + def test_falls_back_to_www_variant_when_bare_host_missing(self): + with patch.dict("alt_ani_cli.extract.HOST_RULES", {"www.onlywww.example": HostRule("custom", mp4upload.resolve)}): + assert resolver_family("onlywww.example") == "mp4upload" + + def test_bare_host_takes_priority_over_www_variant(self): + assert resolver_family("mp4upload.com") == "mp4upload" diff --git a/tests/flow/test_handlers.py b/tests/flow/test_handlers.py index 411af4f..5d7000c 100644 --- a/tests/flow/test_handlers.py +++ b/tests/flow/test_handlers.py @@ -1144,3 +1144,26 @@ def test_antibot_error_leaves_host_unknown(self): assert _PLAYER.online_id in state.failed_ids assert _PLAYER.online_id not in state.player_embeds assert _PLAYER.online_id not in state.player_sources + + +@pytest.mark.unit +class TestResolveStreamNeverReorders: + """order() is an auto-mode, noninteractive-only concern — the interactive picker must never trigger it.""" + + def test_health_order_is_never_called(self): + state = _make_state( + ref=_SERIES_REF, + targets=[_EP1], + ep_idx=0, + players=[_PLAYER], + chosen_player=_PLAYER, + ) + with ( + patch("alt_ani_cli.shinden.api.resolve_embed", side_effect=AntiBotError("blocked")), + patch("alt_ani_cli.ui.progress.spinner", _noop_spinner), + patch("alt_ani_cli.ui.progress.warn"), + patch("alt_ani_cli.health.ResolverHealth.order") as mock_order, + ): + HANDLERS[Screen.RESOLVE_STREAM](state) + + mock_order.assert_not_called() diff --git a/tests/flow/test_state.py b/tests/flow/test_state.py new file mode 100644 index 0000000..2ee057c --- /dev/null +++ b/tests/flow/test_state.py @@ -0,0 +1,89 @@ +"""Tests for FlowState — ResolverHealth lives for the whole interactive session.""" + +import argparse +from unittest.mock import MagicMock, patch + +import pytest + +from alt_ani_cli.flow.handlers import handle_episode_dispatch, handle_resolve_stream +from alt_ani_cli.flow.state import FlowState +from alt_ani_cli.health import HostState, ResolverHealth, Signal +from alt_ani_cli.shinden.models import EpisodeRow, PlayerEntry, SeriesRef + +_SERIES_REF = SeriesRef(id="1", slug="fate", title="Fate", url="http://shinden.pl/series/1-fate") +_EP1 = EpisodeRow(number=1.0, title="Ep 1", url="http://shinden.pl/ep/1") +_PLAYER = PlayerEntry(online_id="pid1", player="Sibnet", lang_audio="jp", lang_subs="pl") + + +def _make_args(**overrides): + defaults = dict( + query=[], + url=None, + resume=False, + download=False, + delete_history=False, + episode=None, + quality=None, + select_nth=None, + vlc=False, + no_detach=False, + debug=False, + player_name=None, + lang=None, + subs=None, + allow_fallback=False, + show_sources=False, + cookies_file=None, + cookies_browser=None, + ) + defaults.update(overrides) + return argparse.Namespace(**defaults) + + +@pytest.mark.unit +class TestFlowStateHealth: + def test_has_resolver_health_instance(self): + state = FlowState(args=_make_args(), client=MagicMock()) + assert isinstance(state.health, ResolverHealth) + + def test_two_instances_do_not_share_store(self): + state_a = FlowState(args=_make_args(), client=MagicMock()) + state_b = FlowState(args=_make_args(), client=MagicMock()) + + state_a.health.record("host.com", "a", Signal.HARD) + + assert state_a.health.state("host.com") == HostState.UNAVAILABLE + assert state_b.health.state("host.com") == HostState.UNKNOWN + + def test_episode_dispatch_does_not_reset_health(self): + state = FlowState(args=_make_args(), client=MagicMock(), ref=_SERIES_REF, targets=[_EP1], ep_idx=0) + state.health.record("host.com", "a", Signal.HARD) + + mock_resp = MagicMock() + mock_resp.raise_for_status = MagicMock() + mock_resp.text = "" + state.client.get.return_value = mock_resp + + with ( + patch("alt_ani_cli.shinden.episode.parse_players", return_value=[_PLAYER]), + patch("alt_ani_cli.shinden.episode.sort_players", return_value=[_PLAYER]), + patch("alt_ani_cli.diagnostics.player_selected"), + ): + handle_episode_dispatch(state) + + assert state.health.state("host.com") == HostState.UNAVAILABLE + + def test_resolve_stream_passes_state_health_to_fallback(self): + state = FlowState( + args=_make_args(), + client=MagicMock(), + ref=_SERIES_REF, + targets=[_EP1], + ep_idx=0, + players=[_PLAYER], + chosen_player=_PLAYER, + ) + with patch("alt_ani_cli.cli._resolve_with_fallback", return_value=(None, None)) as mock_fallback: + handle_resolve_stream(state) + + assert mock_fallback.call_args.kwargs["health"] is state.health diff --git a/tests/test_diagnostics.py b/tests/test_diagnostics.py index 162b23d..acc9ca7 100644 --- a/tests/test_diagnostics.py +++ b/tests/test_diagnostics.py @@ -183,18 +183,167 @@ def test_session_start_records_environment_without_urls(self, log_path): assert "python=3.14.0" in content assert "https://" not in content - def test_all_public_events_can_be_emitted_without_raising(self, log_path): + def test_session_start_without_mode_omits_field(self, log_path): diagnostics.session_start("1.0.0", "3.14.0", "Windows") + + content = log_path.read_text(encoding="utf-8") + assert "mode=" not in content + + @pytest.mark.parametrize("mode", ["interactive", "noninteractive"]) + def test_session_start_records_mode(self, log_path, mode): + diagnostics.session_start("1.0.0", "3.14.0", "Windows", mode=mode) + + content = log_path.read_text(encoding="utf-8") + assert f"mode={mode}" in content + + def test_host_health_records_states_and_context_without_urls(self, log_path): + secret_url = "https://evilcdn.example.com/embed/abc?token=SUPERSECRETTOKEN123" + diagnostics.host_health( + host=diagnostics._host_of_url(secret_url), + from_state="degraded", + to_state="unavailable", + signal="transient", + online_id="123", + category="timeout", + http_status=None, + resolver="vidara", + evidence_expired=False, + ) + + content = log_path.read_text(encoding="utf-8") + assert "host=evilcdn.example.com" in content + assert "from_state=degraded" in content + assert "to_state=unavailable" in content + assert "signal=transient" in content + assert "online_id=123" in content + assert "category=timeout" in content + assert "resolver=vidara" in content + assert "evidence_expired=False" in content + assert "http_status=" not in content + assert secret_url not in content + assert "SUPERSECRETTOKEN123" not in content + assert "http://" not in content + assert "https://" not in content + + def test_host_health_bool_and_apostrophe_values_round_trip(self, log_path): + diagnostics.host_health( + host="h.com", + from_state="healthy", + to_state="degraded", + signal="soft", + online_id="1", + category="parser_drift", + http_status=None, + resolver="Bob's Resolver", + evidence_expired=True, + ) + + line = log_path.read_text(encoding="utf-8").splitlines()[0] + assert "evidence_expired=True" in line + _date, _time, rest = line.split(" ", 2) + (resolver_token,) = [t for t in shlex.split(rest) if t.startswith("resolver=")] + assert resolver_token.partition("=")[2] == "Bob's Resolver" + + @pytest.mark.parametrize("action", ["deferred", "retry", "dropped"]) + def test_health_defer_action_values_round_trip(self, log_path, action): + diagnostics.health_defer( + action=action, + host="vidawra.cc", + online_id="123", + player="vidara", + state="unavailable", + category="timeout", + http_status=None, + ) + + content = log_path.read_text(encoding="utf-8") + assert f"action={action}" in content + + def test_health_defer_records_context_without_urls(self, log_path): + diagnostics.health_defer( + action="deferred", + host="vidawra.cc", + online_id="123", + player="vidara", + state="unavailable", + category="timeout", + http_status=None, + ) + + content = log_path.read_text(encoding="utf-8") + assert "host=vidawra.cc" in content + assert "online_id=123" in content + assert "player=vidara" in content + assert "state=unavailable" in content + assert "category=timeout" in content + assert "http_status=" not in content + assert "https://" not in content + + def test_health_defer_omits_none_http_status(self, log_path): + diagnostics.health_defer( + action="retry", host="h.com", online_id="1", player="p", state="degraded", category="timeout", http_status=None + ) + + content = log_path.read_text(encoding="utf-8") + assert "http_status=" not in content + + def test_health_reorder_records_number_before_after_states_without_urls(self, log_path): + diagnostics.health_reorder(number=3.0, before="1,2,3", after="2,1,3", states="healthy,unknown,unavailable") + + content = log_path.read_text(encoding="utf-8") + assert "number=3.0" in content + assert "before=1,2,3" in content + assert "after=2,1,3" in content + assert "states=healthy,unknown,unavailable" in content + assert "http://" not in content + assert "https://" not in content + + def test_health_reorder_comma_lists_round_trip_through_shlex(self, log_path): + diagnostics.health_reorder(number=1.0, before="a1,b1,c1", after="c1,a1,b1", states="healthy,unknown,degraded") + + line = log_path.read_text(encoding="utf-8").splitlines()[0] + _date, _time, rest = line.split(" ", 2) + tokens = shlex.split(rest) + (before_token,) = [t for t in tokens if t.startswith("before=")] + (after_token,) = [t for t in tokens if t.startswith("after=")] + (states_token,) = [t for t in tokens if t.startswith("states=")] + assert before_token == "before=a1,b1,c1" + assert after_token == "after=c1,a1,b1" + assert states_token == "states=healthy,unknown,degraded" + + def test_all_public_events_can_be_emitted_without_raising(self, log_path): + diagnostics.session_start("1.0.0", "3.14.0", "Windows", mode="interactive") diagnostics.series_selected("s1", "Test Series") diagnostics.episode_selected(1.0, "Episode One") diagnostics.player_selected("p1", "mp4upload", "mp4upload.com") diagnostics.resolve_result("mp4upload.com", True, None, 0.5) + diagnostics.host_health( + host="mp4upload.com", + from_state="healthy", + to_state="degraded", + signal="soft", + online_id="p1", + category="parser_drift", + http_status=None, + resolver="mp4upload", + evidence_expired=False, + ) + diagnostics.health_defer( + action="deferred", + host="mp4upload.com", + online_id="p1", + player="mp4upload", + state="degraded", + category="parser_drift", + http_status=None, + ) + diagnostics.health_reorder(number=1.0, before="p1,p2", after="p2,p1", states="healthy,unknown") diagnostics.playback_result("mpv", 0, 10.0, True, None) diagnostics.history_update("s1", 1.0) diagnostics.session_end("ok", None) lines = log_path.read_text(encoding="utf-8").splitlines() - assert len(lines) == 8 + assert len(lines) == 11 @pytest.mark.unit diff --git a/tests/test_health.py b/tests/test_health.py new file mode 100644 index 0000000..bf23e73 --- /dev/null +++ b/tests/test_health.py @@ -0,0 +1,666 @@ +"""Tests for the session-scoped resolver health model and failure classifier.""" + +import ast +from pathlib import Path + +import pytest + +from alt_ani_cli import health +from alt_ani_cli.health import ( + EVIDENCE_TTL_SEC, + HealthTransition, + HostState, + ResolverHealth, + Signal, + _LayerEffect, + classify_failure, +) +from alt_ani_cli.models import PlayerEntry + + +def _player( + online_id: str, + player: str = "Vidoza", + lang_audio: str = "jp", + lang_subs: str = "pl", + max_res: str | None = "1080p", +) -> PlayerEntry: + return PlayerEntry( + online_id=online_id, + player=player, + lang_audio=lang_audio, + lang_subs=lang_subs, + max_res=max_res, + ) + + +class _FakeClock: + def __init__(self, start: float = 0.0) -> None: + self.now = start + + def __call__(self) -> float: + return self.now + + def advance(self, seconds: float) -> None: + self.now += seconds + + +def _classify(**overrides) -> Signal: + kwargs = { + "host": "example.com", + "layer": "custom", + "category": None, + "http_status": None, + "used_fallback": False, + "fallback_category": None, + "fallback_http_status": None, + } + kwargs.update(overrides) + return classify_failure(**kwargs) + + +@pytest.mark.unit +class TestClassifyFailureIgnore: + def test_host_none(self): + assert _classify(host=None, category="timeout") == Signal.IGNORE + + def test_unknown_host(self): + assert _classify(host="unknown-host", category="timeout") == Signal.IGNORE + + def test_shinden_api_layer(self): + assert _classify(layer="shinden_api", category="timeout") == Signal.IGNORE + + def test_category_none(self): + assert _classify(category=None) == Signal.IGNORE + + +@pytest.mark.unit +class TestClassifyFailureUnsupported: + def test_unsupported_layer(self): + assert _classify(layer="unsupported", category="unsupported_host") == Signal.UNSUPPORTED + + +@pytest.mark.unit +class TestClassifyFailureFile: + def test_http_404(self): + assert _classify(category="http_error", http_status=404) == Signal.FILE + + def test_http_410(self): + assert _classify(category="http_error", http_status=410) == Signal.FILE + + def test_no_stream_url(self): + assert _classify(category="no_stream_url") == Signal.FILE + + +@pytest.mark.unit +class TestClassifyFailureSoft: + def test_parser_drift(self): + assert _classify(category="parser_drift") == Signal.SOFT + + def test_unknown_category(self): + assert _classify(category="unknown") == Signal.SOFT + + def test_http_403(self): + assert _classify(category="http_error", http_status=403) == Signal.SOFT + + def test_http_400(self): + assert _classify(category="http_error", http_status=400) == Signal.SOFT + + def test_http_error_no_status(self): + assert _classify(category="http_error", http_status=None) == Signal.SOFT + + def test_unsupported_host_category(self): + assert _classify(category="unsupported_host") == Signal.SOFT + + def test_http_status_outside_known_ranges(self): + assert _classify(category="http_error", http_status=200) == Signal.SOFT + + +@pytest.mark.unit +class TestClassifyFailureTransient: + def test_http_429(self): + assert _classify(category="http_error", http_status=429) == Signal.TRANSIENT + + def test_http_5xx(self): + assert _classify(category="http_error", http_status=503) == Signal.TRANSIENT + + def test_timeout(self): + assert _classify(category="timeout") == Signal.TRANSIENT + + def test_network_error_single_layer(self): + assert _classify(category="network_error") == Signal.TRANSIENT + + +@pytest.mark.unit +class TestClassifyFailureHard: + def test_network_error_both_layers(self): + signal = _classify( + category="network_error", + used_fallback=True, + fallback_category="network_error", + ) + assert signal == Signal.HARD + + +@pytest.mark.unit +class TestClassifyFailureAggregation: + def test_timeout_plus_404_is_file(self): + signal = _classify( + category="timeout", + used_fallback=True, + fallback_category="http_error", + fallback_http_status=404, + ) + assert signal == Signal.FILE + + def test_timeout_plus_unsupported_host_is_soft(self): + signal = _classify( + category="timeout", + used_fallback=True, + fallback_category="unsupported_host", + ) + assert signal == Signal.SOFT + + def test_never_returns_layer_effect(self): + for category in ("no_stream_url", "parser_drift", "unknown", "timeout", "network_error"): + result = _classify(category=category) + assert isinstance(result, Signal) + assert not isinstance(result, _LayerEffect) + + +@pytest.mark.unit +class TestStateTransitions: + def test_start_unknown(self): + h = ResolverHealth(clock=_FakeClock()) + assert h.state("host.com") == HostState.UNKNOWN + + def test_single_file_still_unknown(self): + clock = _FakeClock() + h = ResolverHealth(clock=clock) + h.record("host.com", "a", Signal.FILE) + assert h.state("host.com") == HostState.UNKNOWN + + def test_soft_is_degraded(self): + clock = _FakeClock() + h = ResolverHealth(clock=clock) + h.record("host.com", "a", Signal.SOFT) + assert h.state("host.com") == HostState.DEGRADED + + def test_three_distinct_soft_still_degraded(self): + clock = _FakeClock() + h = ResolverHealth(clock=clock) + h.record("host.com", "a", Signal.SOFT) + h.record("host.com", "b", Signal.SOFT) + h.record("host.com", "c", Signal.SOFT) + assert h.state("host.com") == HostState.DEGRADED + + def test_single_transient_is_degraded(self): + clock = _FakeClock() + h = ResolverHealth(clock=clock) + h.record("host.com", "a", Signal.TRANSIENT) + assert h.state("host.com") == HostState.DEGRADED + + def test_two_transient_same_id_still_degraded(self): + clock = _FakeClock() + h = ResolverHealth(clock=clock) + h.record("host.com", "a", Signal.TRANSIENT) + h.record("host.com", "a", Signal.TRANSIENT) + assert h.state("host.com") == HostState.DEGRADED + + def test_transient_two_different_ids_unavailable(self): + clock = _FakeClock() + h = ResolverHealth(clock=clock) + h.record("host.com", "a", Signal.TRANSIENT) + h.record("host.com", "b", Signal.TRANSIENT) + assert h.state("host.com") == HostState.UNAVAILABLE + + def test_hard_is_unavailable(self): + clock = _FakeClock() + h = ResolverHealth(clock=clock) + h.record("host.com", "a", Signal.HARD) + assert h.state("host.com") == HostState.UNAVAILABLE + + def test_success_after_unavailable_is_healthy(self): + clock = _FakeClock() + h = ResolverHealth(clock=clock) + h.record("host.com", "a", Signal.HARD) + h.record("host.com", "a", Signal.SUCCESS) + assert h.state("host.com") == HostState.HEALTHY + + def test_file_after_transport_unavailable_clears_transport(self): + clock = _FakeClock() + h = ResolverHealth(clock=clock) + h.record("host.com", "a", Signal.TRANSIENT) + h.record("host.com", "b", Signal.TRANSIENT) + assert h.state("host.com") == HostState.UNAVAILABLE + h.record("host.com", "c", Signal.FILE) + assert h.state("host.com") != HostState.UNAVAILABLE + + def test_unsupported_is_unavailable(self): + clock = _FakeClock() + h = ResolverHealth(clock=clock) + h.record("host.com", "a", Signal.UNSUPPORTED) + assert h.state("host.com") == HostState.UNAVAILABLE + + def test_unsupported_does_not_expire(self): + clock = _FakeClock() + h = ResolverHealth(clock=clock) + h.record("host.com", "a", Signal.UNSUPPORTED) + clock.advance(EVIDENCE_TTL_SEC + 10_000) + assert h.state("host.com") == HostState.UNAVAILABLE + + def test_should_defer_transport_unavailable(self): + clock = _FakeClock() + h = ResolverHealth(clock=clock) + h.record("host.com", "a", Signal.HARD) + assert h.should_defer("host.com") is True + + def test_should_defer_unsupported_false(self): + clock = _FakeClock() + h = ResolverHealth(clock=clock) + h.record("host.com", "a", Signal.UNSUPPORTED) + assert h.should_defer("host.com") is False + + def test_should_defer_degraded_false(self): + clock = _FakeClock() + h = ResolverHealth(clock=clock) + h.record("host.com", "a", Signal.SOFT) + assert h.should_defer("host.com") is False + + +@pytest.mark.unit +class TestTtlRegression: + def test_expired_transport_evidence_does_not_resurrect_unavailable(self): + clock = _FakeClock() + h = ResolverHealth(clock=clock) + h.record("host.com", "a", Signal.TRANSIENT) + h.record("host.com", "b", Signal.TRANSIENT) + assert h.state("host.com") == HostState.UNAVAILABLE + + clock.advance(EVIDENCE_TTL_SEC + 1) + assert h.state("host.com") == HostState.UNKNOWN + + transition = h.record("host.com", "c", Signal.TRANSIENT) + assert h.state("host.com") == HostState.DEGRADED + + assert isinstance(transition, HealthTransition) + assert transition.before == HostState.UNAVAILABLE + assert transition.after == HostState.DEGRADED + assert transition.evidence_expired is True + + +@pytest.mark.unit +class TestHealthTransitionContinuity: + def test_chained_transitions_before_matches_previous_after(self): + clock = _FakeClock() + h = ResolverHealth(clock=clock) + transitions = [ + h.record("host.com", "a", Signal.SOFT), + h.record("host.com", "c", Signal.TRANSIENT), + h.record("host.com", "d", Signal.TRANSIENT), + h.record("host.com", "e", Signal.SUCCESS), + ] + + observed = [t for t in transitions if t is not None] + assert [(t.before, t.after) for t in observed] == [ + (HostState.UNKNOWN, HostState.DEGRADED), + (HostState.DEGRADED, HostState.UNAVAILABLE), + (HostState.UNAVAILABLE, HostState.HEALTHY), + ] + + +@pytest.mark.unit +class TestTtlBoundaries: + def test_exactly_at_ttl_still_active(self): + clock = _FakeClock() + h = ResolverHealth(clock=clock) + h.record("host.com", "a", Signal.HARD) + clock.advance(EVIDENCE_TTL_SEC) + assert h.state("host.com") == HostState.UNAVAILABLE + + def test_just_past_ttl_expired(self): + clock = _FakeClock() + h = ResolverHealth(clock=clock) + h.record("host.com", "a", Signal.HARD) + clock.advance(EVIDENCE_TTL_SEC + 0.001) + assert h.state("host.com") == HostState.UNKNOWN + + def test_reverts_to_healthy_after_expiry_when_previously_succeeded(self): + clock = _FakeClock() + h = ResolverHealth(clock=clock) + h.record("host.com", "a", Signal.SUCCESS) + h.record("host.com", "a", Signal.HARD) + clock.advance(EVIDENCE_TTL_SEC + 0.001) + assert h.state("host.com") == HostState.HEALTHY + + def test_ignore_mutates_nothing(self): + clock = _FakeClock() + h = ResolverHealth(clock=clock) + h.record("host.com", "a", Signal.SOFT) + before_state = h.state("host.com") + transition = h.record("host.com", "b", Signal.IGNORE) + assert transition is None + assert h.state("host.com") == before_state + + +@pytest.mark.unit +class TestHostIsolation: + def test_hard_on_one_host_does_not_affect_another(self): + clock = _FakeClock() + h = ResolverHealth(clock=clock) + h.record("dood.la", "a", Signal.HARD) + assert h.state("dood.la") == HostState.UNAVAILABLE + assert h.state("dood.re") == HostState.UNKNOWN + + +@pytest.mark.unit +class TestLearnLabel: + def test_label_to_one_host(self): + h = ResolverHealth(clock=_FakeClock()) + h.learn_label("Vidoza", "vidoza.net") + assert h.predict("Vidoza") == HostState.UNKNOWN # no evidence yet, just a mapping + + def test_label_to_many_hosts(self): + clock = _FakeClock() + h = ResolverHealth(clock=clock) + h.learn_label("Vidoza", "vidoza.net") + h.learn_label("Vidoza", "vidoza.biz") + h.record("vidoza.net", "a", Signal.SUCCESS) + assert h.predict("Vidoza") == HostState.HEALTHY + + def test_relearning_same_host_is_idempotent(self): + clock = _FakeClock() + h = ResolverHealth(clock=clock) + h.record("vidoza.net", "a", Signal.HARD) + h.learn_label("Vidoza", "vidoza.net") + h.learn_label("Vidoza", "vidoza.net") + assert h.predict("Vidoza") == HostState.UNAVAILABLE + + def test_label_normalized_case_and_whitespace(self): + clock = _FakeClock() + h = ResolverHealth(clock=clock) + h.record("vidoza.net", "a", Signal.SUCCESS) + h.learn_label(" Vidoza ", "vidoza.net") + assert h.predict("vidoza") == HostState.HEALTHY + assert h.predict("VIDOZA") == HostState.HEALTHY + + def test_different_labels_do_not_share_mapping(self): + clock = _FakeClock() + h = ResolverHealth(clock=clock) + h.record("vidoza.net", "a", Signal.SUCCESS) + h.learn_label("Vidoza", "vidoza.net") + assert h.predict("Streamtape") == HostState.UNKNOWN + + def test_none_host_is_ignored(self): + h = ResolverHealth(clock=_FakeClock()) + h.learn_label("Vidoza", None) + assert h.predict("Vidoza") == HostState.UNKNOWN + + def test_unknown_host_sentinel_is_ignored(self): + h = ResolverHealth(clock=_FakeClock()) + h.learn_label("Vidoza", "unknown-host") + assert h.predict("Vidoza") == HostState.UNKNOWN + + +@pytest.mark.unit +class TestPredict: + def test_no_data_is_unknown(self): + h = ResolverHealth(clock=_FakeClock()) + assert h.predict("Vidoza") == HostState.UNKNOWN + + def test_healthy(self): + clock = _FakeClock() + h = ResolverHealth(clock=clock) + h.record("a.com", "1", Signal.SUCCESS) + h.learn_label("Vidoza", "a.com") + assert h.predict("Vidoza") == HostState.HEALTHY + + def test_degraded(self): + clock = _FakeClock() + h = ResolverHealth(clock=clock) + h.record("a.com", "1", Signal.SOFT) + h.learn_label("Vidoza", "a.com") + assert h.predict("Vidoza") == HostState.DEGRADED + + def test_unavailable(self): + clock = _FakeClock() + h = ResolverHealth(clock=clock) + h.record("a.com", "1", Signal.HARD) + h.learn_label("Vidoza", "a.com") + assert h.predict("Vidoza") == HostState.UNAVAILABLE + + def test_healthy_plus_degraded_is_healthy(self): + clock = _FakeClock() + h = ResolverHealth(clock=clock) + h.record("a.com", "1", Signal.SUCCESS) + h.record("b.com", "1", Signal.SOFT) + h.learn_label("Vidoza", "a.com") + h.learn_label("Vidoza", "b.com") + assert h.predict("Vidoza") == HostState.HEALTHY + + def test_healthy_plus_unavailable_is_healthy(self): + clock = _FakeClock() + h = ResolverHealth(clock=clock) + h.record("a.com", "1", Signal.SUCCESS) + h.record("b.com", "1", Signal.HARD) + h.learn_label("Vidoza", "a.com") + h.learn_label("Vidoza", "b.com") + assert h.predict("Vidoza") == HostState.HEALTHY + + def test_unknown_plus_degraded_is_degraded(self): + clock = _FakeClock() + h = ResolverHealth(clock=clock) + h.record("b.com", "1", Signal.SOFT) + h.learn_label("Vidoza", "a.com") # never recorded -> UNKNOWN + h.learn_label("Vidoza", "b.com") + assert h.predict("Vidoza") == HostState.DEGRADED + + def test_degraded_plus_unavailable_is_degraded(self): + clock = _FakeClock() + h = ResolverHealth(clock=clock) + h.record("a.com", "1", Signal.SOFT) + h.record("b.com", "1", Signal.HARD) + h.learn_label("Vidoza", "a.com") + h.learn_label("Vidoza", "b.com") + assert h.predict("Vidoza") == HostState.DEGRADED + + def test_all_unavailable_is_unavailable(self): + clock = _FakeClock() + h = ResolverHealth(clock=clock) + h.record("a.com", "1", Signal.HARD) + h.record("b.com", "1", Signal.HARD) + h.learn_label("Vidoza", "a.com") + h.learn_label("Vidoza", "b.com") + assert h.predict("Vidoza") == HostState.UNAVAILABLE + + def test_unsupported_stays_unavailable(self): + clock = _FakeClock() + h = ResolverHealth(clock=clock) + h.record("a.com", "1", Signal.UNSUPPORTED) + h.learn_label("Vidoza", "a.com") + assert h.predict("Vidoza") == HostState.UNAVAILABLE + + def test_ttl_changes_prediction_without_relearning(self): + clock = _FakeClock() + h = ResolverHealth(clock=clock) + h.record("a.com", "1", Signal.HARD) + h.learn_label("Vidoza", "a.com") + assert h.predict("Vidoza") == HostState.UNAVAILABLE + clock.advance(EVIDENCE_TTL_SEC + 1) + assert h.predict("Vidoza") == HostState.UNKNOWN + + +@pytest.mark.unit +class TestOrder: + def test_empty_list(self): + h = ResolverHealth(clock=_FakeClock()) + assert h.order([]) == [] + + def test_single_element_unchanged(self): + h = ResolverHealth(clock=_FakeClock()) + p = _player("1") + assert h.order([p]) == [p] + + def test_no_evidence_returns_identical_order(self): + h = ResolverHealth(clock=_FakeClock()) + players = [_player("1", player="A"), _player("2", player="B"), _player("3", player="C")] + assert h.order(players) == players + + def test_stable_within_same_rank(self): + h = ResolverHealth(clock=_FakeClock()) + players = [_player("1", player="A"), _player("2", player="B")] + assert [p.online_id for p in h.order(players)] == ["1", "2"] + + def test_healthy_moves_before_unknown_within_group(self): + clock = _FakeClock() + h = ResolverHealth(clock=clock) + players = [_player("1", player="A"), _player("2", player="B")] + h.record("b-host.com", "x", Signal.SUCCESS) + h.learn_label("B", "b-host.com") + assert [p.online_id for p in h.order(players)] == ["2", "1"] + + def test_degraded_ranks_between_unknown_and_unavailable(self): + clock = _FakeClock() + h = ResolverHealth(clock=clock) + players = [ + _player("1", player="A"), + _player("2", player="B"), + _player("3", player="C"), + _player("4", player="D"), + ] + h.record("a-host.com", "x", Signal.HARD) + h.learn_label("A", "a-host.com") + h.record("c-host.com", "x", Signal.SUCCESS) + h.learn_label("C", "c-host.com") + h.record("d-host.com", "x", Signal.SOFT) + h.learn_label("D", "d-host.com") + # B stays UNKNOWN (no learn_label call at all) + assert [p.online_id for p in h.order(players)] == ["3", "2", "4", "1"] + + def test_healthy_480p_does_not_outrank_unknown_1080p(self): + clock = _FakeClock() + h = ResolverHealth(clock=clock) + p_1080 = _player("1", player="A", max_res="1080p") + p_480 = _player("2", player="B", max_res="480p") + players = [p_1080, p_480] + h.record("b-host.com", "x", Signal.SUCCESS) + h.learn_label("B", "b-host.com") + assert [p.online_id for p in h.order(players)] == ["1", "2"] + + def test_unavailable_1080p_does_not_fall_behind_720p(self): + clock = _FakeClock() + h = ResolverHealth(clock=clock) + p_1080 = _player("1", player="A", max_res="1080p") + p_720 = _player("2", player="B", max_res="720p") + players = [p_1080, p_720] + h.record("a-host.com", "x", Signal.HARD) + h.learn_label("A", "a-host.com") + assert [p.online_id for p in h.order(players)] == ["1", "2"] + + def test_never_crosses_lang_audio_boundary(self): + clock = _FakeClock() + h = ResolverHealth(clock=clock) + p_jp = _player("1", player="A", lang_audio="jp") + p_pl = _player("2", player="B", lang_audio="pl") + players = [p_jp, p_pl] + h.record("b-host.com", "x", Signal.SUCCESS) + h.learn_label("B", "b-host.com") + # B is HEALTHY but has a different lang_audio, so it forms its own group and cannot outrank A. + assert [p.online_id for p in h.order(players)] == ["1", "2"] + + def test_never_crosses_lang_subs_boundary(self): + clock = _FakeClock() + h = ResolverHealth(clock=clock) + p_pl_subs = _player("1", player="A", lang_subs="pl") + p_en_subs = _player("2", player="B", lang_subs="en") + players = [p_pl_subs, p_en_subs] + h.record("b-host.com", "x", Signal.SUCCESS) + h.learn_label("B", "b-host.com") + # Same reasoning as lang_audio: a different lang_subs is a different group. + assert [p.online_id for p in h.order(players)] == ["1", "2"] + + def test_never_crosses_a_sort_players_group_boundary(self): + clock = _FakeClock() + h = ResolverHealth(clock=clock) + group1 = [_player("1", player="A", max_res="1080p"), _player("2", player="B", max_res="1080p")] + group2 = [_player("3", player="C", max_res="720p")] + players = group1 + group2 + h.record("b-host.com", "x", Signal.SUCCESS) + h.learn_label("B", "b-host.com") + ordered = h.order(players) + assert {p.online_id for p in ordered[:2]} == {"1", "2"} + assert ordered[2].online_id == "3" + + def test_does_not_merge_two_identical_groups_split_by_another_group(self): + clock = _FakeClock() + h = ResolverHealth(clock=clock) + a = _player("A", player="A", max_res="1080p") + b = _player("B", player="B", max_res="1080p") + c = _player("C", player="C", max_res="720p") + d = _player("D", player="D", max_res="1080p") + players = [a, b, c, d] + h.record("d-host.com", "x", Signal.SUCCESS) + h.learn_label("D", "d-host.com") + ordered = h.order(players) + # D must stay in its own trailing group and never jump in front of C. + assert [p.online_id for p in ordered] == ["A", "B", "C", "D"] + + def test_same_length_and_online_id_set(self): + clock = _FakeClock() + h = ResolverHealth(clock=clock) + players = [_player("1", player="A"), _player("2", player="B"), _player("3", player="C")] + h.record("a-host.com", "x", Signal.HARD) + h.learn_label("A", "a-host.com") + ordered = h.order(players) + assert len(ordered) == len(players) + assert {p.online_id for p in ordered} == {p.online_id for p in players} + + +@pytest.mark.unit +class TestModulePurity: + def test_no_forbidden_imports(self): + source = Path(health.__file__).read_text(encoding="utf-8") + tree = ast.parse(source) + forbidden = {"diagnostics", "extract", "cli", "ui", "shinden", "curl_cffi", "yt_dlp"} + imported: set[str] = set() + for node in ast.walk(tree): + if isinstance(node, ast.Import): + for alias in node.names: + imported.add(alias.name.split(".")[0]) + elif isinstance(node, ast.ImportFrom) and node.module: + for part in node.module.split("."): + imported.add(part) + for alias in node.names: + imported.add(alias.name.split(".")[0]) + assert imported.isdisjoint(forbidden), imported & forbidden + + +@pytest.mark.unit +class TestCategoryLiteralsMatchExtractCommon: + def test_known_category_constants_are_classified(self): + from alt_ani_cli.extract import common as extract_common + + assert ( + classify_failure( + host="example.com", + layer="custom", + category=extract_common.CATEGORY_NO_STREAM_URL, + http_status=None, + used_fallback=False, + fallback_category=None, + fallback_http_status=None, + ) + == Signal.FILE + ) + assert ( + classify_failure( + host="example.com", + layer="custom", + category=extract_common.CATEGORY_PARSER_DRIFT, + http_status=None, + used_fallback=False, + fallback_category=None, + fallback_http_status=None, + ) + == Signal.SOFT + ) diff --git a/tests/test_replay_failed_resolvers.py b/tests/test_replay_failed_resolvers.py index 145031b..2e43baf 100644 --- a/tests/test_replay_failed_resolvers.py +++ b/tests/test_replay_failed_resolvers.py @@ -180,6 +180,204 @@ def test_player_context_reset_by_series_selected(self, tmp_path): assert cases[0].online_id is None + def test_new_health_events_and_mode_are_parsed_without_creating_replay_cases(self, tmp_path): + lines = [ + _line("session_start", version="1.0.0", python="3.14.0", platform="Windows", mode="interactive"), + _line("series_selected", id="123", title="Attack on Titan"), + _line("episode_selected", number=5, title="Ep Five"), + _line("player_selected", online_id="99", player="CDA", host="cda.pl"), + _line( + "host_health", + host="cda.pl", + from_state="healthy", + to_state="degraded", + signal="soft", + online_id="99", + category="parser_drift", + resolver="ytdlp", + evidence_expired=False, + ), + _line( + "health_defer", + action="deferred", + host="cda.pl", + online_id="99", + player="CDA", + state="degraded", + category="parser_drift", + ), + _line( + "resolve_result", + host="cda.pl", + ok=False, + exc="NoStreamError", + layer="ytdlp", + category="http_error", + http_status=403, + used_fallback=False, + elapsed=1.234, + ), + ] + log = tmp_path / "session.log" + log.write_text("\n".join(lines) + "\n", encoding="utf-8") + + cases, skipped = parse_diagnostics_log(log) + + assert skipped == 0 + assert len(cases) == 1 + case = cases[0] + assert case.series_id == "123" + assert case.episode_number == 5.0 + assert case.online_id == "99" + assert case.player_name == "CDA" + assert case.host_before == "cda.pl" + + def test_noninteractive_run_with_two_failing_players_yields_two_cases(self, tmp_path): + lines = [ + _line("session_start", version="1.0.0", python="3.14.0", platform="Windows", mode="noninteractive"), + _line("series_selected", id="123", title="Attack on Titan"), + _line("episode_selected", number=5, title="Ep Five"), + _line("player_selected", online_id="99", player="CDA"), + _line( + "host_health", + host="cda.pl", + from_state="unknown", + to_state="degraded", + signal="soft", + online_id="99", + category="parser_drift", + resolver="ytdlp", + evidence_expired=False, + ), + _line( + "resolve_result", + host="cda.pl", + ok=False, + exc="NoStreamError", + layer="ytdlp", + category="parser_drift", + used_fallback=False, + elapsed=1.1, + ), + _line("player_selected", online_id="88", player="Vidara"), + _line( + "host_health", + host="vidawra.cc", + from_state="unknown", + to_state="degraded", + signal="soft", + online_id="88", + category="http_error", + http_status=403, + resolver="vidara", + evidence_expired=False, + ), + _line( + "resolve_result", + host="vidawra.cc", + ok=False, + exc="NoStreamError", + layer="custom", + category="http_error", + http_status=403, + used_fallback=False, + elapsed=0.9, + ), + ] + log = tmp_path / "session.log" + log.write_text("\n".join(lines) + "\n", encoding="utf-8") + + cases, skipped = parse_diagnostics_log(log) + + assert skipped == 0 + assert len(cases) == 2 + assert cases[0].online_id == "99" + assert cases[0].player_name == "CDA" + assert cases[0].host_before == "cda.pl" + assert cases[1].online_id == "88" + assert cases[1].player_name == "Vidara" + assert cases[1].host_before == "vidawra.cc" + + def test_deferred_and_dropped_health_defer_events_never_create_cases(self, tmp_path): + """deferred/dropped never extract, so they must never surface as replayable cases — only real attempts do.""" + lines = [ + _line("player_selected", online_id="a1", player="APlayer"), + _line( + "health_defer", action="deferred", host="hostx.example", online_id="a1", player="APlayer", state="unavailable" + ), + _line("player_selected", online_id="b1", player="BPlayer"), + _line( + "resolve_result", + host="hosty.example", + ok=False, + exc="NoStreamError", + layer="custom", + category="http_error", + http_status=403, + elapsed=0.5, + ), + _line("player_selected", online_id="a1", player="APlayer"), + _line("health_defer", action="retry", host="hostx.example", online_id="a1", player="APlayer", state="unavailable"), + _line( + "resolve_result", + host="hostx.example", + ok=False, + exc="NoStreamError", + layer="custom", + category="network_error", + elapsed=0.3, + ), + _line( + "health_defer", action="dropped", host="hostx.example", online_id="c1", player="CPlayer", state="unavailable" + ), + ] + log = tmp_path / "session.log" + log.write_text("\n".join(lines) + "\n", encoding="utf-8") + + cases, skipped = parse_diagnostics_log(log) + + assert skipped == 0 + assert len(cases) == 2 + assert cases[0].online_id == "b1" + assert cases[0].host_before == "hosty.example" + assert cases[1].online_id == "a1" + assert cases[1].host_before == "hostx.example" + + def test_health_reorder_is_parsed_without_creating_a_case_or_disturbing_context(self, tmp_path): + """health_reorder never carries an online_id/host of its own — replay must ignore its payload.""" + lines = [ + _line("episode_selected", number=5, title="Ep Five"), + _line( + "health_reorder", + number=5, + before="a1,b1", + after="b1,a1", + states="healthy,degraded", + ), + _line("player_selected", online_id="b1", player="BPlayer"), + _line( + "resolve_result", + host="hostb.example", + ok=False, + exc="NoStreamError", + layer="custom", + category="http_error", + http_status=403, + elapsed=0.5, + ), + ] + log = tmp_path / "session.log" + log.write_text("\n".join(lines) + "\n", encoding="utf-8") + + cases, skipped = parse_diagnostics_log(log) + + assert skipped == 0 + assert len(cases) == 1 + assert cases[0].online_id == "b1" + assert cases[0].player_name == "BPlayer" + assert cases[0].episode_number == 5.0 + assert cases[0].host_before == "hostb.example" + def test_quoted_values_with_spaces_round_trip(self, tmp_path): lines = [ _line("episode_selected", number=1, title="A Title With Spaces"), From 6deb10337a5aea21f4289bbad2abf8fd15d2b909 Mon Sep 17 00:00:00 2001 From: Aleksander <71835339+Cybernetic-Ransomware@users.noreply.github.com> Date: Sun, 4 Oct 2026 12:10:00 +0200 Subject: [PATCH 02/11] fix(extract): send Sec-Fetch-Site same-origin to Playmate API (#18) * fix(extract): send Sec-Fetch-Site same-origin to Playmate API * fix(extract): detect Playmate HLS master.txt streams as m3u8 --- README.md | 8 +------- src/alt_ani_cli/extract/playmate.py | 3 ++- tests/extract/test_playmate.py | 3 ++- 3 files changed, 5 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index a39151f..ec634e0 100644 --- a/README.md +++ b/README.md @@ -171,15 +171,9 @@ just replay-failures "PATH_TO_LOG" # replay resolve_result failures from a diag ## Supported video hosts -Native extractors: mp4upload, streamtape, dood, Lycoris Cafe, Vidara/Viewdara, Uqload, Flyf, +Native extractors: mp4upload, streamtape, dood, Lycoris Cafe, Vidara/Viewdara, Playmate, Uqload, Flyf, streamwish/filemoon/morencius family (JWPlayer), CDA, sibnet, VK. -**Playmate is currently broken.** The extractor exists (`extract/playmate.py`), but a live -replay (see `tools/replay_failed_resolvers.py` below) confirmed the upstream protocol has -changed: the current request now gets HTTP 403, and the additional request elements it needs -have not been reverse-engineered yet. Playmate links fall back to yt-dlp, which also cannot -resolve them. - Lycoris Cafe embeds are resolved through the host API and expose the available direct qualities (`1080p`, `720p`, `480p`) plus `source-mkv` when the API provides it. All other hosts fall back to yt-dlp (1500+ supported sites). diff --git a/src/alt_ani_cli/extract/playmate.py b/src/alt_ani_cli/extract/playmate.py index 4508742..4a8389d 100644 --- a/src/alt_ani_cli/extract/playmate.py +++ b/src/alt_ani_cli/extract/playmate.py @@ -23,6 +23,7 @@ def resolve(embed_url: str, referer: str) -> Stream: "Origin": base, "Referer": embed_url, "User-Agent": USER_AGENT, + "Sec-Fetch-Site": "same-origin", }, ) resp.raise_for_status() @@ -35,5 +36,5 @@ def resolve(embed_url: str, referer: str) -> Stream: return Stream( url=url, headers={"Referer": embed_url, "Origin": base, "User-Agent": USER_AGENT}, - ext="m3u8" if "m3u8" in url else "mp4", + ext="m3u8" if "m3u8" in url or "/hls/" in url else "mp4", ) diff --git a/tests/extract/test_playmate.py b/tests/extract/test_playmate.py index 69c1da5..6b1b9ed 100644 --- a/tests/extract/test_playmate.py +++ b/tests/extract/test_playmate.py @@ -10,7 +10,7 @@ _EMBED = "https://playmate.to/embed/MFyuwmxvBGiUE" _REFERER = "https://shinden.pl/" -_M3U8_URL = "https://cdn.playmate.to/hls/abc/master.m3u8?token=deadbeef" +_M3U8_URL = "https://cdn.playmate.to/hls/abc/master.txt?token=deadbeef" _API_RESPONSE = {"sx": _M3U8_URL} @@ -51,6 +51,7 @@ def test_api_request_url_payload_and_headers(self): assert call.kwargs["json"] == {"c": "MFyuwmxvBGiUE", "d": "web"} assert call.kwargs["headers"]["Origin"] == "https://playmate.to" assert call.kwargs["headers"]["Referer"] == _EMBED + assert call.kwargs["headers"]["Sec-Fetch-Site"] == "same-origin" def test_missing_sx_raises_value_error(self): with _make_session_patch({"status": 404}): From 71b1a5fecdb24539d7146bf2fa893bef80425521 Mon Sep 17 00:00:00 2001 From: Aleksander <71835339+Cybernetic-Ransomware@users.noreply.github.com> Date: Sun, 4 Oct 2026 12:22:47 +0200 Subject: [PATCH 03/11] fix(extract): route iosbgaigo.com to the vidara resolver (#19) --- src/alt_ani_cli/extract/__init__.py | 3 +++ tests/extract/test_dispatch.py | 8 ++++++++ 2 files changed, 11 insertions(+) diff --git a/src/alt_ani_cli/extract/__init__.py b/src/alt_ani_cli/extract/__init__.py index 7d7c7d2..6f05d91 100644 --- a/src/alt_ani_cli/extract/__init__.py +++ b/src/alt_ani_cli/extract/__init__.py @@ -102,6 +102,9 @@ class HostRule: # /api/stream protocol, confirmed by live replay "morningmarkets.art": HostRule("custom", vidara.resolve), "morningmarkets.fit": HostRule("custom", vidara.resolve), + # iosbgaigo — JWPlayer source is set from a POST /api/stream response at runtime, confirmed by live replay + "iosbgaigo.com": HostRule("custom", vidara.resolve), + "www.iosbgaigo.com": HostRule("custom", vidara.resolve), # lycoris "lycoris.cafe": HostRule("custom", lycoris.resolve), "www.lycoris.cafe": HostRule("custom", lycoris.resolve), diff --git a/tests/extract/test_dispatch.py b/tests/extract/test_dispatch.py index 62abe95..7727435 100644 --- a/tests/extract/test_dispatch.py +++ b/tests/extract/test_dispatch.py @@ -163,6 +163,13 @@ def test_morningmarkets_hosts_route_to_vidara_extractor(self): assert HOST_RULES["morningmarkets.art"] == HostRule("custom", vidara.resolve) assert HOST_RULES["morningmarkets.fit"] == HostRule("custom", vidara.resolve) + def test_iosbgaigo_hosts_route_to_vidara_extractor(self): + """JWPlayer source comes from a runtime POST /api/stream, so generic JWPlayer sees no URL.""" + from alt_ani_cli.extract import vidara + + assert HOST_RULES["iosbgaigo.com"] == HostRule("custom", vidara.resolve) + assert HOST_RULES["www.iosbgaigo.com"] == HostRule("custom", vidara.resolve) + def test_dood_yt_routes_to_dood_extractor(self): from alt_ani_cli.extract import dood @@ -410,6 +417,7 @@ class TestResolverFamily: ("dood.yt", "dood"), ("vidawra.cc", "vidara"), ("morningmarkets.art", "vidara"), + ("iosbgaigo.com", "vidara"), ("streamtape.to", "streamtape"), ("mp4upload.com", "mp4upload"), ("lycoris.cafe", "lycoris"), From fc70cfdab39bdcc47a2537c26ae3eac6995ce569 Mon Sep 17 00:00:00 2001 From: Aleksander <71835339+Cybernetic-Ransomware@users.noreply.github.com> Date: Sun, 4 Oct 2026 16:13:32 +0200 Subject: [PATCH 04/11] refactor(download): use native yt-dlp downloaders instead of forced ffmpeg (#20) --- README.md | 3 +- src/alt_ani_cli/content/ui_content_pl-pl.yaml | 1 + src/alt_ani_cli/download.py | 45 ++++-- tests/test_download.py | 140 ++++++++++++++++++ 4 files changed, 175 insertions(+), 14 deletions(-) create mode 100644 tests/test_download.py diff --git a/README.md b/README.md index ec634e0..bd24d41 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,8 @@ Python CLI for watching and downloading anime from [shinden.pl](https://shinden. ```powershell winget install mpv.net # or: scoop install mpv ``` -- [ffmpeg](https://ffmpeg.org/) (optional) — enables HLS merging during downloads +- [ffmpeg](https://ffmpeg.org/) (optional) — yt-dlp downloads HLS natively; when ffmpeg is available it remuxes + the result into a proper MP4. Without it, an HLS download stays an MPEG-TS stream despite the `.mp4` extension. ```powershell winget install ffmpeg # or: scoop install ffmpeg ``` 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 8492d4f..b2200be 100644 --- a/src/alt_ani_cli/content/ui_content_pl-pl.yaml +++ b/src/alt_ani_cli/content/ui_content_pl-pl.yaml @@ -75,6 +75,7 @@ progress: download: starting: "Pobieranie: {title} ep{ep_label} → {dir}" saved: "Zapisano: {path}" + legacy_part_moved: "Niedokończone pobieranie ze starszej wersji (ffmpeg) przeniesiono do: {path} — pobieranie zacznie się od nowa." menu: start_mode: diff --git a/src/alt_ani_cli/download.py b/src/alt_ani_cli/download.py index 17000e5..e260f93 100644 --- a/src/alt_ani_cli/download.py +++ b/src/alt_ani_cli/download.py @@ -13,6 +13,8 @@ "[info] ", ) +_LEGACY_PART_SUFFIX = ".legacy-ffmpeg" + class _TruncLogger: """yt-dlp logger that truncates long lines and suppresses noisy extraction chatter.""" @@ -40,6 +42,26 @@ def error(self, msg: str) -> None: self._emit(f"[error] {msg}") +def _output_template(stream: Stream, base: Path) -> str: + # Generic extraction names extensionless direct URLs "unknown_video"; HLS keeps yt-dlp's ext since it remuxes to mp4. + ext = "%(ext)s" if stream.ext == "m3u8" else stream.ext + return f"{base}.{ext}" + + +def _quarantine_legacy_hls_part(final_path: Path) -> Path | None: + """Move aside a .part left by the old external-ffmpeg downloader, which HlsFD would otherwise append to.""" + part = final_path.with_name(f"{final_path.name}.part") + if not part.is_file() or final_path.with_name(f"{final_path.name}.ytdl").exists(): + return None + backup = part.with_name(f"{part.name}{_LEGACY_PART_SUFFIX}") + n = 1 + while backup.exists(): + backup = part.with_name(f"{part.name}{_LEGACY_PART_SUFFIX}.{n}") + n += 1 + part.rename(backup) + return backup + + def run( stream: Stream, ep: EpisodeRow, @@ -51,28 +73,25 @@ def run( dest_dir.mkdir(parents=True, exist_ok=True) safe_title = "".join(c for c in series.title if c.isalnum() or c in " _-").strip() ep_label = f"{ep.number:g}" - out_template = str(dest_dir / f"{safe_title} - ep{ep_label}.%(ext)s") opts: dict = { - "outtmpl": out_template, + "outtmpl": _output_template(stream, dest_dir / f"{safe_title} - ep{ep_label}"), "http_headers": { **stream.headers, "User-Agent": stream.headers.get("User-Agent", USER_AGENT), }, "quiet": True, + "noprogress": True, "logger": _TruncLogger(), } - # Use ffmpeg for HLS if available — avoids timestamp warnings and supports merging - if shutil.which("ffmpeg"): - opts["external_downloader"] = "ffmpeg" - opts["external_downloader_args"] = {"ffmpeg_i": ["-loglevel", "warning"]} - progress.info(CONTENT["download"]["starting"].format(title=safe_title, ep_label=ep_label, dir=dest_dir)) with YoutubeDL(opts) as ydl: - ydl.download([stream.url]) - - # Find the downloaded file and report its path - candidates = sorted(dest_dir.glob(f"{safe_title} - ep{ep_label}.*")) - if candidates: - progress.success(CONTENT["download"]["saved"].format(path=candidates[-1])) + 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) + + if final_path.exists(): + progress.success(CONTENT["download"]["saved"].format(path=final_path)) diff --git a/tests/test_download.py b/tests/test_download.py new file mode 100644 index 0000000..b3e2333 --- /dev/null +++ b/tests/test_download.py @@ -0,0 +1,140 @@ +from pathlib import Path +from unittest.mock import patch + +import pytest + +from alt_ani_cli import download +from alt_ani_cli.config import USER_AGENT +from alt_ani_cli.extract.common import Stream +from alt_ani_cli.models import EpisodeRow, SeriesRef + +_EP = EpisodeRow(number=1, title="Ep", url="https://shinden.pl/episode/1") +_SERIES = SeriesRef(id="1", slug="show", title="Show", url="https://shinden.pl/series/1-show") + + +class _FakeYDL: + """Stands in for yt_dlp.YoutubeDL: records opts and the on-disk state seen when the download starts.""" + + instances: list[_FakeYDL] = [] + info: dict = {} + + def __init__(self, opts: dict) -> None: + self.opts = opts + self.final_path: Path | None = None + self.part_existed_at_download: bool | None = None + _FakeYDL.instances.append(self) + + def __enter__(self) -> _FakeYDL: + return self + + def __exit__(self, *exc) -> None: + return None + + def extract_info(self, url: str, download: bool = True) -> dict: + assert download is False + return dict(self.info) + + def prepare_filename(self, info: dict) -> str: + return self.opts["outtmpl"].replace("%(ext)s", info["ext"]) + + 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() + return info + + +@pytest.fixture +def fake_ydl(): + _FakeYDL.instances = [] + _FakeYDL.info = {"ext": "mp4", "protocol": "m3u8_native"} + with patch("yt_dlp.YoutubeDL", _FakeYDL): + yield _FakeYDL + + +def _run(stream: Stream, dest: Path) -> _FakeYDL: + download.run(stream, _EP, _SERIES, dest_dir=dest) + return _FakeYDL.instances[-1] + + +@pytest.mark.unit +class TestDownloadOptions: + def test_no_external_downloader_even_with_ffmpeg_on_path(self, fake_ydl, tmp_path): + with patch("shutil.which", return_value=r"C:\ffmpeg\bin\ffmpeg.exe"): + ydl = _run(Stream(url="https://h/master.m3u8", ext="m3u8"), tmp_path) + assert "external_downloader" not in ydl.opts + assert "external_downloader_args" not in ydl.opts + + def test_stream_headers_passed_through_unchanged(self, fake_ydl, tmp_path): + headers = {"Referer": "https://embed.example/e/1", "Origin": "https://embed.example", "User-Agent": "UA/1.0"} + ydl = _run(Stream(url="https://h/master.m3u8", headers=headers, ext="m3u8"), tmp_path) + assert ydl.opts["http_headers"] == headers + + def test_missing_user_agent_falls_back_to_default(self, fake_ydl, tmp_path): + ydl = _run(Stream(url="https://h/master.m3u8", headers={"Referer": "https://r/"}, ext="m3u8"), tmp_path) + assert ydl.opts["http_headers"] == {"Referer": "https://r/", "User-Agent": USER_AGENT} + + def test_progress_lines_suppressed(self, fake_ydl, tmp_path): + ydl = _run(Stream(url="https://h/master.m3u8", ext="m3u8"), tmp_path) + assert ydl.opts["noprogress"] is True + + +@pytest.mark.unit +class TestOutputExtension: + def test_direct_mp4_without_url_extension_avoids_unknown_video(self, fake_ydl, tmp_path): + fake_ydl.info = {"ext": "unknown_video", "protocol": "https"} + ydl = _run(Stream(url="https://cdn.example/d/abc123", ext="mp4"), tmp_path) + assert ydl.final_path == tmp_path / "Show - ep1.mp4" + + def test_direct_mkv_keeps_mkv(self, fake_ydl, tmp_path): + fake_ydl.info = {"ext": "unknown_video", "protocol": "https"} + ydl = _run(Stream(url="https://cdn.example/d/abc123", ext="mkv"), tmp_path) + assert ydl.final_path == tmp_path / "Show - ep1.mkv" + + def test_hls_uses_ytdlp_ext_not_m3u8(self, fake_ydl, tmp_path): + ydl = _run(Stream(url="https://h/master.m3u8", ext="m3u8"), tmp_path) + assert ydl.opts["outtmpl"].endswith(".%(ext)s") + assert ydl.final_path == tmp_path / "Show - ep1.mp4" + + +@pytest.mark.unit +class TestLegacyHlsPartGuard: + def test_hls_without_part_touches_nothing(self, fake_ydl, tmp_path): + _run(Stream(url="https://h/master.m3u8", ext="m3u8"), tmp_path) + assert list(tmp_path.iterdir()) == [] + + def test_hls_legacy_part_is_moved_aside_before_download(self, fake_ydl, tmp_path): + part = tmp_path / "Show - ep1.mp4.part" + part.write_bytes(b"legacy mp4 bytes") + with patch.object(download.progress, "warn") as warn: + ydl = _run(Stream(url="https://h/master.m3u8", ext="m3u8"), tmp_path) + backup = tmp_path / "Show - ep1.mp4.part.legacy-ffmpeg" + assert ydl.part_existed_at_download is False + assert backup.read_bytes() == b"legacy mp4 bytes" + assert str(backup) in warn.call_args.args[0] + + def test_hls_part_with_ytdl_is_native_resume_state(self, fake_ydl, tmp_path): + part = tmp_path / "Show - ep1.mp4.part" + ytdl = tmp_path / "Show - ep1.mp4.ytdl" + part.write_bytes(b"ts fragments") + ytdl.write_text("{}") + ydl = _run(Stream(url="https://h/master.m3u8", ext="m3u8"), tmp_path) + assert ydl.part_existed_at_download is True + assert sorted(p.name for p in tmp_path.iterdir()) == [part.name, ytdl.name] + + def test_direct_part_without_ytdl_is_left_for_range_resume(self, fake_ydl, tmp_path): + fake_ydl.info = {"ext": "mp4", "protocol": "https"} + part = tmp_path / "Show - ep1.mp4.part" + part.write_bytes(b"partial mp4") + ydl = _run(Stream(url="https://cdn.example/video.mp4", ext="mp4"), tmp_path) + assert ydl.part_existed_at_download is True + assert [p.name for p in tmp_path.iterdir()] == [part.name] + + def test_existing_backup_is_not_overwritten(self, tmp_path): + final = tmp_path / "Show - ep1.mp4" + Path(f"{final}.part").write_bytes(b"newer legacy") + Path(f"{final}.part.legacy-ffmpeg").write_bytes(b"older legacy") + backup = download._quarantine_legacy_hls_part(final) + assert backup == tmp_path / "Show - ep1.mp4.part.legacy-ffmpeg.1" + 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() From 00fbfc48950b83bf04d6606c275d949ee0fa654b Mon Sep 17 00:00:00 2001 From: Aleksander <71835339+Cybernetic-Ransomware@users.noreply.github.com> Date: Sun, 4 Oct 2026 16:40:22 +0200 Subject: [PATCH 05/11] fix(download): retry requests and fail on missing HLS fragments (#21) * fix(download): retry requests and fail on missing HLS fragments * docs(download): clarify retry policy comment --- src/alt_ani_cli/download.py | 4 ++++ tests/test_download.py | 20 ++++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/src/alt_ani_cli/download.py b/src/alt_ani_cli/download.py index e260f93..b575662 100644 --- a/src/alt_ani_cli/download.py +++ b/src/alt_ani_cli/download.py @@ -80,6 +80,10 @@ def run( **stream.headers, "User-Agent": stream.headers.get("User-Agent", USER_AGENT), }, + # Match yt-dlp CLI retry counts, but fail instead of silently skipping unavailable HLS fragments. + "retries": 10, + "fragment_retries": 10, + "skip_unavailable_fragments": False, "quiet": True, "noprogress": True, "logger": _TruncLogger(), diff --git a/tests/test_download.py b/tests/test_download.py index b3e2333..4e62a9d 100644 --- a/tests/test_download.py +++ b/tests/test_download.py @@ -78,6 +78,26 @@ def test_progress_lines_suppressed(self, fake_ydl, tmp_path): assert ydl.opts["noprogress"] is True +@pytest.mark.unit +class TestRetryPolicy: + def test_request_retries_set_explicitly(self, fake_ydl, tmp_path): + ydl = _run(Stream(url="https://h/master.m3u8", ext="m3u8"), tmp_path) + assert ydl.opts["retries"] == 10 + + def test_fragment_retries_set_explicitly(self, fake_ydl, tmp_path): + ydl = _run(Stream(url="https://h/master.m3u8", ext="m3u8"), tmp_path) + assert ydl.opts["fragment_retries"] == 10 + + def test_failed_fragments_are_not_skipped(self, fake_ydl, tmp_path): + ydl = _run(Stream(url="https://h/master.m3u8", ext="m3u8"), tmp_path) + assert ydl.opts["skip_unavailable_fragments"] is False + + def test_policy_applies_to_direct_downloads(self, fake_ydl, tmp_path): + fake_ydl.info = {"ext": "mp4", "protocol": "https"} + ydl = _run(Stream(url="https://cdn.example/video.mp4", ext="mp4"), tmp_path) + assert (ydl.opts["retries"], ydl.opts["fragment_retries"], ydl.opts["skip_unavailable_fragments"]) == (10, 10, False) + + @pytest.mark.unit class TestOutputExtension: def test_direct_mp4_without_url_extension_avoids_unknown_video(self, fake_ydl, tmp_path): From 56b5c8361f09fcc3316282faadc8a380806d3ee6 Mon Sep 17 00:00:00 2001 From: Aleksander <71835339+Cybernetic-Ransomware@users.noreply.github.com> Date: Sun, 4 Oct 2026 17:06:24 +0200 Subject: [PATCH 06/11] fix(download): return to player pick on download failure instead of crashing (#22) --- src/alt_ani_cli/cli.py | 3 +- .../content/exceptions_content_en-us.yaml | 3 + .../content/exceptions_content_pl-pl.yaml | 3 + src/alt_ani_cli/content/ui_content_pl-pl.yaml | 1 + src/alt_ani_cli/diagnostics.py | 4 + src/alt_ani_cli/download.py | 26 ++- src/alt_ani_cli/errors.py | 4 + src/alt_ani_cli/flow/handlers.py | 23 ++- tests/cli/test_errors.py | 19 +- tests/flow/test_handlers.py | 167 +++++++++++++++++- tests/test_diagnostics.py | 6 + tests/test_download.py | 65 +++++++ 12 files changed, 308 insertions(+), 16 deletions(-) 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] From da27e0c1ed6d826c6070442f8968d5f7a35a6fde Mon Sep 17 00:00:00 2001 From: Aleksander <71835339+Cybernetic-Ransomware@users.noreply.github.com> Date: Sun, 4 Oct 2026 17:44:55 +0200 Subject: [PATCH 07/11] feat(history): track downloaded episodes and add continue-downloading resume (#23) * feat(history): track downloaded episodes and add continue-downloading resume * fix(flow): mark session downloads in the episode picker outside download resume --- README.md | 7 +- src/alt_ani_cli/cli.py | 17 +- src/alt_ani_cli/content/ui_content_pl-pl.yaml | 13 +- src/alt_ani_cli/flow/handlers.py | 61 ++++- src/alt_ani_cli/flow/state.py | 5 +- src/alt_ani_cli/history.py | 58 ++-- src/alt_ani_cli/ui/menus.py | 33 ++- tests/cli/test_noninteractive.py | 88 +++++- tests/conftest.py | 9 + tests/content/test_ui_labels.py | 14 +- tests/flow/test_handlers.py | 252 +++++++++++++++++- tests/test_history.py | 131 +++++++++ tests/ui/test_menus.py | 92 ++++++- 13 files changed, 730 insertions(+), 50 deletions(-) diff --git a/README.md b/README.md index bd24d41..5488999 100644 --- a/README.md +++ b/README.md @@ -74,9 +74,9 @@ Run without arguments for an interactive wizard (search → select series → pi |------|-------------| | `QUERY` | Title to search on shinden.pl | | `--url URL` | Skip search — use a direct series URL | -| `-c`, `--continue` | Resume from watch history | +| `-c`, `--continue` | Resume from watch history (with `-d`: resume from download history — first episode not yet downloaded) | | `-d`, `--download` | Download instead of playing | -| `-D`, `--delete-history` | Clear watch history and exit | +| `-D`, `--delete-history` | Clear watch and download history and exit | | `-e RANGE` | Episode number or range: `5`, `1-5`, `-1` (last), `1 5 7` | | `-q QUALITY` | Quality: `best`, `worst`, `1080p`, `720p` … (default: interactive menu) | | `-S N` | Auto-select N-th search result (1-based, skips menus) | @@ -112,6 +112,9 @@ alt-ani-cli -d -e 1-3 -q 720p --url https://shinden.pl/series/65137-fate-strange # Resume from history alt-ani-cli -c +# Download the next missing episode of the most recently downloaded series +alt-ani-cli -c -d + # Polish dub only alt-ani-cli --lang pl vinland saga diff --git a/src/alt_ani_cli/cli.py b/src/alt_ani_cli/cli.py index 358be7d..39f3a55 100644 --- a/src/alt_ani_cli/cli.py +++ b/src/alt_ani_cli/cli.py @@ -485,7 +485,15 @@ def _run_noninteractive(args, client) -> None: # noqa: C901 _ap.ArgumentParser(prog="alt-ani-cli").error(_CLI["errors"]["missing_input"]) - if args.resume: + downloaded_eps: frozenset[float] = frozenset() + if args.resume and args.download: + downloads = history.list_downloads() + if not downloads: + progress.error(_PROG["download_history_empty"]) + sys.exit(1) + ref, downloaded_eps = downloads[0] + last_ep = 0.0 + elif args.resume: all_entries = history.list_all() if not all_entries: progress.error(_PROG["history_empty"]) @@ -523,6 +531,12 @@ def _run_noninteractive(args, client) -> None: # noqa: C901 if not targets: progress.error(_PROG["range_not_found"].format(range=repr(args.episode))) sys.exit(1) + elif args.resume and args.download: + remaining = [ep for ep in episodes if ep.number not in downloaded_eps] + if not remaining: + progress.info(_PROG["downloaded_all"].format(title=ref.title)) + return + targets = [remaining[0]] elif args.resume and last_ep > 0: remaining = [ep for ep in episodes if ep.number > last_ep] if not remaining: @@ -605,6 +619,7 @@ def _run_noninteractive(args, client) -> None: # noqa: C901 completed = False if _action == "download": download.run(stream, ep, ref) + history.record_download(ref, ep.number) elif _action == "debug": _print_debug(stream, embed) else: 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 b6ac347..e02a35f 100644 --- a/src/alt_ani_cli/content/ui_content_pl-pl.yaml +++ b/src/alt_ani_cli/content/ui_content_pl-pl.yaml @@ -16,7 +16,7 @@ cli: help: query: "Tytuł do wyszukania na shinden.pl (pomijany gdy --url lub -c)." url: "Bezpośredni URL serii (https://shinden.pl/series/...)." - resume: "Kontynuuj oglądanie z historii." + resume: "Kontynuuj oglądanie z historii (z -d: kontynuuj pobieranie)." download: "Pobierz odcinek zamiast odtwarzać." delete_history: "Wyczyść historię i wyjdź." episode: 'Numer odcinka lub zakres: "5", "1-5", "-1" (ostatni).' @@ -58,6 +58,8 @@ progress: 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." history_empty: "Historia jest pusta." + download_history_empty: "Historia pobierania jest pusta." + downloaded_all: "Wszystkie dostępne odcinki są już pobrane ({title})." history_cleared: "Historia wyczyszczona." interrupted: "Przerwano przez użytkownika." http_error: "Błąd HTTP {status} — {url}" @@ -83,8 +85,8 @@ menu: question: "Co chcesz zrobić?" options: search: "Szukaj nowego anime" - resume: "Kontynuuj z historii" - resume_with_count: "Kontynuuj z historii ({count} pozycji)" + resume_watch: "Kontynuuj oglądanie ({count} pozycji)" + resume_download: "Kontynuuj pobieranie ({count} pozycji)" url: "Wklej URL serii" quit: "Wyjdź" instruction: "↑↓ = nawigacja | Enter = wybierz | Ctrl+O = wersja | ESC = wyjście" @@ -124,10 +126,15 @@ menu: default_prompt: "Kontynuuj oglądanie" label: "{title} — ostatni ep {last_ep:g}" instruction: "Wpisz aby filtrować | Enter = wybierz | ESC = wstecz" + download_resume: + default_prompt: "Kontynuuj pobieranie" + label: "{title} — pobrano {count} odc." + instruction: "Wpisz aby filtrować | Enter = wybierz | ESC = wstecz" episodes: default_prompt: "Wybierz odcinek" label_unwatched: " {number:g}. {title}" label_watched: "✓ {number:g}. {title}" + label_downloaded: "↓ {number:g}. {title}" instruction_single: "Wpisz aby filtrować | Enter = wybierz | ESC = wstecz" instruction_multi: "Wpisz aby filtrować | Spacja = zaznacz/odznacz | Ctrl+R = wszystkie | Enter = potwierdź | ESC = wstecz" invalid_multi: "Zaznacz co najmniej jeden odcinek — użyj Spacji." diff --git a/src/alt_ani_cli/flow/handlers.py b/src/alt_ani_cli/flow/handlers.py index 0a57b29..2cb7367 100644 --- a/src/alt_ani_cli/flow/handlers.py +++ b/src/alt_ani_cli/flow/handlers.py @@ -101,11 +101,23 @@ def key(h: SeriesHit): return sorted(hits, key=key) +def _reset_download_resume(state: FlowState) -> None: + """Leave download-resume mode so a newly picked series is not auto-downloaded.""" + if state.resume_mode == "download" and state.episode_action == "download": + state.episode_action = None + state.resume_mode = None + state.downloaded_eps = set() + + +def _first_missing_index(episodes: list[EpisodeRow], downloaded: set[float]) -> int: + return next((i for i, ep in enumerate(episodes) if ep.number not in downloaded), len(episodes) - 1) + + def handle_start_mode(state: FlowState) -> ScreenResult: args = state.args # When launched with flags, skip the interactive menu if args.resume: - return Screen.RESUME_PICK + return Screen.DOWNLOAD_RESUME_PICK if args.download else Screen.RESUME_PICK if args.url: ref = shinden_series.parse_series_url(args.url) state.ref = ref @@ -115,12 +127,10 @@ def handle_start_mode(state: FlowState) -> ScreenResult: state.query = " ".join(args.query) return Screen.SERIES_PICK - all_entries = history.list_all() + watch_count = len(history.list_all()) + download_count = len(history.list_downloads()) while True: - choice = menus.select_start_mode( - has_history=bool(all_entries), - history_count=len(all_entries), - ) + choice = menus.select_start_mode(watch_count=watch_count, download_count=download_count) if choice != "version": break _sm = _M["start_mode"] @@ -129,7 +139,11 @@ def handle_start_mode(state: FlowState) -> ScreenResult: return BACK # ESC from first screen → exit via empty history_stack if choice == "quit": return None - if choice == "resume": + if choice == "resume_download": + state.hits = [] + return Screen.DOWNLOAD_RESUME_PICK + _reset_download_resume(state) + if choice == "resume_watch": state.hits = [] return Screen.RESUME_PICK if choice == "url": @@ -151,6 +165,7 @@ def handle_url_input(state: FlowState) -> ScreenResult: if url is None: return BACK ref = shinden_series.parse_series_url(url) + _reset_download_resume(state) state.ref = ref state.last_ep = 0.0 return Screen.FETCH_EPISODES @@ -164,7 +179,26 @@ def handle_resume_pick(state: FlowState) -> ScreenResult: result = menus.select_series_from_history(all_entries) if result is None: return BACK + _reset_download_resume(state) state.ref, state.last_ep = result + state.resume_mode = "watch" + return Screen.FETCH_EPISODES + + +def handle_download_resume_pick(state: FlowState) -> ScreenResult: + entries = history.list_downloads() + if not entries: + progress.error(_PROG["download_history_empty"]) + return BACK + result = menus.select_series_from_download_history(entries) + if result is None: + return BACK + ref, downloaded = result + state.ref = ref + state.last_ep = 0.0 + state.resume_mode = "download" + state.downloaded_eps = set(downloaded) + state.episode_action = "download" return Screen.FETCH_EPISODES @@ -196,6 +230,7 @@ def handle_series_pick(state: FlowState) -> ScreenResult: if action == "pick": hit = payload ref = shinden_series.parse_series_url(hit.url) + _reset_download_resume(state) state.ref = SeriesRef(id=ref.id, slug=ref.slug, title=hit.title, url=ref.url) state.last_ep = 0.0 return Screen.FETCH_EPISODES @@ -283,7 +318,10 @@ def handle_episodes_pick(state: FlowState) -> ScreenResult: default_index = None watched = set(state.completed_eps) - if state.last_ep > 0: + downloaded = set(state.downloaded_eps) + if state.resume_mode == "download": + default_index = _first_missing_index(state.episodes, downloaded) + elif state.last_ep > 0: watched |= {ep.number for ep in state.episodes if ep.number <= state.last_ep} default_index = next( (i for i, ep in enumerate(state.episodes) if ep.number > state.last_ep), @@ -295,6 +333,7 @@ def handle_episodes_pick(state: FlowState) -> ScreenResult: prompt=CONTENT["menu"]["episodes"]["prompt_with_title"].format(title=state.ref.title), multi=True, watched_numbers=watched, + downloaded_numbers=downloaded, default_index=default_index, ) if result is None: @@ -320,7 +359,8 @@ def handle_episode_dispatch(state: FlowState) -> ScreenResult: args = state.args raw_players = shinden_episode.parse_players(ep_resp.text) - players = shinden_episode.sort_players(raw_players, download=args.download) + download_mode = args.download or state.episode_action == "download" + players = shinden_episode.sort_players(raw_players, download=download_mode) if not players: progress.warn(_PROG["no_players"].format(number=ep.number)) @@ -501,6 +541,8 @@ def handle_run_action(state: FlowState) -> ScreenResult: state.embed = None return _fail_chosen_player(state, ep) diagnostics.download_result(host, ok=True, exc=None) + history.record_download(state.ref, ep.number) + state.downloaded_eps.add(ep.number) elif args.debug or state.episode_action == "debug": _print_debug(stream, state.embed) else: @@ -525,6 +567,7 @@ def handle_run_action(state: FlowState) -> ScreenResult: Screen.SEARCH_QUERY: handle_search_query, Screen.URL_INPUT: handle_url_input, Screen.RESUME_PICK: handle_resume_pick, + Screen.DOWNLOAD_RESUME_PICK: handle_download_resume_pick, Screen.SERIES_PICK: handle_series_pick, Screen.FETCH_EPISODES: handle_fetch_episodes, Screen.EPISODES_PICK: handle_episodes_pick, diff --git a/src/alt_ani_cli/flow/state.py b/src/alt_ani_cli/flow/state.py index ca4b268..7328d0c 100644 --- a/src/alt_ani_cli/flow/state.py +++ b/src/alt_ani_cli/flow/state.py @@ -2,7 +2,7 @@ from dataclasses import dataclass, field from enum import Enum, auto -from typing import Any +from typing import Any, Literal from alt_ani_cli.health import ResolverHealth @@ -12,6 +12,7 @@ class Screen(Enum): SEARCH_QUERY = auto() URL_INPUT = auto() RESUME_PICK = auto() + DOWNLOAD_RESUME_PICK = auto() SERIES_PICK = auto() FETCH_EPISODES = auto() # wirtualny — I/O bez UI EPISODES_PICK = auto() @@ -59,6 +60,8 @@ class FlowState: hits: list = field(default_factory=list) ref: Any = None last_ep: float = 0.0 + resume_mode: Literal["watch", "download"] | None = None + downloaded_eps: set[float] = field(default_factory=set) # episodes episodes: list = field(default_factory=list) diff --git a/src/alt_ani_cli/history.py b/src/alt_ani_cli/history.py index 3aa7b3d..35ac688 100644 --- a/src/alt_ani_cli/history.py +++ b/src/alt_ani_cli/history.py @@ -22,31 +22,53 @@ def _save(data: dict) -> None: os.replace(tmp, HISTORY_FILE) +def _merge_entry(data: dict, series: SeriesRef) -> dict: + """Return the series entry with fresh metadata, keeping fields owned by the other history kind.""" + entry = data["series"].setdefault(series.id, {}) + entry.update(title=series.title, slug=series.slug, url=series.url) + return entry + + +def _ref(series_id: str, entry: dict) -> SeriesRef: + return SeriesRef( + id=series_id, + slug=entry.get("slug", ""), + title=entry.get("title", series_id), + url=entry.get("url", ""), + ) + + def upsert(series: SeriesRef, last_ep: float) -> None: data = _load() - data["series"][series.id] = { - "title": series.title, - "slug": series.slug, - "url": series.url, - "last_ep": last_ep, - "updated_at": datetime.now(UTC).isoformat(), - } + entry = _merge_entry(data, series) + entry["last_ep"] = last_ep + entry["updated_at"] = datetime.now(UTC).isoformat() + _save(data) + + +def record_download(series: SeriesRef, ep_number: float) -> None: + data = _load() + entry = _merge_entry(data, series) + downloaded = {float(n) for n in entry.get("downloaded_eps", [])} + downloaded.add(float(ep_number)) + entry["downloaded_eps"] = sorted(downloaded) + entry["download_updated_at"] = datetime.now(UTC).isoformat() _save(data) def list_all() -> list[tuple[SeriesRef, float]]: + """Watch history only — download-only entries have no last_ep and are skipped.""" + data = _load() + watched = [(sid, e) for sid, e in data["series"].items() if "last_ep" in e] + watched.sort(key=lambda item: item[1].get("updated_at", ""), reverse=True) + return [(_ref(sid, e), float(e["last_ep"])) for sid, e in watched] + + +def list_downloads() -> list[tuple[SeriesRef, frozenset[float]]]: data = _load() - result: list[tuple[SeriesRef, float]] = [] - for series_id, entry in data["series"].items(): - ref = SeriesRef( - id=series_id, - slug=entry.get("slug", ""), - title=entry.get("title", series_id), - url=entry.get("url", ""), - ) - result.append((ref, float(entry.get("last_ep", 0)))) - result.sort(key=lambda x: data["series"][x[0].id].get("updated_at", ""), reverse=True) - return result + downloads = [(sid, e) for sid, e in data["series"].items() if e.get("downloaded_eps")] + downloads.sort(key=lambda item: item[1].get("download_updated_at", ""), reverse=True) + return [(_ref(sid, e), frozenset(float(n) for n in e["downloaded_eps"])) for sid, e in downloads] def clear() -> None: diff --git a/src/alt_ani_cli/ui/menus.py b/src/alt_ani_cli/ui/menus.py index 9cd4776..78f5eb3 100644 --- a/src/alt_ani_cli/ui/menus.py +++ b/src/alt_ani_cli/ui/menus.py @@ -377,6 +377,19 @@ def select_series_from_history( ) +def select_series_from_download_history( + entries: list[tuple[SeriesRef, frozenset[float]]], + prompt: str = _M["download_resume"]["default_prompt"], +) -> tuple[SeriesRef, frozenset[float]] | None: + _dr = _M["download_resume"] + return _run_simple_picker( + entries, + lambda e: _dr["label"].format(title=e[0].title, count=len(e[1])), + prompt=prompt, + instruction=_dr["instruction"], + ) + + _EP_WINDOW_MAX_ROWS = 15 @@ -410,12 +423,19 @@ def select_episodes( multi: bool = False, watched_numbers: set[float] | None = None, default_index: int | None = None, + downloaded_numbers: set[float] | None = None, ) -> list[EpisodeRow] | None: _watched = watched_numbers or set() + _downloaded = downloaded_numbers or set() _ep = _M["episodes"] def _label(ep: EpisodeRow) -> str: - tmpl = _ep["label_watched"] if ep.number in _watched else _ep["label_unwatched"] + if ep.number in _watched: + tmpl = _ep["label_watched"] + elif ep.number in _downloaded: + tmpl = _ep["label_downloaded"] + else: + tmpl = _ep["label_unwatched"] return tmpl.format(number=ep.number, title=ep.title) if not _use_inquirer(): @@ -588,15 +608,16 @@ def _(event): def select_start_mode( - has_history: bool, history_count: int = 0 -) -> Literal["search", "resume", "url", "quit", "version"] | None: + watch_count: int = 0, download_count: int = 0 +) -> Literal["search", "resume_watch", "resume_download", "url", "quit", "version"] | None: _sm = _M["start_mode"] _opts = _sm["options"] options_plain: list[tuple[str, str]] = [("search", _opts["search"])] - if has_history: - resume_label = _opts["resume_with_count"].format(count=history_count) if history_count else _opts["resume"] - options_plain.append(("resume", resume_label)) + if watch_count: + options_plain.append(("resume_watch", _opts["resume_watch"].format(count=watch_count))) + if download_count: + options_plain.append(("resume_download", _opts["resume_download"].format(count=download_count))) options_plain.append(("url", _opts["url"])) options_plain.append(("quit", _opts["quit"])) diff --git a/tests/cli/test_noninteractive.py b/tests/cli/test_noninteractive.py index eb6f74c..33f6e0a 100644 --- a/tests/cli/test_noninteractive.py +++ b/tests/cli/test_noninteractive.py @@ -1,13 +1,15 @@ """Tests for noninteractive diagnostics wiring — session lifecycle, episode/player context.""" import argparse +from contextlib import contextmanager +from types import SimpleNamespace from unittest.mock import MagicMock, patch import pytest from alt_ani_cli import diagnostics from alt_ani_cli.cli import _run_noninteractive, main -from alt_ani_cli.errors import AntiBotError, NoStreamError +from alt_ani_cli.errors import AntiBotError, DownloadFailedError, NoStreamError from alt_ani_cli.models import EmbedURL, EpisodeRow, PlayerEntry, SeriesRef _REF = SeriesRef(id="1", slug="fate", title="Fate", url="http://shinden.pl/series/1-fate") @@ -296,3 +298,87 @@ def _resolve_embed_side_effect(_client, online_id): assert resolve_embed_ids[:2] == ["pa1", "pb1"] second_episode_resolve_ids = resolve_embed_ids[2:] assert second_episode_resolve_ids[0] == "qb2" + + +_EPISODES = [EpisodeRow(number=float(n), title=f"Ep {n}", url=f"http://shinden.pl/ep/{n}") for n in (1, 2, 3, 4, 5)] +_STREAM = MagicMock(qualities={}) + + +@contextmanager +def _resume_env(watch=(), downloads=(), download_side_effect=None): + """Patch everything after series resolution so only target selection and history writes are exercised.""" + client = MagicMock() + client.get.return_value = MagicMock(raise_for_status=MagicMock(), text="") + with ( + patch("alt_ani_cli.cli.history.list_all", return_value=list(watch)), + patch("alt_ani_cli.cli.history.list_downloads", return_value=list(downloads)), + patch("alt_ani_cli.cli.shinden_series.parse_series_url", return_value=_REF), + patch("alt_ani_cli.cli.shinden_series.list_episodes", return_value=(_REF, _EPISODES)), + patch("alt_ani_cli.cli.shinden_episode.parse_players", return_value=[_PLAYER_A]), + patch("alt_ani_cli.cli.shinden_episode.sort_players", return_value=[_PLAYER_A]), + patch("alt_ani_cli.cli._resolve_with_fallback", return_value=(_STREAM, None)), + patch("alt_ani_cli.cli._pick_quality", return_value=_STREAM), + patch("alt_ani_cli.cli.download.run", side_effect=download_side_effect) as mock_download, + patch("alt_ani_cli.cli.player_runner.play", return_value=MagicMock(rc=0, elapsed=5.0)) as mock_play, + patch("alt_ani_cli.cli.history.upsert") as mock_upsert, + patch("alt_ani_cli.cli.history.record_download") as mock_record, + ): + yield client, SimpleNamespace(download=mock_download, play=mock_play, upsert=mock_upsert, record=mock_record) + + +@pytest.mark.unit +class TestNoninteractiveDownloadHistory: + def test_continue_without_download_uses_watch_history(self): + with _resume_env(watch=[(_REF, 2.0)], downloads=[(_REF, frozenset({1.0}))]) as (client, m): + _run_noninteractive(_make_args(url=None, resume=True), client) + played_title = m.play.call_args.kwargs["title"] + assert "Odcinek 3" in played_title + m.download.assert_not_called() + m.upsert.assert_called_once_with(_REF, last_ep=3.0) + m.record.assert_not_called() + + def test_continue_with_download_uses_download_history(self): + with _resume_env(watch=[(_REF, 4.0)], downloads=[(_REF, frozenset({1.0, 2.0}))]) as (client, m): + _run_noninteractive(_make_args(url=None, resume=True, download=True), client) + (_, ep, _), _ = m.download.call_args + assert ep.number == 3.0 + m.record.assert_called_once_with(_REF, 3.0) + m.upsert.assert_not_called() + m.play.assert_not_called() + + def test_continue_with_download_picks_first_gap(self): + with _resume_env(downloads=[(_REF, frozenset({1.0, 2.0, 4.0}))]) as (client, m): + _run_noninteractive(_make_args(url=None, resume=True, download=True), client) + (_, ep, _), _ = m.download.call_args + assert ep.number == 3.0 + + def test_continue_with_download_all_downloaded_skips_download(self): + everything = frozenset(ep.number for ep in _EPISODES) + with ( + _resume_env(downloads=[(_REF, everything)]) as (client, m), + patch("alt_ani_cli.cli.progress.info") as mock_info, + ): + _run_noninteractive(_make_args(url=None, resume=True, download=True), client) + m.download.assert_not_called() + m.record.assert_not_called() + assert any(_REF.title in str(c.args[0]) for c in mock_info.call_args_list) + + def test_continue_with_download_empty_history_exits(self): + with _resume_env(watch=[(_REF, 2.0)]) as (client, m), pytest.raises(SystemExit): + _run_noninteractive(_make_args(url=None, resume=True, download=True), client) + m.download.assert_not_called() + + def test_plain_download_records_download_history(self): + with _resume_env() as (client, m): + _run_noninteractive(_make_args(download=True, episode="2"), client) + m.record.assert_called_once_with(_REF, 2.0) + m.upsert.assert_not_called() + + def test_failed_download_does_not_record_history(self): + with ( + _resume_env(download_side_effect=DownloadFailedError("x")) as (client, m), + pytest.raises(DownloadFailedError), + ): + _run_noninteractive(_make_args(download=True, episode="2"), client) + m.record.assert_not_called() + m.upsert.assert_not_called() diff --git a/tests/conftest.py b/tests/conftest.py index a13e8aa..d8a968b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -15,6 +15,15 @@ def _isolate_diagnostics_dir(tmp_path, monkeypatch): handler.close() +@pytest.fixture(autouse=True) +def _isolate_history_file(tmp_path, monkeypatch): + """Successful downloads write history — never touch the real history.json.""" + from alt_ani_cli import history + + monkeypatch.setattr(history, "STATE_DIR", tmp_path / "state") + monkeypatch.setattr(history, "HISTORY_FILE", tmp_path / "state" / "history.json") + + @pytest.fixture def player_show_html(): return '' diff --git a/tests/content/test_ui_labels.py b/tests/content/test_ui_labels.py index 66009b9..4706fe4 100644 --- a/tests/content/test_ui_labels.py +++ b/tests/content/test_ui_labels.py @@ -36,6 +36,12 @@ def test_watched_and_unwatched_templates_differ(self): unwatched = _ep["label_unwatched"].format(number=1.0, title="Ep A") assert watched != unwatched + def test_downloaded_template_differs_from_watched_and_unwatched(self): + _ep = CONTENT["menu"]["episodes"] + downloaded = _ep["label_downloaded"].format(number=1.0, title="Ep A") + assert downloaded != _ep["label_watched"].format(number=1.0, title="Ep A") + assert downloaded != _ep["label_unwatched"].format(number=1.0, title="Ep A") + def test_select_episodes_renders_watched_template(self, monkeypatch): monkeypatch.setattr("alt_ani_cli.ui.menus._USE_INQUIRER", False) episodes = [ @@ -101,7 +107,7 @@ class TestStartModeLabels: def test_resume_count_in_label(self, monkeypatch, capsys): monkeypatch.setattr("alt_ani_cli.ui.menus._USE_INQUIRER", False) with patch("builtins.input", return_value="2"): - select_start_mode(has_history=True, history_count=5) + select_start_mode(watch_count=5) assert "5" in capsys.readouterr().out @@ -117,7 +123,11 @@ class TestTemplatePlaceholders: (("menu", "series", "label_without_date"), ("{title}", "{id}")), (("menu", "episodes", "label_watched"), ("{number", "{title}")), (("menu", "episodes", "label_unwatched"), ("{number", "{title}")), - (("menu", "start_mode", "options", "resume_with_count"), ("{count}",)), + (("menu", "start_mode", "options", "resume_watch"), ("{count}",)), + (("menu", "start_mode", "options", "resume_download"), ("{count}",)), + (("menu", "download_resume", "label"), ("{title}", "{count}")), + (("menu", "episodes", "label_downloaded"), ("{number", "{title}")), + (("progress", "downloaded_all"), ("{title}",)), (("menu", "start_mode", "version_body"), ("{version}",)), (("menu", "player", "label_ok"), ("{player}", "{res}", "{audio}", "{subs}", "{origin}")), (("menu", "player", "label_failed"), ("{player}", "{res}", "{audio}", "{subs}", "{origin}")), diff --git a/tests/flow/test_handlers.py b/tests/flow/test_handlers.py index 7e40737..9c34880 100644 --- a/tests/flow/test_handlers.py +++ b/tests/flow/test_handlers.py @@ -446,7 +446,7 @@ def test_esc_from_search_query_returns_to_start(self): client = MagicMock() call_count = {"n": 0} - def _fake_start(has_history, history_count=0): + def _fake_start(**kw): call_count["n"] += 1 if call_count["n"] == 1: return "search" @@ -468,7 +468,7 @@ def test_esc_from_series_pick_returns_to_search_query(self): query_calls = {"n": 0} start_calls = {"n": 0} - def _fake_start(has_history, history_count=0): + def _fake_start(**kw): start_calls["n"] += 1 if start_calls["n"] == 1: return "search" @@ -1330,3 +1330,251 @@ def test_health_order_is_never_called(self): HANDLERS[Screen.RESOLVE_STREAM](state) mock_order.assert_not_called() + + +_EP4 = EpisodeRow(number=4.0, title="Ep 4", url="http://shinden.pl/ep/4") +_EP5 = EpisodeRow(number=5.0, title="Ep 5", url="http://shinden.pl/ep/5") +_EP6 = EpisodeRow(number=6.0, title="Ep 6", url="http://shinden.pl/ep/6") + + +@pytest.mark.unit +class TestStartModeHistoryOptions: + def _run(self, choice, watch=(), downloads=(), state=None): + state = state or _make_state() + with ( + patch("alt_ani_cli.history.list_all", return_value=list(watch)), + patch("alt_ani_cli.history.list_downloads", return_value=list(downloads)), + patch("alt_ani_cli.ui.menus.select_start_mode", return_value=choice) as mock_menu, + ): + result = HANDLERS[Screen.START_MODE](state) + return state, result, mock_menu.call_args.kwargs + + def test_menu_receives_both_counts(self): + _, _, kwargs = self._run( + None, + watch=[(_SERIES_REF, 1.0)] * 22, + downloads=[(_SERIES_REF, frozenset({1.0}))] * 3, + ) + assert kwargs == {"watch_count": 22, "download_count": 3} + + def test_resume_watch_goes_to_resume_pick(self): + _, result, _ = self._run("resume_watch", watch=[(_SERIES_REF, 1.0)]) + assert result is Screen.RESUME_PICK + + def test_resume_download_goes_to_download_resume_pick(self): + _, result, _ = self._run("resume_download", downloads=[(_SERIES_REF, frozenset({1.0}))]) + assert result is Screen.DOWNLOAD_RESUME_PICK + + @pytest.mark.parametrize( + "choice, expected", + [("search", Screen.SEARCH_QUERY), ("url", Screen.URL_INPUT), ("resume_watch", Screen.RESUME_PICK)], + ) + def test_leaving_download_resume_clears_its_state(self, choice, expected): + state = _make_state(resume_mode="download", downloaded_eps={1.0}, episode_action="download") + _, result, _ = self._run(choice, state=state) + assert result is expected + assert state.resume_mode is None + assert state.downloaded_eps == set() + assert state.episode_action is None + + def test_cached_download_action_outside_resume_mode_is_kept(self): + state = _make_state(episode_action="download") + self._run("search", state=state) + assert state.episode_action == "download" + + def test_args_resume_with_download_skips_menu_to_download_resume(self): + state = _make_state(args=_make_args(resume=True, download=True)) + assert HANDLERS[Screen.START_MODE](state) is Screen.DOWNLOAD_RESUME_PICK + + +@pytest.mark.unit +class TestHandleResumePickModes: + def test_resume_watch_keeps_last_ep(self): + state = _make_state(resume_mode="download", downloaded_eps={1.0}, episode_action="download") + with ( + patch("alt_ani_cli.history.list_all", return_value=[(_SERIES_REF, 5.0)]), + patch("alt_ani_cli.ui.menus.select_series_from_history", return_value=(_SERIES_REF, 5.0)), + ): + result = HANDLERS[Screen.RESUME_PICK](state) + assert result is Screen.FETCH_EPISODES + assert state.ref == _SERIES_REF + assert state.last_ep == 5.0 + assert state.resume_mode == "watch" + assert state.downloaded_eps == set() + assert state.episode_action is None + + def test_resume_download_sets_download_state(self): + state = _make_state(last_ep=3.0) + with ( + patch("alt_ani_cli.history.list_downloads", return_value=[(_SERIES_REF, frozenset({1.0, 2.0}))]), + patch( + "alt_ani_cli.ui.menus.select_series_from_download_history", + return_value=(_SERIES_REF, frozenset({1.0, 2.0})), + ), + ): + result = HANDLERS[Screen.DOWNLOAD_RESUME_PICK](state) + assert result is Screen.FETCH_EPISODES + assert state.ref == _SERIES_REF + assert state.resume_mode == "download" + assert state.downloaded_eps == {1.0, 2.0} + assert state.episode_action == "download" + assert state.last_ep == 0.0 + + def test_resume_download_esc_returns_back(self): + state = _make_state() + with ( + patch("alt_ani_cli.history.list_downloads", return_value=[(_SERIES_REF, frozenset({1.0}))]), + patch("alt_ani_cli.ui.menus.select_series_from_download_history", return_value=None), + ): + result = HANDLERS[Screen.DOWNLOAD_RESUME_PICK](state) + assert isinstance(result, _BackSentinel) + assert state.resume_mode is None + assert state.episode_action is None + + def test_resume_download_empty_history_returns_back(self): + state = _make_state() + with ( + patch("alt_ani_cli.history.list_downloads", return_value=[]), + patch("alt_ani_cli.ui.progress.error") as mock_err, + ): + result = HANDLERS[Screen.DOWNLOAD_RESUME_PICK](state) + assert isinstance(result, _BackSentinel) + mock_err.assert_called_once() + + +@pytest.mark.unit +class TestEpisodesPickDownloadResume: + def _pick(self, episodes, downloaded): + state = _make_state(ref=_SERIES_REF, episodes=episodes, resume_mode="download", downloaded_eps=set(downloaded)) + with patch("alt_ani_cli.ui.menus.select_episodes", return_value=[episodes[-1]]) as mock_sel: + HANDLERS[Screen.EPISODES_PICK](state) + return mock_sel.call_args + + def test_passes_downloaded_numbers_and_cursor_on_first_missing(self): + args, kwargs = self._pick([_EP1, _EP2, _EP3], {1.0, 2.0}) + assert args[0] == [_EP1, _EP2, _EP3] + assert kwargs["downloaded_numbers"] == {1.0, 2.0} + assert kwargs["default_index"] == 2 + assert kwargs["watched_numbers"] == set() + + def test_gap_in_downloads_puts_cursor_on_gap_not_after_max(self): + _, kwargs = self._pick([_EP1, _EP2, _EP3, _EP4, _EP5, _EP6], {1.0, 2.0, 3.0, 5.0}) + assert kwargs["default_index"] == 3 + + def test_all_downloaded_shows_full_list_with_cursor_on_last(self): + args, kwargs = self._pick([_EP1, _EP2, _EP3], {1.0, 2.0, 3.0}) + assert args[0] == [_EP1, _EP2, _EP3] + assert kwargs["default_index"] == 2 + + def test_watch_resume_cursor_and_watched_ignore_downloads(self): + state = _make_state( + ref=_SERIES_REF, episodes=[_EP1, _EP2, _EP3], last_ep=1.0, resume_mode="watch", downloaded_eps={2.0} + ) + with patch("alt_ani_cli.ui.menus.select_episodes", return_value=[_EP2]) as mock_sel: + HANDLERS[Screen.EPISODES_PICK](state) + kwargs = mock_sel.call_args.kwargs + assert kwargs["watched_numbers"] == {1.0} + assert kwargs["default_index"] == 1 + + def test_manual_download_outside_resume_marks_episode_without_moving_cursor(self): + state = _make_state(ref=_SERIES_REF, episodes=[_EP1, _EP2, _EP3], downloaded_eps={1.0}) + with patch("alt_ani_cli.ui.menus.select_episodes", return_value=[_EP2]) as mock_sel: + HANDLERS[Screen.EPISODES_PICK](state) + kwargs = mock_sel.call_args.kwargs + assert kwargs["downloaded_numbers"] == {1.0} + assert kwargs["watched_numbers"] == set() + assert kwargs["default_index"] is None + + +@pytest.mark.unit +class TestRunActionDownloadHistory: + def test_success_records_download_and_updates_state(self): + state = _download_state(downloaded_eps={5.0}) + with ( + patch("alt_ani_cli.download.run"), + patch("alt_ani_cli.history.record_download") as mock_record, + patch("alt_ani_cli.history.upsert") as mock_upsert, + ): + handle_run_action(state) + mock_record.assert_called_once_with(_SERIES_REF, _EP1.number) + mock_upsert.assert_not_called() + assert state.downloaded_eps == {5.0, _EP1.number} + assert state.completed_eps == set() + + def test_failure_does_not_record_download(self): + state = _download_state() + with ( + patch("alt_ani_cli.download.run", side_effect=DownloadFailedError("x")), + patch("alt_ani_cli.history.record_download") as mock_record, + patch("alt_ani_cli.ui.progress.error"), + ): + handle_run_action(state) + mock_record.assert_not_called() + assert state.downloaded_eps == set() + + def test_success_is_persisted_without_touching_watch_history(self): + from alt_ani_cli import history + + state = _download_state() + with patch("alt_ani_cli.download.run"): + handle_run_action(state) + assert history.list_downloads() == [(_SERIES_REF, frozenset({_EP1.number}))] + assert history.list_all() == [] + + +@pytest.mark.unit +class TestEpisodeDispatchDownloadSorting: + def test_cached_download_action_uses_download_ranking(self): + state = _make_ep_dispatch_state() + state.episode_action = "download" + with ( + patch("alt_ani_cli.shinden.episode.parse_players", return_value=[_PLAYER, _PLAYER2]), + patch("alt_ani_cli.shinden.episode.sort_players", return_value=[_PLAYER, _PLAYER2]) as mock_sort, + ): + HANDLERS[Screen.EPISODE_DISPATCH](state) + assert mock_sort.call_args.kwargs["download"] is True + + def test_play_action_keeps_playback_ranking(self): + state = _make_ep_dispatch_state() + state.episode_action = "play" + with ( + patch("alt_ani_cli.shinden.episode.parse_players", return_value=[_PLAYER, _PLAYER2]), + patch("alt_ani_cli.shinden.episode.sort_players", return_value=[_PLAYER, _PLAYER2]) as mock_sort, + ): + HANDLERS[Screen.EPISODE_DISPATCH](state) + assert mock_sort.call_args.kwargs["download"] is False + + +@pytest.mark.unit +class TestDownloadResumeBackToSearchRegression: + def test_back_from_download_resume_to_search_clears_download_mode(self): + """START→resume_download→pick→EPISODES_PICK(ESC)→DOWNLOAD_RESUME_PICK(ESC)→START→search.""" + captured: dict = {} + seen_at_search: dict = {} + starts = iter(["resume_download", "search", None]) + download_picks = iter([(_SERIES_REF, frozenset({1.0})), None]) + + def _factory(**kw): + captured["state"] = FlowState(**kw) + return captured["state"] + + def _fake_search_query(): + s = captured["state"] + seen_at_search.update( + resume_mode=s.resume_mode, episode_action=s.episode_action, downloaded_eps=set(s.downloaded_eps) + ) + return None + + with ( + patch("alt_ani_cli.flow.state.FlowState", side_effect=_factory), + patch("alt_ani_cli.history.list_all", return_value=[]), + patch("alt_ani_cli.history.list_downloads", return_value=[(_SERIES_REF, frozenset({1.0}))]), + patch("alt_ani_cli.ui.menus.select_start_mode", side_effect=lambda **kw: next(starts)), + patch("alt_ani_cli.ui.menus.select_series_from_download_history", side_effect=lambda e: next(download_picks)), + patch("alt_ani_cli.shinden.series.list_episodes", return_value=(_SERIES_REF, [_EP1, _EP2])), + patch("alt_ani_cli.ui.menus.select_episodes", return_value=None), + patch("alt_ani_cli.ui.menus.prompt_search_query", side_effect=_fake_search_query), + ): + _run_interactive_wrapped(_make_args(), MagicMock()) + + assert seen_at_search == {"resume_mode": None, "episode_action": None, "downloaded_eps": set()} diff --git a/tests/test_history.py b/tests/test_history.py index 2d3f046..5a31aa1 100644 --- a/tests/test_history.py +++ b/tests/test_history.py @@ -99,3 +99,134 @@ def test_list_missing_updated_at_sorts_last(self, tmp_path): entries = h.list_all() assert entries[0][0].id == "1" assert entries[1][0].id == "2" + + +def _history(tmp_path, data: dict | None = None): + hf = tmp_path / "h.json" + if data is not None: + hf.write_text(json.dumps(data), encoding="utf-8") + return hf, patch("alt_ani_cli.history.HISTORY_FILE", hf), patch("alt_ani_cli.history.STATE_DIR", tmp_path) + + +@pytest.mark.unit +class TestDownloadHistory: + def test_legacy_watch_only_entry_still_lists(self, tmp_path): + _, p_file, p_dir = _history(tmp_path, { + "version": 1, + "series": {"7": {"title": "Old", "slug": "old", "url": "http://x/7", "last_ep": 7, "updated_at": "2024-01-01T00:00:00+00:00"}}, + }) + with p_file, p_dir: + import alt_ani_cli.history as h + entries = h.list_all() + downloads = h.list_downloads() + assert [(e[0].id, e[1]) for e in entries] == [("7", 7.0)] + assert downloads == [] + + def test_record_download_creates_download_only_entry(self, tmp_path): + hf, p_file, p_dir = _history(tmp_path) + with p_file, p_dir: + import alt_ani_cli.history as h + h.record_download(_make_ref(), 1.0) + downloads = h.list_downloads() + assert downloads == [(_make_ref(), frozenset({1.0}))] + entry = json.loads(hf.read_text(encoding="utf-8"))["series"]["123"] + assert entry["downloaded_eps"] == [1.0] + assert "download_updated_at" in entry + assert "last_ep" not in entry + assert "updated_at" not in entry + + def test_record_download_accumulates_without_duplicates(self, tmp_path): + hf, p_file, p_dir = _history(tmp_path) + with p_file, p_dir: + import alt_ani_cli.history as h + for n in (1.0, 2.0, 4.0, 2.0, 1.0): + h.record_download(_make_ref(), n) + downloads = h.list_downloads() + assert downloads[0][1] == frozenset({1.0, 2.0, 4.0}) + assert json.loads(hf.read_text(encoding="utf-8"))["series"]["123"]["downloaded_eps"] == [1.0, 2.0, 4.0] + + def test_list_downloads_sorted_by_download_updated_at_descending(self, tmp_path): + _, p_file, p_dir = _history(tmp_path, { + "version": 1, + "series": { + "1": {"title": "A", "slug": "a", "url": "http://x/1", "downloaded_eps": [1.0], "download_updated_at": "2024-01-01T00:00:00+00:00", + "last_ep": 1.0, "updated_at": "2025-01-01T00:00:00+00:00"}, + "2": {"title": "B", "slug": "b", "url": "http://x/2", "downloaded_eps": [1.0], "download_updated_at": "2024-06-15T00:00:00+00:00"}, + "3": {"title": "C", "slug": "c", "url": "http://x/3", "downloaded_eps": [1.0], "download_updated_at": "2023-12-31T00:00:00+00:00"}, + }, + }) + with p_file, p_dir: + import alt_ani_cli.history as h + downloads = h.list_downloads() + assert [d[0].id for d in downloads] == ["2", "1", "3"] + + def test_list_all_skips_download_only_entries(self, tmp_path): + _, p_file, p_dir = _history(tmp_path) + with p_file, p_dir: + import alt_ani_cli.history as h + h.record_download(_make_ref(id="1", title="Downloaded"), 1.0) + h.upsert(_make_ref(id="2", title="Watched"), last_ep=3.0) + entries = h.list_all() + assert [e[0].id for e in entries] == ["2"] + + def test_watch_upsert_preserves_download_fields(self, tmp_path): + hf, p_file, p_dir = _history(tmp_path) + with p_file, p_dir: + import alt_ani_cli.history as h + for n in (1.0, 2.0, 3.0): + h.record_download(_make_ref(), n) + before = json.loads(hf.read_text(encoding="utf-8"))["series"]["123"]["download_updated_at"] + h.upsert(_make_ref(), last_ep=1.0) + entry = json.loads(hf.read_text(encoding="utf-8"))["series"]["123"] + assert entry["downloaded_eps"] == [1.0, 2.0, 3.0] + assert entry["download_updated_at"] == before + assert entry["last_ep"] == 1.0 + + def test_record_download_preserves_watch_fields(self, tmp_path): + hf, p_file, p_dir = _history(tmp_path) + with p_file, p_dir: + import alt_ani_cli.history as h + h.upsert(_make_ref(), last_ep=5.0) + before = json.loads(hf.read_text(encoding="utf-8"))["series"]["123"]["updated_at"] + h.record_download(_make_ref(), 6.0) + entry = json.loads(hf.read_text(encoding="utf-8"))["series"]["123"] + entries = h.list_all() + assert entry["last_ep"] == 5.0 + assert entry["updated_at"] == before + assert entries[0][1] == 5.0 + + def test_record_download_refreshes_series_metadata(self, tmp_path): + _, p_file, p_dir = _history(tmp_path) + with p_file, p_dir: + import alt_ani_cli.history as h + h.upsert(_make_ref(title="Old Title"), last_ep=1.0) + h.record_download(_make_ref(title="New Title"), 1.0) + entries = h.list_all() + assert entries[0][0].title == "New Title" + + def test_clear_removes_both_history_kinds(self, tmp_path): + _, p_file, p_dir = _history(tmp_path) + with p_file, p_dir: + import alt_ani_cli.history as h + h.upsert(_make_ref(id="1"), last_ep=1.0) + h.record_download(_make_ref(id="2"), 1.0) + h.clear() + assert h.list_all() == [] + assert h.list_downloads() == [] + + def test_version_1_file_without_download_fields_accepts_new_writes(self, tmp_path): + hf, p_file, p_dir = _history(tmp_path, { + "version": 1, + "series": {"123": {"title": "Test Anime", "slug": "test-anime", "url": "http://x", "last_ep": 4.0, + "updated_at": "2024-01-01T00:00:00+00:00"}}, + }) + with p_file, p_dir: + import alt_ani_cli.history as h + assert h.list_downloads() == [] + h.record_download(_make_ref(), 5.0) + entries = h.list_all() + downloads = h.list_downloads() + data = json.loads(hf.read_text(encoding="utf-8")) + assert data["version"] == 1 + assert entries[0][1] == 4.0 + assert downloads[0][1] == frozenset({5.0}) diff --git a/tests/ui/test_menus.py b/tests/ui/test_menus.py index 59aec7b..a1a28c1 100644 --- a/tests/ui/test_menus.py +++ b/tests/ui/test_menus.py @@ -4,6 +4,7 @@ import pytest +from alt_ani_cli.content import CONTENT from alt_ani_cli.models import PlayerSource from alt_ani_cli.shinden.models import EpisodeRow, PlayerEntry, RelatedSeries, SeriesHit, SeriesRef from alt_ani_cli.ui.menus import ( @@ -19,6 +20,7 @@ select_episodes, select_player_once, select_quality, + select_series_from_download_history, select_series_from_history, select_series_once, select_start_mode, @@ -64,27 +66,69 @@ def test_empty_enter_returns_none(self, monkeypatch): assert select_quality({"1080p": "u1"}) is None +def _start_mode_options(monkeypatch, **counts) -> tuple[list[str], str | None]: + monkeypatch.setattr("alt_ani_cli.ui.menus._USE_INQUIRER", False) + printed: list[str] = [] + with ( + patch("builtins.print", side_effect=lambda *a, **k: printed.append(" ".join(str(x) for x in a))), + patch("builtins.input", return_value="1"), + ): + choice = select_start_mode(**counts) + return printed, choice + + +_SM_OPTS = CONTENT["menu"]["start_mode"]["options"] +_WATCH_PREFIX = _SM_OPTS["resume_watch"].split("(")[0].strip() +_DOWNLOAD_PREFIX = _SM_OPTS["resume_download"].split("(")[0].strip() + + @pytest.mark.unit class TestSelectStartMode: def test_search_is_first_option(self, monkeypatch): monkeypatch.setattr("alt_ani_cli.ui.menus._USE_INQUIRER", False) with patch("builtins.input", return_value="1"): - assert select_start_mode(has_history=False) == "search" + assert select_start_mode() == "search" - def test_resume_returns_resume(self, monkeypatch): + def test_resume_watch_returns_resume_watch(self, monkeypatch): monkeypatch.setattr("alt_ani_cli.ui.menus._USE_INQUIRER", False) with patch("builtins.input", return_value="2"): - assert select_start_mode(has_history=True, history_count=3) == "resume" + assert select_start_mode(watch_count=3) == "resume_watch" + + def test_resume_download_returns_resume_download(self, monkeypatch): + monkeypatch.setattr("alt_ani_cli.ui.menus._USE_INQUIRER", False) + with patch("builtins.input", return_value="3"): + assert select_start_mode(watch_count=3, download_count=2) == "resume_download" def test_quit_without_history(self, monkeypatch): monkeypatch.setattr("alt_ani_cli.ui.menus._USE_INQUIRER", False) with patch("builtins.input", return_value="3"): - assert select_start_mode(has_history=False) == "quit" + assert select_start_mode() == "quit" def test_empty_enter_returns_none(self, monkeypatch): monkeypatch.setattr("alt_ani_cli.ui.menus._USE_INQUIRER", False) with patch("builtins.input", return_value=""): - assert select_start_mode(has_history=False) is None + assert select_start_mode() is None + + def test_no_history_shows_no_continue_options(self, monkeypatch): + printed, _ = _start_mode_options(monkeypatch) + assert len(printed) == 3 + assert not any(_WATCH_PREFIX in line or _DOWNLOAD_PREFIX in line for line in printed) + + def test_watch_only_shows_only_continue_watching(self, monkeypatch): + printed, _ = _start_mode_options(monkeypatch, watch_count=22) + assert any(_WATCH_PREFIX in line and "22" in line for line in printed) + assert not any(_DOWNLOAD_PREFIX in line for line in printed) + + def test_download_only_shows_only_continue_downloading(self, monkeypatch): + printed, _ = _start_mode_options(monkeypatch, download_count=3) + assert any(_DOWNLOAD_PREFIX in line and "3" in line for line in printed) + assert not any(_WATCH_PREFIX in line for line in printed) + + def test_both_histories_show_both_options(self, monkeypatch): + printed, _ = _start_mode_options(monkeypatch, watch_count=22, download_count=3) + assert any(_WATCH_PREFIX in line for line in printed) + assert any(_DOWNLOAD_PREFIX in line for line in printed) + assert len(printed) == 5 @pytest.mark.unit @@ -138,6 +182,20 @@ def test_default_index_passed_to_checkbox(self, monkeypatch): assert result == [episodes[2]] assert mock_cb.call_args.kwargs["default"] == 2 + def test_downloaded_marker_differs_from_watched(self, monkeypatch): + monkeypatch.setattr("alt_ani_cli.ui.menus._USE_INQUIRER", False) + episodes = [EpisodeRow(number=n, title=f"Ep {n}", url=f"http://x/{n}") for n in (1, 2, 3)] + printed: list[str] = [] + with ( + patch("builtins.print", side_effect=lambda *a, **k: printed.append(" ".join(str(x) for x in a))), + patch("builtins.input", return_value="3"), + ): + select_episodes(episodes, watched_numbers={1.0}, downloaded_numbers={2.0}) + _ep = CONTENT["menu"]["episodes"] + assert any(_ep["label_watched"].format(number=1, title="Ep 1") in line for line in printed) + assert any(_ep["label_downloaded"].format(number=2, title="Ep 2") in line for line in printed) + assert any(_ep["label_unwatched"].format(number=3, title="Ep 3") in line for line in printed) + @pytest.fixture def pt_session(): @@ -256,6 +314,30 @@ def test_from_history_after_refactor(self, monkeypatch): assert select_series_from_history(entries) == entries[1] +@pytest.mark.unit +class TestSelectSeriesFromDownloadHistory: + _REF = SeriesRef(id="1", slug="tongari", title="Tongari Boushi no Atelier", url="https://shinden.pl/series/1-tongari") + + def test_label_shows_downloaded_count_not_last_ep(self, monkeypatch): + monkeypatch.setattr("alt_ani_cli.ui.menus._USE_INQUIRER", False) + entries = [(self._REF, frozenset({1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0}))] + printed: list[str] = [] + with ( + patch("builtins.print", side_effect=lambda *a, **k: printed.append(" ".join(str(x) for x in a))), + patch("builtins.input", return_value="1"), + ): + result = select_series_from_download_history(entries) + assert result == entries[0] + expected = CONTENT["menu"]["download_resume"]["label"].format(title=self._REF.title, count=7) + assert any(expected in line for line in printed) + assert not any("ostatni ep" in line for line in printed) + + def test_empty_enter_returns_none(self, monkeypatch): + monkeypatch.setattr("alt_ani_cli.ui.menus._USE_INQUIRER", False) + with patch("builtins.input", return_value=""): + assert select_series_from_download_history([(self._REF, frozenset({1.0}))]) is None + + @pytest.mark.unit class TestPickRelated: def test_empty_returns_none(self, monkeypatch): From 8fed33a2941f95c298e748f830d4cc57f69519dc Mon Sep 17 00:00:00 2001 From: Aleksander <71835339+Cybernetic-Ransomware@users.noreply.github.com> Date: Sun, 4 Oct 2026 21:00:28 +0200 Subject: [PATCH 08/11] =?UTF-8?q?feat(download):=20offer=20safe=20redownlo?= =?UTF-8?q?ad=20when=20the=20episode=20file=20already=20e=E2=80=A6=20(#24)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(download): offer safe redownload when the episode file already exists * fix(flow): show the episode picker after cancel or back instead of reapplying --episode * fix(download): abort redownload when stale staging files cannot be removed * fix(download): keep the player usable when a redownload fails on a local file error * fix(download): name the target path when the staging path cannot be prepared --- src/alt_ani_cli/cli.py | 3 +- .../content/exceptions_content_en-us.yaml | 3 + .../content/exceptions_content_pl-pl.yaml | 3 + src/alt_ani_cli/content/ui_content_pl-pl.yaml | 13 + src/alt_ani_cli/download.py | 66 ++- src/alt_ani_cli/errors.py | 4 + src/alt_ani_cli/flow/handlers.py | 43 +- src/alt_ani_cli/flow/state.py | 3 + src/alt_ani_cli/ui/menus.py | 17 + tests/cli/test_noninteractive.py | 10 + tests/flow/test_handlers.py | 385 +++++++++++++++++- tests/test_download.py | 270 +++++++++++- tests/ui/test_menus.py | 65 +++ 13 files changed, 871 insertions(+), 14 deletions(-) diff --git a/src/alt_ani_cli/cli.py b/src/alt_ani_cli/cli.py index 39f3a55..6da2dcc 100644 --- a/src/alt_ani_cli/cli.py +++ b/src/alt_ani_cli/cli.py @@ -17,6 +17,7 @@ from alt_ani_cli.errors import ( AntiBotError, DownloadFailedError, + DownloadTargetError, FilterMismatchError, NoStreamError, ParseError, @@ -699,7 +700,7 @@ def main() -> None: # noqa: C901 else: progress.error(str(exc)) sys.exit(1) - except (AntiBotError, NoStreamError, ParseError, FilterMismatchError, DownloadFailedError) as exc: + except (AntiBotError, NoStreamError, ParseError, FilterMismatchError, DownloadFailedError, DownloadTargetError) 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 2a747a2..24d4dc5 100644 --- a/src/alt_ani_cli/content/exceptions_content_en-us.yaml +++ b/src/alt_ani_cli/content/exceptions_content_en-us.yaml @@ -75,3 +75,6 @@ player: download: failed: "Download failed." + replace_failed: "Could not replace {path} — the previous file was kept unchanged (is it open in another program?)." + staging_locked: "Could not remove leftovers of a previous attempt: {path} — redownload aborted, the previous file was kept unchanged." + staging_mismatch: "Could not prepare a temporary file next to {path} — redownload aborted, the previous file was kept unchanged." 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 c6907ea..d94d894 100644 --- a/src/alt_ani_cli/content/exceptions_content_pl-pl.yaml +++ b/src/alt_ani_cli/content/exceptions_content_pl-pl.yaml @@ -75,3 +75,6 @@ player: download: failed: "Pobieranie nie powiodło się." + replace_failed: "Nie udało się zastąpić pliku {path} — poprzednia wersja pozostała bez zmian (czy plik jest otwarty w innym programie?)." + staging_locked: "Nie udało się usunąć pozostałości poprzedniej próby: {path} — ponowne pobieranie przerwane, poprzedni plik pozostał bez zmian." + staging_mismatch: "Nie udało się przygotować pliku tymczasowego obok {path} — ponowne pobieranie przerwane, poprzedni plik pozostał bez zmian." 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 e02a35f..c7569c9 100644 --- a/src/alt_ani_cli/content/ui_content_pl-pl.yaml +++ b/src/alt_ani_cli/content/ui_content_pl-pl.yaml @@ -79,6 +79,8 @@ download: starting: "Pobieranie: {title} ep{ep_label} → {dir}" saved: "Zapisano: {path}" legacy_part_moved: "Niedokończone pobieranie ze starszej wersji (ffmpeg) przeniesiono do: {path} — pobieranie zacznie się od nowa." + kept_existing: "Pominięto — zostawiam istniejący plik: {path}" + batch_cancelled: "Anulowano pobieranie zaznaczonych odcinków." menu: start_mode: @@ -168,6 +170,17 @@ menu: instruction: "↑↓ = nawigacja | Enter = wybierz | ESC = wstecz" fallback_prompt: "Akcja [1-3, Enter=wstecz]: " fallback_invalid: " Wpisz liczbę 1–3." + existing_file: + header: "Plik już istnieje: +{path}" + message: "Co zrobić z istniejącym plikiem?" + options: + skip: "Pomiń (zostaw istniejący plik)" + overwrite: "Pobierz ponownie i zastąp" + overwrite_remaining: "Pobierz ponownie i zastępuj istniejące pliki także dla pozostałych zaznaczonych odcinków" + cancel: "Anuluj pobieranie zaznaczonych odcinków" + instruction: "↑↓ = nawigacja | Enter = wybierz | ESC = anuluj" + fallback_invalid: " Wpisz liczbę 1–{n}." confirm: option_yes: "Tak" option_no: "Nie" diff --git a/src/alt_ani_cli/download.py b/src/alt_ani_cli/download.py index 6f24a9a..2efac38 100644 --- a/src/alt_ani_cli/download.py +++ b/src/alt_ani_cli/download.py @@ -1,10 +1,13 @@ import gc +import os import shutil +from collections.abc import Callable +from contextlib import suppress from pathlib import Path from alt_ani_cli.config import DOWNLOADS, USER_AGENT from alt_ani_cli.content import CONTENT, EXCEPTIONS_PL -from alt_ani_cli.errors import DownloadFailedError +from alt_ani_cli.errors import DownloadFailedError, DownloadTargetError from alt_ani_cli.extract.common import Stream from alt_ani_cli.models import EpisodeRow, SeriesRef from alt_ani_cli.ui import progress @@ -16,6 +19,7 @@ ) _LEGACY_PART_SUFFIX = ".legacy-ffmpeg" +_STAGING_INFIX = ".redownload" class _TruncLogger: @@ -64,12 +68,53 @@ def _quarantine_legacy_hls_part(final_path: Path) -> Path | None: return backup +def _staging_path(final_path: Path) -> Path: + return final_path.with_name(f"{final_path.stem}{_STAGING_INFIX}{final_path.suffix}") + + +def _staging_artifacts(final_path: Path) -> list[Path]: + prefix = f"{final_path.stem}{_STAGING_INFIX}." + return [path for path in final_path.parent.iterdir() if path.name.startswith(prefix)] + + +def _discard_staging(final_path: Path) -> list[Path]: + """Remove every artifact of a redownload attempt (staged file, .part/.ytdl, fragments, fixup temp); return leftovers.""" + for path in _staging_artifacts(final_path): + if path.is_file(): + with suppress(OSError): + path.unlink() + return _staging_artifacts(final_path) + + +def _redirect_to_staging(ydl, info: dict, final_path: Path) -> Path: + """Point yt-dlp at a sibling staging file so the existing final stays intact until the new copy is complete.""" + # A staging partial may come from another source or a crashed run; resuming or promoting it could splice stale data. + if leftovers := _discard_staging(final_path): + raise DownloadTargetError(EXCEPTIONS_PL["download"]["staging_locked"].format(path=leftovers[0])) + staging = _staging_path(final_path) + ydl.params["outtmpl"]["default"] = str(staging).replace("%", "%%") + if Path(ydl.prepare_filename(info)) != staging: + raise DownloadTargetError(EXCEPTIONS_PL["download"]["staging_mismatch"].format(path=final_path)) + return staging + + +def _promote_staging(staging: Path, final_path: Path) -> None: + try: + os.replace(staging, final_path) + except OSError: + _discard_staging(final_path) + raise DownloadTargetError(EXCEPTIONS_PL["download"]["replace_failed"].format(path=final_path)) from None + + def run( stream: Stream, ep: EpisodeRow, series: SeriesRef, dest_dir: Path = DOWNLOADS, -) -> None: + *, + confirm_overwrite: Callable[[Path], bool] | None = None, +) -> bool: + """Returns False when an existing final was kept; without confirm_overwrite an existing final is never replaced.""" from yt_dlp import YoutubeDL from yt_dlp.utils import DownloadError @@ -94,11 +139,17 @@ def run( progress.info(CONTENT["download"]["starting"].format(title=safe_title, ep_label=ep_label, dir=dest_dir)) failed = False + staging: Path | None = None 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)): + if confirm_overwrite is not None and final_path.exists(): + if not confirm_overwrite(final_path): + return False + staging = _redirect_to_staging(ydl, info, final_path) + target = staging or final_path + if str(info.get("protocol", "")).startswith("m3u8") and (backup := _quarantine_legacy_hls_part(target)): progress.warn(CONTENT["download"]["legacy_part_moved"].format(path=backup)) ydl.process_ie_result(info, download=True) except DownloadError: @@ -107,7 +158,16 @@ def run( if failed: # Outside except on purpose: yt-dlp's leaked .part handle sits in a traceback cycle (WinError 32 on reuse). gc.collect() + if staging is not None: + _discard_staging(final_path) raise DownloadFailedError(EXCEPTIONS_PL["download"]["failed"]) + if staging is not None: + if not staging.is_file(): + _discard_staging(final_path) + raise DownloadFailedError(EXCEPTIONS_PL["download"]["failed"]) + _promote_staging(staging, final_path) + if final_path.exists(): progress.success(CONTENT["download"]["saved"].format(path=final_path)) + return True diff --git a/src/alt_ani_cli/errors.py b/src/alt_ani_cli/errors.py index 25feda0..4fee349 100644 --- a/src/alt_ani_cli/errors.py +++ b/src/alt_ani_cli/errors.py @@ -32,3 +32,7 @@ class FilterMismatchError(ShindenError): class DownloadFailedError(ShindenError): """yt-dlp could not download the stream; carries no library exception or URL.""" + + +class DownloadTargetError(ShindenError): + """A redownload could not be put in place on disk; the source is not at fault and the old file is intact.""" diff --git a/src/alt_ani_cli/flow/handlers.py b/src/alt_ani_cli/flow/handlers.py index 2cb7367..1f90203 100644 --- a/src/alt_ani_cli/flow/handlers.py +++ b/src/alt_ani_cli/flow/handlers.py @@ -12,6 +12,7 @@ from collections.abc import Callable from concurrent.futures import ThreadPoolExecutor, as_completed +from pathlib import Path from urllib.parse import urlparse from curl_cffi import requests as cffi_requests @@ -19,7 +20,7 @@ from alt_ani_cli import __version__, diagnostics, download, history from alt_ani_cli.content import CONTENT -from alt_ani_cli.errors import DownloadFailedError, ShindenError +from alt_ani_cli.errors import DownloadFailedError, DownloadTargetError, ShindenError from alt_ani_cli.flow.state import BACK, FlowState, Screen, ScreenResult from alt_ani_cli.models import EmbedURL, EpisodeRow, PlayerSource, SeriesHit, SeriesMetadata, SeriesRef from alt_ani_cli.shinden import api as shinden_api @@ -293,6 +294,8 @@ def handle_fetch_episodes(state: FlowState) -> ScreenResult: state.completed_eps = set() state.targets = [] state.ep_idx = 0 + state.overwrite_existing_batch = False + state.episode_arg_consumed = False if not episodes: progress.error(_PROG["no_episodes"]) return Screen.SERIES_PICK @@ -304,8 +307,8 @@ def handle_episodes_pick(state: FlowState) -> ScreenResult: raise AssertionError args = state.args - # When --episode was passed from CLI, skip the interactive menu - if args.episode: + # --episode picks the first batch of a series only; later returns here must show the picker + if args.episode and not state.episode_arg_consumed: from alt_ani_cli.cli import _parse_range targets = _parse_range(args.episode, state.episodes) @@ -314,6 +317,8 @@ def handle_episodes_pick(state: FlowState) -> ScreenResult: return BACK state.targets = targets state.ep_idx = 0 + state.overwrite_existing_batch = False + state.episode_arg_consumed = True return Screen.EPISODE_DISPATCH default_index = None @@ -343,6 +348,7 @@ def handle_episodes_pick(state: FlowState) -> ScreenResult: return BACK state.targets = result state.ep_idx = 0 + state.overwrite_existing_batch = False return Screen.EPISODE_DISPATCH @@ -532,15 +538,42 @@ def handle_run_action(state: FlowState) -> ScreenResult: if args.download or state.episode_action == "download": source = state.player_sources.get(state.chosen_player.online_id) host = source.host if source else None + existing: dict = {} + + def confirm_overwrite(path: Path) -> bool: + if state.overwrite_existing_batch: + return True + has_remaining = state.ep_idx < len(state.targets) - 1 + choice = menus.select_existing_download_action(str(path), has_remaining=has_remaining) + existing.update(choice=choice, path=path) + if choice == "overwrite_remaining": + state.overwrite_existing_batch = True + return choice in ("overwrite", "overwrite_remaining") + try: - download.run(stream, ep, state.ref) + downloaded = download.run(stream, ep, state.ref, confirm_overwrite=confirm_overwrite) 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) + except DownloadTargetError as exc: + # A local file problem (e.g. the old file is open in a player): another source would hit it too. + progress.error(str(exc)) + state.stream = None + state.embed = None + return Screen.PLAYER_PICK + if not downloaded and existing["choice"] == "cancel": + state.overwrite_existing_batch = False + state.stream = None + state.embed = None + progress.warn(CONTENT["download"]["batch_cancelled"]) + return Screen.EPISODES_PICK + if downloaded: + diagnostics.download_result(host, ok=True, exc=None) + else: + progress.info(CONTENT["download"]["kept_existing"].format(path=existing["path"])) history.record_download(state.ref, ep.number) state.downloaded_eps.add(ep.number) elif args.debug or state.episode_action == "debug": diff --git a/src/alt_ani_cli/flow/state.py b/src/alt_ani_cli/flow/state.py index 7328d0c..da104e4 100644 --- a/src/alt_ani_cli/flow/state.py +++ b/src/alt_ani_cli/flow/state.py @@ -68,6 +68,9 @@ class FlowState: targets: list = field(default_factory=list) ep_idx: int = 0 completed_eps: set[float] = field(default_factory=set) + # replace already-downloaded files without asking — scoped to the current targets batch only + overwrite_existing_batch: bool = False + episode_arg_consumed: bool = False # player / stream players: list = field(default_factory=list) diff --git a/src/alt_ani_cli/ui/menus.py b/src/alt_ani_cli/ui/menus.py index 78f5eb3..194a059 100644 --- a/src/alt_ani_cli/ui/menus.py +++ b/src/alt_ani_cli/ui/menus.py @@ -725,3 +725,20 @@ def select_action() -> Literal["play", "download", "debug"] | None: instruction=_ac["instruction"], fallback_invalid=_ac["fallback_invalid"], ) + + +def select_existing_download_action( + path: str, *, has_remaining: bool +) -> Literal["skip", "overwrite", "overwrite_remaining", "cancel"]: + """Ask what to do with an already downloaded file; ESC / empty Enter means cancel.""" + _ef = _M["existing_file"] + _opts = _ef["options"] + keys = ["skip", "overwrite", "overwrite_remaining", "cancel"] if has_remaining else ["skip", "overwrite", "cancel"] + progress.warn(_ef["header"].format(path=path)) + choice = _run_keyed_picker( + [(k, _opts[k]) for k in keys], + prompt=_ef["message"], + instruction=_ef["instruction"], + fallback_invalid=_ef["fallback_invalid"].format(n=len(keys)), + ) + return choice or "cancel" diff --git a/tests/cli/test_noninteractive.py b/tests/cli/test_noninteractive.py index 33f6e0a..409aa19 100644 --- a/tests/cli/test_noninteractive.py +++ b/tests/cli/test_noninteractive.py @@ -382,3 +382,13 @@ def test_failed_download_does_not_record_history(self): _run_noninteractive(_make_args(download=True, episode="2"), client) m.record.assert_not_called() m.upsert.assert_not_called() + + def test_download_never_offers_overwrite(self): + with ( + _resume_env() as (client, m), + patch("alt_ani_cli.ui.menus.select_existing_download_action") as mock_prompt, + ): + _run_noninteractive(_make_args(download=True, episode="2"), client) + assert m.download.call_args.kwargs == {} + mock_prompt.assert_not_called() + m.record.assert_called_once_with(_REF, 2.0) diff --git a/tests/flow/test_handlers.py b/tests/flow/test_handlers.py index 9c34880..d7ee305 100644 --- a/tests/flow/test_handlers.py +++ b/tests/flow/test_handlers.py @@ -6,13 +6,14 @@ import argparse from contextlib import contextmanager +from pathlib import Path from unittest.mock import MagicMock, patch import pytest from curl_cffi.requests.exceptions import RequestException as CurlRequestException from alt_ani_cli.content import CONTENT -from alt_ani_cli.errors import AntiBotError, DownloadFailedError, NoStreamError, ShindenError +from alt_ani_cli.errors import AntiBotError, DownloadFailedError, DownloadTargetError, NoStreamError, ShindenError from alt_ani_cli.extract.common import Stream from alt_ani_cli.flow.handlers import ( HANDLERS, @@ -888,10 +889,11 @@ def test_failed_download_retries_on_picked_player_without_asking_action(self): def fake_resolve(client, players, chosen, **kwargs): return streams[chosen.online_id], _EMBED - def fake_download(stream, ep, ref): + def fake_download(stream, ep, ref, **kwargs): downloaded.append(stream.url) if stream is _STREAM: raise DownloadFailedError("x") + return True with ( patch("alt_ani_cli.cli._resolve_with_fallback", side_effect=fake_resolve), @@ -1578,3 +1580,382 @@ def _fake_search_query(): _run_interactive_wrapped(_make_args(), MagicMock()) assert seen_at_search == {"resume_mode": None, "episode_action": None, "downloaded_eps": set()} + + +_EP7 = EpisodeRow(number=7.0, title="Ep 7", url="http://shinden.pl/ep/7") + + +class _FakeDownloads: + """Stands in for download.run: consults confirm_overwrite for 'existing' episodes like the real one does.""" + + def __init__(self, existing=(), fail_urls=()): + self.existing = set(existing) + self.fail_urls = set(fail_urls) + self.events: list[tuple[str, float]] = [] + + def path(self, ep) -> Path: + return Path(f"C:/dl/Fate - ep{ep.number:g}.mp4") + + def __call__(self, stream, ep, ref, *, confirm_overwrite=None): + replacing = False + if confirm_overwrite is not None and ep.number in self.existing: + if not confirm_overwrite(self.path(ep)): + self.events.append(("kept", ep.number)) + return False + replacing = True + if stream.url in self.fail_urls: + self.events.append(("failed", ep.number)) + raise DownloadFailedError("x") + self.events.append(("replaced" if replacing else "downloaded", ep.number)) + return True + + +def _batch_state(targets, **overrides) -> FlowState: + return _download_state(targets=list(targets), **overrides) + + +def _run_download(state, fake, choice=None): + with ( + patch("alt_ani_cli.download.run", side_effect=fake), + patch("alt_ani_cli.ui.menus.select_existing_download_action", return_value=choice) as mock_prompt, + patch("alt_ani_cli.history.record_download") as mock_record, + patch("alt_ani_cli.ui.progress.error"), + patch("alt_ani_cli.ui.progress.warn"), + patch("alt_ani_cli.ui.progress.info"), + ): + result = handle_run_action(state) + return result, mock_prompt, mock_record + + +@pytest.mark.unit +class TestRunActionExistingFile: + def test_missing_file_downloads_without_prompt(self): + state = _batch_state([_EP4, _EP5]) + fake = _FakeDownloads() + result, prompt, record = _run_download(state, fake) + prompt.assert_not_called() + assert fake.events == [("downloaded", 4.0)] + record.assert_called_once_with(_SERIES_REF, 4.0) + assert (result, state.ep_idx) == (Screen.EPISODE_DISPATCH, 1) + + def test_skip_keeps_file_and_records_download(self): + state = _batch_state([_EP4, _EP5]) + fake = _FakeDownloads(existing={4.0}) + result, _, record = _run_download(state, fake, "skip") + assert fake.events == [("kept", 4.0)] + record.assert_called_once_with(_SERIES_REF, 4.0) + assert state.downloaded_eps == {4.0} + assert (result, state.ep_idx) == (Screen.EPISODE_DISPATCH, 1) + assert state.overwrite_existing_batch is False + + def test_skip_does_not_report_download_result(self): + state = _batch_state([_EP4]) + with patch("alt_ani_cli.diagnostics.download_result") as mock_diag: + _run_download(state, _FakeDownloads(existing={4.0}), "skip") + mock_diag.assert_not_called() + + def test_overwrite_replaces_and_records_without_batch_policy(self): + state = _batch_state([_EP4, _EP5]) + fake = _FakeDownloads(existing={4.0}) + result, _, record = _run_download(state, fake, "overwrite") + assert fake.events == [("replaced", 4.0)] + record.assert_called_once_with(_SERIES_REF, 4.0) + assert state.downloaded_eps == {4.0} + assert (result, state.ep_idx) == (Screen.EPISODE_DISPATCH, 1) + assert state.overwrite_existing_batch is False + + def test_cancel_returns_to_episode_pick_without_history(self): + state = _batch_state([_EP4, _EP5, _EP6]) + fake = _FakeDownloads(existing={4.0}) + result, _, record = _run_download(state, fake, "cancel") + assert result is Screen.EPISODES_PICK + assert fake.events == [("kept", 4.0)] + record.assert_not_called() + assert state.downloaded_eps == set() + assert state.ep_idx == 0 + assert state.overwrite_existing_batch is False + assert state.stream is None + + def test_overwrite_remaining_sets_batch_policy(self): + state = _batch_state([_EP4, _EP5]) + fake = _FakeDownloads(existing={4.0}) + _run_download(state, fake, "overwrite_remaining") + assert fake.events == [("replaced", 4.0)] + assert state.overwrite_existing_batch is True + + def test_policy_skips_prompt_for_next_existing_episode(self): + state = _batch_state([_EP4, _EP5], ep_idx=1, overwrite_existing_batch=True) + fake = _FakeDownloads(existing={5.0}) + _, prompt, record = _run_download(state, fake) + prompt.assert_not_called() + assert fake.events == [("replaced", 5.0)] + record.assert_called_once_with(_SERIES_REF, 5.0) + + def test_policy_downloads_missing_episode_normally(self): + state = _batch_state([_EP4, _EP5], ep_idx=1, overwrite_existing_batch=True) + fake = _FakeDownloads() + _, prompt, _ = _run_download(state, fake) + prompt.assert_not_called() + assert fake.events == [("downloaded", 5.0)] + + def test_failed_overwrite_returns_to_player_pick_and_keeps_policy(self): + state = _batch_state([_EP4, _EP5], overwrite_existing_batch=True) + fake = _FakeDownloads(existing={4.0}, fail_urls={_STREAM.url}) + result, _, record = _run_download(state, fake) + assert result is Screen.PLAYER_PICK + assert state.ep_idx == 0 + record.assert_not_called() + assert state.downloaded_eps == set() + assert state.overwrite_existing_batch is True + + @pytest.mark.parametrize( + ("targets", "ep_idx", "has_remaining"), + [([_EP4], 0, False), ([_EP4, _EP5], 0, True), ([_EP4, _EP5], 1, False)], + ) + def test_batch_option_offered_only_with_remaining_episodes(self, targets, ep_idx, has_remaining): + state = _batch_state(targets, ep_idx=ep_idx) + ep = targets[ep_idx] + _, prompt, _ = _run_download(state, _FakeDownloads(existing={ep.number}), "skip") + assert prompt.call_args.args == (str(_FakeDownloads().path(ep)),) + assert prompt.call_args.kwargs == {"has_remaining": has_remaining} + + def test_already_downloaded_episode_still_prompts(self): + state = _batch_state([_EP4], downloaded_eps={4.0}) + _, prompt, _ = _run_download(state, _FakeDownloads(existing={4.0}), "skip") + prompt.assert_called_once() + + +@pytest.mark.unit +class TestOverwriteBatchScenario: + def test_remaining_policy_survives_player_retry(self): + """ep4 existing → overwrite remaining; ep5 missing; ep6 fails on player A, retried on B; ep7 auto.""" + state = _batch_state([_EP4, _EP5, _EP6, _EP7]) + stream_b = Stream(url="https://cdn.example.com/b.mp4", ext="mp4") + fake = _FakeDownloads(existing={4.0, 6.0, 7.0}) + prompts: list[float] = [] + + def answer(path, *, has_remaining): + prompts.append(state.current_ep.number) + return "overwrite_remaining" + + screens = [] + with ( + patch("alt_ani_cli.download.run", side_effect=fake), + patch("alt_ani_cli.ui.menus.select_existing_download_action", side_effect=answer), + patch("alt_ani_cli.history.record_download") as mock_record, + patch("alt_ani_cli.ui.progress.error"), + patch("alt_ani_cli.ui.progress.info"), + ): + for stream in (_STREAM, _STREAM): + state.stream = stream + screens.append(handle_run_action(state)) + fake.fail_urls = {_STREAM.url} + state.stream = _STREAM + screens.append(handle_run_action(state)) + state.chosen_player = _PLAYER2 + state.stream = stream_b + screens.append(handle_run_action(state)) + fake.fail_urls = set() + state.stream = _STREAM + screens.append(handle_run_action(state)) + + assert prompts == [4.0] + assert fake.events == [ + ("replaced", 4.0), + ("downloaded", 5.0), + ("failed", 6.0), + ("replaced", 6.0), + ("replaced", 7.0), + ] + assert screens == [Screen.EPISODE_DISPATCH, Screen.EPISODE_DISPATCH, Screen.PLAYER_PICK] + [Screen.EPISODE_DISPATCH] * 2 + assert [c.args[1] for c in mock_record.call_args_list] == [4.0, 5.0, 6.0, 7.0] + + def test_skip_in_batch_continues_with_next_episode(self): + state = _batch_state([_EP4, _EP5, _EP6]) + fake = _FakeDownloads(existing={4.0}) + with ( + patch("alt_ani_cli.download.run", side_effect=fake), + patch("alt_ani_cli.ui.menus.select_existing_download_action", return_value="skip"), + patch("alt_ani_cli.history.record_download") as mock_record, + patch("alt_ani_cli.ui.progress.info"), + ): + handle_run_action(state) + state.stream = _STREAM + handle_run_action(state) + assert fake.events == [("kept", 4.0), ("downloaded", 5.0)] + assert [c.args[1] for c in mock_record.call_args_list] == [4.0, 5.0] + assert state.ep_idx == 2 + + +@pytest.mark.unit +class TestOverwriteBatchPolicyReset: + def test_new_episode_selection_resets_policy(self): + state = _make_state(ref=_SERIES_REF, episodes=[_EP4, _EP5], overwrite_existing_batch=True) + with patch("alt_ani_cli.ui.menus.select_episodes", return_value=[_EP5]): + HANDLERS[Screen.EPISODES_PICK](state) + assert state.overwrite_existing_batch is False + + def test_cli_episode_range_batch_resets_policy(self): + state = _make_state( + ref=_SERIES_REF, episodes=[_EP4, _EP5], overwrite_existing_batch=True, args=_make_args(episode="4-5") + ) + HANDLERS[Screen.EPISODES_PICK](state) + assert state.overwrite_existing_batch is False + + def test_new_series_resets_policy(self): + state = _make_state(ref=_SERIES_REF, overwrite_existing_batch=True) + with patch("alt_ani_cli.shinden.series.list_episodes", return_value=(_SERIES_REF, [_EP1])): + HANDLERS[Screen.FETCH_EPISODES](state) + assert state.overwrite_existing_batch is False + + def test_player_pick_after_failure_keeps_policy(self): + state = _batch_state([_EP4, _EP5], overwrite_existing_batch=True, failed_ids={_PLAYER.online_id}) + with ( + patch("alt_ani_cli.ui.menus.select_player_once", return_value=("pick", _PLAYER2)), + patch("alt_ani_cli.ui.progress.warn"), + ): + HANDLERS[Screen.PLAYER_PICK](state) + assert state.overwrite_existing_batch is True + + +@pytest.mark.unit +class TestSkipBackfillsDownloadHistory: + def test_skip_on_file_unknown_to_history_marks_it_downloaded(self): + from alt_ani_cli import history + + state = _batch_state([_EP4]) + assert history.list_downloads() == [] + with ( + patch("alt_ani_cli.download.run", side_effect=_FakeDownloads(existing={4.0})), + patch("alt_ani_cli.ui.menus.select_existing_download_action", return_value="skip"), + patch("alt_ani_cli.ui.progress.info"), + ): + handle_run_action(state) + assert history.list_downloads() == [(_SERIES_REF, frozenset({4.0}))] + + def test_skipped_episode_is_marked_on_next_download_resume(self): + from alt_ani_cli import history + + state = _batch_state([_EP4]) + with ( + patch("alt_ani_cli.download.run", side_effect=_FakeDownloads(existing={4.0})), + patch("alt_ani_cli.ui.menus.select_existing_download_action", return_value="skip"), + patch("alt_ani_cli.ui.progress.info"), + ): + handle_run_action(state) + resumed = _make_state() + with patch("alt_ani_cli.ui.menus.select_series_from_download_history", side_effect=lambda e: e[0]): + HANDLERS[Screen.DOWNLOAD_RESUME_PICK](resumed) + assert resumed.downloaded_eps == {4.0} + + +@pytest.mark.unit +class TestEpisodeArgConsumedOnce: + def _state(self, **overrides) -> FlowState: + return _make_state( + ref=_SERIES_REF, + episodes=[_EP4, _EP5, _EP6, _EP7], + args=_make_args(episode="4-7"), + **overrides, + ) + + def test_first_entry_auto_selects_range_without_picker(self): + state = self._state() + with patch("alt_ani_cli.ui.menus.select_episodes") as mock_sel: + result = HANDLERS[Screen.EPISODES_PICK](state) + mock_sel.assert_not_called() + assert result is Screen.EPISODE_DISPATCH + assert state.targets == [_EP4, _EP5, _EP6, _EP7] + assert state.episode_arg_consumed is True + + def test_cancel_on_existing_file_then_shows_picker(self): + state = self._state() + HANDLERS[Screen.EPISODES_PICK](state) + state.players = [_PLAYER, _PLAYER2] + state.chosen_player = _PLAYER + state.stream = _STREAM + state.episode_action = "download" + fake = _FakeDownloads(existing={4.0}) + result, _, record = _run_download(state, fake, "cancel") + assert result is Screen.EPISODES_PICK + assert state.overwrite_existing_batch is False + record.assert_not_called() + assert fake.events == [("kept", 4.0)] + assert state.ep_idx == 0 + + with ( + patch("alt_ani_cli.ui.menus.select_episodes", return_value=[_EP5]) as mock_sel, + patch("alt_ani_cli.cli._parse_range") as mock_parse, + ): + result = HANDLERS[Screen.EPISODES_PICK](state) + mock_sel.assert_called_once() + mock_parse.assert_not_called() + assert result is Screen.EPISODE_DISPATCH + assert state.targets == [_EP5] + + def test_esc_from_player_pick_then_shows_picker(self): + state = self._state() + HANDLERS[Screen.EPISODES_PICK](state) + state.players = [_PLAYER, _PLAYER2] + with patch("alt_ani_cli.ui.menus.select_player_once", return_value=("back", None)): + assert HANDLERS[Screen.PLAYER_PICK](state) is Screen.EPISODES_PICK + with patch("alt_ani_cli.ui.menus.select_episodes", return_value=None) as mock_sel: + HANDLERS[Screen.EPISODES_PICK](state) + mock_sel.assert_called_once() + + def test_new_series_reapplies_episode_arg(self): + state = self._state(episode_arg_consumed=True) + with patch("alt_ani_cli.shinden.series.list_episodes", return_value=(_SERIES_REF, [_EP4, _EP5, _EP6, _EP7])): + HANDLERS[Screen.FETCH_EPISODES](state) + assert state.episode_arg_consumed is False + with patch("alt_ani_cli.ui.menus.select_episodes") as mock_sel: + assert HANDLERS[Screen.EPISODES_PICK](state) is Screen.EPISODE_DISPATCH + mock_sel.assert_not_called() + + def test_without_episode_arg_picker_always_shown(self): + state = _make_state(ref=_SERIES_REF, episodes=[_EP4, _EP5]) + with patch("alt_ani_cli.ui.menus.select_episodes", return_value=[_EP4]) as mock_sel: + HANDLERS[Screen.EPISODES_PICK](state) + HANDLERS[Screen.EPISODES_PICK](state) + assert mock_sel.call_count == 2 + assert state.episode_arg_consumed is False + + +@pytest.mark.unit +class TestRunActionDownloadTargetError: + def _run(self, state): + with ( + patch("alt_ani_cli.download.run", side_effect=DownloadTargetError("C:/dl/Fate - ep4.mp4 locked")), + patch("alt_ani_cli.history.record_download") as mock_record, + patch("alt_ani_cli.diagnostics.download_result") as mock_diag, + patch("alt_ani_cli.ui.progress.error") as mock_error, + ): + result = handle_run_action(state) + return result, mock_record, mock_diag, mock_error + + def test_local_file_error_does_not_fail_the_player(self): + state = _batch_state([_EP4, _EP5], overwrite_existing_batch=True) + result, record, diag, error = self._run(state) + assert result is Screen.PLAYER_PICK + assert state.failed_ids == set() + assert state.ep_idx == 0 + assert state.overwrite_existing_batch is True + assert state.stream is None + record.assert_not_called() + diag.assert_not_called() + assert error.call_args.args[0] == "C:/dl/Fate - ep4.mp4 locked" + + def test_single_player_episode_is_not_skipped(self): + state = _batch_state([_EP4, _EP5], players=(_PLAYER,)) + result, *_ = self._run(state) + assert result is Screen.PLAYER_PICK + assert state.ep_idx == 0 + assert state.failed_ids == set() + + def test_player_pick_offers_same_player_again(self): + state = _batch_state([_EP4]) + self._run(state) + with patch("alt_ani_cli.ui.menus.select_player_once", return_value=("pick", _PLAYER)) as mock_pick: + assert HANDLERS[Screen.PLAYER_PICK](state) is Screen.RESOLVE_STREAM + assert mock_pick.call_args.kwargs["failed"] == set() + assert state.chosen_player is _PLAYER diff --git a/tests/test_download.py b/tests/test_download.py index 5f52e07..3fe2c69 100644 --- a/tests/test_download.py +++ b/tests/test_download.py @@ -7,7 +7,7 @@ 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.errors import DownloadFailedError, DownloadTargetError from alt_ani_cli.extract.common import Stream from alt_ani_cli.models import EpisodeRow, SeriesRef @@ -21,9 +21,13 @@ class _FakeYDL: instances: list[_FakeYDL] = [] info: dict = {} fail_in: str | None = None + extract_calls = 0 + payload: bytes | None = None def __init__(self, opts: dict) -> None: self.opts = opts + # Real YoutubeDL normalises a string outtmpl into params["outtmpl"]["default"]. + self.params = {**opts, "outtmpl": {"default": opts["outtmpl"]}} self.final_path: Path | None = None self.part_existed_at_download: bool | None = None self.exited = False @@ -38,18 +42,25 @@ def __exit__(self, *exc) -> None: def extract_info(self, url: str, download: bool = True) -> dict: assert download is False + _FakeYDL.extract_calls += 1 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: - return self.opts["outtmpl"].replace("%(ext)s", info["ext"]) + return self.params["outtmpl"]["default"].replace("%(ext)s", info["ext"]).replace("%%", "%") 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() + part = self.final_path.with_name(self.final_path.name + ".part") + self.part_existed_at_download = part.exists() + if self.payload is not None: + part.write_bytes(self.payload[:2]) if self.fail_in == "process_ie_result": raise DownloadError("ERROR: fragment 2 not found, unable to continue (https://h/s2.ts?token=SECRET)") + if self.payload is not None: + part.unlink() + self.final_path.write_bytes(self.payload) return info @@ -58,6 +69,8 @@ def fake_ydl(): _FakeYDL.instances = [] _FakeYDL.info = {"ext": "mp4", "protocol": "m3u8_native"} _FakeYDL.fail_in = None + _FakeYDL.extract_calls = 0 + _FakeYDL.payload = None with patch("yt_dlp.YoutubeDL", _FakeYDL): yield _FakeYDL @@ -223,3 +236,254 @@ def process_ie_result(self, info: dict, download: bool = True) -> dict: 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] + + +_HLS = Stream(url="https://h/master.m3u8", ext="m3u8") +_OLD = b"OLD complete episode" +_NEW = b"NEW complete episode" + + +def _existing_final(dest: Path) -> Path: + final = dest / "Show - ep1.mp4" + final.write_bytes(_OLD) + return final + + +def _overwrite(dest: Path) -> bool: + return download.run(_HLS, _EP, _SERIES, dest_dir=dest, confirm_overwrite=lambda _: True) + + +@pytest.mark.unit +class TestExistingFinalWithoutOverwrite: + def test_no_callback_leaves_existing_final_to_ytdlp_no_overwrite(self, fake_ydl, tmp_path): + final = _existing_final(tmp_path) + assert download.run(_HLS, _EP, _SERIES, dest_dir=tmp_path) is True + assert fake_ydl.instances[-1].final_path == final + assert "overwrites" not in fake_ydl.instances[-1].opts + assert final.read_bytes() == _OLD + + def test_declined_overwrite_skips_download_and_keeps_file(self, fake_ydl, tmp_path): + final = _existing_final(tmp_path) + fake_ydl.payload = _NEW + assert download.run(_HLS, _EP, _SERIES, dest_dir=tmp_path, confirm_overwrite=lambda _: False) is False + assert fake_ydl.instances[-1].final_path is None + assert final.read_bytes() == _OLD + assert [p.name for p in tmp_path.iterdir()] == [final.name] + + def test_callback_receives_exact_final_path(self, fake_ydl, tmp_path): + final = _existing_final(tmp_path) + seen: list[Path] = [] + download.run(_HLS, _EP, _SERIES, dest_dir=tmp_path, confirm_overwrite=lambda p: seen.append(p) or False) + assert seen == [final] + + def test_callback_not_consulted_when_final_missing(self, fake_ydl, tmp_path): + fake_ydl.payload = _NEW + asked: list[Path] = [] + download.run(_HLS, _EP, _SERIES, dest_dir=tmp_path, confirm_overwrite=lambda p: asked.append(p) or True) + assert asked == [] + assert (tmp_path / "Show - ep1.mp4").read_bytes() == _NEW + + +@pytest.mark.unit +class TestSafeOverwrite: + def test_success_replaces_final_with_new_content(self, fake_ydl, tmp_path): + final = _existing_final(tmp_path) + fake_ydl.payload = _NEW + assert _overwrite(tmp_path) is True + assert final.read_bytes() == _NEW + + def test_download_targets_staging_not_final(self, fake_ydl, tmp_path): + _existing_final(tmp_path) + fake_ydl.payload = _NEW + _overwrite(tmp_path) + assert fake_ydl.instances[-1].final_path == tmp_path / "Show - ep1.redownload.mp4" + + def test_success_leaves_no_staging_garbage(self, fake_ydl, tmp_path): + final = _existing_final(tmp_path) + fake_ydl.payload = _NEW + _overwrite(tmp_path) + assert [p.name for p in tmp_path.iterdir()] == [final.name] + + def test_success_reports_final_path(self, fake_ydl, tmp_path): + final = _existing_final(tmp_path) + fake_ydl.payload = _NEW + with patch.object(download.progress, "success") as success: + _overwrite(tmp_path) + assert str(final) in success.call_args.args[0] + + def test_failure_keeps_old_final_bytes(self, fake_ydl, tmp_path): + final = _existing_final(tmp_path) + fake_ydl.payload = _NEW + fake_ydl.fail_in = "process_ie_result" + with pytest.raises(DownloadFailedError): + _overwrite(tmp_path) + assert final.read_bytes() == _OLD + + def test_failure_discards_staging_partials(self, fake_ydl, tmp_path): + final = _existing_final(tmp_path) + fake_ydl.payload = _NEW + fake_ydl.fail_in = "process_ie_result" + with pytest.raises(DownloadFailedError): + _overwrite(tmp_path) + assert [p.name for p in tmp_path.iterdir()] == [final.name] + + def test_ytdlp_error_during_overwrite_is_contained(self, fake_ydl, tmp_path): + _existing_final(tmp_path) + fake_ydl.fail_in = "process_ie_result" + with pytest.raises(DownloadFailedError) as info: + _overwrite(tmp_path) + assert info.value.__cause__ is None + assert info.value.__context__ is None + assert "token" not in str(info.value) + + def test_gc_runs_before_staging_cleanup(self, fake_ydl, tmp_path): + _existing_final(tmp_path) + fake_ydl.payload = _NEW + fake_ydl.fail_in = "process_ie_result" + staging_part = tmp_path / "Show - ep1.redownload.mp4.part" + seen: list[tuple[bool, object, bool]] = [] + + def collect() -> int: + seen.append((_FakeYDL.instances[-1].exited, sys.exception(), staging_part.exists())) + return 0 + + with patch.object(download.gc, "collect", side_effect=collect), pytest.raises(DownloadFailedError): + _overwrite(tmp_path) + assert seen == [(True, None, True)] + assert not staging_part.exists() + + def test_extract_info_runs_once_around_the_decision(self, fake_ydl, tmp_path): + _existing_final(tmp_path) + fake_ydl.payload = _NEW + calls_at_decision: list[int] = [] + + def confirm(_: Path) -> bool: + calls_at_decision.append(_FakeYDL.extract_calls) + return True + + download.run(_HLS, _EP, _SERIES, dest_dir=tmp_path, confirm_overwrite=confirm) + assert calls_at_decision == [1] + assert _FakeYDL.extract_calls == 1 + assert len(_FakeYDL.instances) == 1 + + def test_replace_failure_keeps_old_final_and_discards_new_copy(self, fake_ydl, tmp_path): + final = _existing_final(tmp_path) + fake_ydl.payload = _NEW + with ( + patch.object(download.os, "replace", side_effect=PermissionError(13, "in use")), + pytest.raises(DownloadTargetError) as info, + ): + _overwrite(tmp_path) + assert final.read_bytes() == _OLD + assert [p.name for p in tmp_path.iterdir()] == [final.name] + assert str(final) in str(info.value) + assert info.value.__cause__ is None + + def test_staging_path_mismatch_aborts_before_download(self, fake_ydl, tmp_path): + class _StagingIgnoredYDL(_FakeYDL): + def prepare_filename(self, info: dict) -> str: + return self.opts["outtmpl"].replace("%(ext)s", info["ext"]) + + final = _existing_final(tmp_path) + with patch("yt_dlp.YoutubeDL", _StagingIgnoredYDL), pytest.raises(DownloadTargetError) as info: + _overwrite(tmp_path) + assert fake_ydl.instances[-1].final_path is None + assert final.read_bytes() == _OLD + assert str(final) in str(info.value) + + def test_missing_staged_file_after_success_is_a_failure(self, fake_ydl, tmp_path): + final = _existing_final(tmp_path) + with pytest.raises(DownloadFailedError): + _overwrite(tmp_path) + assert final.read_bytes() == _OLD + + def test_staging_template_escapes_percent(self, fake_ydl, tmp_path): + dest = tmp_path / "100%" + dest.mkdir() + final = _existing_final(dest) + fake_ydl.payload = _NEW + _overwrite(dest) + assert final.read_bytes() == _NEW + + +@pytest.mark.unit +class TestOverwritePartialFiles: + def test_stale_staging_partials_are_not_resumed(self, fake_ydl, tmp_path): + _existing_final(tmp_path) + (tmp_path / "Show - ep1.redownload.mp4.part").write_bytes(b"other source ts") + (tmp_path / "Show - ep1.redownload.mp4.ytdl").write_text("{}") + (tmp_path / "Show - ep1.redownload.mp4").write_bytes(b"crashed before promote") + fake_ydl.payload = _NEW + _overwrite(tmp_path) + assert fake_ydl.instances[-1].part_existed_at_download is False + + def test_final_native_resume_state_is_left_alone(self, fake_ydl, tmp_path): + final = _existing_final(tmp_path) + part = tmp_path / "Show - ep1.mp4.part" + ytdl = tmp_path / "Show - ep1.mp4.ytdl" + part.write_bytes(b"ts fragments") + ytdl.write_text("{}") + fake_ydl.payload = _NEW + _overwrite(tmp_path) + assert part.read_bytes() == b"ts fragments" + assert ytdl.read_text() == "{}" + assert final.read_bytes() == _NEW + + def test_final_legacy_part_is_not_spliced_into_redownload(self, fake_ydl, tmp_path): + final = _existing_final(tmp_path) + legacy = tmp_path / "Show - ep1.mp4.part" + legacy.write_bytes(b"legacy mp4 bytes") + fake_ydl.payload = _NEW + _overwrite(tmp_path) + assert fake_ydl.instances[-1].part_existed_at_download is False + assert legacy.read_bytes() == b"legacy mp4 bytes" + assert final.read_bytes() == _NEW + + def test_discard_ignores_similarly_named_episodes(self, tmp_path): + final = tmp_path / "Show - ep1.mp4" + keep = [tmp_path / "Show - ep10.redownload.mp4", tmp_path / "Show - ep1.mp4.part", final] + for p in keep: + p.write_bytes(b"x") + (tmp_path / "Show - ep1.redownload.temp.mp4").write_bytes(b"fixup temp") + download._discard_staging(final) + assert sorted(p.name for p in tmp_path.iterdir()) == sorted(p.name for p in keep) + + +def _unlink_fails_for(name: str): + real_unlink = Path.unlink + + def unlink(self: Path, *args, **kwargs) -> None: + if self.name == name: + raise PermissionError(13, "locked", str(self)) + real_unlink(self, *args, **kwargs) + + return patch.object(Path, "unlink", unlink) + + +@pytest.mark.unit +class TestStrictStagingPreflight: + @pytest.mark.parametrize( + "locked", + ["Show - ep1.redownload.mp4", "Show - ep1.redownload.mp4.part", "Show - ep1.redownload.mp4.ytdl"], + ) + def test_undeletable_stale_staging_aborts_before_download(self, fake_ydl, tmp_path, locked): + final = _existing_final(tmp_path) + (tmp_path / locked).write_bytes(b"stale staging") + fake_ydl.payload = _NEW + with ( + _unlink_fails_for(locked), + pytest.raises(DownloadTargetError) as info, + ): + _overwrite(tmp_path) + assert fake_ydl.instances[-1].final_path is None + assert final.read_bytes() == _OLD + assert (tmp_path / locked).read_bytes() == b"stale staging" + assert locked in str(info.value) + + def test_cleanup_after_failed_attempt_stays_best_effort(self, fake_ydl, tmp_path): + final = _existing_final(tmp_path) + fake_ydl.payload = _NEW + fake_ydl.fail_in = "process_ie_result" + with _unlink_fails_for("Show - ep1.redownload.mp4.part"), pytest.raises(DownloadFailedError): + _overwrite(tmp_path) + assert final.read_bytes() == _OLD diff --git a/tests/ui/test_menus.py b/tests/ui/test_menus.py index a1a28c1..c0a32d6 100644 --- a/tests/ui/test_menus.py +++ b/tests/ui/test_menus.py @@ -18,6 +18,7 @@ pick_related, select_action, select_episodes, + select_existing_download_action, select_player_once, select_quality, select_series_from_download_history, @@ -499,3 +500,67 @@ def test_empty_enter_returns_none(self, monkeypatch): monkeypatch.setattr("alt_ani_cli.ui.menus._USE_INQUIRER", False) with patch("builtins.input", return_value=""): assert confirm("Kontynuować?") is None + + +_EF = CONTENT["menu"]["existing_file"] +_EXISTING = r"C:\dl\Show - ep4.mp4" + + +def _existing_prompt(answer: str, *, has_remaining: bool) -> tuple[str, list[str], list[str]]: + printed: list[str] = [] + with ( + patch("builtins.input", return_value=answer), + patch("builtins.print", side_effect=lambda *a, **k: printed.append(" ".join(str(x) for x in a))), + patch("alt_ani_cli.ui.progress.warn") as warn, + ): + choice = select_existing_download_action(_EXISTING, has_remaining=has_remaining) + return choice, printed, [c.args[0] for c in warn.call_args_list] + + +@pytest.mark.unit +class TestSelectExistingDownloadAction: + @pytest.fixture(autouse=True) + def _fallback(self, monkeypatch): + monkeypatch.setattr("alt_ani_cli.ui.menus._USE_INQUIRER", False) + + def test_single_episode_offers_skip_overwrite_cancel(self): + _, printed, _ = _existing_prompt("1", has_remaining=False) + opts = _EF["options"] + assert printed == [f" 1. {opts['skip']}", f" 2. {opts['overwrite']}", f" 3. {opts['cancel']}"] + + def test_batch_with_remaining_adds_overwrite_remaining(self): + _, printed, _ = _existing_prompt("1", has_remaining=True) + opts = _EF["options"] + assert printed == [ + f" 1. {opts['skip']}", + f" 2. {opts['overwrite']}", + f" 3. {opts['overwrite_remaining']}", + f" 4. {opts['cancel']}", + ] + + @pytest.mark.parametrize( + ("answer", "expected"), [("1", "skip"), ("2", "overwrite"), ("3", "overwrite_remaining"), ("4", "cancel")] + ) + def test_batch_fallback_maps_every_option(self, answer, expected): + assert _existing_prompt(answer, has_remaining=True)[0] == expected + + @pytest.mark.parametrize(("answer", "expected"), [("1", "skip"), ("2", "overwrite"), ("3", "cancel")]) + def test_single_fallback_maps_every_option(self, answer, expected): + assert _existing_prompt(answer, has_remaining=False)[0] == expected + + @pytest.mark.parametrize("has_remaining", [False, True]) + def test_empty_enter_or_esc_means_cancel(self, has_remaining): + assert _existing_prompt("", has_remaining=has_remaining)[0] == "cancel" + + def test_header_shows_exact_path(self): + _, _, warned = _existing_prompt("1", has_remaining=False) + assert warned == [_EF["header"].format(path=_EXISTING)] + + def test_inquirer_esc_means_cancel(self, monkeypatch): + monkeypatch.setattr("alt_ani_cli.ui.menus._USE_INQUIRER", True) + with ( + patch("InquirerPy.inquirer.select", return_value=MagicMock()), + patch("alt_ani_cli.ui.menus._ask", return_value=None), + patch("alt_ani_cli.ui.progress.warn"), + ): + assert select_existing_download_action(_EXISTING, has_remaining=True) == "cancel" From 9eb73182338a264749beb8fd1baed4a55dda2586 Mon Sep 17 00:00:00 2001 From: Aleksander <71835339+Cybernetic-Ransomware@users.noreply.github.com> Date: Sun, 4 Oct 2026 21:24:25 +0200 Subject: [PATCH 09/11] =?UTF-8?q?fix(security):=20redact=20URL=20query=20v?= =?UTF-8?q?alues=20and=20secret=20headers=20in=20user-fac=E2=80=A6=20(#25)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(security): redact URL query values and secret headers in user-facing output * fix(security): redact ambiguous query value tails instead of keeping trailing punctuation --- src/alt_ani_cli/cli.py | 26 ++-- src/alt_ani_cli/download.py | 2 + src/alt_ani_cli/extract/__init__.py | 4 +- src/alt_ani_cli/redaction.py | 48 ++++++++ src/alt_ani_cli/ui/menus.py | 5 +- tests/cli/test_debug_output.py | 74 +++++++++++ tests/cli/test_errors.py | 31 +++++ tests/cli/test_resolve_with_fallback.py | 18 +++ tests/extract/test_dispatch.py | 25 +++- tests/test_download.py | 74 +++++++++++ tests/test_redaction.py | 156 ++++++++++++++++++++++++ tests/ui/test_menus.py | 13 ++ 12 files changed, 460 insertions(+), 16 deletions(-) create mode 100644 src/alt_ani_cli/redaction.py create mode 100644 tests/cli/test_debug_output.py create mode 100644 tests/test_redaction.py diff --git a/src/alt_ani_cli/cli.py b/src/alt_ani_cli/cli.py index 6da2dcc..8c480a4 100644 --- a/src/alt_ani_cli/cli.py +++ b/src/alt_ani_cli/cli.py @@ -28,6 +28,7 @@ from alt_ani_cli.health import HealthTransition, ResolverHealth, Signal, classify_failure from alt_ani_cli.models import EmbedURL, EpisodeRow, PlayerEntry, SeriesRef from alt_ani_cli.player import runner as player_runner +from alt_ani_cli.redaction import redact_headers, redact_text 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 http as shinden_http @@ -196,8 +197,8 @@ def _resolve_embed_with_spinner(client, online_id: str) -> EmbedURL: def _extract_stream(embed: EmbedURL, cookies_file: str | None, cookies_browser: str | None) -> Stream: - # the only place the full embed URL is shown — failure messages use the host only - progress.info(_PROG["embed"].format(url=embed.url)) + # the only place the embed URL is shown — failure messages use the host only + progress.info(_PROG["embed"].format(url=redact_text(embed.url))) return extract.resolve( embed.url, embed.referer, @@ -265,7 +266,7 @@ def _record_extraction_failure( transition = health.record(host, candidate.online_id, signal) _emit_health_transition(transition, category=fields.get("category"), http_status=fields.get("http_status")) diagnostics.resolve_result(host, False, type(exc).__name__, time.monotonic() - start, **fields) - progress.warn(_PROG["player_failed_long"].format(player=repr(candidate.player), number=ep_number, exc=exc)) + progress.warn(_PROG["player_failed_long"].format(player=repr(candidate.player), number=ep_number, exc=redact_text(exc))) @dataclass @@ -463,19 +464,20 @@ def _print_debug(stream: Stream, embed) -> None: from alt_ani_cli.ui.progress import _get con = _get() - con.print(f"\n[bold]Embed URL:[/bold] {embed.url}") - con.print(f"[bold]Direct URL:[/bold] {stream.url}") + con.print(f"\n[bold]Embed URL:[/bold] {redact_text(embed.url)}") + con.print(f"[bold]Direct URL:[/bold] {redact_text(stream.url)}") con.print(f"[bold]Ext:[/bold] {stream.ext}") if stream.headers: t = Table("Header", "Value", title="HTTP headers") - for k, v in stream.headers.items(): + for k, v in redact_headers(stream.headers).items(): t.add_row(k, v) con.print(t) if stream.qualities: t = Table("Quality", "URL", title="Available qualities") - for q, u in sorted(stream.qualities.items(), key=lambda kv: kv[0]): + for q, raw in sorted(stream.qualities.items(), key=lambda kv: kv[0]): + u = redact_text(raw) t.add_row(q, u[:80] + "..." if len(u) > 80 else u) con.print(t) @@ -691,18 +693,18 @@ def main() -> None: # noqa: C901 if exc.response is not None: status = exc.response.status_code url = str(exc.response.url) - progress.error(_PROG["http_error"].format(status=status, url=url)) + progress.error(_PROG["http_error"].format(status=status, url=redact_text(url))) if status == 403 and SHINDEN_BASE in url: if FLARESOLVERR_URL: - progress.warn(_PROG["flaresolverr_unreachable"].format(url=FLARESOLVERR_URL)) + progress.warn(_PROG["flaresolverr_unreachable"].format(url=redact_text(FLARESOLVERR_URL))) else: progress.warn(_PROG["cloudflare_hint"]) else: - progress.error(str(exc)) + progress.error(redact_text(exc)) sys.exit(1) except (AntiBotError, NoStreamError, ParseError, FilterMismatchError, DownloadFailedError, DownloadTargetError) as exc: diagnostics.session_end("error", type(exc).__name__) - progress.error(str(exc)) + progress.error(redact_text(exc)) sys.exit(1) except PlayerNotFoundError as exc: diagnostics.session_end("error", type(exc).__name__) @@ -710,7 +712,7 @@ def main() -> None: # noqa: C901 sys.exit(1) except ShindenError as exc: diagnostics.session_end("error", type(exc).__name__) - progress.error(_PROG["shinden_error"].format(exc=exc)) + progress.error(_PROG["shinden_error"].format(exc=redact_text(exc))) sys.exit(1) else: diagnostics.session_end("ok", None) diff --git a/src/alt_ani_cli/download.py b/src/alt_ani_cli/download.py index 2efac38..f1c8759 100644 --- a/src/alt_ani_cli/download.py +++ b/src/alt_ani_cli/download.py @@ -10,6 +10,7 @@ from alt_ani_cli.errors import DownloadFailedError, DownloadTargetError from alt_ani_cli.extract.common import Stream from alt_ani_cli.models import EpisodeRow, SeriesRef +from alt_ani_cli.redaction import redact_text from alt_ani_cli.ui import progress _SUPPRESS_PREFIXES = ( @@ -28,6 +29,7 @@ class _TruncLogger: def _emit(self, msg: str) -> None: if any(msg.startswith(p) for p in _SUPPRESS_PREFIXES): return + msg = redact_text(msg) cols = shutil.get_terminal_size((120, 24)).columns if len(msg) > cols: msg = msg[: cols - 1] + "…" diff --git a/src/alt_ani_cli/extract/__init__.py b/src/alt_ani_cli/extract/__init__.py index 6f05d91..e766e17 100644 --- a/src/alt_ani_cli/extract/__init__.py +++ b/src/alt_ani_cli/extract/__init__.py @@ -21,6 +21,7 @@ ytdlp_resolver, ) from alt_ani_cli.extract.common import Stream +from alt_ani_cli.redaction import redact_text # ebd.cda.pl/800x450/{id} → yt-dlp does not understand the embed URL; rewrite to www.cda.pl/video/{id} _EBD_CDA_RE = re.compile(r"/\d+x\d+/([0-9a-z]+)$", re.IGNORECASE) @@ -155,7 +156,8 @@ def resolver_family(host: str) -> str: def _exc_text(exc: Exception, embed_url: str, host: str) -> str: """Format an exception for user-facing messages, replacing the embed URL with its host.""" - return f"{type(exc).__name__}: {exc}".replace(repr(embed_url), host).replace(embed_url, host) + text = f"{type(exc).__name__}: {exc}".replace(repr(embed_url), host).replace(embed_url, host) + return redact_text(text) # Classify by ExtractError.category or exception type only — messages may carry URLs/tokens. diff --git a/src/alt_ani_cli/redaction.py b/src/alt_ani_cli/redaction.py new file mode 100644 index 0000000..a01ea77 --- /dev/null +++ b/src/alt_ani_cli/redaction.py @@ -0,0 +1,48 @@ +"""Display-only redaction of URL query values and secret headers — never apply to request data.""" + +import re +from collections.abc import Mapping + +REDACTED = "