Fix movie watchlist completion sync
This commit is contained in:
@@ -1,5 +1,10 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 0.45.10 - 2026-05-26
|
||||||
|
|
||||||
|
- Fixed watchlist-synced movie downloads so successful movie jobs move existing watchlist entries into `Finished` instead of leaving them stuck in `Watching` or `Planned`.
|
||||||
|
- Preserved queued job media type when creating missing watchlist entries from completed downloads, which prevents synced movies from being recreated as TV entries and keeps Jellyfin routing correct.
|
||||||
|
|
||||||
## 0.45.9 - 2026-05-26
|
## 0.45.9 - 2026-05-26
|
||||||
|
|
||||||
- Clarified queue cleanup wording so the UI and README now match the real `Clear finished` behavior, which removes both finished and canceled jobs.
|
- Clarified queue cleanup wording so the UI and README now match the real `Clear finished` behavior, which removes both finished and canceled jobs.
|
||||||
|
|||||||
@@ -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.8`
|
Current version: `0.45.10`
|
||||||
|
|
||||||
## Project Layout
|
## Project Layout
|
||||||
|
|
||||||
@@ -216,6 +216,7 @@ Watchlist queueing:
|
|||||||
- Manual `Download all` and automatic watchlist downloads now queue directly from the stored watchlist title and `show_id`, so they no longer fail just because a fresh provider search returns a shifted or incomplete result list.
|
- Manual `Download all` and automatic watchlist downloads now queue directly from the stored watchlist title and `show_id`, so they no longer fail just because a fresh provider search returns a shifted or incomplete result list.
|
||||||
- Removing a watchlist entry now detaches its existing queue jobs from later watchlist sync, so finishing an old queued download cannot silently recreate that deleted show.
|
- Removing a watchlist entry now detaches its existing queue jobs from later watchlist sync, so finishing an old queued download cannot silently recreate that deleted show.
|
||||||
- Manual watchlist `Download all` now uses the anime's own configured auto-download quality when one is set.
|
- Manual watchlist `Download all` now uses the anime's own configured auto-download quality when one is set.
|
||||||
|
- Completed movie downloads now keep their `movie` media type during watchlist sync and move movie-tagged watchlist entries into `Finished`, which keeps later Jellyfin handoff eligibility consistent.
|
||||||
|
|
||||||
Discord notification events:
|
Discord notification events:
|
||||||
|
|
||||||
|
|||||||
@@ -2001,13 +2001,14 @@ class WatchlistStore:
|
|||||||
)
|
)
|
||||||
return self.schedule_refresh(existing["show_id"], source=source)
|
return self.schedule_refresh(existing["show_id"], source=source)
|
||||||
|
|
||||||
def mark_downloaded(self, show_id, title="", mode="", episodes=""):
|
def mark_downloaded(self, show_id, title="", mode="", episodes="", media_type=""):
|
||||||
normalized_show_id = str(show_id or "").strip()
|
normalized_show_id = str(show_id or "").strip()
|
||||||
if not normalized_show_id:
|
if not normalized_show_id:
|
||||||
raise ValueError("Missing show_id for watchlist download sync.")
|
raise ValueError("Missing show_id for watchlist download sync.")
|
||||||
|
|
||||||
normalized_title = str(title or "").strip()
|
normalized_title = str(title or "").strip()
|
||||||
normalized_mode = str(mode or "").strip().lower()
|
normalized_mode = str(mode or "").strip().lower()
|
||||||
|
normalized_media_type = normalize_media_type(media_type or "tv")
|
||||||
episode_spec = str(episodes or "").strip()
|
episode_spec = str(episodes or "").strip()
|
||||||
now = now_iso()
|
now = now_iso()
|
||||||
downloaded_episode_values = []
|
downloaded_episode_values = []
|
||||||
@@ -2030,7 +2031,10 @@ class WatchlistStore:
|
|||||||
completed_item = dict(existing_item)
|
completed_item = dict(existing_item)
|
||||||
completed_item["downloaded_sub_episodes"] = decode_episode_values(sub_payload)
|
completed_item["downloaded_sub_episodes"] = decode_episode_values(sub_payload)
|
||||||
completed_item["downloaded_dub_episodes"] = decode_episode_values(dub_payload)
|
completed_item["downloaded_dub_episodes"] = decode_episode_values(dub_payload)
|
||||||
is_complete, _reason = watchlist_item_download_complete(completed_item, mode=normalized_mode)
|
if normalize_media_type(existing_item.get("media_type")) == "movie":
|
||||||
|
is_complete = True
|
||||||
|
else:
|
||||||
|
is_complete, _reason = watchlist_item_download_complete(completed_item, mode=normalized_mode)
|
||||||
target_category = "finished" if is_complete else existing_item["category"]
|
target_category = "finished" if is_complete else existing_item["category"]
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""
|
"""
|
||||||
@@ -2058,7 +2062,10 @@ class WatchlistStore:
|
|||||||
completed_item = dict(matched_item)
|
completed_item = dict(matched_item)
|
||||||
completed_item["downloaded_sub_episodes"] = decode_episode_values(sub_payload)
|
completed_item["downloaded_sub_episodes"] = decode_episode_values(sub_payload)
|
||||||
completed_item["downloaded_dub_episodes"] = decode_episode_values(dub_payload)
|
completed_item["downloaded_dub_episodes"] = decode_episode_values(dub_payload)
|
||||||
is_complete, _reason = watchlist_item_download_complete(completed_item, mode=normalized_mode)
|
if normalize_media_type(matched_item.get("media_type")) == "movie":
|
||||||
|
is_complete = True
|
||||||
|
else:
|
||||||
|
is_complete, _reason = watchlist_item_download_complete(completed_item, mode=normalized_mode)
|
||||||
target_category = "finished" if is_complete else matched_item["category"]
|
target_category = "finished" if is_complete else matched_item["category"]
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""
|
"""
|
||||||
@@ -2117,7 +2124,7 @@ class WatchlistStore:
|
|||||||
"alternative_titles_json": None,
|
"alternative_titles_json": None,
|
||||||
"anidb_aid": None,
|
"anidb_aid": None,
|
||||||
"anidb_title": None,
|
"anidb_title": None,
|
||||||
"media_type": "tv",
|
"media_type": normalized_media_type,
|
||||||
"auto_download_series": None,
|
"auto_download_series": None,
|
||||||
"downloaded_sub_episodes_json": encode_episode_values(downloaded_episode_values) if normalized_mode == "sub" else None,
|
"downloaded_sub_episodes_json": encode_episode_values(downloaded_episode_values) if normalized_mode == "sub" else None,
|
||||||
"downloaded_dub_episodes_json": encode_episode_values(downloaded_episode_values) if normalized_mode == "dub" else None,
|
"downloaded_dub_episodes_json": encode_episode_values(downloaded_episode_values) if normalized_mode == "dub" else None,
|
||||||
@@ -2599,7 +2606,13 @@ def sync_downloaded_job_to_watchlist(job):
|
|||||||
if not show_id:
|
if not show_id:
|
||||||
debug_log("watchlist.sync.resolve_failed", job=job, reason=reason)
|
debug_log("watchlist.sync.resolve_failed", job=job, reason=reason)
|
||||||
raise ValueError(f"Could not resolve a watchlist show_id for the completed download: {reason}.")
|
raise ValueError(f"Could not resolve a watchlist show_id for the completed download: {reason}.")
|
||||||
return WATCHLIST.mark_downloaded(show_id, title=title, mode=job.get("mode"), episodes=job.get("episodes"))
|
return WATCHLIST.mark_downloaded(
|
||||||
|
show_id,
|
||||||
|
title=title,
|
||||||
|
mode=job.get("mode"),
|
||||||
|
episodes=job.get("episodes"),
|
||||||
|
media_type=job.get("media_type"),
|
||||||
|
)
|
||||||
|
|
||||||
def prepare_download_job(job):
|
def prepare_download_job(job):
|
||||||
config = ensure_runtime()["config"]
|
config = ensure_runtime()["config"]
|
||||||
|
|||||||
+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.9"
|
VERSION = "0.45.10"
|
||||||
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"
|
||||||
|
|||||||
+29
-1
@@ -1251,6 +1251,24 @@ class WatchlistCompletionTests(unittest.TestCase):
|
|||||||
self.assertEqual(item["finished_badge"], "downloaded")
|
self.assertEqual(item["finished_badge"], "downloaded")
|
||||||
self.assertEqual(item["downloaded_dub_episodes"], ["1", "2", "3"])
|
self.assertEqual(item["downloaded_dub_episodes"], ["1", "2", "3"])
|
||||||
|
|
||||||
|
def test_mark_downloaded_moves_existing_movie_to_finished(self):
|
||||||
|
self.seed_watchlist_item(
|
||||||
|
show_id="movie-1",
|
||||||
|
title="Movie Show",
|
||||||
|
category="watching",
|
||||||
|
downloaded=False,
|
||||||
|
media_type="movie",
|
||||||
|
)
|
||||||
|
|
||||||
|
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("movie-1", title="Movie Show", media_type="movie")
|
||||||
|
|
||||||
|
self.assertEqual(item["category"], "finished")
|
||||||
|
self.assertEqual(item["media_type"], "movie")
|
||||||
|
self.assertTrue(item["downloaded"])
|
||||||
|
|
||||||
def test_mark_downloaded_creates_missing_show_in_finished_category(self):
|
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(
|
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")
|
APP.WATCHLIST, "refresh", side_effect=AssertionError("mark_downloaded should not refresh synchronously")
|
||||||
@@ -1262,6 +1280,16 @@ class WatchlistCompletionTests(unittest.TestCase):
|
|||||||
self.assertEqual(item["finished_badge"], "downloaded")
|
self.assertEqual(item["finished_badge"], "downloaded")
|
||||||
self.assertEqual(item["status"], "queued")
|
self.assertEqual(item["status"], "queued")
|
||||||
|
|
||||||
|
def test_mark_downloaded_creates_missing_movie_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(
|
||||||
|
APP.WATCHLIST, "refresh", side_effect=AssertionError("mark_downloaded should not refresh synchronously")
|
||||||
|
):
|
||||||
|
item = APP.WATCHLIST.mark_downloaded("movie-2", title="Downloaded Movie", media_type="movie")
|
||||||
|
|
||||||
|
self.assertEqual(item["category"], "finished")
|
||||||
|
self.assertEqual(item["media_type"], "movie")
|
||||||
|
self.assertTrue(item["downloaded"])
|
||||||
|
|
||||||
def test_mark_downloaded_does_not_recreate_removed_show(self):
|
def test_mark_downloaded_does_not_recreate_removed_show(self):
|
||||||
self.seed_watchlist_item(show_id="show-removed", title="Removed Show", category="watching")
|
self.seed_watchlist_item(show_id="show-removed", title="Removed Show", category="watching")
|
||||||
|
|
||||||
@@ -1369,7 +1397,7 @@ class WatchlistCompletionTests(unittest.TestCase):
|
|||||||
) as mark_downloaded:
|
) as mark_downloaded:
|
||||||
APP.sync_downloaded_job_to_watchlist(job)
|
APP.sync_downloaded_job_to_watchlist(job)
|
||||||
|
|
||||||
mark_downloaded.assert_called_once_with("show-42", title="Queue Show", mode="sub", episodes=None)
|
mark_downloaded.assert_called_once_with("show-42", title="Queue Show", mode="sub", episodes=None, media_type=None)
|
||||||
|
|
||||||
def test_prepare_download_job_persists_resolved_show_id(self):
|
def test_prepare_download_job_persists_resolved_show_id(self):
|
||||||
job = {"query": "Queue Show", "mode": "sub", "result_index": 1, "title": "Queue Show", "show_id": ""}
|
job = {"query": "Queue Show", "mode": "sub", "result_index": 1, "title": "Queue Show", "show_id": ""}
|
||||||
|
|||||||
Reference in New Issue
Block a user