From 1ceb773f99e92b85cf1c038cff862537e3fc7045 Mon Sep 17 00:00:00 2001 From: Dymas Date: Mon, 25 May 2026 23:51:16 +0200 Subject: [PATCH] Harden queue identity and Jellyfin move checks --- CHANGELOG.md | 6 ++++ README.md | 5 +++- VERSION | 2 +- app.py | 22 ++++++++++++++- app_support.py | 2 +- search_page.py | 3 +- test_app.py | 64 ++++++++++++++++++++++++++++++++++++++++++- watchlist_identity.py | 37 ++++++++++++++++++------- 8 files changed, 124 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 35efbd5..7b63087 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 0.45.6 - 2026-05-25 + +- Hardened Search page queueing so explicit downloads now submit the selected anime title directly instead of replaying a fragile web-result ordinal as `ani-cli -S`, which avoids cross-search mismatches between the API and `ani-cli`. +- Tightened watchlist download identity fallback so jobs without a stored result index now sync back only when the fallback search produces one unique confident title match, skipping ambiguous matches instead of silently picking result `1`. +- Tightened Jellyfin handoff recovery so a missing source folder with an existing target now counts as `already moved` only when the target library actually looks complete; incomplete targets are surfaced as failures for retry and notification handling. + ## 0.45.5 - 2026-05-25 - Reworked the watchlist card action layout into two rows so `Refresh` and `Remove` sit on the first line, with `Download all` and `Auto-download` underneath for a less cluttered button group. diff --git a/README.md b/README.md index 9cbcce9..4bb6c39 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.45.5` +Current version: `0.45.6` ## Project Layout @@ -131,6 +131,7 @@ Container notes: - Sync successful downloads back into the watchlist. - Keep queue and watchlist data in SQLite under `.ani-cli-web/`. - Browse host folders directly from Config page path fields when choosing library locations. +- Prefer title-based queueing and unique-match fallback identity checks so search and watchlist downloads do not depend on fragile cross-tool result ordering. ## Pages @@ -148,6 +149,7 @@ Use it to: Highlights: - Search stays focused on finding shows, loading episodes, and queueing new downloads. +- Search-page queueing now sends the selected title directly, which avoids relying on `ani-cli` search result numbers matching the web API ordering. - Added-to-queue notices now point to the dedicated Queue page for progress tracking. - Search request guards still avoid stale responses overwriting newer results. - The shared sidebar menu now shows one navigation entry per line for cleaner balance across all pages. @@ -205,6 +207,7 @@ Jellyfin handoff: - Automatic Jellyfin moves run after a watchlist refresh updates an entry and the show now looks complete. - TV entries only move after the anime is marked `Finished` and at least one downloaded language covers the expected episode total. - Movie entries move after they are downloaded and the local movie library folder exists. +- Existing Jellyfin targets are only treated as already moved when the destination library still contains the expected media files. - The manual `Run now` action on `/config` scans the whole watchlist and moves anything already eligible. Discord notification events: diff --git a/VERSION b/VERSION index d5a5182..830a435 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.45.5 +0.45.6 diff --git a/app.py b/app.py index ae957c1..89c8f47 100644 --- a/app.py +++ b/app.py @@ -1012,6 +1012,25 @@ def _move_tree_contents(source_dir, target_dir): return moved +def jellyfin_media_files(target_dir): + video_suffixes = {".mp4", ".mkv", ".avi", ".mov", ".webm", ".m4v", ".ts"} + return [ + path + for path in target_dir.rglob("*") + if path.is_file() and path.suffix.lower() in video_suffixes + ] + + +def jellyfin_target_looks_complete(item, target_dir): + files = jellyfin_media_files(target_dir) + if normalize_media_type(item.get("media_type")) == "movie": + return bool(files) + expected_count = int(item.get("expected_count") or 0) + if expected_count < 1: + return False + return len(files) >= expected_count + + def move_watchlist_item_to_jellyfin(item, config): source_dir = watchlist_source_library_dir(item, config) target_dir = jellyfin_target_library_dir(item, config) @@ -1024,7 +1043,8 @@ def move_watchlist_item_to_jellyfin(item, config): pass if not source_dir.exists(): if target_dir.exists(): - return {"moved": False, "reason": "already_moved", "item": item, "source": str(source_dir), "target": str(target_dir)} + reason = "already_moved" if jellyfin_target_looks_complete(item, target_dir) else "target_incomplete" + return {"moved": False, "reason": reason, "item": item, "source": str(source_dir), "target": str(target_dir)} return {"moved": False, "reason": "source_missing", "item": item, "source": str(source_dir), "target": str(target_dir)} target_dir.parent.mkdir(parents=True, exist_ok=True) if not target_dir.exists(): diff --git a/app_support.py b/app_support.py index 15d9b70..24cd983 100644 --- a/app_support.py +++ b/app_support.py @@ -20,7 +20,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.45.5" +VERSION = "0.45.6" ALLANIME_BASE = "allanime.day" ALLANIME_API = f"https://api.{ALLANIME_BASE}" ALLANIME_REFERER = "https://allmanga.to" diff --git a/search_page.py b/search_page.py index adc907b..504737a 100644 --- a/search_page.py +++ b/search_page.py @@ -537,12 +537,11 @@ INDEX_HTML = r""" if (!state.selected) return; const payload = { show_id: state.selected.id, - query: state.selected.query, + query: state.selected.title, title: state.selected.title, anime_name: $("animeNameInput").value || state.selected.title, media_type: $("trackMediaType").value, season: $("seasonInput").value || "1", - index: state.selected.index, mode: state.config.mode, quality: $("search-quality").value, episodes: $("episodesInput").value, diff --git a/test_app.py b/test_app.py index a819ee8..6ccb67e 100644 --- a/test_app.py +++ b/test_app.py @@ -1279,6 +1279,37 @@ class WatchlistCompletionTests(unittest.TestCase): self.assertEqual(prepared["show_id"], "show-42") self.assertEqual(prepared["title"], "Queue Show Season 2") + def test_prepare_download_job_resolves_unique_match_without_result_index(self): + job = {"query": "Queue Show", "mode": "sub", "title": "Queue Show", "show_id": ""} + + with mock.patch.object( + APP, + "search_anime", + return_value=[ + {"id": "show-42", "title": "Queue Show"}, + {"id": "show-99", "title": "Different Show"}, + ], + ): + prepared = APP.prepare_download_job(dict(job)) + + self.assertEqual(prepared["show_id"], "show-42") + self.assertEqual(prepared["title"], "Queue Show") + + def test_prepare_download_job_skips_ambiguous_match_without_result_index(self): + job = {"query": "Queue Show", "mode": "sub", "title": "Queue Show", "show_id": ""} + + with mock.patch.object( + APP, + "search_anime", + return_value=[ + {"id": "show-42", "title": "Queue Show"}, + {"id": "show-99", "title": "Queue Show (2024)"}, + ], + ): + prepared = APP.prepare_download_job(dict(job)) + + self.assertEqual(prepared["show_id"], "") + def test_command_for_job_omits_result_switch_when_result_index_missing(self): job = APP.app_support.build_job( { @@ -1448,11 +1479,13 @@ class JellyfinSyncTests(unittest.TestCase): self.assertEqual(send_webhook.call_args.args[2]["reason"], "target_missing") def test_trigger_jellyfin_sync_for_item_skips_already_moved_notification(self): - self.seed_watchlist_item() + self.seed_watchlist_item(expected_count=1, dub_count=1, downloaded_dub_episodes_json=APP.encode_episode_values(["1"])) item = APP.WATCHLIST.get("show-jelly-1") with tempfile.TemporaryDirectory() as temp_root: jellyfin_tv = Path(temp_root) / "jellyfin-tv" / "Jelly Show" jellyfin_tv.mkdir(parents=True, exist_ok=True) + (jellyfin_tv / "Season 01").mkdir(parents=True, exist_ok=True) + (jellyfin_tv / "Season 01" / "Jelly Show - S01E01.mp4").write_bytes(b"episode-data") with mock.patch.object(APP, "send_discord_webhook_event") as send_webhook: result = APP.trigger_jellyfin_sync_for_item( item, @@ -1471,6 +1504,31 @@ class JellyfinSyncTests(unittest.TestCase): self.assertEqual(result["reason"], "already_moved") send_webhook.assert_not_called() + def test_trigger_jellyfin_sync_for_item_flags_incomplete_target_when_source_missing(self): + self.seed_watchlist_item(expected_count=12, dub_count=12) + item = APP.WATCHLIST.get("show-jelly-1") + with tempfile.TemporaryDirectory() as temp_root: + jellyfin_tv = Path(temp_root) / "jellyfin-tv" / "Jelly Show" + jellyfin_tv.mkdir(parents=True, exist_ok=True) + with mock.patch.object(APP, "send_discord_webhook_event") as send_webhook: + result = APP.trigger_jellyfin_sync_for_item( + item, + automatic=False, + config={ + "download_dir": str(Path(temp_root) / "downloads"), + "jellyfin_sync_enabled": True, + "jellyfin_tv_dir": str(Path(temp_root) / "jellyfin-tv"), + "jellyfin_movie_dir": "", + "discord_webhook_url": "https://discord.example/webhook", + "discord_webhook_events": ["jellyfin_sync_failed"], + }, + ) + + self.assertFalse(result["moved"]) + self.assertEqual(result["reason"], "target_incomplete") + send_webhook.assert_called_once() + self.assertEqual(send_webhook.call_args.args[1], "jellyfin_sync_failed") + def test_trigger_jellyfin_sync_for_item_skips_incomplete_series(self): self.seed_watchlist_item( expected_count=12, @@ -2568,6 +2626,10 @@ class TemplateHelperTests(unittest.TestCase): def test_search_page_sends_watchlist_auto_download_series(self): self.assertIn('auto_download_series: $("seasonInput").value || "1"', APP.INDEX_HTML) + def test_search_page_queues_selected_title_without_result_index(self): + self.assertIn('query: state.selected.title,', APP.INDEX_HTML) + self.assertNotIn('index: state.selected.index,', APP.INDEX_HTML) + def test_pages_share_serial_polling_helper(self): self.assertIn("function startSerialPoll(callback, intervalMs)", APP.QUEUE_HTML) self.assertIn("function startSerialPoll(callback, intervalMs)", APP.WATCHLIST_HTML) diff --git a/watchlist_identity.py b/watchlist_identity.py index 1342fef..dc346d3 100644 --- a/watchlist_identity.py +++ b/watchlist_identity.py @@ -45,10 +45,14 @@ def resolve_job_watchlist_identity( title = str(job.get("title") or job.get("anime_name") or "").strip() if show_id: return _resolution_result(show_id, title, "", return_reason) - try: - index = max(1, int(job.get("result_index") or 1)) - except (TypeError, ValueError): - return _resolution_result("", title, "invalid queued result index", return_reason) + raw_index = job.get("result_index") + if raw_index in (None, ""): + index = None + else: + try: + index = max(1, int(raw_index)) + except (TypeError, ValueError): + return _resolution_result("", title, "invalid queued result index", return_reason) query = str(job.get("query") or title).strip() if not query or not title: return _resolution_result("", title, "missing queued search query or title", return_reason) @@ -56,13 +60,26 @@ def resolve_job_watchlist_identity( results = search_fn(query, job.get("mode") or default_mode) except Exception as exc: return _resolution_result("", title, f"search fallback failed: {exc}", return_reason) - if index > len(results): - return _resolution_result("", title, "queued result index no longer exists in search results", return_reason) - result = results[index - 1] - candidate_title = str(result.get("title") or "").strip() - if not titles_confidently_match(title, candidate_title, normalize_title_key_fn, base_title_variants_fn): + matches = [] + for position, result in enumerate(results, start=1): + candidate_title = str(result.get("title") or "").strip() + if not titles_confidently_match(title, candidate_title, normalize_title_key_fn, base_title_variants_fn): + continue + resolved_show_id = str(result.get("id") or "").strip() + if not resolved_show_id: + continue + matches.append((position, result, candidate_title, resolved_show_id)) + if not matches: + candidate_title = str((results[0].get("title") if results else "") or "").strip() return _resolution_result("", title, f"title mismatch with fallback search result: {candidate_title or 'unknown'}", return_reason) - resolved_show_id = str(result.get("id") or "").strip() + if index is not None: + indexed = next((match for match in matches if match[0] == index), None) + if indexed is not None: + _position, result, candidate_title, resolved_show_id = indexed + return _resolution_result(resolved_show_id, candidate_title or title, "", return_reason) + if len(matches) != 1: + return _resolution_result("", title, "ambiguous fallback search results", return_reason) + _position, result, candidate_title, resolved_show_id = matches[0] if not resolved_show_id: return _resolution_result("", title, "fallback search result did not include a show_id", return_reason) return _resolution_result(resolved_show_id, candidate_title or title, "", return_reason)