Harden queue identity and Jellyfin move checks

This commit is contained in:
Dymas
2026-05-25 23:51:16 +02:00
parent 8fb32a59ba
commit 1ceb773f99
8 changed files with 124 additions and 17 deletions
+6
View File
@@ -1,5 +1,11 @@
# Changelog # 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 ## 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. - 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.
+4 -1
View File
@@ -2,7 +2,7 @@
Small local web UI for a system-wide `ani-cli` install. Small local web UI for a system-wide `ani-cli` install.
Current version: `0.45.5` Current version: `0.45.6`
## Project Layout ## Project Layout
@@ -131,6 +131,7 @@ Container notes:
- Sync successful downloads back into the watchlist. - Sync successful downloads back into the watchlist.
- Keep queue and watchlist data in SQLite under `.ani-cli-web/`. - Keep queue and watchlist data in SQLite under `.ani-cli-web/`.
- Browse host folders directly from Config page path fields when choosing library locations. - 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 ## Pages
@@ -148,6 +149,7 @@ Use it to:
Highlights: Highlights:
- Search stays focused on finding shows, loading episodes, and queueing new downloads. - 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. - Added-to-queue notices now point to the dedicated Queue page for progress tracking.
- Search request guards still avoid stale responses overwriting newer results. - 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. - 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. - 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. - 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. - 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. - The manual `Run now` action on `/config` scans the whole watchlist and moves anything already eligible.
Discord notification events: Discord notification events:
+1 -1
View File
@@ -1 +1 @@
0.45.5 0.45.6
+21 -1
View File
@@ -1012,6 +1012,25 @@ def _move_tree_contents(source_dir, target_dir):
return moved 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): def move_watchlist_item_to_jellyfin(item, config):
source_dir = watchlist_source_library_dir(item, config) source_dir = watchlist_source_library_dir(item, config)
target_dir = jellyfin_target_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 pass
if not source_dir.exists(): if not source_dir.exists():
if target_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)} 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) target_dir.parent.mkdir(parents=True, exist_ok=True)
if not target_dir.exists(): if not target_dir.exists():
+1 -1
View File
@@ -20,7 +20,7 @@ from uuid import uuid4
PROJECT_ROOT = Path(__file__).resolve().parent PROJECT_ROOT = Path(__file__).resolve().parent
ANI_CLI = os.environ.get("ANI_CLI_BIN") or shutil.which("ani-cli") or "ani-cli" ANI_CLI = os.environ.get("ANI_CLI_BIN") or shutil.which("ani-cli") or "ani-cli"
APP_NAME = "ani-cli-web" APP_NAME = "ani-cli-web"
VERSION = "0.45.5" VERSION = "0.45.6"
ALLANIME_BASE = "allanime.day" ALLANIME_BASE = "allanime.day"
ALLANIME_API = f"https://api.{ALLANIME_BASE}" ALLANIME_API = f"https://api.{ALLANIME_BASE}"
ALLANIME_REFERER = "https://allmanga.to" ALLANIME_REFERER = "https://allmanga.to"
+1 -2
View File
@@ -537,12 +537,11 @@ INDEX_HTML = r"""<!doctype html>
if (!state.selected) return; if (!state.selected) return;
const payload = { const payload = {
show_id: state.selected.id, show_id: state.selected.id,
query: state.selected.query, query: state.selected.title,
title: state.selected.title, title: state.selected.title,
anime_name: $("animeNameInput").value || state.selected.title, anime_name: $("animeNameInput").value || state.selected.title,
media_type: $("trackMediaType").value, media_type: $("trackMediaType").value,
season: $("seasonInput").value || "1", season: $("seasonInput").value || "1",
index: state.selected.index,
mode: state.config.mode, mode: state.config.mode,
quality: $("search-quality").value, quality: $("search-quality").value,
episodes: $("episodesInput").value, episodes: $("episodesInput").value,
+63 -1
View File
@@ -1279,6 +1279,37 @@ class WatchlistCompletionTests(unittest.TestCase):
self.assertEqual(prepared["show_id"], "show-42") self.assertEqual(prepared["show_id"], "show-42")
self.assertEqual(prepared["title"], "Queue Show Season 2") 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): def test_command_for_job_omits_result_switch_when_result_index_missing(self):
job = APP.app_support.build_job( 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") self.assertEqual(send_webhook.call_args.args[2]["reason"], "target_missing")
def test_trigger_jellyfin_sync_for_item_skips_already_moved_notification(self): 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") item = APP.WATCHLIST.get("show-jelly-1")
with tempfile.TemporaryDirectory() as temp_root: with tempfile.TemporaryDirectory() as temp_root:
jellyfin_tv = Path(temp_root) / "jellyfin-tv" / "Jelly Show" jellyfin_tv = Path(temp_root) / "jellyfin-tv" / "Jelly Show"
jellyfin_tv.mkdir(parents=True, exist_ok=True) 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: with mock.patch.object(APP, "send_discord_webhook_event") as send_webhook:
result = APP.trigger_jellyfin_sync_for_item( result = APP.trigger_jellyfin_sync_for_item(
item, item,
@@ -1471,6 +1504,31 @@ class JellyfinSyncTests(unittest.TestCase):
self.assertEqual(result["reason"], "already_moved") self.assertEqual(result["reason"], "already_moved")
send_webhook.assert_not_called() 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): def test_trigger_jellyfin_sync_for_item_skips_incomplete_series(self):
self.seed_watchlist_item( self.seed_watchlist_item(
expected_count=12, expected_count=12,
@@ -2568,6 +2626,10 @@ class TemplateHelperTests(unittest.TestCase):
def test_search_page_sends_watchlist_auto_download_series(self): def test_search_page_sends_watchlist_auto_download_series(self):
self.assertIn('auto_download_series: $("seasonInput").value || "1"', APP.INDEX_HTML) 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): def test_pages_share_serial_polling_helper(self):
self.assertIn("function startSerialPoll(callback, intervalMs)", APP.QUEUE_HTML) self.assertIn("function startSerialPoll(callback, intervalMs)", APP.QUEUE_HTML)
self.assertIn("function startSerialPoll(callback, intervalMs)", APP.WATCHLIST_HTML) self.assertIn("function startSerialPoll(callback, intervalMs)", APP.WATCHLIST_HTML)
+22 -5
View File
@@ -45,8 +45,12 @@ def resolve_job_watchlist_identity(
title = str(job.get("title") or job.get("anime_name") or "").strip() title = str(job.get("title") or job.get("anime_name") or "").strip()
if show_id: if show_id:
return _resolution_result(show_id, title, "", return_reason) return _resolution_result(show_id, title, "", return_reason)
raw_index = job.get("result_index")
if raw_index in (None, ""):
index = None
else:
try: try:
index = max(1, int(job.get("result_index") or 1)) index = max(1, int(raw_index))
except (TypeError, ValueError): except (TypeError, ValueError):
return _resolution_result("", title, "invalid queued result index", return_reason) return _resolution_result("", title, "invalid queued result index", return_reason)
query = str(job.get("query") or title).strip() query = str(job.get("query") or title).strip()
@@ -56,13 +60,26 @@ def resolve_job_watchlist_identity(
results = search_fn(query, job.get("mode") or default_mode) results = search_fn(query, job.get("mode") or default_mode)
except Exception as exc: except Exception as exc:
return _resolution_result("", title, f"search fallback failed: {exc}", return_reason) return _resolution_result("", title, f"search fallback failed: {exc}", return_reason)
if index > len(results): matches = []
return _resolution_result("", title, "queued result index no longer exists in search results", return_reason) for position, result in enumerate(results, start=1):
result = results[index - 1]
candidate_title = str(result.get("title") or "").strip() candidate_title = str(result.get("title") or "").strip()
if not titles_confidently_match(title, candidate_title, normalize_title_key_fn, base_title_variants_fn): if not titles_confidently_match(title, candidate_title, normalize_title_key_fn, base_title_variants_fn):
return _resolution_result("", title, f"title mismatch with fallback search result: {candidate_title or 'unknown'}", return_reason) continue
resolved_show_id = str(result.get("id") or "").strip() 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)
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: if not resolved_show_id:
return _resolution_result("", title, "fallback search result did not include a show_id", return_reason) 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) return _resolution_result(resolved_show_id, candidate_title or title, "", return_reason)