From e48bc219442b523da7fb5378c08f23fe20f6349a Mon Sep 17 00:00:00 2001 From: Dymas Date: Mon, 25 May 2026 10:11:43 +0200 Subject: [PATCH] Track downloaded episodes for auto-download --- CHANGELOG.md | 5 ++ README.md | 4 +- VERSION | 2 +- app.py | 127 +++++++++++++++++++++++++++++++++++++++++++------ app_support.py | 2 +- test_app.py | 49 +++++++++++++++++-- 6 files changed, 167 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b52c2f2..1914d36 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## 0.40.0 - 2026-05-25 + +- Added per-watchlist storage for downloaded sub and dub episode lists so auto-download can compare actual downloaded history instead of only refresh count deltas. +- Changed auto-download to queue any available episodes that are still missing after a later manual or scheduled refresh, including backlog episodes that existed before tracking started. + ## 0.39.1 - 2026-05-25 - Replaced the auto-download name datalist with an explicit dropdown that lists the current title, AniDB alternative titles, and a `Custom` option. diff --git a/README.md b/README.md index 5075f83..d54517e 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Small local web UI for a system-wide `ani-cli` install. -Current version: `0.39.1` +Current version: `0.40.0` ## Project Layout @@ -255,7 +255,7 @@ Refresh behavior: - Duplicate refresh queueing is avoided. - Auto-download does not trigger when a show is first added to the watchlist. - Auto-download only runs for entries in `Watching` after later manual refreshes or scheduled refreshes. -- Auto-download only queues newly found episodes that are not already pending or previously downloaded through the app. +- Auto-download uses stored downloaded-episode history per language, so it can queue any still-missing episodes after a later refresh, including backlog episodes that existed before the show was first tracked. - Scheduled refresh runs log start, stop, success, and failure to stdout. - `Refresh All` works one show at a time with a configurable delay. - When a Discord webhook is configured, new-episode refresh results include one notification card per anime with sub and dub counts and a cached thumbnail attachment when available. diff --git a/VERSION b/VERSION index d2e2400..9b0025a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.39.1 +0.40.0 diff --git a/app.py b/app.py index 69b3a55..68af747 100644 --- a/app.py +++ b/app.py @@ -938,6 +938,61 @@ def episode_specs_for_values(values, available_episodes): return specs +def encode_episode_values(values): + normalized = [] + seen = set() + for value in values or []: + text = str(value or "").strip() + if text and text not in seen: + seen.add(text) + normalized.append(text) + return json.dumps(normalized, ensure_ascii=True) + + +def decode_episode_values(payload): + if not payload: + return [] + try: + raw = json.loads(payload) + except (TypeError, ValueError, json.JSONDecodeError): + return [] + normalized = [] + seen = set() + for value in raw if isinstance(raw, list) else []: + text = str(value or "").strip() + if text and text not in seen: + seen.add(text) + normalized.append(text) + return normalized + + +def expand_episode_spec_against_available(episode_spec, available_episodes): + specs = [part for part in re.split(r"\s+", str(episode_spec or "").strip()) if part] + values = [str(value).strip() for value in available_episodes or [] if str(value).strip()] + if not specs or not values: + return [] + index_map = {value: index for index, value in enumerate(values)} + expanded = [] + seen = set() + for spec in specs: + if "-" in spec: + start, end = [piece.strip() for piece in spec.split("-", 1)] + if start in index_map and end in index_map: + start_index = index_map[start] + end_index = index_map[end] + if start_index > end_index: + start_index, end_index = end_index, start_index + for value in values[start_index : end_index + 1]: + if value not in seen: + seen.add(value) + expanded.append(value) + continue + if spec in index_map and spec not in seen: + seen.add(spec) + expanded.append(spec) + return expanded + + class WatchlistStore: def __init__(self, config_getter=None, start_worker=True): self.lock = threading.RLock() @@ -1139,6 +1194,8 @@ class WatchlistStore: auto_download_quality TEXT, auto_download_name TEXT, auto_download_series TEXT, + downloaded_sub_episodes_json TEXT, + downloaded_dub_episodes_json TEXT, thumbnail_path TEXT, thumbnail_checked_at TEXT ) @@ -1181,6 +1238,10 @@ class WatchlistStore: conn.execute("ALTER TABLE watchlist ADD COLUMN auto_download_name TEXT") if "auto_download_series" not in columns: conn.execute("ALTER TABLE watchlist ADD COLUMN auto_download_series TEXT") + if "downloaded_sub_episodes_json" not in columns: + conn.execute("ALTER TABLE watchlist ADD COLUMN downloaded_sub_episodes_json TEXT") + if "downloaded_dub_episodes_json" not in columns: + conn.execute("ALTER TABLE watchlist ADD COLUMN downloaded_dub_episodes_json TEXT") conn.execute( "UPDATE watchlist SET category = 'watching' WHERE category IS NULL OR TRIM(category) = ''" ) @@ -1218,6 +1279,8 @@ class WatchlistStore: item["auto_download_quality"] = quality if quality in QUALITY_CHOICES else defaults["auto_download_quality"] item["auto_download_name"] = sanitize_path_component(item.get("auto_download_name") or defaults["auto_download_name"], defaults["auto_download_name"]) item["auto_download_series"] = normalize_season(item.get("auto_download_series") or defaults["auto_download_series"]) + item["downloaded_sub_episodes"] = decode_episode_values(item.get("downloaded_sub_episodes_json")) + item["downloaded_dub_episodes"] = decode_episode_values(item.get("downloaded_dub_episodes_json")) if item.get("animeschedule_route"): title = str(item.get("animeschedule_title") or item.get("title") or "").strip() or "unknown title" item["thumbnail_debug"] = f"AnimeSchedule: {item['animeschedule_route']} - {title}" @@ -1241,8 +1304,9 @@ class WatchlistStore: anilist_id, anilist_title, anilist_site_url, alternative_titles_json, anidb_aid, anidb_title, auto_download_mode, auto_download_quality, auto_download_name, auto_download_series, + downloaded_sub_episodes_json, downloaded_dub_episodes_json, thumbnail_path, thumbnail_checked_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(show_id) DO UPDATE SET title = excluded.title, category = excluded.category, @@ -1269,6 +1333,8 @@ class WatchlistStore: auto_download_quality = excluded.auto_download_quality, auto_download_name = excluded.auto_download_name, auto_download_series = excluded.auto_download_series, + downloaded_sub_episodes_json = excluded.downloaded_sub_episodes_json, + downloaded_dub_episodes_json = excluded.downloaded_dub_episodes_json, thumbnail_path = excluded.thumbnail_path, thumbnail_checked_at = excluded.thumbnail_checked_at """, @@ -1300,6 +1366,8 @@ class WatchlistStore: item.get("auto_download_quality"), item.get("auto_download_name"), item.get("auto_download_series"), + item.get("downloaded_sub_episodes_json"), + item.get("downloaded_dub_episodes_json"), item.get("thumbnail_path"), item.get("thumbnail_checked_at"), ), @@ -1353,6 +1421,8 @@ class WatchlistStore: "auto_download_quality": None, "auto_download_name": None, "auto_download_series": None, + "downloaded_sub_episodes_json": None, + "downloaded_dub_episodes_json": None, "thumbnail_path": None, "thumbnail_checked_at": None, }, @@ -1485,6 +1555,8 @@ class WatchlistStore: "anidb_title": None, "thumbnail_path": None, "thumbnail_checked_at": None, + "downloaded_sub_episodes_json": None, + "downloaded_dub_episodes_json": None, } self._upsert_conn(conn, item) @@ -1675,29 +1747,53 @@ class WatchlistStore: ) return self.schedule_refresh(existing["show_id"], source=source) - def mark_downloaded(self, show_id, title=""): + def mark_downloaded(self, show_id, title="", mode="", episodes=""): normalized_show_id = str(show_id or "").strip() if not normalized_show_id: raise ValueError("Missing show_id for watchlist download sync.") normalized_title = str(title or "").strip() + normalized_mode = str(mode or "").strip().lower() + episode_spec = str(episodes or "").strip() now = now_iso() + downloaded_episode_values = [] + if normalized_mode in MODE_CHOICES and episode_spec: + try: + available_episodes = episode_list(normalized_show_id, normalized_mode) + except Exception: + available_episodes = [] + downloaded_episode_values = expand_episode_spec_against_available(episode_spec, available_episodes) with self.lock, self._connect() as conn: row = conn.execute("SELECT * FROM watchlist WHERE show_id = ?", (normalized_show_id,)).fetchone() if row: + existing_item = self._row_to_item(row) + sub_payload = existing_item.get("downloaded_sub_episodes_json") + dub_payload = existing_item.get("downloaded_dub_episodes_json") + if normalized_mode == "sub" and downloaded_episode_values: + sub_payload = encode_episode_values(existing_item.get("downloaded_sub_episodes", []) + downloaded_episode_values) + elif normalized_mode == "dub" and downloaded_episode_values: + dub_payload = encode_episode_values(existing_item.get("downloaded_dub_episodes", []) + downloaded_episode_values) conn.execute( """ UPDATE watchlist - SET category = 'finished', downloaded = 1, status = 'queued', status_message = ?, updated_at = ? + SET category = 'finished', downloaded = 1, status = 'queued', status_message = ?, updated_at = ?, + downloaded_sub_episodes_json = ?, downloaded_dub_episodes_json = ? WHERE show_id = ? """, - ("Queued refresh after successful download.", now, normalized_show_id), + ("Queued refresh after successful download.", now, sub_payload, dub_payload, normalized_show_id), ) else: matched_row = self._find_download_title_match_conn(conn, normalized_title, exclude_show_id=normalized_show_id) if matched_row is not None: + matched_item = self._row_to_item(matched_row) previous_show_id = str(matched_row["show_id"]).strip() resolved_title = normalized_title or str(matched_row["title"] or "").strip() or "Unknown Anime" + sub_payload = matched_item.get("downloaded_sub_episodes_json") + dub_payload = matched_item.get("downloaded_dub_episodes_json") + if normalized_mode == "sub" and downloaded_episode_values: + sub_payload = encode_episode_values(matched_item.get("downloaded_sub_episodes", []) + downloaded_episode_values) + elif normalized_mode == "dub" and downloaded_episode_values: + dub_payload = encode_episode_values(matched_item.get("downloaded_dub_episodes", []) + downloaded_episode_values) conn.execute( """ UPDATE watchlist @@ -1708,7 +1804,9 @@ class WatchlistStore: downloaded = 1, status = 'queued', status_message = ?, - updated_at = ? + updated_at = ?, + downloaded_sub_episodes_json = ?, + downloaded_dub_episodes_json = ? WHERE show_id = ? """, ( @@ -1716,6 +1814,8 @@ class WatchlistStore: resolved_title, "Queued refresh after successful download.", now, + sub_payload, + dub_payload, previous_show_id, ), ) @@ -1751,6 +1851,8 @@ class WatchlistStore: "anidb_aid": None, "anidb_title": None, "auto_download_series": None, + "downloaded_sub_episodes_json": encode_episode_values(downloaded_episode_values) if normalized_mode == "sub" else None, + "downloaded_dub_episodes_json": encode_episode_values(downloaded_episode_values) if normalized_mode == "dub" else None, "thumbnail_path": None, "thumbnail_checked_at": None, } @@ -2029,8 +2131,6 @@ def queue_watchlist_auto_download(item, refresh_source="manual"): return {"queued": False, "reason": "category"} if not item.get("previous_last_checked"): return {"queued": False, "reason": "initial_refresh"} - if not item.get("had_new_episodes"): - return {"queued": False, "reason": "no_new_episodes"} mode = str(item.get("auto_download_mode") or config.get("auto_download_mode") or "dub").strip().lower() quality = str(item.get("auto_download_quality") or config.get("auto_download_quality") or "best").strip().lower() @@ -2039,16 +2139,15 @@ def queue_watchlist_auto_download(item, refresh_source="manual"): if quality not in QUALITY_CHOICES: quality = "best" available_episodes = list(item.get(f"{mode}_episode_values") or []) - previous_count = int(item.get("previous_dub_count") or 0) if mode == "dub" else int(item.get("previous_sub_count") or 0) - new_episodes = available_episodes[previous_count:] if previous_count < len(available_episodes) else [] - if not new_episodes: - return {"queued": False, "reason": "mode_has_no_new_episodes"} + downloaded_episodes = set(item.get("downloaded_dub_episodes", []) if mode == "dub" else item.get("downloaded_sub_episodes", [])) + if not available_episodes: + return {"queued": False, "reason": "mode_has_no_available_episodes"} covered = runtime["download_queue"].covered_episodes(item.get("show_id"), mode, available_episodes) - missing = [episode for episode in new_episodes if episode not in covered] + missing = [episode for episode in available_episodes if episode not in downloaded_episodes and episode not in covered] specs = episode_specs_for_values(missing, available_episodes) if not specs: - return {"queued": False, "reason": "already_covered"} + return {"queued": False, "reason": "nothing_missing"} query = str(item.get("title") or item.get("show_id") or "").strip() if not query: @@ -2139,7 +2238,7 @@ def sync_downloaded_job_to_watchlist(job): if not show_id: debug_log("watchlist.sync.resolve_failed", job=job, reason=reason) raise ValueError(f"Could not resolve a watchlist show_id for the completed download: {reason}.") - return WATCHLIST.mark_downloaded(show_id, title=title) + return WATCHLIST.mark_downloaded(show_id, title=title, mode=job.get("mode"), episodes=job.get("episodes")) def prepare_download_job(job): config = ensure_runtime()["config"] diff --git a/app_support.py b/app_support.py index 4be289b..a70bc80 100644 --- a/app_support.py +++ b/app_support.py @@ -19,7 +19,7 @@ from uuid import uuid4 PROJECT_ROOT = Path(__file__).resolve().parent ANI_CLI = os.environ.get("ANI_CLI_BIN") or shutil.which("ani-cli") or "ani-cli" APP_NAME = "ani-cli-web" -VERSION = "0.39.1" +VERSION = "0.40.0" ALLANIME_BASE = "allanime.day" ALLANIME_API = f"https://api.{ALLANIME_BASE}" ALLANIME_REFERER = "https://allmanga.to" diff --git a/test_app.py b/test_app.py index a24ab5f..8bd95d4 100644 --- a/test_app.py +++ b/test_app.py @@ -989,13 +989,14 @@ class WatchlistCompletionTests(unittest.TestCase): with mock.patch.object(APP.WATCHLIST, "schedule_refresh", side_effect=lambda show_id, source="manual": APP.WATCHLIST.get(show_id)), mock.patch.object( APP.WATCHLIST, "refresh", side_effect=AssertionError("mark_downloaded should not refresh synchronously") - ): - item = APP.WATCHLIST.mark_downloaded("show-9", title="Queue Show") + ), mock.patch.object(APP, "episode_list", return_value=["1", "2", "3"]): + item = APP.WATCHLIST.mark_downloaded("show-9", title="Queue Show", mode="dub", episodes="2-3") self.assertEqual(item["category"], "finished") self.assertTrue(item["downloaded"]) self.assertEqual(item["finished_badge"], "downloaded") self.assertEqual(item["status"], "queued") + self.assertEqual(item["downloaded_dub_episodes"], ["2", "3"]) def test_mark_downloaded_creates_missing_show_in_finished_category(self): with mock.patch.object(APP.WATCHLIST, "schedule_refresh", side_effect=lambda show_id, source="manual": APP.WATCHLIST.get(show_id)), mock.patch.object( @@ -1069,7 +1070,7 @@ class WatchlistCompletionTests(unittest.TestCase): ) as mark_downloaded: APP.sync_downloaded_job_to_watchlist(job) - mark_downloaded.assert_called_once_with("show-42", title="Queue Show") + mark_downloaded.assert_called_once_with("show-42", title="Queue Show", mode="sub", episodes=None) def test_prepare_download_job_persists_resolved_show_id(self): job = {"query": "Queue Show", "mode": "sub", "result_index": 1, "title": "Queue Show", "show_id": ""} @@ -1511,7 +1512,7 @@ class AutoDownloadQueueTests(unittest.TestCase): "title": "Queue Show", "category": "watching", "previous_last_checked": "2026-05-25T10:00:00+00:00", - "had_new_episodes": True, + "had_new_episodes": False, "previous_dub_count": 10, "previous_sub_count": 10, "dub_episode_values": ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12"], @@ -1520,6 +1521,8 @@ class AutoDownloadQueueTests(unittest.TestCase): "auto_download_quality": "best", "auto_download_name": 'Queue Show: Alt/Name', "auto_download_series": "3", + "downloaded_dub_episodes": ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"], + "downloaded_sub_episodes": [], } download_queue = mock.Mock() download_queue.covered_episodes.return_value = {"11"} @@ -1559,6 +1562,44 @@ class AutoDownloadQueueTests(unittest.TestCase): } ) + def test_queue_watchlist_auto_download_can_queue_existing_backlog_after_initial_seed_refresh(self): + item = { + "show_id": "show-99", + "title": "Backlog Show", + "category": "watching", + "previous_last_checked": "2026-05-25T10:00:00+00:00", + "had_new_episodes": False, + "dub_episode_values": ["1", "2", "3"], + "sub_episode_values": [], + "auto_download_mode": "dub", + "auto_download_quality": "best", + "auto_download_name": "Backlog Show", + "auto_download_series": "1", + "downloaded_dub_episodes": [], + "downloaded_sub_episodes": [], + } + download_queue = mock.Mock() + download_queue.covered_episodes.return_value = set() + download_queue.add.return_value = {"id": "job-backlog", "episodes": "1-3"} + runtime = { + "config": { + "auto_download_enabled": True, + "auto_download_mode": "dub", + "auto_download_quality": "best", + "download_dir": "/tmp/downloads", + }, + "download_queue": download_queue, + } + with mock.patch.object(APP, "ensure_runtime", return_value=runtime), mock.patch.object( + APP, + "search_anime", + return_value=[{"id": "show-99", "title": "Backlog Show", "query": "Backlog Show", "index": 1}], + ): + result = APP.queue_watchlist_auto_download(item, refresh_source="manual") + + self.assertTrue(result["queued"]) + self.assertEqual(result["episodes"], ["1-3"]) + class StartupBehaviorTests(unittest.TestCase): def test_main_does_not_eagerly_initialize_runtime(self):