Fix partial series completion for watchlist and Jellyfin
This commit is contained in:
@@ -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,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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user