Add Jellyfin library handoff controls

This commit is contained in:
Dymas
2026-05-25 17:10:23 +02:00
parent 139d7d9e2f
commit ece4ce0d5e
8 changed files with 371 additions and 28 deletions
+127 -23
View File
@@ -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(