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 = "" + +SENSITIVE_HEADERS = frozenset( + { + "authorization", + "proxy-authorization", + "cookie", + "set-cookie", + "x-api-key", + "api-key", + "x-auth-token", + } +) + +# Stops only at chars invalid unencoded in a URL (or `#`), so ambiguous tails get redacted; skips `\\?\` paths. +_QUERY_RE = re.compile(r"(?<=\S)(?[?#])(?P[^\s\"`#]*)") +_PARAM_RE = re.compile(r"(?P^|&)(?P[^=&]+)=(?P[^&]*)") +_USERINFO_RE = re.compile(r"(?<=://)[^/\s@?#]+@") + + +def _redact_query(match: re.Match[str]) -> str: + body = _PARAM_RE.sub(lambda m: f"{m['lead']}{m['name']}={REDACTED}", match["query"]) + return f"{match['sep']}{body}" + + +def redact_text(text: object) -> str: + """Redact all query values and userinfo, keeping names/host/path; idempotent on its own output.""" + try: + s = str(text) + except Exception: + return REDACTED + s = _USERINFO_RE.sub(f"{REDACTED}@", s) + return _QUERY_RE.sub(_redact_query, s) + + +def redact_header_value(name: str, value: object) -> str: + if str(name).strip().lower() in SENSITIVE_HEADERS: + return REDACTED + return redact_text(value) + + +def redact_headers(headers: Mapping[str, object]) -> dict[str, str]: + return {k: redact_header_value(k, v) for k, v in headers.items()} diff --git a/src/alt_ani_cli/ui/menus.py b/src/alt_ani_cli/ui/menus.py index 194a059..e4a3b8f 100644 --- a/src/alt_ani_cli/ui/menus.py +++ b/src/alt_ani_cli/ui/menus.py @@ -15,6 +15,7 @@ from alt_ani_cli.content import CONTENT from alt_ani_cli.models import EpisodeRow, PlayerEntry, PlayerSource, RelatedSeries, SeriesHit, SeriesMetadata, SeriesRef +from alt_ani_cli.redaction import redact_text from alt_ani_cli.ui import progress _RES_RE = re.compile(r"(\d+)") @@ -540,9 +541,9 @@ def format_player_source(p: PlayerEntry, resolved: PlayerSource | None) -> tuple if p.subs_author: lines.append(_pl["source_author"].format(author=p.subs_author)) if p.source: - lines.append(_pl["source_url"].format(url=p.source)) + lines.append(_pl["source_url"].format(url=redact_text(p.source))) if resolved: - lines.append(_pl["source_embed"].format(url=resolved.embed_url)) + lines.append(_pl["source_embed"].format(url=redact_text(resolved.embed_url))) body = "\n".join(lines) or _pl["source_empty"] return _pl["source_header"].format(player=p.player), body diff --git a/tests/cli/test_debug_output.py b/tests/cli/test_debug_output.py new file mode 100644 index 0000000..4f14132 --- /dev/null +++ b/tests/cli/test_debug_output.py @@ -0,0 +1,74 @@ +"""--debug rendering: secrets are redacted at display time only.""" + +import io +from unittest.mock import patch + +import pytest +from rich.console import Console + +from alt_ani_cli.cli import _print_debug +from alt_ani_cli.extract.common import Stream +from alt_ani_cli.models import EmbedURL +from alt_ani_cli.player import mpv + +_URL = "https://cdn.example/x.mp4?token=SECRET" +_QUALITIES = {"1080p": "https://cdn.example/x1080.mp4?sig=ABC"} +_HEADERS = { + "Authorization": "Bearer VERY_SECRET", + "Cookie": "session=VERY_SECRET_TOO", + "Referer": "https://embed.example/e/1?token=REF_SECRET", + "User-Agent": "test-agent", +} +_EMBED = EmbedURL(url="https://embed.example/e/1?token=EMBED_SECRET", referer="https://shinden.pl/") + + +def _stream() -> Stream: + return Stream(url=_URL, headers=dict(_HEADERS), qualities=dict(_QUALITIES), ext="mp4") + + +def _render(stream: Stream) -> str: + buf = io.StringIO() + console = Console(file=buf, width=300, legacy_windows=False, highlight=False) + with patch("alt_ani_cli.ui.progress._get", return_value=console): + _print_debug(stream, _EMBED) + return buf.getvalue() + + +@pytest.mark.unit +class TestPrintDebugRedaction: + def test_no_secret_in_output(self): + out = _render(_stream()) + for secret in ("SECRET", "ABC", "VERY_SECRET", "VERY_SECRET_TOO", "REF_SECRET", "EMBED_SECRET", "Bearer"): + assert secret not in out + + def test_hosts_and_paths_visible(self): + out = _render(_stream()) + assert "cdn.example/x.mp4?token=" in out + assert "cdn.example/x1080.mp4?sig=" in out + assert "embed.example/e/1?token=" in out + assert "" in out + + def test_user_agent_still_visible(self): + assert "test-agent" in _render(_stream()) + + +@pytest.mark.unit +class TestRedactionLeavesRuntimeDataIntact: + def test_print_debug_does_not_mutate_stream(self): + stream = _stream() + _render(stream) + assert stream.url == _URL + assert stream.qualities == _QUALITIES + assert stream.headers == _HEADERS + assert _EMBED.url == "https://embed.example/e/1?token=EMBED_SECRET" + + def test_player_command_still_gets_raw_url_and_headers(self): + stream = _stream() + _render(stream) + with patch("alt_ani_cli.player.mpv._find", return_value="mpv"): + cmd = mpv.build(stream, title="t") + assert _URL in cmd + assert "--referrer=https://embed.example/e/1?token=REF_SECRET" in cmd + fields = next(a for a in cmd if a.startswith("--http-header-fields=")) + assert "Authorization: Bearer VERY_SECRET" in fields + assert "Cookie: session=VERY_SECRET_TOO" in fields diff --git a/tests/cli/test_errors.py b/tests/cli/test_errors.py index 08c36f2..a04481a 100644 --- a/tests/cli/test_errors.py +++ b/tests/cli/test_errors.py @@ -107,3 +107,34 @@ def test_download_failure_exits_1_with_plain_message(self, capsys): combined = "".join(capsys.readouterr()) assert "Pobieranie nie powiodło się." in combined assert "Błąd shinden" not in combined + + +@pytest.mark.unit +class TestHTTPErrorUrlRedaction: + def test_signed_query_value_not_shown(self, capsys): + exc = _make_http_error(403, "https://example.test/path?token=SECRET") + with ( + patch("sys.argv", ["alt-ani-cli", "--url", "https://shinden.pl/series/1-test"]), + patch("alt_ani_cli.cli._run_noninteractive", side_effect=exc), + patch("alt_ani_cli.cli.shinden_http.make_client"), + pytest.raises(SystemExit), + ): + main() + combined = "".join(capsys.readouterr()) + assert "SECRET" not in combined + assert "example.test/path" in combined + assert "token=" in combined + + def test_shinden_detection_still_uses_full_url(self, capsys): + exc = _make_http_error(403, "https://shinden.pl/episode/1/view?sig=SECRET") + with ( + patch("sys.argv", ["alt-ani-cli", "--url", "https://shinden.pl/series/1-test"]), + patch("alt_ani_cli.cli._run_noninteractive", side_effect=exc), + patch("alt_ani_cli.cli.shinden_http.make_client"), + patch("alt_ani_cli.cli.FLARESOLVERR_URL", ""), + pytest.raises(SystemExit), + ): + main() + combined = "".join(capsys.readouterr()) + assert "Cloudflare" in combined + assert "SECRET" not in combined diff --git a/tests/cli/test_resolve_with_fallback.py b/tests/cli/test_resolve_with_fallback.py index ef44917..a21d2e4 100644 --- a/tests/cli/test_resolve_with_fallback.py +++ b/tests/cli/test_resolve_with_fallback.py @@ -612,3 +612,21 @@ def test_health_none_never_calls_learn_label(self): assert result is not None assert embed is _EMBED + + +@pytest.mark.unit +class TestResolveWithFallbackRedaction: + def test_embed_and_failure_output_redact_query_values(self, capsys): + embed = EmbedURL(url="https://vidawra.cc/e/abc123?t=EMBED_SECRET", referer="https://shinden.pl/") + exc = NoStreamError("all extractors failed: https://cdn.example/master.m3u8?token=SECRET") + 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_resolve, + patch("alt_ani_cli.cli.diagnostics.resolve_result"), + ): + _resolve_with_fallback(MagicMock(), [_PLAYER], _PLAYER, False, 1.0) + combined = "".join(capsys.readouterr()) + assert "EMBED_SECRET" not in combined and "SECRET" not in combined + assert "vidawra.cc/e/abc123?t=" in combined + assert "cdn.example/master.m3u8?token=" in combined + assert mock_resolve.call_args[0][0] == "https://vidawra.cc/e/abc123?t=EMBED_SECRET" diff --git a/tests/extract/test_dispatch.py b/tests/extract/test_dispatch.py index 7727435..1e275cf 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, mp4upload, resolve, resolver_family +from alt_ani_cli.extract import HOST_RULES, HostRule, _exc_text, _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/" @@ -239,6 +239,29 @@ def test_failure_messages_use_host_not_embed_url(self): assert embed_url not in exc_text assert "unknownhost.tv" in exc_text + def test_exc_text_redacts_other_signed_urls(self): + embed_url = "https://player.example/e/abc" + exc = TimeoutError(f"Timeout while fetching {embed_url} -> https://cdn.example/master.m3u8?token=SECRET") + text = _exc_text(exc, embed_url, "player.example") + assert embed_url not in text + assert "-> " in text and text.startswith("TimeoutError: Timeout while fetching player.example") + assert "SECRET" not in text + assert "cdn.example/master.m3u8?token=" in text + + def test_signed_url_in_failure_messages_redacted(self): + embed_url = "https://unknownhost.tv/embed/abc" + signed = "https://cdn.example/master.m3u8?token=SECRET&expires=999" + on_fallback = MagicMock() + with ( + patch("alt_ani_cli.extract.jwplayer.resolve", side_effect=ValueError(f"Timeout while fetching {signed}")), + patch("alt_ani_cli.extract.ytdlp_resolver.resolve", side_effect=Exception(f"HTTP 403 for {signed}")), + pytest.raises(NoStreamError) as exc_info, + ): + resolve(embed_url, _REFERER, on_fallback=on_fallback) + for text in (str(exc_info.value), on_fallback.call_args[0][2]): + assert "SECRET" not in text and "999" not in text + assert "cdn.example/master.m3u8?token=&expires=" in text + def _http_error(status_code: int) -> cffi_exceptions.HTTPError: resp = MagicMock(status_code=status_code) diff --git a/tests/test_download.py b/tests/test_download.py index 3fe2c69..5395c71 100644 --- a/tests/test_download.py +++ b/tests/test_download.py @@ -1,3 +1,4 @@ +import os import sys from pathlib import Path from unittest.mock import patch @@ -487,3 +488,76 @@ def test_cleanup_after_failed_attempt_stays_best_effort(self, fake_ydl, tmp_path with _unlink_fails_for("Show - ep1.redownload.mp4.part"), pytest.raises(DownloadFailedError): _overwrite(tmp_path) assert final.read_bytes() == _OLD + + +def _terminal(columns: int): + return patch("alt_ani_cli.download.shutil.get_terminal_size", return_value=os.terminal_size((columns, 24))) + + +@pytest.mark.unit +class TestTruncLoggerRedaction: + def test_secret_query_value_never_printed(self, capsys): + with _terminal(200): + download._TruncLogger().info("[hlsnative] Downloading m3u8 manifest https://cdn.example/hls/master.m3u8?token=SECRET") + out = capsys.readouterr().out + assert "SECRET" not in out + assert "" in out + assert "cdn.example/hls/master.m3u8?token=" in out + + def test_redaction_happens_before_truncation(self, capsys): + head = "x" * 40 + " https://h.example/m.m3u8?token=" + # Truncating first would keep "token=SECRET_…" — and a cut value no longer looks like a full query. + with _terminal(len(head) + 8): + download._TruncLogger().info(head + "SECRET_TAIL_VALUE_THAT_IS_LONG") + out = capsys.readouterr().out + assert "SECRET" not in out + assert out.rstrip("\n") == head + "&expires=" in out + + def test_suppressed_prefixes_still_suppressed(self, capsys): + download._TruncLogger().info("[generic] Extracting URL: https://h/m?token=SECRET") + assert capsys.readouterr().out == "" + + def test_real_ytdlp_timeout_message_does_not_leak(self, capsys): + msg = ( + "ERROR: [generic] master: Unable to download webpage: " + "https://host.example/hls/master.m3u8?token=THIS_MUST_NOT_LEAK&expires=999 (caused by TransportError('timed out'))" + ) + with _terminal(400): + download._TruncLogger().error(msg) + out = capsys.readouterr().out + assert "THIS_MUST_NOT_LEAK" not in out + assert "999" not in out + assert "host.example/hls/master.m3u8?token=&expires=" in out + assert "timed out" in out + + def test_local_paths_untouched(self, capsys): + with _terminal(200): + download._TruncLogger().info(r"[download] Destination: C:\Users\Test\Videos\Show - ep4.mp4") + assert r"C:\Users\Test\Videos\Show - ep4.mp4" in capsys.readouterr().out + + +@pytest.mark.unit +class TestRedactionDoesNotTouchTransport: + def test_signed_url_and_headers_reach_ytdlp_verbatim(self, fake_ydl, tmp_path): + url = "https://cdn.example/hls/master.m3u8?token=SECRET&expires=999" + headers = {"Referer": "https://embed.example/e/1?token=REF", "Cookie": "session=S", "User-Agent": "UA/1.0"} + seen: list[str] = [] + original = _FakeYDL.extract_info + + def spy(self, u, download=True): + seen.append(u) + return original(self, u, download) + + with patch.object(_FakeYDL, "extract_info", spy): + ydl = _run(Stream(url=url, headers=dict(headers), ext="m3u8"), tmp_path) + assert seen == [url] + assert ydl.opts["http_headers"] == headers diff --git a/tests/test_redaction.py b/tests/test_redaction.py new file mode 100644 index 0000000..fffd3c2 --- /dev/null +++ b/tests/test_redaction.py @@ -0,0 +1,156 @@ +import pytest + +from alt_ani_cli.redaction import REDACTED, redact_header_value, redact_headers, redact_text + + +@pytest.mark.unit +class TestRedactText: + def test_single_query_value_redacted(self): + assert redact_text("https://cdn/x.m3u8?token=SECRET") == f"https://cdn/x.m3u8?token={REDACTED}" + + def test_all_parameter_values_redacted(self): + out = redact_text("https://cdn.example/a?a=1&token=2&expires=3") + assert out == f"https://cdn.example/a?a={REDACTED}&token={REDACTED}&expires={REDACTED}" + + def test_non_secret_looking_values_redacted_too(self): + out = redact_text("https://cdn.example/hls/master.m3u8?token=ABC&expires=123&quality=1080") + assert out == f"https://cdn.example/hls/master.m3u8?token={REDACTED}&expires={REDACTED}&quality={REDACTED}" + + def test_url_without_query_unchanged(self): + url = "https://player.example:8443/e/abc123/video.mp4" + assert redact_text(url) == url + + def test_scheme_host_port_path_preserved(self): + out = redact_text("https://cdn.example:8443/hls/v1/master.m3u8?sig=XYZ") + assert out.startswith("https://cdn.example:8443/hls/v1/master.m3u8?sig=") + assert "XYZ" not in out + + def test_bare_filename_with_query(self): + out = redact_text("ERROR: fragment failed:\nmaster.m3u8?token=SECRET&x=1") + assert out == f"ERROR: fragment failed:\nmaster.m3u8?token={REDACTED}&x={REDACTED}" + + def test_request_path_with_query(self): + assert redact_text("GET /hls/master.txt?sig=ABC") == f"GET /hls/master.txt?sig={REDACTED}" + + def test_aws_presigned_query(self): + out = redact_text("https://b.s3.amazonaws.com/k?X-Amz-Credential=AAA&X-Amz-Signature=BBB&X-Amz-Date=CCC") + for secret in ("AAA", "BBB", "CCC"): + assert secret not in out + assert "X-Amz-Credential=" in out and "X-Amz-Signature=" in out and "X-Amz-Date=" in out + + def test_cloudfront_signed_query(self): + out = redact_text("https://d1.cloudfront.net/v.mp4?Policy=AAA&Signature=BBB&Key-Pair-Id=CCC") + for secret in ("AAA", "BBB", "CCC"): + assert secret not in out + assert "Policy=" in out and "Signature=" in out and "Key-Pair-Id=" in out + + def test_plain_fragment_and_surrounding_text_stay_readable(self): + out = redact_text("ERROR: unable to download https://cdn.example/hls/master.m3u8?token=ABC#part1 HTTP Error 403") + assert out == f"ERROR: unable to download https://cdn.example/hls/master.m3u8?token={REDACTED}#part1 HTTP Error 403" + + def test_query_like_fragment_values_redacted(self): + out = redact_text("https://host.example/cb#access_token=SECRET&expires_in=3600") + assert out == f"https://host.example/cb#access_token={REDACTED}&expires_in={REDACTED}" + + def test_multiline_ytdlp_message_keeps_status_and_location(self): + msg = "ERROR: unable to download\nhttps://cdn.example/hls/master.m3u8?token=ABC&expires=123\nHTTP Error 403" + out = redact_text(msg) + assert "ABC" not in out and "=123" not in out + assert "cdn.example/hls/master.m3u8?token=" in out and "&expires=" in out + assert "HTTP Error 403" in out + + def test_punctuation_only_value_fully_redacted(self): + out = redact_text("https://h/x?token=!!!!") + assert out == f"https://h/x?token={REDACTED}" + assert "!" not in out + + @pytest.mark.parametrize("tail", [".", ")", ",", ";", ":", "!", "]", "}", ">", "'"]) + def test_trailing_punctuation_is_treated_as_part_of_value(self, tail): + out = redact_text(f"https://h/x?token=abc{tail}") + assert out == f"https://h/x?token={REDACTED}" + assert "abc" not in out + + def test_question_mark_inside_value_does_not_end_it(self): + out = redact_text("https://h/x?token=abc?MORE") + assert out == f"https://h/x?token={REDACTED}" + assert "abc" not in out and "MORE" not in out + + def test_nested_query_after_question_mark_redacted(self): + out = redact_text("https://h/x?token=abc?more=SECOND") + assert out == f"https://h/x?token={REDACTED}" + assert "abc" not in out and "SECOND" not in out + + def test_redaction_continues_to_next_whitespace(self): + out = redact_text("failed GET 'https://cdn/x?token=S') then retry") + assert out == f"failed GET 'https://cdn/x?token={REDACTED} then retry" + + def test_url_userinfo_redacted(self): + out = redact_text("https://user:hunter2@host.example/path") + assert out == f"https://{REDACTED}@host.example/path" + + @pytest.mark.parametrize( + "text", + [ + "https://cdn.example/hls/master.m3u8?token=ABC&expires=123&quality=1080", + "master.m3u8?token=SECRET&x=1 then (https://h/x?a=1). ", + "https://u:p@h.example/a?b=1#c=2", + "https://h/p?a=1&&flag&c=", + "https://h/x?token=abc?more=SECOND", + "https://h/x?token=!!!!", + ], + ) + def test_idempotent(self, text): + once = redact_text(text) + assert redact_text(once) == once + + @pytest.mark.parametrize( + "text", + ["", "?", "??==&&", "=&?#", "https://", "?=&=", "a?b=c?d=e#f=g#h", "\x00\xff?\ud800=1", "?"], + ) + def test_malformed_input_never_raises(self, text): + assert isinstance(redact_text(text), str) + + def test_non_string_input_never_raises(self): + class Boom: + def __str__(self): + raise RuntimeError("nope") + + assert redact_text(None) == "None" + assert redact_text(ValueError("x https://h/a?t=S")) == f"x https://h/a?t={REDACTED}" + assert redact_text(Boom()) == REDACTED + + @pytest.mark.parametrize( + "text", + [ + r"C:\Users\Test\Videos\Show - ep4.mp4", + r"\\?\C:\Users\Test\Videos\a=b\Show.mp4", + "Shingeki no Kyojin: The Final Season - Kanketsu-hen", + "Overwrite existing file? [y/N]", + "Pobieranie nie powiodło się.", + ], + ) + def test_text_without_url_query_unchanged(self, text): + assert redact_text(text) == text + + +@pytest.mark.unit +class TestRedactHeaders: + @pytest.mark.parametrize( + "name", + ["Authorization", "proxy-authorization", "COOKIE", "Set-Cookie", "X-Api-Key", "api-key", "X-Auth-Token"], + ) + def test_sensitive_header_fully_redacted(self, name): + assert redact_header_value(name, "Bearer abc https://h/x") == REDACTED + + def test_other_header_gets_query_redaction(self): + out = redact_header_value("Referer", "https://host/e?id=123&token=ABC") + assert out == f"https://host/e?id={REDACTED}&token={REDACTED}" + + def test_plain_header_value_unchanged(self): + assert redact_header_value("User-Agent", "Mozilla/5.0 (Windows NT 10.0)") == "Mozilla/5.0 (Windows NT 10.0)" + + def test_redact_headers_returns_new_dict(self): + headers = {"Cookie": "s=1", "Referer": "https://h/e?t=2"} + out = redact_headers(headers) + assert out == {"Cookie": REDACTED, "Referer": f"https://h/e?t={REDACTED}"} + assert headers == {"Cookie": "s=1", "Referer": "https://h/e?t=2"} diff --git a/tests/ui/test_menus.py b/tests/ui/test_menus.py index c0a32d6..08f729c 100644 --- a/tests/ui/test_menus.py +++ b/tests/ui/test_menus.py @@ -478,6 +478,19 @@ def test_full_info(self): assert "https://miorosubs.com/" in body assert "https://ebd.cda.pl/620x395/xyz" in body + def test_query_values_redacted_for_display_only(self): + source = "https://subs.example/release?id=7&token=SRC_SECRET" + embed_url = "https://player.example/e/abc123?sig=EMBED_SECRET" + p = PlayerEntry(online_id="p1", player="CDA", lang_audio="jp", lang_subs="pl", subs_author="Mioro-Subs", source=source) + resolved = PlayerSource(online_id="p1", host="player.example", embed_url=embed_url) + title, body = format_player_source(p, resolved) + assert "SRC_SECRET" not in body and "EMBED_SECRET" not in body and "=7" not in body + assert "Mioro-Subs" in body + assert "subs.example/release?id=&token=" in body + assert "player.example/e/abc123?sig=" in body + assert "Źródło:" in body and "Embed:" in body + assert p.source == source and resolved.embed_url == embed_url + def test_no_info_renders_empty_message(self): title, body = format_player_source(_PLAYER, None) assert "CDA" in title From 5cfce38932ad366ab4bf4c9b07594612cc99f4e4 Mon Sep 17 00:00:00 2001 From: Aleksander <71835339+Cybernetic-Ransomware@users.noreply.github.com> Date: Sun, 4 Oct 2026 21:43:36 +0200 Subject: [PATCH 10/11] Feat/download source pin (#26) * feat(download): pin the chosen source for the remaining episodes of a download batch * fix(download): let --select-nth override the source pin and require a manual pick when the pin has no match --- src/alt_ani_cli/content/ui_content_pl-pl.yaml | 11 +- src/alt_ani_cli/flow/handlers.py | 70 +++- src/alt_ani_cli/flow/pin.py | 50 +++ src/alt_ani_cli/flow/state.py | 5 + src/alt_ani_cli/ui/menus.py | 24 +- tests/flow/test_handlers.py | 381 ++++++++++++++++++ tests/flow/test_pin.py | 95 +++++ tests/ui/test_menus.py | 38 ++ 8 files changed, 654 insertions(+), 20 deletions(-) create mode 100644 src/alt_ani_cli/flow/pin.py create mode 100644 tests/flow/test_pin.py 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 c7569c9..95e9103 100644 --- a/src/alt_ani_cli/content/ui_content_pl-pl.yaml +++ b/src/alt_ani_cli/content/ui_content_pl-pl.yaml @@ -54,6 +54,7 @@ progress: 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ę." + pin_no_match: "Przypiętego źródła nie ma w odcinku {number:g}." 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." @@ -166,10 +167,18 @@ menu: options: play: "Oglądaj w mpv/vlc" download: "Pobierz na dysk" + download_pin: "Pobierz i używaj tego źródła dla kolejnych odcinków" debug: "Pokaż linki (debug)" instruction: "↑↓ = nawigacja | Enter = wybierz | ESC = wstecz" fallback_prompt: "Akcja [1-3, Enter=wstecz]: " - fallback_invalid: " Wpisz liczbę 1–3." + fallback_invalid: " Wpisz liczbę 1–{n}." + pin_fallback: + message: "Ten player różni się od przypiętego źródła. Co zrobić?" + options: + keep: "Pobierz ten odcinek i zachowaj przypięte źródło" + repin: "Pobierz i używaj tego źródła dla kolejnych odcinków" + instruction: "↑↓ = nawigacja | Enter = wybierz | ESC = wstecz" + fallback_invalid: " Wpisz liczbę 1–2." existing_file: header: "Plik już istnieje: {path}" diff --git a/src/alt_ani_cli/flow/handlers.py b/src/alt_ani_cli/flow/handlers.py index 1f90203..35d840f 100644 --- a/src/alt_ani_cli/flow/handlers.py +++ b/src/alt_ani_cli/flow/handlers.py @@ -21,6 +21,7 @@ from alt_ani_cli import __version__, diagnostics, download, history from alt_ani_cli.content import CONTENT from alt_ani_cli.errors import DownloadFailedError, DownloadTargetError, ShindenError +from alt_ani_cli.flow.pin import find_pinned, player_fingerprint 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 @@ -110,6 +111,10 @@ def _reset_download_resume(state: FlowState) -> None: state.downloaded_eps = set() +def _has_remaining_targets(state: FlowState) -> bool: + return state.ep_idx < len(state.targets) - 1 + + 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) @@ -295,6 +300,7 @@ def handle_fetch_episodes(state: FlowState) -> ScreenResult: state.targets = [] state.ep_idx = 0 state.overwrite_existing_batch = False + state.pinned_player = None state.episode_arg_consumed = False if not episodes: progress.error(_PROG["no_episodes"]) @@ -318,6 +324,7 @@ def handle_episodes_pick(state: FlowState) -> ScreenResult: state.targets = targets state.ep_idx = 0 state.overwrite_existing_batch = False + state.pinned_player = None state.episode_arg_consumed = True return Screen.EPISODE_DISPATCH @@ -349,6 +356,7 @@ def handle_episodes_pick(state: FlowState) -> ScreenResult: state.targets = result state.ep_idx = 0 state.overwrite_existing_batch = False + state.pinned_player = None return Screen.EPISODE_DISPATCH @@ -388,22 +396,32 @@ def handle_episode_dispatch(state: FlowState) -> ScreenResult: else: use_full = menus.confirm(_M["filter_confirm"]["question"].format(filters=filters)) if not use_full: # False or None (ESC) → back to episode selection + state.pinned_player = None return Screen.EPISODES_PICK else: players = filtered state.players = players state.chosen_player = None + state.player_picked_manually = False state.failed_ids = set() state.stream = None state.embed = None state.player_sources = {} state.player_embeds = {} - if args.select_nth or len(players) == 1: - state.chosen_player = players[0] - diagnostics.player_selected(state.chosen_player.online_id, state.chosen_player.player, None) - return Screen.RESOLVE_STREAM + if args.select_nth: + return _auto_pick_player(state, players[0]) + + # matched on the final sorted/filtered list from the episode HTML alone — no resolve_embed needed + if state.pinned_player is not None: + pinned = find_pinned(players, state.pinned_player) + if pinned is not None: + return _auto_pick_player(state, pinned) + # a missing pinned source needs a deliberate fallback, even with a single player left + progress.info(_PROG["pin_no_match"].format(number=ep.number)) + elif len(players) == 1: + return _auto_pick_player(state, players[0]) if args.show_sources: _prefetch_player_sources(state) @@ -411,6 +429,12 @@ def handle_episode_dispatch(state: FlowState) -> ScreenResult: return Screen.PLAYER_PICK +def _auto_pick_player(state: FlowState, player) -> ScreenResult: + state.chosen_player = player + diagnostics.player_selected(player.online_id, player.player, None) + return Screen.RESOLVE_STREAM + + def handle_player_pick(state: FlowState) -> ScreenResult: ep = state.current_ep ep_label = f"{ep.number:g}" if ep is not None else "?" @@ -426,9 +450,11 @@ def handle_player_pick(state: FlowState) -> ScreenResult: sources=state.player_sources, ) if action == "back": + state.pinned_player = None # leaving the batch return Screen.EPISODES_PICK # ESC → back to episode selection if action == "pick": state.chosen_player = payload + state.player_picked_manually = True source = state.player_sources.get(payload.online_id) diagnostics.player_selected(payload.online_id, payload.player, source.host if source else None) return Screen.RESOLVE_STREAM @@ -505,18 +531,40 @@ def handle_action_pick(state: FlowState) -> ScreenResult: state.episode_action = "debug" return Screen.RUN_ACTION if state.episode_action is not None: + if state.episode_action == "download" and _offers_repin(state): + choice = menus.select_pin_fallback_action() + if choice is None: + return _back_from_action(state) + if choice == "repin": + state.pinned_player = player_fingerprint(state.chosen_player) return Screen.RUN_ACTION - action = menus.select_action() + action = menus.select_action(offer_pin=_has_remaining_targets(state)) if action is None: state.episode_action = None # reset cache - # ESC → back to quality pick if there were qualities, otherwise player - if state.stream and state.stream.qualities: - return Screen.QUALITY_PICK - return Screen.PLAYER_PICK + return _back_from_action(state) + if action == "download_pin": + state.pinned_player = player_fingerprint(state.chosen_player) + action = "download" state.episode_action = action return Screen.RUN_ACTION +def _offers_repin(state: FlowState) -> bool: + """A manual fallback must not silently replace the pin, so ask when the picked player differs from it.""" + return ( + state.pinned_player is not None + and state.player_picked_manually + and _has_remaining_targets(state) + and player_fingerprint(state.chosen_player) != state.pinned_player + ) + + +def _back_from_action(state: FlowState) -> ScreenResult: + if state.stream and state.stream.qualities: + return Screen.QUALITY_PICK + return Screen.PLAYER_PICK + + def handle_run_action(state: FlowState) -> ScreenResult: if state.stream is None: raise AssertionError @@ -543,8 +591,7 @@ def handle_run_action(state: FlowState) -> ScreenResult: 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) + choice = menus.select_existing_download_action(str(path), has_remaining=_has_remaining_targets(state)) existing.update(choice=choice, path=path) if choice == "overwrite_remaining": state.overwrite_existing_batch = True @@ -566,6 +613,7 @@ def confirm_overwrite(path: Path) -> bool: return Screen.PLAYER_PICK if not downloaded and existing["choice"] == "cancel": state.overwrite_existing_batch = False + state.pinned_player = None state.stream = None state.embed = None progress.warn(CONTENT["download"]["batch_cancelled"]) diff --git a/src/alt_ani_cli/flow/pin.py b/src/alt_ani_cli/flow/pin.py new file mode 100644 index 0000000..0110476 --- /dev/null +++ b/src/alt_ani_cli/flow/pin.py @@ -0,0 +1,50 @@ +"""Session-only download source pin: a player identity that survives across episodes of one batch.""" + +from dataclasses import dataclass +from urllib.parse import urlparse + +from alt_ani_cli.models import PlayerEntry + + +@dataclass(frozen=True) +class PlayerFingerprint: + """Episode-independent player identity; online_id and date_added change between episodes, so they are excluded.""" + + player: str + lang_audio: str + lang_subs: str + max_res: str + subs_author: str + source: str + + +def _norm_text(value: str | None) -> str: + return " ".join((value or "").split()).casefold() + + +def source_identity(source: str | None) -> str: + """Hostname without www. for URLs (scheme, path, query and fragment ignored); normalized text otherwise.""" + text = (source or "").strip() + try: + host = urlparse(text).hostname + except ValueError: + host = None + if host: + return host.casefold().removeprefix("www.") + return _norm_text(text) + + +def player_fingerprint(p: PlayerEntry) -> PlayerFingerprint: + return PlayerFingerprint( + player=_norm_text(p.player), + lang_audio=_norm_text(p.lang_audio), + lang_subs=_norm_text(p.lang_subs), + max_res=_norm_text(p.max_res), + subs_author=_norm_text(p.subs_author), + source=source_identity(p.source), + ) + + +def find_pinned(players: list[PlayerEntry], pin: PlayerFingerprint) -> PlayerEntry | None: + """First player matching the pin, in the given (already sorted and filtered) order.""" + return next((p for p in players if player_fingerprint(p) == pin), None) diff --git a/src/alt_ani_cli/flow/state.py b/src/alt_ani_cli/flow/state.py index da104e4..5437dc7 100644 --- a/src/alt_ani_cli/flow/state.py +++ b/src/alt_ani_cli/flow/state.py @@ -4,6 +4,7 @@ from enum import Enum, auto from typing import Any, Literal +from alt_ani_cli.flow.pin import PlayerFingerprint from alt_ani_cli.health import ResolverHealth @@ -71,10 +72,14 @@ class FlowState: # replace already-downloaded files without asking — scoped to the current targets batch only overwrite_existing_batch: bool = False episode_arg_consumed: bool = False + # download source pin — scoped to the current targets batch, never persisted + pinned_player: PlayerFingerprint | None = None # player / stream players: list = field(default_factory=list) chosen_player: Any = None + # True only when chosen_player came from PLAYER_PICK (not pin / --select-nth / single-player auto-pick) + player_picked_manually: bool = False failed_ids: set[str] = field(default_factory=set) stream: Any = None embed: Any = None diff --git a/src/alt_ani_cli/ui/menus.py b/src/alt_ani_cli/ui/menus.py index e4a3b8f..f2e2d17 100644 --- a/src/alt_ani_cli/ui/menus.py +++ b/src/alt_ani_cli/ui/menus.py @@ -712,19 +712,27 @@ def _label(opt: str) -> str: return _run_simple_picker(all_options, _label, prompt=prompt, instruction=_q["instruction"], mode="select") -def select_action() -> Literal["play", "download", "debug"] | None: +def select_action(*, offer_pin: bool = False) -> Literal["play", "download", "download_pin", "debug"] | None: + """offer_pin adds "download and keep using this source" — only meaningful when later episodes remain.""" _ac = _M["action"] _ac_opts = _ac["options"] - _options: list[tuple[str, str]] = [ - ("play", _ac_opts["play"]), - ("download", _ac_opts["download"]), - ("debug", _ac_opts["debug"]), - ] + keys = ["play", "download", "download_pin", "debug"] if offer_pin else ["play", "download", "debug"] return _run_keyed_picker( # type: ignore[return-value] - _options, + [(k, _ac_opts[k]) for k in keys], prompt=_ac["message"], instruction=_ac["instruction"], - fallback_invalid=_ac["fallback_invalid"], + fallback_invalid=_ac["fallback_invalid"].format(n=len(keys)), + ) + + +def select_pin_fallback_action() -> Literal["keep", "repin"] | None: + _pf = _M["pin_fallback"] + _opts = _pf["options"] + return _run_keyed_picker( # type: ignore[return-value] + [("keep", _opts["keep"]), ("repin", _opts["repin"])], + prompt=_pf["message"], + instruction=_pf["instruction"], + fallback_invalid=_pf["fallback_invalid"], ) diff --git a/tests/flow/test_handlers.py b/tests/flow/test_handlers.py index d7ee305..f11b232 100644 --- a/tests/flow/test_handlers.py +++ b/tests/flow/test_handlers.py @@ -23,6 +23,7 @@ _sorted_by_date_desc, handle_run_action, ) +from alt_ani_cli.flow.pin import player_fingerprint from alt_ani_cli.flow.state import BACK, FlowState, Screen, _BackSentinel from alt_ani_cli.models import EmbedURL, PlayerSource, RelatedSeries, SeriesMetadata from alt_ani_cli.player.runner import PlayResult @@ -1959,3 +1960,383 @@ def test_player_pick_offers_same_player_again(self): assert HANDLERS[Screen.PLAYER_PICK](state) is Screen.RESOLVE_STREAM assert mock_pick.call_args.kwargs["failed"] == set() assert state.chosen_player is _PLAYER + + +_PIN_A1 = PlayerEntry( + online_id="a1", player="CDA", lang_audio="jp", lang_subs="pl", subs_author="Mioro", source="https://www.miorosubs.com/?ep=4" +) +_PIN_A2 = PlayerEntry( + online_id="a2", player="CDA", lang_audio="jp", lang_subs="pl", subs_author="Mioro", source="http://miorosubs.com/?ep=5" +) +_PIN_A3 = PlayerEntry( + online_id="a3", player="CDA", lang_audio="jp", lang_subs="pl", subs_author="Mioro", source="https://miorosubs.com/x" +) +_OTHER = PlayerEntry(online_id="o1", player="Sibnet", lang_audio="jp", lang_subs="pl", source="https://anisubs.pl/") +_OTHER_EN = PlayerEntry(online_id="o2", player="CDA", lang_audio="jp", lang_subs="en") +_PIN_FP = player_fingerprint(_PIN_A1) + + +def _pin_batch_state(**overrides) -> FlowState: + defaults = dict( + targets=[_EP4, _EP5, _EP6], + ep_idx=1, + players=[_PIN_A1, _OTHER], + chosen_player=_PIN_A1, + episode_action="download", + pinned_player=_PIN_FP, + ) + defaults.update(overrides) + return _download_state(**defaults) + + +def _pin_dispatch(players, *, state=None, confirm=True, **arg_overrides): + """Run EPISODE_DISPATCH with parse/sort mocked; sort_players returns `players` as the final order.""" + if state is None: + state = _pin_batch_state(args=_make_args(**arg_overrides)) + resp = MagicMock() + resp.text = "" + state.client.get.return_value = resp + with ( + patch("alt_ani_cli.shinden.episode.parse_players", return_value=list(players)), + patch("alt_ani_cli.shinden.episode.sort_players", return_value=list(players)) as mock_sort, + patch("alt_ani_cli.shinden.api.resolve_embed") as mock_embed, + patch("alt_ani_cli.diagnostics.player_selected") as mock_diag, + patch("alt_ani_cli.ui.menus.confirm", return_value=confirm), + patch("alt_ani_cli.ui.progress.info") as mock_info, + patch("alt_ani_cli.ui.progress.warn"), + ): + result = HANDLERS[Screen.EPISODE_DISPATCH](state) + return state, result, {"sort": mock_sort, "embed": mock_embed, "diag": mock_diag, "info": mock_info} + + +@pytest.mark.unit +class TestPinFirstSelection: + def test_download_and_pin_sets_pin_and_downloads_current_episode(self): + state = _pin_batch_state(ep_idx=0, episode_action=None, pinned_player=None) + with patch("alt_ani_cli.ui.menus.select_action", return_value="download_pin") as mock_action: + assert HANDLERS[Screen.ACTION_PICK](state) is Screen.RUN_ACTION + assert mock_action.call_args.kwargs == {"offer_pin": True} + assert state.episode_action == "download" + assert state.pinned_player == _PIN_FP + + fake = _FakeDownloads() + result, _, record = _run_download(state, fake) + assert fake.events == [("downloaded", 4.0)] + record.assert_called_once_with(_SERIES_REF, 4.0) + assert (result, state.ep_idx) == (Screen.EPISODE_DISPATCH, 1) + assert state.pinned_player == _PIN_FP + + @pytest.mark.parametrize(("targets", "ep_idx"), [([_EP4], 0), ([_EP4, _EP5], 1)]) + def test_option_hidden_for_last_or_only_target(self, targets, ep_idx): + state = _pin_batch_state(targets=targets, ep_idx=ep_idx, episode_action=None, pinned_player=None) + with patch("alt_ani_cli.ui.menus.select_action", return_value="download") as mock_action: + HANDLERS[Screen.ACTION_PICK](state) + assert mock_action.call_args.kwargs == {"offer_pin": False} + assert state.pinned_player is None + + def test_plain_download_does_not_pin(self): + state = _pin_batch_state(ep_idx=0, episode_action=None, pinned_player=None) + with patch("alt_ani_cli.ui.menus.select_action", return_value="download"): + HANDLERS[Screen.ACTION_PICK](state) + assert state.pinned_player is None + + def test_pinning_does_not_touch_overwrite_policy(self): + state = _pin_batch_state(ep_idx=0, episode_action=None, pinned_player=None) + with patch("alt_ani_cli.ui.menus.select_action", return_value="download_pin"): + HANDLERS[Screen.ACTION_PICK](state) + assert state.overwrite_existing_batch is False + + +@pytest.mark.unit +class TestPinAutoMatch: + def test_same_fingerprint_new_online_id_skips_player_pick(self): + state, result, mocks = _pin_dispatch([_OTHER, _PIN_A2]) + assert result is Screen.RESOLVE_STREAM + assert state.chosen_player is _PIN_A2 + assert state.player_picked_manually is False + mocks["diag"].assert_called_once_with(_PIN_A2.online_id, _PIN_A2.player, None) + assert state.pinned_player == _PIN_FP + + def test_matching_issues_no_extra_requests(self): + state, _, mocks = _pin_dispatch([_OTHER, _PIN_A2]) + mocks["embed"].assert_not_called() + state.client.get.assert_called_once_with(_EP5.url) + assert state.player_sources == {} + + def test_cached_download_action_keeps_download_sort(self): + _, _, mocks = _pin_dispatch([_OTHER, _PIN_A2]) + assert mocks["sort"].call_args.kwargs == {"download": True} + + def test_first_of_several_identical_fingerprints_in_sorted_order(self): + state, _, _ = _pin_dispatch([_OTHER, _PIN_A3, _PIN_A2]) + assert state.chosen_player is _PIN_A3 + + def test_match_runs_on_filtered_list(self): + state, result, _ = _pin_dispatch([_OTHER_EN, _OTHER, _PIN_A2], subs="pl") + assert result is Screen.RESOLVE_STREAM + assert state.players == [_OTHER, _PIN_A2] + assert state.chosen_player is _PIN_A2 + + def test_pinned_candidate_filtered_out_shows_normal_picker(self): + state, result, _ = _pin_dispatch([_OTHER, _PIN_A2, _OTHER_EN], player_name="CDA", subs="en") + assert result is Screen.PLAYER_PICK + assert state.players == [_OTHER_EN] + assert state.chosen_player is None + assert state.pinned_player == _PIN_FP + + def test_pinned_candidate_filtered_out_of_several_shows_picker(self): + other_sibnet = PlayerEntry(online_id="o3", player="Sibnet", lang_audio="jp", lang_subs="en") + state, result, _ = _pin_dispatch([_OTHER, _PIN_A2, other_sibnet], player_name="Sibnet") + assert result is Screen.PLAYER_PICK + assert state.players == [_OTHER, other_sibnet] + assert state.chosen_player is None + assert state.pinned_player == _PIN_FP + + def test_filter_fallback_full_list_can_match_pin(self): + state, result, _ = _pin_dispatch([_OTHER, _PIN_A2], lang="xx", allow_fallback=True) + assert result is Screen.RESOLVE_STREAM + assert state.chosen_player is _PIN_A2 + + def test_no_match_shows_picker_and_keeps_pin(self): + state, result, mocks = _pin_dispatch([_OTHER, _OTHER_EN]) + assert result is Screen.PLAYER_PICK + assert state.chosen_player is None + assert state.pinned_player == _PIN_FP + mocks["info"].assert_called_with(CONTENT["progress"]["pin_no_match"].format(number=5.0)) + + def test_without_pin_dispatch_is_unchanged(self): + state, result, mocks = _pin_dispatch([_OTHER, _PIN_A2], state=_pin_batch_state(pinned_player=None)) + assert result is Screen.PLAYER_PICK + mocks["diag"].assert_not_called() + no_match = CONTENT["progress"]["pin_no_match"].format(number=5.0) + assert all(c.args[0] != no_match for c in mocks["info"].call_args_list) + + def test_select_nth_takes_precedence_over_pin(self): + state, result, mocks = _pin_dispatch([_OTHER, _PIN_A2], select_nth=1) + assert result is Screen.RESOLVE_STREAM + assert state.chosen_player is _OTHER + assert state.pinned_player == _PIN_FP + mocks["diag"].assert_called_once_with(_OTHER.online_id, _OTHER.player, None) + + def test_pin_without_match_and_single_player_shows_picker(self): + state, result, mocks = _pin_dispatch([_OTHER]) + assert result is Screen.PLAYER_PICK + assert state.chosen_player is None + assert state.pinned_player == _PIN_FP + mocks["diag"].assert_not_called() + + def test_pin_with_match_and_single_player_auto_picks_match(self): + state, result, _ = _pin_dispatch([_PIN_A2]) + assert result is Screen.RESOLVE_STREAM + assert state.chosen_player is _PIN_A2 + + def test_without_pin_single_player_is_still_auto_selected(self): + state, result, mocks = _pin_dispatch([_OTHER], state=_pin_batch_state(pinned_player=None)) + assert result is Screen.RESOLVE_STREAM + assert state.chosen_player is _OTHER + mocks["diag"].assert_called_once_with(_OTHER.online_id, _OTHER.player, None) + + def test_select_nth_still_applies_when_pin_has_no_match(self): + state, result, _ = _pin_dispatch([_OTHER, _OTHER_EN], select_nth=1) + assert result is Screen.RESOLVE_STREAM + assert state.chosen_player is _OTHER + + +@pytest.mark.unit +class TestPinnedPlayerFailure: + def test_resolve_failure_returns_to_player_pick_and_keeps_pin(self): + state, _, _ = _pin_dispatch([_OTHER, _PIN_A2]) + with patch("alt_ani_cli.cli._resolve_with_fallback", return_value=(None, None)): + assert HANDLERS[Screen.RESOLVE_STREAM](state) is Screen.PLAYER_PICK + assert state.failed_ids == {_PIN_A2.online_id} + assert state.ep_idx == 1 + assert state.pinned_player == _PIN_FP + + with ( + patch("alt_ani_cli.ui.menus.select_player_once", return_value=("pick", _OTHER)) as mock_pick, + patch("alt_ani_cli.ui.progress.warn"), + ): + assert HANDLERS[Screen.PLAYER_PICK](state) is Screen.RESOLVE_STREAM + assert mock_pick.call_args.kwargs["failed"] == {_PIN_A2.online_id} + assert state.chosen_player is _OTHER + + def test_download_failed_returns_to_player_pick_and_keeps_pin(self): + state = _pin_batch_state(players=[_OTHER, _PIN_A2], chosen_player=_PIN_A2) + result, _, record = _run_download(state, _FakeDownloads(fail_urls={_STREAM.url})) + assert result is Screen.PLAYER_PICK + assert state.failed_ids == {_PIN_A2.online_id} + assert state.ep_idx == 1 + assert state.pinned_player == _PIN_FP + record.assert_not_called() + + def test_download_target_error_keeps_pin_and_player(self): + state = _pin_batch_state(players=[_OTHER, _PIN_A2], chosen_player=_PIN_A2) + with ( + patch("alt_ani_cli.download.run", side_effect=DownloadTargetError("locked")), + patch("alt_ani_cli.history.record_download") as mock_record, + patch("alt_ani_cli.ui.progress.error"), + ): + assert handle_run_action(state) is Screen.PLAYER_PICK + assert state.failed_ids == set() + assert state.ep_idx == 1 + assert state.pinned_player == _PIN_FP + mock_record.assert_not_called() + + +def _manual_fallback(picked, *, state=None, pin_choice=None, qualities=None): + """PLAYER_PICK (manual pick) → RESOLVE_STREAM → ACTION_PICK with download cached and pin active.""" + if state is None: + state = _pin_batch_state(players=[_PIN_A2, _OTHER, _PIN_A3], chosen_player=None) + stream = Stream(url=_STREAM.url, ext="m3u8", qualities=qualities or {}) + with ( + patch("alt_ani_cli.ui.menus.select_player_once", return_value=("pick", picked)), + patch("alt_ani_cli.cli._resolve_with_fallback", return_value=(stream, _EMBED)), + patch("alt_ani_cli.ui.menus.select_pin_fallback_action", return_value=pin_choice) as mock_pin, + patch("alt_ani_cli.ui.menus.select_action") as mock_action, + patch("alt_ani_cli.ui.progress.warn"), + ): + assert HANDLERS[Screen.PLAYER_PICK](state) is Screen.RESOLVE_STREAM + state.quality = "best" + assert HANDLERS[Screen.RESOLVE_STREAM](state) is Screen.ACTION_PICK + result = HANDLERS[Screen.ACTION_PICK](state) + mock_action.assert_not_called() + return state, result, mock_pin + + +@pytest.mark.unit +class TestPinManualFallback: + def test_keep_downloads_without_changing_pin(self): + state, result, mock_pin = _manual_fallback(_OTHER, pin_choice="keep") + mock_pin.assert_called_once() + assert result is Screen.RUN_ACTION + assert state.pinned_player == _PIN_FP + assert state.episode_action == "download" + + def test_repin_replaces_pin_with_current_player(self): + state, result, _ = _manual_fallback(_OTHER, pin_choice="repin") + assert result is Screen.RUN_ACTION + assert state.pinned_player == player_fingerprint(_OTHER) + + def test_same_fingerprint_manual_pick_does_not_ask(self): + state, result, mock_pin = _manual_fallback(_PIN_A3) + mock_pin.assert_not_called() + assert result is Screen.RUN_ACTION + assert state.pinned_player == _PIN_FP + + def test_last_target_does_not_ask(self): + state = _pin_batch_state(ep_idx=2, players=[_PIN_A2, _OTHER], chosen_player=None) + state, result, mock_pin = _manual_fallback(_OTHER, state=state) + mock_pin.assert_not_called() + assert result is Screen.RUN_ACTION + assert state.pinned_player == _PIN_FP + + def test_esc_returns_to_player_pick_keeping_pin_and_action(self): + state, result, _ = _manual_fallback(_OTHER, pin_choice=None) + assert result is Screen.PLAYER_PICK + assert state.pinned_player == _PIN_FP + assert state.episode_action == "download" + + def test_esc_with_qualities_returns_to_quality_pick(self): + _, result, _ = _manual_fallback(_OTHER, pin_choice=None, qualities={"720p": "u"}) + assert result is Screen.QUALITY_PICK + + def test_auto_picked_player_with_other_fingerprint_does_not_ask(self): + state, _, _ = _pin_dispatch([_OTHER, _OTHER_EN], select_nth=1) + state.stream = _STREAM + with patch("alt_ani_cli.ui.menus.select_pin_fallback_action") as mock_pin: + assert HANDLERS[Screen.ACTION_PICK](state) is Screen.RUN_ACTION + mock_pin.assert_not_called() + + def test_manual_flag_resets_on_next_episode(self): + state, _, _ = _manual_fallback(_OTHER, pin_choice="keep") + assert state.player_picked_manually is True + state.ep_idx = 2 + _pin_dispatch([_OTHER, _PIN_A2], state=state) + assert state.player_picked_manually is False + + +@pytest.mark.unit +class TestPinReset: + def test_new_episode_batch_clears_pin(self): + state = _make_state(ref=_SERIES_REF, episodes=[_EP4, _EP5], pinned_player=_PIN_FP) + with patch("alt_ani_cli.ui.menus.select_episodes", return_value=[_EP5]): + HANDLERS[Screen.EPISODES_PICK](state) + assert state.pinned_player is None + + def test_cli_episode_range_batch_clears_pin(self): + state = _make_state(ref=_SERIES_REF, episodes=[_EP4, _EP5], pinned_player=_PIN_FP, args=_make_args(episode="4-5")) + HANDLERS[Screen.EPISODES_PICK](state) + assert state.pinned_player is None + + def test_cancel_batch_clears_pin(self): + state = _pin_batch_state(chosen_player=_PIN_A2) + result, _, _ = _run_download(state, _FakeDownloads(existing={5.0}), "cancel") + assert result is Screen.EPISODES_PICK + assert state.pinned_player is None + + def test_esc_from_player_pick_clears_pin(self): + state = _pin_batch_state() + with patch("alt_ani_cli.ui.menus.select_player_once", return_value=("back", None)): + assert HANDLERS[Screen.PLAYER_PICK](state) is Screen.EPISODES_PICK + assert state.pinned_player is None + + def test_new_series_clears_pin(self): + state = _make_state(ref=_SERIES_REF, pinned_player=_PIN_FP) + with patch("alt_ani_cli.shinden.series.list_episodes", return_value=(_SERIES_REF, [_EP1])): + HANDLERS[Screen.FETCH_EPISODES](state) + assert state.pinned_player is None + + @pytest.mark.parametrize("answer", [False, None]) + def test_filter_mismatch_back_to_episode_pick_clears_pin(self, answer): + state = _pin_batch_state(args=_make_args(lang="xx")) + state, result, _ = _pin_dispatch([_OTHER, _PIN_A2], state=state, confirm=answer) + assert result is Screen.EPISODES_PICK + assert state.pinned_player is None + + def test_quality_pick_keeps_pin(self): + state = _pin_batch_state(stream=Stream(url=_STREAM.url, ext="m3u8", qualities={"720p": "u"})) + with patch("alt_ani_cli.ui.menus.select_quality", return_value="720p"): + HANDLERS[Screen.QUALITY_PICK](state) + assert state.pinned_player == _PIN_FP + + def test_next_episode_in_batch_keeps_pin(self): + state = _pin_batch_state(chosen_player=_PIN_A2) + result, _, _ = _run_download(state, _FakeDownloads()) + assert (result, state.ep_idx) == (Screen.EPISODE_DISPATCH, 2) + assert state.pinned_player == _PIN_FP + + +@pytest.mark.unit +class TestPinIndependentOfOverwritePolicy: + def test_overwrite_remaining_does_not_pin(self): + state = _pin_batch_state(pinned_player=None, chosen_player=_PIN_A2) + _run_download(state, _FakeDownloads(existing={5.0}), "overwrite_remaining") + assert state.overwrite_existing_batch is True + assert state.pinned_player is None + + def test_policy_and_pin_both_survive_pinned_download_failure(self): + state = _pin_batch_state(players=[_OTHER, _PIN_A2], chosen_player=_PIN_A2, overwrite_existing_batch=True) + _run_download(state, _FakeDownloads(existing={5.0}, fail_urls={_STREAM.url})) + assert state.overwrite_existing_batch is True + assert state.pinned_player == _PIN_FP + + def test_repin_keeps_overwrite_policy(self): + state = _pin_batch_state(players=[_PIN_A2, _OTHER], chosen_player=None, overwrite_existing_batch=True) + state, _, _ = _manual_fallback(_OTHER, state=state, pin_choice="repin") + assert state.overwrite_existing_batch is True + + +@pytest.mark.unit +class TestPinNotPersisted: + def test_history_file_has_no_pin_data(self): + from alt_ani_cli import history + + state = _pin_batch_state(chosen_player=_PIN_A2) + with ( + patch("alt_ani_cli.download.run", side_effect=_FakeDownloads()), + patch("alt_ani_cli.ui.progress.info"), + ): + handle_run_action(state) + text = history.HISTORY_FILE.read_text(encoding="utf-8") + assert "miorosubs" not in text + assert "pinned" not in text.casefold() diff --git a/tests/flow/test_pin.py b/tests/flow/test_pin.py new file mode 100644 index 0000000..0d916a1 --- /dev/null +++ b/tests/flow/test_pin.py @@ -0,0 +1,95 @@ +"""Tests for the download source pin fingerprint (flow/pin.py).""" + +import dataclasses + +import pytest + +from alt_ani_cli.flow.pin import PlayerFingerprint, find_pinned, player_fingerprint, source_identity +from alt_ani_cli.models import PlayerEntry + +_BASE = PlayerEntry( + online_id="111", + player="CDA", + lang_audio="jp", + lang_subs="pl", + max_res="1080p", + date_added="2024-01-01", + subs_author="Mioro-Subs", + source="https://www.miorosubs.com/?ep=4", +) + + +@pytest.mark.unit +class TestPlayerFingerprint: + def test_ignores_online_id_and_date_added(self): + other = dataclasses.replace(_BASE, online_id="222", date_added="2024-02-02") + assert player_fingerprint(other) == player_fingerprint(_BASE) + + @pytest.mark.parametrize( + ("field", "value"), + [ + ("player", "Sibnet"), + ("lang_audio", "pl"), + ("lang_subs", "en"), + ("max_res", "720p"), + ("subs_author", "Other-Subs"), + ], + ) + def test_identity_field_change_gives_different_fingerprint(self, field, value): + other = dataclasses.replace(_BASE, **{field: value}) + assert player_fingerprint(other) != player_fingerprint(_BASE) + + def test_different_source_hosts_give_different_fingerprint(self): + other = dataclasses.replace(_BASE, source="https://anisubs.pl/?ep=4") + assert player_fingerprint(other) != player_fingerprint(_BASE) + + def test_source_url_episode_query_does_not_split_identity(self): + other = dataclasses.replace(_BASE, online_id="222", source="http://miorosubs.com/?ep=5") + assert player_fingerprint(other) == player_fingerprint(_BASE) + + def test_text_fields_normalize_whitespace_and_case(self): + other = dataclasses.replace(_BASE, player=" cda ", subs_author="mioro-subs", max_res="1080P") + assert player_fingerprint(other) == player_fingerprint(_BASE) + + def test_none_and_empty_optional_fields_are_equal(self): + a = PlayerEntry(online_id="1", player="CDA", lang_audio="jp", lang_subs="") + b = PlayerEntry(online_id="2", player="CDA", lang_audio="jp", lang_subs="", max_res="", subs_author=" ", source="") + assert player_fingerprint(a) == player_fingerprint(b) + + def test_is_hashable_value(self): + fp = player_fingerprint(_BASE) + assert isinstance(fp, PlayerFingerprint) + assert {fp} == {player_fingerprint(_BASE)} + + +@pytest.mark.unit +class TestSourceIdentity: + def test_url_variants_share_host_identity(self): + assert source_identity("https://www.miorosubs.com/?ep=4") == "miorosubs.com" + assert source_identity("http://miorosubs.com/?ep=5") == "miorosubs.com" + + def test_hostname_is_casefolded_without_www(self): + assert source_identity("https://www.MioroSubs.com/?episode=4#top") == "miorosubs.com" + + def test_non_url_text_collapses_whitespace_and_case(self): + assert source_identity(" Grupa Mioro\tSubs ") == "grupa mioro subs" + + def test_none_and_blank_are_empty(self): + assert source_identity(None) == "" + assert source_identity(" ") == "" + + def test_malformed_url_falls_back_to_text(self): + assert source_identity("http://[broken") == "http://[broken" + + +@pytest.mark.unit +class TestFindPinned: + def test_returns_first_match_in_given_order(self): + a = dataclasses.replace(_BASE, online_id="a") + b = dataclasses.replace(_BASE, online_id="b") + other = dataclasses.replace(_BASE, online_id="c", player="Sibnet") + assert find_pinned([other, b, a], player_fingerprint(_BASE)) is b + + def test_no_match_returns_none(self): + other = dataclasses.replace(_BASE, player="Sibnet") + assert find_pinned([other], player_fingerprint(_BASE)) is None diff --git a/tests/ui/test_menus.py b/tests/ui/test_menus.py index 08f729c..9128d60 100644 --- a/tests/ui/test_menus.py +++ b/tests/ui/test_menus.py @@ -19,6 +19,7 @@ select_action, select_episodes, select_existing_download_action, + select_pin_fallback_action, select_player_once, select_quality, select_series_from_download_history, @@ -154,6 +155,42 @@ def test_empty_enter_returns_none(self, monkeypatch): with patch("builtins.input", return_value=""): assert select_action() is None + def test_pin_option_hidden_by_default(self, monkeypatch): + monkeypatch.setattr("alt_ani_cli.ui.menus._USE_INQUIRER", False) + printed: list[str] = [] + with ( + patch("builtins.input", return_value="1"), + patch("builtins.print", side_effect=lambda *a, **k: printed.append(" ".join(str(x) for x in a))), + ): + select_action() + assert not any(_AC_OPTS["download_pin"] in line for line in printed) + + @pytest.mark.parametrize(("answer", "expected"), [("1", "play"), ("2", "download"), ("3", "download_pin"), ("4", "debug")]) + def test_offer_pin_adds_option_after_download(self, monkeypatch, answer, expected): + monkeypatch.setattr("alt_ani_cli.ui.menus._USE_INQUIRER", False) + 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))), + ): + assert select_action(offer_pin=True) == expected + assert printed[2] == f" 3. {_AC_OPTS['download_pin']}" + + +@pytest.mark.unit +class TestSelectPinFallbackAction: + @pytest.mark.parametrize(("answer", "expected"), [("1", "keep"), ("2", "repin"), ("", None)]) + def test_fallback_maps_options(self, monkeypatch, answer, expected): + monkeypatch.setattr("alt_ani_cli.ui.menus._USE_INQUIRER", False) + 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))), + ): + assert select_pin_fallback_action() == expected + opts = CONTENT["menu"]["pin_fallback"]["options"] + assert printed[:2] == [f" 1. {opts['keep']}", f" 2. {opts['repin']}"] + @pytest.mark.unit class TestSelectEpisodes: @@ -515,6 +552,7 @@ def test_empty_enter_returns_none(self, monkeypatch): assert confirm("Kontynuować?") is None +_AC_OPTS = CONTENT["menu"]["action"]["options"] _EF = CONTENT["menu"]["existing_file"] _EXISTING = r"C:\dl\Show - ep4.mp4" From e339c3b2e5bef7cf84d94adef8fd5c4cb8436c42 Mon Sep 17 00:00:00 2001 From: Aleksander <71835339+Cybernetic-Ransomware@users.noreply.github.com> Date: Mon, 5 Oct 2026 03:08:53 +0200 Subject: [PATCH 11/11] test(extract): add fixture-based regression tests for custom extractors (#27) --- tests/extract/conftest.py | 54 +++++++++++++++++++ tests/extract/test_dood.py | 23 ++++++++ tests/extract/test_fixture_files.py | 34 ++++++++++++ tests/extract/test_flyf.py | 23 ++++++++ tests/extract/test_jwplayer.py | 21 ++++++++ tests/extract/test_lycoris.py | 26 +++++++++ tests/extract/test_mp4upload.py | 17 ++++++ tests/extract/test_playmate.py | 23 ++++++++ tests/extract/test_streamtape.py | 18 +++++++ tests/extract/test_uqload.py | 19 +++++++ tests/extract/test_vidara.py | 21 ++++++++ tests/fixtures/extract/dood_embed.html | 23 ++++++++ tests/fixtures/extract/dood_pass_md5.txt | 1 + tests/fixtures/extract/flyf_assign.json | 4 ++ .../extract/jwplayer_morencius_packed.html | 17 ++++++ tests/fixtures/extract/lycoris_embed.json | 11 ++++ tests/fixtures/extract/mp4upload_embed.html | 25 +++++++++ tests/fixtures/extract/playmate_stream.json | 3 ++ tests/fixtures/extract/streamtape_embed.html | 18 +++++++ tests/fixtures/extract/uqload_dl_packed.html | 16 ++++++ tests/fixtures/extract/vidara_stream.json | 7 +++ 21 files changed, 404 insertions(+) create mode 100644 tests/extract/conftest.py create mode 100644 tests/extract/test_fixture_files.py create mode 100644 tests/fixtures/extract/dood_embed.html create mode 100644 tests/fixtures/extract/dood_pass_md5.txt create mode 100644 tests/fixtures/extract/flyf_assign.json create mode 100644 tests/fixtures/extract/jwplayer_morencius_packed.html create mode 100644 tests/fixtures/extract/lycoris_embed.json create mode 100644 tests/fixtures/extract/mp4upload_embed.html create mode 100644 tests/fixtures/extract/playmate_stream.json create mode 100644 tests/fixtures/extract/streamtape_embed.html create mode 100644 tests/fixtures/extract/uqload_dl_packed.html create mode 100644 tests/fixtures/extract/vidara_stream.json diff --git a/tests/extract/conftest.py b/tests/extract/conftest.py new file mode 100644 index 0000000..eb670a7 --- /dev/null +++ b/tests/extract/conftest.py @@ -0,0 +1,54 @@ +import json +import socket +from pathlib import Path +from unittest.mock import MagicMock + +import pytest +from curl_cffi.curl import Curl + +FIXTURES_DIR = Path(__file__).parent.parent / "fixtures" / "extract" + + +@pytest.fixture(autouse=True) +def _no_outbound_network(monkeypatch): + """Fail fast if a mis-targeted Session patch would let a test reach libcurl or a raw socket.""" + + def _blocked(*args, **kwargs): + raise AssertionError("outbound network attempted in tests/extract") + + monkeypatch.setattr(Curl, "perform", _blocked) + monkeypatch.setattr(socket.socket, "connect", _blocked) + + +@pytest.fixture +def load_fixture(): + def _load(name: str): + text = (FIXTURES_DIR / name).read_text(encoding="utf-8") + return json.loads(text) if name.endswith(".json") else text + + return _load + + +def _fake_response(body): + resp = MagicMock(status_code=200, **{"raise_for_status.return_value": None}) + if isinstance(body, str): + resp.text = body + else: + resp.json.return_value = body + return resp + + +@pytest.fixture +def fake_session(monkeypatch): + """Serve bodies in call order: ``str`` as ``.text``, anything else as ``.json()``.""" + + def _install(module, *, get=(), post=()): + session = MagicMock() + session.get.side_effect = [_fake_response(b) for b in get] + session.post.side_effect = [_fake_response(b) for b in post] + session.__enter__.return_value = session + session.__exit__.return_value = False + monkeypatch.setattr(module.cffi_requests, "Session", MagicMock(return_value=session)) + return session + + return _install diff --git a/tests/extract/test_dood.py b/tests/extract/test_dood.py index 93e660b..5c3c998 100644 --- a/tests/extract/test_dood.py +++ b/tests/extract/test_dood.py @@ -5,6 +5,7 @@ import pytest +from alt_ani_cli.extract import dood from alt_ani_cli.extract.dood import resolve _EMBED = "https://dood.la/e/xyz" @@ -97,3 +98,25 @@ def test_bad_base_url_raises_before_http(self): resolve("not-a-url", _REFERER) session.get.assert_not_called() assert exc_info.value.category == "parser_drift" + + +@pytest.mark.unit +class TestResolveDoodFixture: + _EMBED = "https://dood.example/e/testfilecode" + + def test_embed_and_pass_md5_fixtures_resolve_to_tokenized_url(self, fake_session, load_fixture): + session = fake_session(dood, get=[load_fixture("dood_embed.html"), load_fixture("dood_pass_md5.txt")]) + with patch("alt_ani_cli.extract.dood.time.time", return_value=1700000000.0): + stream = resolve(self._EMBED, _REFERER) + + assert stream.url == "https://cdn.example/stream/testfilecode~?token=testtokenAbc123&expiry=1700000000000" + assert stream.ext == "mp4" + assert stream.headers["Referer"] == "https://dood.example/" + assert "User-Agent" in stream.headers + + embed_call, pass_call = session.get.call_args_list + assert embed_call.args == (self._EMBED,) + assert embed_call.kwargs["headers"]["Referer"] == _REFERER + assert pass_call.args == ("https://dood.example/pass_md5/42-1700000000-testhash/testfilecode",) + assert pass_call.kwargs["headers"]["Referer"] == self._EMBED + assert pass_call.kwargs["headers"]["X-Requested-With"] == "XMLHttpRequest" diff --git a/tests/extract/test_fixture_files.py b/tests/extract/test_fixture_files.py new file mode 100644 index 0000000..b7307e0 --- /dev/null +++ b/tests/extract/test_fixture_files.py @@ -0,0 +1,34 @@ +"""Invariants for the host response fixtures in tests/fixtures/extract/.""" + +import json +import re +from pathlib import Path + +import pytest + +FIXTURES_DIR = Path(__file__).parent.parent / "fixtures" / "extract" + +# Scheme-ful URLs (JSON-escaped slashes included) and protocol-relative string literals. +_URL_HOST_RE = re.compile(r"https?:\\?/\\?/([^/\\\"'\s:?]+)|[\"']//([^/\"'\s:?]+)") + + +def _fixture_files(): + return sorted(p for p in FIXTURES_DIR.iterdir() if p.is_file()) + + +@pytest.mark.unit +class TestExtractFixtureFiles: + def test_directory_exists_and_is_not_empty(self): + assert FIXTURES_DIR.is_dir() + assert _fixture_files() + + @pytest.mark.parametrize("path", _fixture_files(), ids=lambda p: p.name) + def test_fixture_is_utf8_and_json_fixtures_parse(self, path): + text = path.read_bytes().decode("utf-8") + if path.suffix == ".json": + json.loads(text) + + @pytest.mark.parametrize("path", _fixture_files(), ids=lambda p: p.name) + def test_fixture_urls_point_only_at_reserved_example_hosts(self, path): + hosts = {a or b for a, b in _URL_HOST_RE.findall(path.read_text(encoding="utf-8"))} + assert all(h.endswith(".example") for h in hosts), hosts diff --git a/tests/extract/test_flyf.py b/tests/extract/test_flyf.py index 1cabb1a..f4dbb95 100644 --- a/tests/extract/test_flyf.py +++ b/tests/extract/test_flyf.py @@ -5,6 +5,7 @@ import pytest +from alt_ani_cli.extract import flyf from alt_ani_cli.extract.flyf import resolve _EMBED = "https://flyf.lat/embed/Gdbv8BEkZhOFZ2O" @@ -83,3 +84,25 @@ def test_bad_embed_url_raises_before_http(self): resolve("not-a-url", _REFERER) session.get.assert_not_called() assert exc_info.value.category == "parser_drift" + + +@pytest.mark.unit +class TestResolveFlyfFixture: + _EMBED = "https://flyf.example/embed/testfilecode" + + def test_assign_fixture_resolves_to_raw_file(self, fake_session, load_fixture): + session = fake_session(flyf, get=[load_fixture("flyf_assign.json")]) + stream = resolve(self._EMBED, _REFERER) + + assert stream.url == "https://cdn.example/raw/testtoken0000" + assert stream.ext == "mp4" + assert stream.headers["Referer"] == self._EMBED + assert "User-Agent" in stream.headers + + call = session.get.call_args + assert call.args == ("https://api.flyfile.app/api/streaming/assign/testfilecode",) + headers = call.kwargs["headers"] + assert headers["Referer"] == self._EMBED + assert headers["X-FlyFile-View"] == "embed" + assert headers["X-Embed-Referrer"] == self._EMBED + assert headers["X-Adblock-Detected"] == "0" diff --git a/tests/extract/test_jwplayer.py b/tests/extract/test_jwplayer.py index 34de9bd..af50a67 100644 --- a/tests/extract/test_jwplayer.py +++ b/tests/extract/test_jwplayer.py @@ -5,6 +5,7 @@ import pytest +from alt_ani_cli.extract import jwplayer from alt_ani_cli.extract.jwplayer import _best_hls_url, _decode_base_n, _normalize_stream_url, resolve, unpack_packer _SIMPLE_PACKED = ( @@ -171,3 +172,23 @@ def test_no_video_url_raises_value_error(self): with pytest.raises(ValueError, match="jwplayer") as exc_info: resolve(_EMBED, _REFERER) assert exc_info.value.category == "parser_drift" + + +@pytest.mark.unit +class TestResolveJwplayerFixture: + def test_morencius_packed_page_resolves_hls4_relative_path(self, fake_session, load_fixture): + html = load_fixture("jwplayer_morencius_packed.html") + # Words live only in the packer key table, so the stream URL can only come out of the real unpacker. + assert '"hls4"' not in html and "master.m3u8" not in html + session = fake_session(jwplayer, get=[html]) + + stream = resolve(_EMBED, _REFERER) + + assert stream.url == "https://morencius.com/stream/testfilecode/testtoken/1700000000/master.m3u8" + assert stream.ext == "m3u8" + assert stream.headers["Referer"] == _EMBED + assert "User-Agent" in stream.headers + + call = session.get.call_args + assert call.args == (_EMBED,) + assert call.kwargs["headers"]["Referer"] == _REFERER diff --git a/tests/extract/test_lycoris.py b/tests/extract/test_lycoris.py index 7868db8..9161b63 100644 --- a/tests/extract/test_lycoris.py +++ b/tests/extract/test_lycoris.py @@ -5,6 +5,7 @@ import pytest +from alt_ani_cli.extract import lycoris from alt_ani_cli.extract.lycoris import resolve _EMBED = "https://www.lycoris.cafe/embed?id=180136&episode=1" @@ -98,3 +99,28 @@ def test_bad_embed_url_raises_before_http(self): resolve("not-a-url", _REFERER) session.get.assert_not_called() assert exc_info.value.category == "parser_drift" + + +@pytest.mark.unit +class TestResolveLycorisFixture: + _EMBED = "https://lycoris.example/embed?id=4242&episode=1" + + def test_embed_api_fixture_resolves_best_url_and_qualities(self, fake_session, load_fixture): + session = fake_session(lycoris, get=[load_fixture("lycoris_embed.json")]) + stream = resolve(self._EMBED, _REFERER) + + base = "https://cdn.example/d/testfilecode" + assert stream.url == f"{base}/episode-1-1080p.mp4" + assert stream.ext == "mp4" + assert stream.qualities == { + "1080p": f"{base}/episode-1-1080p.mp4", + "720p": f"{base}/episode-1-720p.mp4", + "480p": f"{base}/episode-1-480p.mp4", + "source-mkv": f"{base}/episode-1-1080p.mkv", + } + assert stream.headers["Referer"] == self._EMBED + + call = session.get.call_args + assert call.args == ("https://lycoris.example/api/embed",) + assert call.kwargs["params"] == {"id": "4242", "episode": "1"} + assert call.kwargs["headers"]["Referer"] == self._EMBED diff --git a/tests/extract/test_mp4upload.py b/tests/extract/test_mp4upload.py index e8ec4a7..45552f1 100644 --- a/tests/extract/test_mp4upload.py +++ b/tests/extract/test_mp4upload.py @@ -6,6 +6,7 @@ import pytest +from alt_ani_cli.extract import mp4upload from alt_ani_cli.extract.mp4upload import resolve _EMBED = "https://www.mp4upload.com/embed-abc123.html" @@ -67,3 +68,19 @@ def test_referer_in_stream_headers(self): with _make_session_patch(""): stream = resolve(_EMBED, _REFERER) assert stream.headers.get("Referer") == _EMBED + + +@pytest.mark.unit +class TestResolveMp4uploadFixture: + def test_videojs_object_src_fixture_resolves(self, fake_session, load_fixture): + session = fake_session(mp4upload, get=[load_fixture("mp4upload_embed.html")]) + stream = resolve(_EMBED, _REFERER) + + assert stream.url == "https://cdn.example:183/d/testfilecodetesttoken/video.mp4" + assert stream.ext == "mp4" + assert stream.headers["Referer"] == _EMBED + assert "User-Agent" in stream.headers + + call = session.get.call_args + assert call.args == (_EMBED,) + assert call.kwargs["headers"]["Referer"] == _REFERER diff --git a/tests/extract/test_playmate.py b/tests/extract/test_playmate.py index 6b1b9ed..d75bf3b 100644 --- a/tests/extract/test_playmate.py +++ b/tests/extract/test_playmate.py @@ -5,6 +5,7 @@ import pytest +from alt_ani_cli.extract import playmate from alt_ani_cli.extract.playmate import resolve _EMBED = "https://playmate.to/embed/MFyuwmxvBGiUE" @@ -76,3 +77,25 @@ def test_mp4_sx_gets_mp4_ext(self): with _make_session_patch({"sx": "https://cdn.example.com/video.mp4"}): stream = resolve(_EMBED, _REFERER) assert stream.ext == "mp4" + + +@pytest.mark.unit +class TestResolvePlaymateFixture: + _EMBED = "https://playmate.example/embed/testfilecode" + + def test_api_fixture_master_txt_is_recognised_as_hls(self, fake_session, load_fixture): + session = fake_session(playmate, post=[load_fixture("playmate_stream.json")]) + stream = resolve(self._EMBED, _REFERER) + + assert stream.url == "https://cdn.example/hls/testfilecode/master.txt?t=testtoken&e=1700000000" + assert stream.ext == "m3u8" + assert stream.headers["Referer"] == self._EMBED + assert stream.headers["Origin"] == "https://playmate.example" + + call = session.post.call_args + assert call.args == ("https://playmate.example/api/s",) + assert call.kwargs["json"] == {"c": "testfilecode", "d": "web"} + headers = call.kwargs["headers"] + assert headers["Origin"] == "https://playmate.example" + assert headers["Referer"] == self._EMBED + assert headers["Sec-Fetch-Site"] == "same-origin" diff --git a/tests/extract/test_streamtape.py b/tests/extract/test_streamtape.py index 973724d..b61426e 100644 --- a/tests/extract/test_streamtape.py +++ b/tests/extract/test_streamtape.py @@ -5,6 +5,7 @@ import pytest +from alt_ani_cli.extract import streamtape from alt_ani_cli.extract.streamtape import resolve _EMBED = "https://streamtape.com/e/abc123" @@ -78,3 +79,20 @@ def test_no_match_raises_value_error(self): with pytest.raises(ValueError, match="streamtape") as exc_info: resolve(_EMBED, _REFERER) assert exc_info.value.category == "parser_drift" + + +@pytest.mark.unit +class TestResolveStreamtapeFixture: + def test_robotlink_concatenation_fixture_ignores_decoy_div(self, fake_session, load_fixture): + session = fake_session(streamtape, get=[load_fixture("streamtape_embed.html")]) + stream = resolve(_EMBED, _REFERER) + + assert stream.url == ( + "https://streamtape.example/get_video?id=testfilecode&expires=1700000000&ip=TESTIP0000&token=testtoken0000" + ) + assert stream.ext == "mp4" + assert stream.headers["Referer"] == _EMBED + + call = session.get.call_args + assert call.args == (_EMBED,) + assert call.kwargs["headers"]["Referer"] == _REFERER diff --git a/tests/extract/test_uqload.py b/tests/extract/test_uqload.py index bec13e1..c257914 100644 --- a/tests/extract/test_uqload.py +++ b/tests/extract/test_uqload.py @@ -5,6 +5,7 @@ import pytest +from alt_ani_cli.extract import uqload from alt_ani_cli.extract.uqload import resolve _EMBED = "https://uqload.is/e/mhq7xbccftp5" @@ -88,3 +89,21 @@ def test_m3u8_url_gets_m3u8_ext(self): with _make_session_patch(html): stream = resolve(_EMBED, _REFERER) assert stream.ext == "m3u8" + + +@pytest.mark.unit +class TestResolveUqloadFixture: + def test_packed_dl_fixture_resolves_after_unpacking(self, fake_session, load_fixture): + body = load_fixture("uqload_dl_packed.html") + assert "v.mp4" not in body + session = fake_session(uqload, post=[body]) + stream = resolve(_EMBED, _REFERER) + + assert stream.url == "https://cdn.example/3rfktestfilecode/v.mp4" + assert stream.ext == "mp4" + assert stream.headers["Referer"] == _EMBED + + call = session.post.call_args + assert call.args == ("https://uqload.is/dl",) + assert call.kwargs["data"] == {"op": "embed", "file_code": "mhq7xbccftp5", "auto": "0", "referer": _EMBED} + assert call.kwargs["headers"]["Referer"] == _EMBED diff --git a/tests/extract/test_vidara.py b/tests/extract/test_vidara.py index b862dc0..67c7114 100644 --- a/tests/extract/test_vidara.py +++ b/tests/extract/test_vidara.py @@ -5,6 +5,7 @@ import pytest +from alt_ani_cli.extract import vidara from alt_ani_cli.extract.vidara import resolve _EMBED = "https://vidara.to/e/gGnwW3ekLDWQX" @@ -90,3 +91,23 @@ def test_works_with_alternate_host_viewdara(self): call = session.post.call_args assert call.args == ("https://viewdara.com/api/stream",) assert call.kwargs["headers"]["Origin"] == "https://viewdara.com" + + +@pytest.mark.unit +class TestResolveVidaraFixture: + @pytest.mark.parametrize("host", ["vidara.to", "iosbgaigo.com"]) + def test_stream_api_fixture_is_host_agnostic(self, fake_session, load_fixture, host): + embed = f"https://{host}/e/testfilecode" + session = fake_session(vidara, post=[load_fixture("vidara_stream.json")]) + stream = resolve(embed, _REFERER) + + assert stream.url == "https://cdn.example/hls/testfilecode/master.m3u8?t=testtoken&e=1700000000" + assert stream.ext == "m3u8" + assert stream.headers["Referer"] == embed + assert stream.headers["Origin"] == f"https://{host}" + + call = session.post.call_args + assert call.args == (f"https://{host}/api/stream",) + assert call.kwargs["json"] == {"filecode": "testfilecode", "device": "web"} + assert call.kwargs["headers"]["Origin"] == f"https://{host}" + assert call.kwargs["headers"]["Referer"] == embed diff --git a/tests/fixtures/extract/dood_embed.html b/tests/fixtures/extract/dood_embed.html new file mode 100644 index 0000000..a7570f7 --- /dev/null +++ b/tests/fixtures/extract/dood_embed.html @@ -0,0 +1,23 @@ + + + + + +testfilecode.mp4 - DoodStream + + + + + +
+ +
+ + + diff --git a/tests/fixtures/extract/dood_pass_md5.txt b/tests/fixtures/extract/dood_pass_md5.txt new file mode 100644 index 0000000..b0bc9c8 --- /dev/null +++ b/tests/fixtures/extract/dood_pass_md5.txt @@ -0,0 +1 @@ +https://cdn.example/stream/testfilecode~ diff --git a/tests/fixtures/extract/flyf_assign.json b/tests/fixtures/extract/flyf_assign.json new file mode 100644 index 0000000..5e650e6 --- /dev/null +++ b/tests/fixtures/extract/flyf_assign.json @@ -0,0 +1,4 @@ +{ + "url": "https://cdn.example", + "token": "testtoken0000" +} diff --git a/tests/fixtures/extract/jwplayer_morencius_packed.html b/tests/fixtures/extract/jwplayer_morencius_packed.html new file mode 100644 index 0000000..e6f7f71 --- /dev/null +++ b/tests/fixtures/extract/jwplayer_morencius_packed.html @@ -0,0 +1,17 @@ + + + + + +Watch testfilecode + + + + + +
+ + + diff --git a/tests/fixtures/extract/lycoris_embed.json b/tests/fixtures/extract/lycoris_embed.json new file mode 100644 index 0000000..4b0e900 --- /dev/null +++ b/tests/fixtures/extract/lycoris_embed.json @@ -0,0 +1,11 @@ +{ + "episodeInfo": { + "primarySource": { + "FHD": "https://cdn.example/d/testfilecode/episode-1-1080p.mp4", + "HD": "https://cdn.example/d/testfilecode/episode-1-720p.mp4", + "SD": "https://cdn.example/d/testfilecode/episode-1-480p.mp4", + "preview": "https://cdn.example/d/testfilecode/preview.mp4", + "SourceMKV": "https://cdn.example/d/testfilecode/episode-1-1080p.mkv" + } + } +} diff --git a/tests/fixtures/extract/mp4upload_embed.html b/tests/fixtures/extract/mp4upload_embed.html new file mode 100644 index 0000000..e403dfd --- /dev/null +++ b/tests/fixtures/extract/mp4upload_embed.html @@ -0,0 +1,25 @@ + + + + +testfilecode.mp4 + + + + + + + + diff --git a/tests/fixtures/extract/playmate_stream.json b/tests/fixtures/extract/playmate_stream.json new file mode 100644 index 0000000..f92bcfb --- /dev/null +++ b/tests/fixtures/extract/playmate_stream.json @@ -0,0 +1,3 @@ +{ + "sx": "https://cdn.example/hls/testfilecode/master.txt?t=testtoken&e=1700000000" +} diff --git a/tests/fixtures/extract/streamtape_embed.html b/tests/fixtures/extract/streamtape_embed.html new file mode 100644 index 0000000..816a555 --- /dev/null +++ b/tests/fixtures/extract/streamtape_embed.html @@ -0,0 +1,18 @@ + + + + +testfilecode.mp4 + + + + +
+ +
+ + + + diff --git a/tests/fixtures/extract/uqload_dl_packed.html b/tests/fixtures/extract/uqload_dl_packed.html new file mode 100644 index 0000000..13a335a --- /dev/null +++ b/tests/fixtures/extract/uqload_dl_packed.html @@ -0,0 +1,16 @@ + + + + +testfilecode.mp4 + + + + +
+
+ + + diff --git a/tests/fixtures/extract/vidara_stream.json b/tests/fixtures/extract/vidara_stream.json new file mode 100644 index 0000000..418a643 --- /dev/null +++ b/tests/fixtures/extract/vidara_stream.json @@ -0,0 +1,7 @@ +{ + "default_sub_lang": "Polish", + "filecode": "testfilecode", + "streaming_url": "https://cdn.example/hls/testfilecode/master.m3u8?t=testtoken&e=1700000000", + "subtitles": null, + "title": "" +}