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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down Expand Up @@ -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

Expand Down
17 changes: 16 additions & 1 deletion src/alt_ani_cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
13 changes: 10 additions & 3 deletions src/alt_ani_cli/content/ui_content_pl-pl.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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).'
Expand Down Expand Up @@ -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}"
Expand All @@ -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"
Expand Down Expand Up @@ -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."
Expand Down
61 changes: 52 additions & 9 deletions src/alt_ani_cli/flow/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"]
Expand All @@ -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":
Expand All @@ -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
Expand All @@ -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


Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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),
Expand All @@ -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:
Expand All @@ -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))
Expand Down Expand Up @@ -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:
Expand All @@ -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,
Expand Down
5 changes: 4 additions & 1 deletion src/alt_ani_cli/flow/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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()
Expand Down Expand Up @@ -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)
Expand Down
58 changes: 40 additions & 18 deletions src/alt_ani_cli/history.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading
Loading