From ece4ce0d5eda6a6a9763818ce9a99ab9e2b3727f Mon Sep 17 00:00:00 2001 From: Dymas Date: Mon, 25 May 2026 17:10:23 +0200 Subject: [PATCH] Add Jellyfin library handoff controls --- CHANGELOG.md | 5 ++ README.md | 19 +++++- VERSION | 2 +- app.py | 157 ++++++++++++++++++++++++++++++++++++++++++++++++ app_support.py | 15 ++++- config_page.py | 47 +++++++++++++++ http_handler.py | 4 ++ test_app.py | 150 ++++++++++++++++++++++++++++++++++++++------- 8 files changed, 371 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 123cf2f..32d7a5f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## 0.43.0 - 2026-05-25 + +- Added a Jellyfin section on the Config page with enable or disable control, separate TV and movie destination folders, and a manual `Run now` handoff trigger. +- Added automatic Jellyfin handoff for completed watchlist entries, moving TV libraries only once the show is finished and fully downloaded in at least one language, while downloaded movies move into the configured Jellyfin movie library. + ## 0.42.0 - 2026-05-25 - Added a watchlist `TV` or `Movie` media tag with a migration-safe SQLite update for existing installs, plus Search and Watchlist controls to set or change that tag. diff --git a/README.md b/README.md index e13d17a..a993a2d 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.42.0` +Current version: `0.43.0` ## Project Layout @@ -126,6 +126,7 @@ Container notes: - Auto-download newly discovered episodes for `Watching` entries after later refreshes. - Send Discord webhook notifications for refresh discoveries and failures. - Send Discord webhook notifications for completed downloads. +- Optionally move fully completed watchlist libraries into separate Jellyfin TV and movie folders. - Cache watchlist thumbnails locally. - Sync successful downloads back into the watchlist. - Keep queue and watchlist data in SQLite under `.ani-cli-web/`. @@ -184,8 +185,10 @@ Use it to set: 6. Delay between shows during `Refresh All`. 7. Global auto-download enable or disable. 8. Default auto-download language and quality. -9. Discord webhook URL. -10. Discord notification events plus a test action. +9. Jellyfin automatic handoff enable or disable. +10. Jellyfin TV and movie library paths plus a manual trigger action. +11. Discord webhook URL. +12. Discord notification events plus a test action. Layout: @@ -195,6 +198,13 @@ Layout: Saved settings are written to `.ani-cli-web/config.json`. +Jellyfin handoff: + +- Automatic Jellyfin moves run after a watchlist refresh updates an entry and the show now looks complete. +- TV entries only move after the anime is marked `Finished` and at least one downloaded language covers the expected episode total. +- Movie entries move after they are downloaded and the local movie library folder exists. +- The manual `Run now` action on `/config` scans the whole watchlist and moves anything already eligible. + Discord notification events: - Download completed. @@ -361,6 +371,9 @@ Saved config fields include: - `auth_username` - `auth_password` +- `jellyfin_sync_enabled` +- `jellyfin_tv_dir` +- `jellyfin_movie_dir` - `discord_webhook_url` - `discord_webhook_events` - `watchlist_auto_refresh_enabled` diff --git a/VERSION b/VERSION index 787ffc3..8298bb0 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.42.0 +0.43.0 diff --git a/app.py b/app.py index 352a99c..c36bb1c 100644 --- a/app.py +++ b/app.py @@ -919,6 +919,100 @@ def watchlist_status_message(sub_count, dub_count, expected_count=0, airing_stat return ". ".join(parts) + "." +def watchlist_download_library_name(item): + return sanitize_path_component( + item.get("auto_download_name") or item.get("title") or item.get("show_id") or "Anime", + sanitize_path_component(item.get("title") or item.get("show_id") or "Anime", "Anime"), + ) + + +def watchlist_source_library_dir(item, config): + media_type = normalize_media_type(item.get("media_type")) + return Path(str((config or {}).get("download_dir") or DEFAULT_CONFIG["download_dir"])).expanduser() / media_type / watchlist_download_library_name(item) + + +def jellyfin_target_library_dir(item, config): + media_type = normalize_media_type(item.get("media_type")) + key = "jellyfin_movie_dir" if media_type == "movie" else "jellyfin_tv_dir" + root = str((config or {}).get(key) or "").strip() + if not root: + return None + return Path(root).expanduser() / watchlist_download_library_name(item) + + +def watchlist_item_ready_for_jellyfin(item): + if not isinstance(item, dict): + return False, "invalid_item" + if not bool(item.get("downloaded")): + return False, "not_downloaded" + if normalize_watchlist_category(item.get("category")) != "finished": + return False, "category" + if normalize_media_type(item.get("media_type")) == "movie": + return True, "ready" + 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: + return False, "episodes_missing" + return True, "ready" + + +def _move_tree_contents(source_dir, target_dir): + moved = [] + for source_path in sorted(source_dir.rglob("*"), key=lambda path: (len(path.parts), str(path))): + if source_path.is_dir(): + continue + relative = source_path.relative_to(source_dir) + destination = target_dir / relative + destination.parent.mkdir(parents=True, exist_ok=True) + final_destination = app_support.unique_destination(destination) + shutil.move(str(source_path), str(final_destination)) + moved.append(str(final_destination)) + for path in sorted(source_dir.rglob("*"), key=lambda path: len(path.parts), reverse=True): + if path.is_dir(): + try: + path.rmdir() + except OSError: + pass + try: + source_dir.rmdir() + except OSError: + pass + return moved + + +def move_watchlist_item_to_jellyfin(item, config): + source_dir = watchlist_source_library_dir(item, config) + target_dir = jellyfin_target_library_dir(item, config) + if target_dir is None: + return {"moved": False, "reason": "target_missing", "item": item} + try: + if source_dir.resolve() == target_dir.resolve(): + return {"moved": False, "reason": "same_path", "item": item, "source": str(source_dir), "target": str(target_dir)} + except OSError: + pass + if not source_dir.exists(): + return {"moved": False, "reason": "source_missing", "item": item, "source": str(source_dir), "target": str(target_dir)} + target_dir.parent.mkdir(parents=True, exist_ok=True) + if not target_dir.exists(): + shutil.move(str(source_dir), str(target_dir)) + moved_files = [str(path) for path in target_dir.rglob("*") if path.is_file()] + else: + moved_files = _move_tree_contents(source_dir, target_dir) + return { + "moved": True, + "reason": "moved", + "item": item, + "source": str(source_dir), + "target": str(target_dir), + "moved_files": moved_files, + } + + def episode_specs_for_values(values, available_episodes): ordered = [str(value).strip() for value in available_episodes or [] if str(value).strip()] targets = {str(value).strip() for value in values or [] if str(value).strip()} @@ -1003,6 +1097,7 @@ class WatchlistStore: self.lock = threading.RLock() self.config_getter = config_getter or (lambda: dict(DEFAULT_CONFIG)) self.auto_download_fn = None + self.jellyfin_sync_fn = None self.thumbnail_events = {} self.refresh_pending = [] self.refresh_pending_set = set() @@ -1736,6 +1831,11 @@ class WatchlistStore: item["auto_download_result"] = self.auto_download_fn(item, refresh_source=refresh_source) except Exception as exc: debug_log("watchlist.auto_download.error", show_id=normalized_show_id, source=refresh_source, error=exc) + if self.jellyfin_sync_fn is not None: + try: + item["jellyfin_sync_result"] = self.jellyfin_sync_fn(item, automatic=True) + except Exception as exc: + debug_log("watchlist.jellyfin_sync.error", show_id=normalized_show_id, source=refresh_source, error=exc) return item def refresh_all(self): @@ -2214,6 +2314,61 @@ def queue_watchlist_auto_download(item, refresh_source="manual"): return {"queued": True, "mode": mode, "quality": quality, "episodes": specs, "jobs": jobs} +def trigger_jellyfin_sync_for_item(item, automatic=False, config=None): + active_config = normalize_config(config or get_config_snapshot()) + if automatic and not bool(active_config.get("jellyfin_sync_enabled")): + return {"moved": False, "reason": "disabled", "item": item} + ready, reason = watchlist_item_ready_for_jellyfin(item) + if not ready: + return {"moved": False, "reason": reason, "item": item} + media_type = normalize_media_type(item.get("media_type")) + target_key = "jellyfin_movie_dir" if media_type == "movie" else "jellyfin_tv_dir" + if not str(active_config.get(target_key) or "").strip(): + return {"moved": False, "reason": "target_missing", "item": item} + result = move_watchlist_item_to_jellyfin(item, active_config) + if result.get("moved"): + debug_log( + "jellyfin.sync.moved", + show_id=item.get("show_id"), + media_type=media_type, + source=result.get("source"), + target=result.get("target"), + automatic=automatic, + ) + return result + + +def run_jellyfin_sync(config=None): + runtime = ensure_runtime() + active_config = normalize_config(config or runtime["config"] or {}) + items = [] + moved = 0 + for show_id in runtime["watchlist"].all_show_ids(): + try: + item = runtime["watchlist"].get(show_id) + except KeyError: + continue + result = trigger_jellyfin_sync_for_item(item, automatic=False, config=active_config) + items.append( + { + "show_id": item.get("show_id"), + "title": item.get("title"), + "media_type": item.get("media_type"), + "moved": bool(result.get("moved")), + "reason": result.get("reason"), + "target": result.get("target"), + } + ) + if result.get("moved"): + moved += 1 + return { + "message": f"Moved {moved} watchlist entr{'y' if moved == 1 else 'ies'} to Jellyfin libraries.", + "moved": moved, + "total": len(items), + "items": items, + } + + def update_watchlist_category(show_id, category): ensure_runtime() item = WATCHLIST.update_category(show_id, category) @@ -2353,6 +2508,7 @@ def build_runtime(start_workers=None): start_worker=worker_state, ) watchlist.auto_download_fn = queue_watchlist_auto_download + watchlist.jellyfin_sync_fn = trigger_jellyfin_sync_for_item watchlist_refresh = WatchlistRefreshJobs( watchlist, config_getter=lambda: get_config_snapshot(config), @@ -2469,6 +2625,7 @@ Handler = build_handler_class( index_html=INDEX_HTML, queue_html=QUEUE_HTML, remove_from_watchlist=remove_from_watchlist, + run_jellyfin_sync=run_jellyfin_sync, runtime_state=runtime_state, save_runtime_config=save_runtime_config, search_anime=search_anime, diff --git a/app_support.py b/app_support.py index 5b25a5d..e58f6f2 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.42.0" +VERSION = "0.43.0" ALLANIME_BASE = "allanime.day" ALLANIME_API = f"https://api.{ALLANIME_BASE}" ALLANIME_REFERER = "https://allmanga.to" @@ -184,6 +184,9 @@ DEFAULT_CONFIG = { "auto_download_enabled": False, "auto_download_mode": "dub", "auto_download_quality": "best", + "jellyfin_sync_enabled": False, + "jellyfin_tv_dir": "", + "jellyfin_movie_dir": "", "discord_webhook_url": "", "discord_webhook_events": [], "auth_username": str(os.environ.get("ANI_CLI_WEB_AUTH_USERNAME") or "").strip(), @@ -337,6 +340,9 @@ def normalize_config(data): auto_download_quality = str(config.get("auto_download_quality", "best")).lower() if auto_download_quality.endswith("p") and auto_download_quality[:-1].isdigit(): auto_download_quality = auto_download_quality[:-1] + jellyfin_sync_enabled = config.get("jellyfin_sync_enabled") + jellyfin_tv_dir = str(config.get("jellyfin_tv_dir") or "").strip() + jellyfin_movie_dir = str(config.get("jellyfin_movie_dir") or "").strip() webhook_url = str(config.get("discord_webhook_url") or "").strip() webhook_events = config.get("discord_webhook_events") auth_username = str(config.get("auth_username") or "").strip() @@ -350,6 +356,10 @@ def normalize_config(data): auto_download_enabled = auto_download_enabled.strip().lower() in {"1", "true", "yes", "on"} else: auto_download_enabled = bool(auto_download_enabled) + if isinstance(jellyfin_sync_enabled, str): + jellyfin_sync_enabled = jellyfin_sync_enabled.strip().lower() in {"1", "true", "yes", "on"} + else: + jellyfin_sync_enabled = bool(jellyfin_sync_enabled) try: auto_refresh_minutes = int(str(auto_refresh_minutes).strip() or WATCHLIST_AUTO_REFRESH_DEFAULT_MINUTES) @@ -377,6 +387,9 @@ def normalize_config(data): config["auto_download_enabled"] = auto_download_enabled config["auto_download_mode"] = auto_download_mode if auto_download_mode in MODE_CHOICES else "dub" config["auto_download_quality"] = auto_download_quality if auto_download_quality in QUALITY_CHOICES else "best" + config["jellyfin_sync_enabled"] = jellyfin_sync_enabled + config["jellyfin_tv_dir"] = str(Path(jellyfin_tv_dir).expanduser()) if jellyfin_tv_dir else "" + config["jellyfin_movie_dir"] = str(Path(jellyfin_movie_dir).expanduser()) if jellyfin_movie_dir else "" if isinstance(webhook_events, str): webhook_events = [part.strip() for part in webhook_events.split(",")] elif not isinstance(webhook_events, list): diff --git a/config_page.py b/config_page.py index 0ad774b..6cf0347 100644 --- a/config_page.py +++ b/config_page.py @@ -431,6 +431,31 @@ CONFIG_HTML = r"""
Adding a new watchlist entry does not auto-download anything. Auto-download only reacts to later manual refreshes or scheduled refreshes that find new episodes.
+
+
+
+

