Harden queue identity and Jellyfin move checks
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user