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
+5
View File
@@ -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.
+16 -3
View File
@@ -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`
+1 -1
View File
@@ -1 +1 @@
0.42.0
0.43.0
+157
View File
@@ -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,
+14 -1
View File
@@ -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):
+47
View File
@@ -431,6 +431,31 @@ CONFIG_HTML = r"""<!doctype html>
<div class="field-hint">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.</div>
</section>
<section class="settings settings-main">
<div class="toolbar">
<div>
<h2>Jellyfin handoff</h2>
<p class="muted">Move fully completed anime libraries from the default download area into the folders that Jellyfin scans.</p>
</div>
<button id="runJellyfinSyncBtn" type="button">Run now</button>
</div>
<div class="form-grid">
<label>Automatic handoff
<select id="jellyfinSyncEnabled">
<option value="false">Disabled</option>
<option value="true">Enabled</option>
</select>
</label>
<label>Jellyfin TV library
<input id="jellyfinTvDir" placeholder="/srv/media/anime-tv">
</label>
<label>Jellyfin movie library
<input id="jellyfinMovieDir" placeholder="/srv/media/anime-movies">
</label>
</div>
<div class="field-hint">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.</div>
</section>
<section class="settings settings-main">
<div class="toolbar">
<div>
@@ -468,6 +493,9 @@ CONFIG_HTML = r"""<!doctype html>
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"""<!doctype html>
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"""<!doctype html>
$("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"""<!doctype html>
}
}
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"""<!doctype html>
$("saveConfigBtn").addEventListener("click", saveConfig);
$("testWebhookBtn").addEventListener("click", testWebhook);
$("runJellyfinSyncBtn").addEventListener("click", runJellyfinSync);
(async function init() {
try {
+4
View File
@@ -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))
+108 -4
View File
@@ -339,6 +339,7 @@ class QueueApiTests(unittest.TestCase):
self.assertEqual(restored["media_type"], "movie")
def test_movie_finalizer_uses_movie_folder_and_single_file_name(self):
with tempfile.TemporaryDirectory() as temp_root:
job = APP.app_support.build_job(
{
"query": "Movie Show",
@@ -348,10 +349,10 @@ class QueueApiTests(unittest.TestCase):
"mode": "sub",
"quality": "best",
"episodes": "1",
"download_dir": "/tmp/example",
"download_dir": temp_root,
"season": "1",
},
{"mode": "sub", "quality": "best", "download_dir": "/tmp/example"},
{"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)
@@ -360,7 +361,7 @@ class QueueApiTests(unittest.TestCase):
moved = APP.app_support.finalize_library_files(job)
self.assertEqual(moved, ["/tmp/example/movie/Movie Show/Movie Show.mp4"])
self.assertEqual(moved, [f"{temp_root}/movie/Movie Show/Movie Show.mp4"])
self.assertFalse(source.exists())
self.assertTrue(Path(moved[0]).exists())
@@ -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(