From ff605aca0388073b0aa9966dd2064363c7e5c5f9 Mon Sep 17 00:00:00 2001 From: Dymas Date: Tue, 26 May 2026 07:26:54 +0200 Subject: [PATCH] Fix movie watchlist completion sync --- CHANGELOG.md | 5 +++++ README.md | 3 ++- VERSION | 2 +- app.py | 23 ++++++++++++++++++----- app_support.py | 2 +- test_app.py | 30 +++++++++++++++++++++++++++++- 6 files changed, 56 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ffd8c0..7771630 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # 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 - Clarified queue cleanup wording so the UI and README now match the real `Clear finished` behavior, which removes both finished and canceled jobs. diff --git a/README.md b/README.md index 7d0766d..672fae9 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.8` +Current version: `0.45.10` ## 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. - 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. +- 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: diff --git a/VERSION b/VERSION index 631073f..91d6116 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.45.9 +0.45.10 diff --git a/app.py b/app.py index 69b59ec..663c737 100644 --- a/app.py +++ b/app.py @@ -2001,13 +2001,14 @@ class WatchlistStore: ) 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() if not normalized_show_id: raise ValueError("Missing show_id for watchlist download sync.") normalized_title = str(title or "").strip() normalized_mode = str(mode or "").strip().lower() + normalized_media_type = normalize_media_type(media_type or "tv") episode_spec = str(episodes or "").strip() now = now_iso() downloaded_episode_values = [] @@ -2030,7 +2031,10 @@ class WatchlistStore: 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) + 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"] conn.execute( """ @@ -2058,7 +2062,10 @@ class WatchlistStore: 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) + 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"] conn.execute( """ @@ -2117,7 +2124,7 @@ class WatchlistStore: "alternative_titles_json": None, "anidb_aid": None, "anidb_title": None, - "media_type": "tv", + "media_type": normalized_media_type, "auto_download_series": 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, @@ -2599,7 +2606,13 @@ def sync_downloaded_job_to_watchlist(job): if not show_id: 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}.") - 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): config = ensure_runtime()["config"] diff --git a/app_support.py b/app_support.py index a4d91ca..e27e426 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.9" +VERSION = "0.45.10" 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 b062e29..bad30f1 100644 --- a/test_app.py +++ b/test_app.py @@ -1251,6 +1251,24 @@ class WatchlistCompletionTests(unittest.TestCase): self.assertEqual(item["finished_badge"], "downloaded") 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): 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") @@ -1262,6 +1280,16 @@ class WatchlistCompletionTests(unittest.TestCase): self.assertEqual(item["finished_badge"], "downloaded") 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): self.seed_watchlist_item(show_id="show-removed", title="Removed Show", category="watching") @@ -1369,7 +1397,7 @@ class WatchlistCompletionTests(unittest.TestCase): ) as mark_downloaded: 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): job = {"query": "Queue Show", "mode": "sub", "result_index": 1, "title": "Queue Show", "show_id": ""}