Fix partial series completion for watchlist and Jellyfin

This commit is contained in:
Dymas
2026-05-25 23:09:14 +02:00
parent c006fb2d1d
commit 94ded2725e
6 changed files with 123 additions and 20 deletions
+5
View File
@@ -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.
+2 -1
View File
@@ -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
+1 -1
View File
@@ -1 +1 @@
0.45.1
0.45.2
+43 -6
View File
@@ -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,
+1 -1
View File
@@ -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"
+71 -11
View File
@@ -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(