From 94ded2725e80ae815da07697a9f56f98cc1c760b Mon Sep 17 00:00:00 2001 From: Dymas Date: Mon, 25 May 2026 23:09:14 +0200 Subject: [PATCH] Fix partial series completion for watchlist and Jellyfin --- CHANGELOG.md | 5 +++ README.md | 3 +- VERSION | 2 +- app.py | 49 ++++++++++++++++++++++++++---- app_support.py | 2 +- test_app.py | 82 +++++++++++++++++++++++++++++++++++++++++++------- 6 files changed, 123 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 014461b..d0d1bda 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## 0.45.2 - 2026-05-25 + +- Fixed watchlist completion so a successful partial dub or sub download no longer moves a TV series to `Finished`; the category now changes only when the downloaded mode has every expected episode both available and downloaded. +- Fixed Jellyfin TV handoff to use that same mode-aware completion rule, preventing partial dub libraries such as `8/12` from being moved early. + ## 0.45.1 - 2026-05-25 - Expanded the `download_completed` Discord webhook so it includes the full saved file list instead of only the first few paths, splitting the list across extra embeds when needed. diff --git a/README.md b/README.md index f9627a4..65cb389 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.1` +Current version: `0.45.2` ## Project Layout @@ -221,6 +221,7 @@ Webhook details: - Download-completed notifications include the full saved file list, split across extra Discord embeds when needed. - Jellyfin handoff notifications fire when a finished library is moved successfully and also on actionable move failures such as missing targets or move errors. +- Watchlist entries only move to `Finished` automatically when the downloaded mode itself is complete, and Jellyfin handoff for TV entries follows that same mode-aware completion rule. ### Watchlist diff --git a/VERSION b/VERSION index ff12df2..9f3a7d7 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.45.1 +0.45.2 diff --git a/app.py b/app.py index 5f0900b..a053d5f 100644 --- a/app.py +++ b/app.py @@ -950,14 +950,40 @@ def watchlist_item_ready_for_jellyfin(item): return False, "category" if normalize_media_type(item.get("media_type")) == "movie": return True, "ready" + preferred_mode = watchlist_preferred_completion_mode(item) + return watchlist_item_download_complete(item, mode=preferred_mode) + + +def watchlist_preferred_completion_mode(item, fallback_mode=""): + fallback = str(fallback_mode or "").strip().lower() + if fallback in MODE_CHOICES: + return fallback + configured = str((item or {}).get("auto_download_mode") or "").strip().lower() + if configured in MODE_CHOICES: + return configured + downloaded_dub = len((item or {}).get("downloaded_dub_episodes") or []) + downloaded_sub = len((item or {}).get("downloaded_sub_episodes") or []) + if downloaded_dub and not downloaded_sub: + return "dub" + if downloaded_sub and not downloaded_dub: + return "sub" + return "dub" + + +def watchlist_item_download_complete(item, mode=""): + if not isinstance(item, dict): + return False, "invalid_item" + normalized_mode = watchlist_preferred_completion_mode(item, fallback_mode=mode) expected_count = int(item.get("expected_count") or 0) if expected_count < 1: return False, "expected_count" if normalize_animeschedule_status(item.get("airing_status")) != "Finished": return False, "airing_status" - downloaded_sub = len(item.get("downloaded_sub_episodes") or []) - downloaded_dub = len(item.get("downloaded_dub_episodes") or []) - if max(downloaded_sub, downloaded_dub) < expected_count: + available_count = int(item.get(f"{normalized_mode}_count") or 0) + if available_count < expected_count: + return False, "episodes_unavailable" + downloaded_count = len(item.get(f"downloaded_{normalized_mode}_episodes") or []) + if downloaded_count < expected_count: return False, "episodes_missing" return True, "ready" @@ -1949,14 +1975,19 @@ class WatchlistStore: 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) + completed_item = dict(existing_item) + completed_item["downloaded_sub_episodes"] = decode_episode_values(sub_payload) + completed_item["downloaded_dub_episodes"] = decode_episode_values(dub_payload) + is_complete, _reason = watchlist_item_download_complete(completed_item, mode=normalized_mode) + target_category = "finished" if is_complete else existing_item["category"] conn.execute( """ UPDATE watchlist - SET category = 'finished', downloaded = 1, status = 'queued', status_message = ?, updated_at = ?, + SET category = ?, 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, sub_payload, dub_payload, normalized_show_id), + (target_category, "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) @@ -1970,13 +2001,18 @@ class WatchlistStore: 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) + completed_item = dict(matched_item) + completed_item["downloaded_sub_episodes"] = decode_episode_values(sub_payload) + completed_item["downloaded_dub_episodes"] = decode_episode_values(dub_payload) + is_complete, _reason = watchlist_item_download_complete(completed_item, mode=normalized_mode) + target_category = "finished" if is_complete else matched_item["category"] conn.execute( """ UPDATE watchlist SET show_id = ?, title = ?, - category = 'finished', + category = ?, downloaded = 1, status = 'queued', status_message = ?, @@ -1988,6 +2024,7 @@ class WatchlistStore: ( normalized_show_id, resolved_title, + target_category, "Queued refresh after successful download.", now, sub_payload, diff --git a/app_support.py b/app_support.py index 9cf052f..67b1c1e 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.1" +VERSION = "0.45.2" 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 1ca808f..beab215 100644 --- a/test_app.py +++ b/test_app.py @@ -1090,19 +1090,48 @@ class WatchlistCompletionTests(unittest.TestCase): self.assertEqual(item["status"], "queued") self.assertEqual(item["status_message"], "Queued from test.") - def test_mark_downloaded_moves_existing_category_to_finished_and_sets_download_flag(self): - self.seed_watchlist_item(show_id="show-9", title="Queue Show", category="planned", downloaded=False) + def test_mark_downloaded_keeps_existing_category_when_selected_mode_is_incomplete(self): + self.seed_watchlist_item( + show_id="show-9", + title="Queue Show", + category="planned", + downloaded=False, + expected_count=12, + airing_status="Finished", + dub_count=8, + ) + + 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") + ), mock.patch.object(APP, "episode_list", return_value=["1", "2", "3", "4", "5", "6", "7", "8"]): + item = APP.WATCHLIST.mark_downloaded("show-9", title="Queue Show", mode="dub", episodes="2-3") + + self.assertEqual(item["category"], "planned") + self.assertTrue(item["downloaded"]) + self.assertEqual(item["finished_badge"], "") + self.assertEqual(item["status"], "queued") + self.assertEqual(item["downloaded_dub_episodes"], ["2", "3"]) + + def test_mark_downloaded_moves_existing_category_to_finished_when_selected_mode_is_complete(self): + self.seed_watchlist_item( + show_id="show-9c", + title="Queue Show Complete", + category="watching", + downloaded=False, + expected_count=3, + airing_status="Finished", + dub_count=3, + ) 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") ), 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") + item = APP.WATCHLIST.mark_downloaded("show-9c", title="Queue Show Complete", mode="dub", episodes="1-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"]) + self.assertEqual(item["downloaded_dub_episodes"], ["1", "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( @@ -1116,12 +1145,20 @@ class WatchlistCompletionTests(unittest.TestCase): self.assertEqual(item["status"], "queued") def test_mark_downloaded_reconciles_unique_title_match_to_new_show_id(self): - self.seed_watchlist_item(show_id="legacy-show-9", title="Queue Show", category="watching", downloaded=False) + self.seed_watchlist_item( + show_id="legacy-show-9", + title="Queue Show", + category="watching", + downloaded=False, + expected_count=3, + airing_status="Finished", + sub_count=3, + ) 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("resolved-show-9", title="Queue Show") + ), mock.patch.object(APP, "episode_list", return_value=["1", "2", "3"]): + item = APP.WATCHLIST.mark_downloaded("resolved-show-9", title="Queue Show", mode="sub", episodes="1-3") self.assertEqual(item["show_id"], "resolved-show-9") self.assertEqual(item["category"], "finished") @@ -1287,10 +1324,11 @@ class JellyfinSyncTests(unittest.TestCase): "anidb_aid": None, "anidb_title": None, "media_type": "tv", + "auto_download_mode": "dub", "auto_download_name": "Jelly Show", "auto_download_series": "1", - "downloaded_sub_episodes_json": APP.encode_episode_values([str(index) for index in range(1, 13)]), - "downloaded_dub_episodes_json": None, + "downloaded_sub_episodes_json": None, + "downloaded_dub_episodes_json": APP.encode_episode_values([str(index) for index in range(1, 13)]), "thumbnail_path": None, "thumbnail_checked_at": None, } @@ -1398,7 +1436,29 @@ class JellyfinSyncTests(unittest.TestCase): send_webhook.assert_not_called() def test_trigger_jellyfin_sync_for_item_skips_incomplete_series(self): - self.seed_watchlist_item(expected_count=24, downloaded_sub_episodes_json=APP.encode_episode_values([str(index) for index in range(1, 13)])) + self.seed_watchlist_item( + expected_count=12, + dub_count=8, + dub_latest_episode="8", + downloaded_dub_episodes_json=APP.encode_episode_values([str(index) for index in range(1, 9)]), + ) + item = APP.WATCHLIST.get("show-jelly-1") + + result = APP.trigger_jellyfin_sync_for_item( + item, + automatic=False, + config={"download_dir": "/tmp/example", "jellyfin_tv_dir": "/tmp/jellyfin-tv", "jellyfin_movie_dir": ""}, + ) + + self.assertFalse(result["moved"]) + self.assertEqual(result["reason"], "episodes_unavailable") + + def test_trigger_jellyfin_sync_for_item_skips_when_dub_not_fully_downloaded(self): + self.seed_watchlist_item( + expected_count=12, + dub_count=12, + downloaded_dub_episodes_json=APP.encode_episode_values([str(index) for index in range(1, 9)]), + ) item = APP.WATCHLIST.get("show-jelly-1") result = APP.trigger_jellyfin_sync_for_item(