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
+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,