Jellyfin handoff

+

Move fully completed anime libraries from the default download area into the folders that Jellyfin scans.

+
+ +
+
+ + + +
+
TV entries move only after the show is marked `Finished` and at least one downloaded language covers the expected episode count. Movie entries move after they are downloaded. The manual trigger scans the current watchlist and moves anything already eligible.
+
+
@@ -468,6 +493,9 @@ CONFIG_HTML = r""" auto_download_enabled: false, auto_download_mode: "dub", auto_download_quality: "best", + jellyfin_sync_enabled: false, + jellyfin_tv_dir: "", + jellyfin_movie_dir: "", discord_webhook_url: "", discord_webhook_events: [] } @@ -495,6 +523,9 @@ CONFIG_HTML = r""" auto_download_enabled: $("autoDownloadEnabled").value === "true", auto_download_mode: $("autoDownloadMode").value, auto_download_quality: $("autoDownloadQuality").value, + jellyfin_sync_enabled: $("jellyfinSyncEnabled").value === "true", + jellyfin_tv_dir: $("jellyfinTvDir").value, + jellyfin_movie_dir: $("jellyfinMovieDir").value, discord_webhook_url: $("discordWebhookUrl").value, discord_webhook_events: selectedWebhookEvents() }; @@ -511,6 +542,9 @@ CONFIG_HTML = r""" $("autoDownloadEnabled").value = String(Boolean(data.auto_download_enabled)); $("autoDownloadMode").value = data.auto_download_mode; $("autoDownloadQuality").value = data.auto_download_quality; + $("jellyfinSyncEnabled").value = String(Boolean(data.jellyfin_sync_enabled)); + $("jellyfinTvDir").value = data.jellyfin_tv_dir || ""; + $("jellyfinMovieDir").value = data.jellyfin_movie_dir || ""; $("discordWebhookUrl").value = data.discord_webhook_url || ""; const selected = new Set(data.discord_webhook_events || []); for (const input of document.querySelectorAll(".webhook-event")) { @@ -546,6 +580,18 @@ CONFIG_HTML = r""" } } + async function runJellyfinSync() { + try { + const data = await api("/api/config/jellyfin/sync", { + method: "POST", + body: JSON.stringify(formConfigPayload()) + }); + setNotice(data.message || "Jellyfin handoff completed."); + } catch (error) { + setNotice(error.message); + } + } + async function loadDeps() { const data = await api("/api/dependencies"); const el = $("deps"); @@ -569,6 +615,7 @@ CONFIG_HTML = r""" $("saveConfigBtn").addEventListener("click", saveConfig); $("testWebhookBtn").addEventListener("click", testWebhook); + $("runJellyfinSyncBtn").addEventListener("click", runJellyfinSync); (async function init() { try { diff --git a/http_handler.py b/http_handler.py index 0e782ea..d1a6b0b 100644 --- a/http_handler.py +++ b/http_handler.py @@ -52,6 +52,7 @@ class HandlerContext: index_html: str queue_html: str remove_from_watchlist: object + run_jellyfin_sync: object runtime_state: object save_runtime_config: object search_anime: object @@ -490,6 +491,9 @@ class Handler(BaseHTTPRequestHandler): self.ensure_client_access() if parsed.path == "/api/config": Handler.update_config(self, Handler.require_json_object(self, self.body_json())) + elif parsed.path == "/api/config/jellyfin/sync": + payload = Handler.require_json_object(self, self.body_json()) + self.json(Handler._context(self).run_jellyfin_sync(payload)) elif parsed.path == "/api/config/webhook/test": payload = Handler.require_json_object(self, self.body_json()) self.json(Handler._context(self).test_discord_webhook_config(payload)) diff --git a/test_app.py b/test_app.py index c700c62..dc002eb 100644 --- a/test_app.py +++ b/test_app.py @@ -339,30 +339,31 @@ class QueueApiTests(unittest.TestCase): self.assertEqual(restored["media_type"], "movie") def test_movie_finalizer_uses_movie_folder_and_single_file_name(self): - job = APP.app_support.build_job( - { - "query": "Movie Show", - "title": "Movie Show", - "anime_name": "Movie Show", - "media_type": "movie", - "mode": "sub", - "quality": "best", - "episodes": "1", - "download_dir": "/tmp/example", - "season": "1", - }, - {"mode": "sub", "quality": "best", "download_dir": "/tmp/example"}, - ) - staging_dir = APP.app_support.job_staging_dir(job) - staging_dir.mkdir(parents=True, exist_ok=True) - source = staging_dir / "Movie Show Episode 1.mp4" - source.write_bytes(b"movie-bytes") + with tempfile.TemporaryDirectory() as temp_root: + job = APP.app_support.build_job( + { + "query": "Movie Show", + "title": "Movie Show", + "anime_name": "Movie Show", + "media_type": "movie", + "mode": "sub", + "quality": "best", + "episodes": "1", + "download_dir": temp_root, + "season": "1", + }, + {"mode": "sub", "quality": "best", "download_dir": temp_root}, + ) + staging_dir = APP.app_support.job_staging_dir(job) + staging_dir.mkdir(parents=True, exist_ok=True) + source = staging_dir / "Movie Show Episode 1.mp4" + source.write_bytes(b"movie-bytes") - moved = APP.app_support.finalize_library_files(job) + moved = APP.app_support.finalize_library_files(job) - self.assertEqual(moved, ["/tmp/example/movie/Movie Show/Movie Show.mp4"]) - self.assertFalse(source.exists()) - self.assertTrue(Path(moved[0]).exists()) + self.assertEqual(moved, [f"{temp_root}/movie/Movie Show/Movie Show.mp4"]) + self.assertFalse(source.exists()) + self.assertTrue(Path(moved[0]).exists()) def test_shutdown_wait_cancels_active_process_for_deterministic_teardown(self): queue = object.__new__(APP.DownloadQueue) @@ -1167,6 +1168,82 @@ class WatchlistCompletionTests(unittest.TestCase): self.assertEqual(result["job"], job) self.assertIn("Queued Queue Show for dub download.", result["message"]) + +class JellyfinSyncTests(unittest.TestCase): + def setUp(self): + with APP.WATCHLIST._connect() as conn: + conn.execute("DELETE FROM watchlist") + + def seed_watchlist_item(self, **overrides): + now = APP.now_iso() + item = { + "show_id": "show-jelly-1", + "title": "Jelly Show", + "category": "finished", + "downloaded": True, + "status": "updated", + "status_message": "Finished.", + "created_at": now, + "updated_at": now, + "last_checked": now, + "sub_count": 12, + "dub_count": 12, + "expected_count": 12, + "airing_status": "Finished", + "sub_latest_episode": "12", + "dub_latest_episode": "12", + "animeschedule_route": None, + "animeschedule_title": None, + "anidb_aid": None, + "anidb_title": None, + "media_type": "tv", + "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, + "thumbnail_path": None, + "thumbnail_checked_at": None, + } + item.update(overrides) + with APP.WATCHLIST.lock, APP.WATCHLIST._connect() as conn: + APP.WATCHLIST._upsert_conn(conn, item) + + def test_run_jellyfin_sync_moves_eligible_tv_library(self): + self.seed_watchlist_item() + with tempfile.TemporaryDirectory() as temp_root: + download_root = Path(temp_root) / "downloads" + source_file = download_root / "tv" / "Jelly Show" / "Season 01" / "Jelly Show - S01E01.mp4" + source_file.parent.mkdir(parents=True, exist_ok=True) + source_file.write_bytes(b"episode-data") + jellyfin_tv = Path(temp_root) / "jellyfin-tv" + + result = APP.run_jellyfin_sync( + { + "download_dir": str(download_root), + "jellyfin_sync_enabled": True, + "jellyfin_tv_dir": str(jellyfin_tv), + "jellyfin_movie_dir": "", + } + ) + + self.assertEqual(result["moved"], 1) + target_file = jellyfin_tv / "Jelly Show" / "Season 01" / "Jelly Show - S01E01.mp4" + self.assertTrue(target_file.exists()) + self.assertFalse((download_root / "tv" / "Jelly Show").exists()) + + 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)])) + 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_missing") + def test_sync_downloaded_job_to_watchlist_rejects_ambiguous_fallback_match(self): job = {"query": "Queue Show", "mode": "sub", "result_index": 1, "title": "Queue Show"} @@ -1364,6 +1441,9 @@ class ConfigSnapshotTests(unittest.TestCase): self.assertFalse(config["auto_download_enabled"]) self.assertEqual(config["auto_download_mode"], "dub") self.assertEqual(config["auto_download_quality"], "best") + self.assertFalse(config["jellyfin_sync_enabled"]) + self.assertEqual(config["jellyfin_tv_dir"], "") + self.assertEqual(config["jellyfin_movie_dir"], "") self.assertEqual(config["discord_webhook_url"], "") self.assertEqual(config["discord_webhook_events"], []) @@ -1376,6 +1456,9 @@ class ConfigSnapshotTests(unittest.TestCase): "auto_download_enabled": "true", "auto_download_mode": "sub", "auto_download_quality": "720p", + "jellyfin_sync_enabled": "true", + "jellyfin_tv_dir": "~/jellyfin/tv", + "jellyfin_movie_dir": "~/jellyfin/movies", } ) self.assertTrue(config["watchlist_auto_refresh_enabled"]) @@ -1384,6 +1467,9 @@ class ConfigSnapshotTests(unittest.TestCase): self.assertTrue(config["auto_download_enabled"]) self.assertEqual(config["auto_download_mode"], "sub") self.assertEqual(config["auto_download_quality"], "720") + self.assertTrue(config["jellyfin_sync_enabled"]) + self.assertIn("jellyfin", config["jellyfin_tv_dir"]) + self.assertIn("jellyfin", config["jellyfin_movie_dir"]) def test_normalize_config_filters_discord_webhook_events(self): config = APP.normalize_config( @@ -1728,6 +1814,9 @@ class StartupBehaviorTests(unittest.TestCase): "auto_download_enabled": True, "auto_download_mode": "sub", "auto_download_quality": "720", + "jellyfin_sync_enabled": True, + "jellyfin_tv_dir": "/tmp/jellyfin-tv", + "jellyfin_movie_dir": "/tmp/jellyfin-movies", "discord_webhook_url": "https://discord.example/webhook", "discord_webhook_events": ["runtime_error"], } @@ -1741,6 +1830,9 @@ class StartupBehaviorTests(unittest.TestCase): self.assertTrue(APP.CONFIG["auto_download_enabled"]) self.assertEqual(APP.CONFIG["auto_download_mode"], "sub") self.assertEqual(APP.CONFIG["auto_download_quality"], "720") + self.assertTrue(APP.CONFIG["jellyfin_sync_enabled"]) + self.assertEqual(APP.CONFIG["jellyfin_tv_dir"], "/tmp/jellyfin-tv") + self.assertEqual(APP.CONFIG["jellyfin_movie_dir"], "/tmp/jellyfin-movies") self.assertEqual(APP.CONFIG["discord_webhook_url"], "https://discord.example/webhook") self.assertEqual(APP.CONFIG["discord_webhook_events"], ["runtime_error"]) APP.WATCHLIST_AUTO_REFRESH.notify_config_changed.assert_called_once_with() @@ -1778,7 +1870,7 @@ class HandlerRouteTests(unittest.TestCase): APP.initialize_runtime(start_workers=False) def test_config_post_accepts_watchlist_schedule_fields(self): - body = b'{"download_dir":"/tmp/example","mode":"sub","quality":"best","watchlist_auto_refresh_enabled":true,"watchlist_auto_refresh_minutes":"25","watchlist_refresh_delay_seconds":"9","auto_download_enabled":true,"auto_download_mode":"dub","auto_download_quality":"720"}' + body = b'{"download_dir":"/tmp/example","mode":"sub","quality":"best","watchlist_auto_refresh_enabled":true,"watchlist_auto_refresh_minutes":"25","watchlist_refresh_delay_seconds":"9","auto_download_enabled":true,"auto_download_mode":"dub","auto_download_quality":"720","jellyfin_sync_enabled":true,"jellyfin_tv_dir":"/tmp/jellyfin-tv","jellyfin_movie_dir":"/tmp/jellyfin-movies"}' handler = DummyHandler("/api/config", body=body, content_length=len(body)) APP.Handler.do_POST(handler) self.assertEqual(handler.json_status, HTTPStatus.OK) @@ -1788,6 +1880,9 @@ class HandlerRouteTests(unittest.TestCase): self.assertTrue(handler.json_payload["auto_download_enabled"]) self.assertEqual(handler.json_payload["auto_download_mode"], "dub") self.assertEqual(handler.json_payload["auto_download_quality"], "720") + self.assertTrue(handler.json_payload["jellyfin_sync_enabled"]) + self.assertEqual(handler.json_payload["jellyfin_tv_dir"], "/tmp/jellyfin-tv") + self.assertEqual(handler.json_payload["jellyfin_movie_dir"], "/tmp/jellyfin-movies") def test_version_route_includes_ani_cli_version(self): handler = DummyHandler("/api/version") @@ -1984,6 +2079,15 @@ class HandlerRouteTests(unittest.TestCase): self.assertEqual(handler.json_payload, payload) self.assertEqual(handler.json_status, HTTPStatus.OK) + def test_jellyfin_sync_post_returns_summary_payload(self): + body = b'{"download_dir":"/tmp/example","jellyfin_sync_enabled":true,"jellyfin_tv_dir":"/tmp/jellyfin-tv","jellyfin_movie_dir":"/tmp/jellyfin-movies"}' + handler = DummyHandler("/api/config/jellyfin/sync", body=body, content_length=len(body)) + payload = {"message": "Moved 1 watchlist entry to Jellyfin libraries.", "moved": 1, "total": 2, "items": []} + handler.handler_context = mock.Mock(run_jellyfin_sync=mock.Mock(return_value=payload)) + APP.Handler.do_POST(handler) + self.assertEqual(handler.json_payload, payload) + self.assertEqual(handler.json_status, HTTPStatus.OK) + def test_generic_handler_exception_skips_traceback_when_debug_disabled(self): handler = DummyHandler("/api/config") with mock.patch.object(http_handler, "debug_enabled", return_value=False), mock.patch.object(