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"""