Add Jellyfin library handoff controls
This commit is contained in:
@@ -1,5 +1,10 @@
|
|||||||
# Changelog
|
# 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
|
## 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.
|
- 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.
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
Small local web UI for a system-wide `ani-cli` install.
|
Small local web UI for a system-wide `ani-cli` install.
|
||||||
|
|
||||||
Current version: `0.42.0`
|
Current version: `0.43.0`
|
||||||
|
|
||||||
## Project Layout
|
## Project Layout
|
||||||
|
|
||||||
@@ -126,6 +126,7 @@ Container notes:
|
|||||||
- Auto-download newly discovered episodes for `Watching` entries after later refreshes.
|
- 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 refresh discoveries and failures.
|
||||||
- Send Discord webhook notifications for completed downloads.
|
- Send Discord webhook notifications for completed downloads.
|
||||||
|
- Optionally move fully completed watchlist libraries into separate Jellyfin TV and movie folders.
|
||||||
- Cache watchlist thumbnails locally.
|
- Cache watchlist thumbnails locally.
|
||||||
- Sync successful downloads back into the watchlist.
|
- Sync successful downloads back into the watchlist.
|
||||||
- Keep queue and watchlist data in SQLite under `.ani-cli-web/`.
|
- 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`.
|
6. Delay between shows during `Refresh All`.
|
||||||
7. Global auto-download enable or disable.
|
7. Global auto-download enable or disable.
|
||||||
8. Default auto-download language and quality.
|
8. Default auto-download language and quality.
|
||||||
9. Discord webhook URL.
|
9. Jellyfin automatic handoff enable or disable.
|
||||||
10. Discord notification events plus a test action.
|
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:
|
Layout:
|
||||||
|
|
||||||
@@ -195,6 +198,13 @@ Layout:
|
|||||||
|
|
||||||
Saved settings are written to `.ani-cli-web/config.json`.
|
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:
|
Discord notification events:
|
||||||
|
|
||||||
- Download completed.
|
- Download completed.
|
||||||
@@ -361,6 +371,9 @@ Saved config fields include:
|
|||||||
|
|
||||||
- `auth_username`
|
- `auth_username`
|
||||||
- `auth_password`
|
- `auth_password`
|
||||||
|
- `jellyfin_sync_enabled`
|
||||||
|
- `jellyfin_tv_dir`
|
||||||
|
- `jellyfin_movie_dir`
|
||||||
- `discord_webhook_url`
|
- `discord_webhook_url`
|
||||||
- `discord_webhook_events`
|
- `discord_webhook_events`
|
||||||
- `watchlist_auto_refresh_enabled`
|
- `watchlist_auto_refresh_enabled`
|
||||||
|
|||||||
@@ -919,6 +919,100 @@ def watchlist_status_message(sub_count, dub_count, expected_count=0, airing_stat
|
|||||||
return ". ".join(parts) + "."
|
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):
|
def episode_specs_for_values(values, available_episodes):
|
||||||
ordered = [str(value).strip() for value in available_episodes or [] if str(value).strip()]
|
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()}
|
targets = {str(value).strip() for value in values or [] if str(value).strip()}
|
||||||
@@ -1003,6 +1097,7 @@ class WatchlistStore:
|
|||||||
self.lock = threading.RLock()
|
self.lock = threading.RLock()
|
||||||
self.config_getter = config_getter or (lambda: dict(DEFAULT_CONFIG))
|
self.config_getter = config_getter or (lambda: dict(DEFAULT_CONFIG))
|
||||||
self.auto_download_fn = None
|
self.auto_download_fn = None
|
||||||
|
self.jellyfin_sync_fn = None
|
||||||
self.thumbnail_events = {}
|
self.thumbnail_events = {}
|
||||||
self.refresh_pending = []
|
self.refresh_pending = []
|
||||||
self.refresh_pending_set = set()
|
self.refresh_pending_set = set()
|
||||||
@@ -1736,6 +1831,11 @@ class WatchlistStore:
|
|||||||
item["auto_download_result"] = self.auto_download_fn(item, refresh_source=refresh_source)
|
item["auto_download_result"] = self.auto_download_fn(item, refresh_source=refresh_source)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
debug_log("watchlist.auto_download.error", show_id=normalized_show_id, source=refresh_source, error=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
|
return item
|
||||||
|
|
||||||
def refresh_all(self):
|
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}
|
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):
|
def update_watchlist_category(show_id, category):
|
||||||
ensure_runtime()
|
ensure_runtime()
|
||||||
item = WATCHLIST.update_category(show_id, category)
|
item = WATCHLIST.update_category(show_id, category)
|
||||||
@@ -2353,6 +2508,7 @@ def build_runtime(start_workers=None):
|
|||||||
start_worker=worker_state,
|
start_worker=worker_state,
|
||||||
)
|
)
|
||||||
watchlist.auto_download_fn = queue_watchlist_auto_download
|
watchlist.auto_download_fn = queue_watchlist_auto_download
|
||||||
|
watchlist.jellyfin_sync_fn = trigger_jellyfin_sync_for_item
|
||||||
watchlist_refresh = WatchlistRefreshJobs(
|
watchlist_refresh = WatchlistRefreshJobs(
|
||||||
watchlist,
|
watchlist,
|
||||||
config_getter=lambda: get_config_snapshot(config),
|
config_getter=lambda: get_config_snapshot(config),
|
||||||
@@ -2469,6 +2625,7 @@ Handler = build_handler_class(
|
|||||||
index_html=INDEX_HTML,
|
index_html=INDEX_HTML,
|
||||||
queue_html=QUEUE_HTML,
|
queue_html=QUEUE_HTML,
|
||||||
remove_from_watchlist=remove_from_watchlist,
|
remove_from_watchlist=remove_from_watchlist,
|
||||||
|
run_jellyfin_sync=run_jellyfin_sync,
|
||||||
runtime_state=runtime_state,
|
runtime_state=runtime_state,
|
||||||
save_runtime_config=save_runtime_config,
|
save_runtime_config=save_runtime_config,
|
||||||
search_anime=search_anime,
|
search_anime=search_anime,
|
||||||
|
|||||||
+14
-1
@@ -20,7 +20,7 @@ from uuid import uuid4
|
|||||||
PROJECT_ROOT = Path(__file__).resolve().parent
|
PROJECT_ROOT = Path(__file__).resolve().parent
|
||||||
ANI_CLI = os.environ.get("ANI_CLI_BIN") or shutil.which("ani-cli") or "ani-cli"
|
ANI_CLI = os.environ.get("ANI_CLI_BIN") or shutil.which("ani-cli") or "ani-cli"
|
||||||
APP_NAME = "ani-cli-web"
|
APP_NAME = "ani-cli-web"
|
||||||
VERSION = "0.42.0"
|
VERSION = "0.43.0"
|
||||||
ALLANIME_BASE = "allanime.day"
|
ALLANIME_BASE = "allanime.day"
|
||||||
ALLANIME_API = f"https://api.{ALLANIME_BASE}"
|
ALLANIME_API = f"https://api.{ALLANIME_BASE}"
|
||||||
ALLANIME_REFERER = "https://allmanga.to"
|
ALLANIME_REFERER = "https://allmanga.to"
|
||||||
@@ -184,6 +184,9 @@ DEFAULT_CONFIG = {
|
|||||||
"auto_download_enabled": False,
|
"auto_download_enabled": False,
|
||||||
"auto_download_mode": "dub",
|
"auto_download_mode": "dub",
|
||||||
"auto_download_quality": "best",
|
"auto_download_quality": "best",
|
||||||
|
"jellyfin_sync_enabled": False,
|
||||||
|
"jellyfin_tv_dir": "",
|
||||||
|
"jellyfin_movie_dir": "",
|
||||||
"discord_webhook_url": "",
|
"discord_webhook_url": "",
|
||||||
"discord_webhook_events": [],
|
"discord_webhook_events": [],
|
||||||
"auth_username": str(os.environ.get("ANI_CLI_WEB_AUTH_USERNAME") or "").strip(),
|
"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()
|
auto_download_quality = str(config.get("auto_download_quality", "best")).lower()
|
||||||
if auto_download_quality.endswith("p") and auto_download_quality[:-1].isdigit():
|
if auto_download_quality.endswith("p") and auto_download_quality[:-1].isdigit():
|
||||||
auto_download_quality = auto_download_quality[:-1]
|
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_url = str(config.get("discord_webhook_url") or "").strip()
|
||||||
webhook_events = config.get("discord_webhook_events")
|
webhook_events = config.get("discord_webhook_events")
|
||||||
auth_username = str(config.get("auth_username") or "").strip()
|
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"}
|
auto_download_enabled = auto_download_enabled.strip().lower() in {"1", "true", "yes", "on"}
|
||||||
else:
|
else:
|
||||||
auto_download_enabled = bool(auto_download_enabled)
|
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:
|
try:
|
||||||
auto_refresh_minutes = int(str(auto_refresh_minutes).strip() or WATCHLIST_AUTO_REFRESH_DEFAULT_MINUTES)
|
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_enabled"] = auto_download_enabled
|
||||||
config["auto_download_mode"] = auto_download_mode if auto_download_mode in MODE_CHOICES else "dub"
|
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["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):
|
if isinstance(webhook_events, str):
|
||||||
webhook_events = [part.strip() for part in webhook_events.split(",")]
|
webhook_events = [part.strip() for part in webhook_events.split(",")]
|
||||||
elif not isinstance(webhook_events, list):
|
elif not isinstance(webhook_events, list):
|
||||||
|
|||||||
@@ -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>
|
<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>
|
||||||
|
|
||||||
|
<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">
|
<section class="settings settings-main">
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<div>
|
<div>
|
||||||
@@ -468,6 +493,9 @@ CONFIG_HTML = r"""<!doctype html>
|
|||||||
auto_download_enabled: false,
|
auto_download_enabled: false,
|
||||||
auto_download_mode: "dub",
|
auto_download_mode: "dub",
|
||||||
auto_download_quality: "best",
|
auto_download_quality: "best",
|
||||||
|
jellyfin_sync_enabled: false,
|
||||||
|
jellyfin_tv_dir: "",
|
||||||
|
jellyfin_movie_dir: "",
|
||||||
discord_webhook_url: "",
|
discord_webhook_url: "",
|
||||||
discord_webhook_events: []
|
discord_webhook_events: []
|
||||||
}
|
}
|
||||||
@@ -495,6 +523,9 @@ CONFIG_HTML = r"""<!doctype html>
|
|||||||
auto_download_enabled: $("autoDownloadEnabled").value === "true",
|
auto_download_enabled: $("autoDownloadEnabled").value === "true",
|
||||||
auto_download_mode: $("autoDownloadMode").value,
|
auto_download_mode: $("autoDownloadMode").value,
|
||||||
auto_download_quality: $("autoDownloadQuality").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_url: $("discordWebhookUrl").value,
|
||||||
discord_webhook_events: selectedWebhookEvents()
|
discord_webhook_events: selectedWebhookEvents()
|
||||||
};
|
};
|
||||||
@@ -511,6 +542,9 @@ CONFIG_HTML = r"""<!doctype html>
|
|||||||
$("autoDownloadEnabled").value = String(Boolean(data.auto_download_enabled));
|
$("autoDownloadEnabled").value = String(Boolean(data.auto_download_enabled));
|
||||||
$("autoDownloadMode").value = data.auto_download_mode;
|
$("autoDownloadMode").value = data.auto_download_mode;
|
||||||
$("autoDownloadQuality").value = data.auto_download_quality;
|
$("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 || "";
|
$("discordWebhookUrl").value = data.discord_webhook_url || "";
|
||||||
const selected = new Set(data.discord_webhook_events || []);
|
const selected = new Set(data.discord_webhook_events || []);
|
||||||
for (const input of document.querySelectorAll(".webhook-event")) {
|
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() {
|
async function loadDeps() {
|
||||||
const data = await api("/api/dependencies");
|
const data = await api("/api/dependencies");
|
||||||
const el = $("deps");
|
const el = $("deps");
|
||||||
@@ -569,6 +615,7 @@ CONFIG_HTML = r"""<!doctype html>
|
|||||||
|
|
||||||
$("saveConfigBtn").addEventListener("click", saveConfig);
|
$("saveConfigBtn").addEventListener("click", saveConfig);
|
||||||
$("testWebhookBtn").addEventListener("click", testWebhook);
|
$("testWebhookBtn").addEventListener("click", testWebhook);
|
||||||
|
$("runJellyfinSyncBtn").addEventListener("click", runJellyfinSync);
|
||||||
|
|
||||||
(async function init() {
|
(async function init() {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ class HandlerContext:
|
|||||||
index_html: str
|
index_html: str
|
||||||
queue_html: str
|
queue_html: str
|
||||||
remove_from_watchlist: object
|
remove_from_watchlist: object
|
||||||
|
run_jellyfin_sync: object
|
||||||
runtime_state: object
|
runtime_state: object
|
||||||
save_runtime_config: object
|
save_runtime_config: object
|
||||||
search_anime: object
|
search_anime: object
|
||||||
@@ -490,6 +491,9 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
self.ensure_client_access()
|
self.ensure_client_access()
|
||||||
if parsed.path == "/api/config":
|
if parsed.path == "/api/config":
|
||||||
Handler.update_config(self, Handler.require_json_object(self, self.body_json()))
|
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":
|
elif parsed.path == "/api/config/webhook/test":
|
||||||
payload = Handler.require_json_object(self, self.body_json())
|
payload = Handler.require_json_object(self, self.body_json())
|
||||||
self.json(Handler._context(self).test_discord_webhook_config(payload))
|
self.json(Handler._context(self).test_discord_webhook_config(payload))
|
||||||
|
|||||||
+127
-23
@@ -339,30 +339,31 @@ class QueueApiTests(unittest.TestCase):
|
|||||||
self.assertEqual(restored["media_type"], "movie")
|
self.assertEqual(restored["media_type"], "movie")
|
||||||
|
|
||||||
def test_movie_finalizer_uses_movie_folder_and_single_file_name(self):
|
def test_movie_finalizer_uses_movie_folder_and_single_file_name(self):
|
||||||
job = APP.app_support.build_job(
|
with tempfile.TemporaryDirectory() as temp_root:
|
||||||
{
|
job = APP.app_support.build_job(
|
||||||
"query": "Movie Show",
|
{
|
||||||
"title": "Movie Show",
|
"query": "Movie Show",
|
||||||
"anime_name": "Movie Show",
|
"title": "Movie Show",
|
||||||
"media_type": "movie",
|
"anime_name": "Movie Show",
|
||||||
"mode": "sub",
|
"media_type": "movie",
|
||||||
"quality": "best",
|
"mode": "sub",
|
||||||
"episodes": "1",
|
"quality": "best",
|
||||||
"download_dir": "/tmp/example",
|
"episodes": "1",
|
||||||
"season": "1",
|
"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)
|
staging_dir = APP.app_support.job_staging_dir(job)
|
||||||
source = staging_dir / "Movie Show Episode 1.mp4"
|
staging_dir.mkdir(parents=True, exist_ok=True)
|
||||||
source.write_bytes(b"movie-bytes")
|
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.assertEqual(moved, [f"{temp_root}/movie/Movie Show/Movie Show.mp4"])
|
||||||
self.assertFalse(source.exists())
|
self.assertFalse(source.exists())
|
||||||
self.assertTrue(Path(moved[0]).exists())
|
self.assertTrue(Path(moved[0]).exists())
|
||||||
|
|
||||||
def test_shutdown_wait_cancels_active_process_for_deterministic_teardown(self):
|
def test_shutdown_wait_cancels_active_process_for_deterministic_teardown(self):
|
||||||
queue = object.__new__(APP.DownloadQueue)
|
queue = object.__new__(APP.DownloadQueue)
|
||||||
@@ -1167,6 +1168,82 @@ class WatchlistCompletionTests(unittest.TestCase):
|
|||||||
self.assertEqual(result["job"], job)
|
self.assertEqual(result["job"], job)
|
||||||
self.assertIn("Queued Queue Show for dub download.", result["message"])
|
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):
|
def test_sync_downloaded_job_to_watchlist_rejects_ambiguous_fallback_match(self):
|
||||||
job = {"query": "Queue Show", "mode": "sub", "result_index": 1, "title": "Queue Show"}
|
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.assertFalse(config["auto_download_enabled"])
|
||||||
self.assertEqual(config["auto_download_mode"], "dub")
|
self.assertEqual(config["auto_download_mode"], "dub")
|
||||||
self.assertEqual(config["auto_download_quality"], "best")
|
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_url"], "")
|
||||||
self.assertEqual(config["discord_webhook_events"], [])
|
self.assertEqual(config["discord_webhook_events"], [])
|
||||||
|
|
||||||
@@ -1376,6 +1456,9 @@ class ConfigSnapshotTests(unittest.TestCase):
|
|||||||
"auto_download_enabled": "true",
|
"auto_download_enabled": "true",
|
||||||
"auto_download_mode": "sub",
|
"auto_download_mode": "sub",
|
||||||
"auto_download_quality": "720p",
|
"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"])
|
self.assertTrue(config["watchlist_auto_refresh_enabled"])
|
||||||
@@ -1384,6 +1467,9 @@ class ConfigSnapshotTests(unittest.TestCase):
|
|||||||
self.assertTrue(config["auto_download_enabled"])
|
self.assertTrue(config["auto_download_enabled"])
|
||||||
self.assertEqual(config["auto_download_mode"], "sub")
|
self.assertEqual(config["auto_download_mode"], "sub")
|
||||||
self.assertEqual(config["auto_download_quality"], "720")
|
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):
|
def test_normalize_config_filters_discord_webhook_events(self):
|
||||||
config = APP.normalize_config(
|
config = APP.normalize_config(
|
||||||
@@ -1728,6 +1814,9 @@ class StartupBehaviorTests(unittest.TestCase):
|
|||||||
"auto_download_enabled": True,
|
"auto_download_enabled": True,
|
||||||
"auto_download_mode": "sub",
|
"auto_download_mode": "sub",
|
||||||
"auto_download_quality": "720",
|
"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_url": "https://discord.example/webhook",
|
||||||
"discord_webhook_events": ["runtime_error"],
|
"discord_webhook_events": ["runtime_error"],
|
||||||
}
|
}
|
||||||
@@ -1741,6 +1830,9 @@ class StartupBehaviorTests(unittest.TestCase):
|
|||||||
self.assertTrue(APP.CONFIG["auto_download_enabled"])
|
self.assertTrue(APP.CONFIG["auto_download_enabled"])
|
||||||
self.assertEqual(APP.CONFIG["auto_download_mode"], "sub")
|
self.assertEqual(APP.CONFIG["auto_download_mode"], "sub")
|
||||||
self.assertEqual(APP.CONFIG["auto_download_quality"], "720")
|
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_url"], "https://discord.example/webhook")
|
||||||
self.assertEqual(APP.CONFIG["discord_webhook_events"], ["runtime_error"])
|
self.assertEqual(APP.CONFIG["discord_webhook_events"], ["runtime_error"])
|
||||||
APP.WATCHLIST_AUTO_REFRESH.notify_config_changed.assert_called_once_with()
|
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)
|
APP.initialize_runtime(start_workers=False)
|
||||||
|
|
||||||
def test_config_post_accepts_watchlist_schedule_fields(self):
|
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))
|
handler = DummyHandler("/api/config", body=body, content_length=len(body))
|
||||||
APP.Handler.do_POST(handler)
|
APP.Handler.do_POST(handler)
|
||||||
self.assertEqual(handler.json_status, HTTPStatus.OK)
|
self.assertEqual(handler.json_status, HTTPStatus.OK)
|
||||||
@@ -1788,6 +1880,9 @@ class HandlerRouteTests(unittest.TestCase):
|
|||||||
self.assertTrue(handler.json_payload["auto_download_enabled"])
|
self.assertTrue(handler.json_payload["auto_download_enabled"])
|
||||||
self.assertEqual(handler.json_payload["auto_download_mode"], "dub")
|
self.assertEqual(handler.json_payload["auto_download_mode"], "dub")
|
||||||
self.assertEqual(handler.json_payload["auto_download_quality"], "720")
|
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):
|
def test_version_route_includes_ani_cli_version(self):
|
||||||
handler = DummyHandler("/api/version")
|
handler = DummyHandler("/api/version")
|
||||||
@@ -1984,6 +2079,15 @@ class HandlerRouteTests(unittest.TestCase):
|
|||||||
self.assertEqual(handler.json_payload, payload)
|
self.assertEqual(handler.json_payload, payload)
|
||||||
self.assertEqual(handler.json_status, HTTPStatus.OK)
|
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):
|
def test_generic_handler_exception_skips_traceback_when_debug_disabled(self):
|
||||||
handler = DummyHandler("/api/config")
|
handler = DummyHandler("/api/config")
|
||||||
with mock.patch.object(http_handler, "debug_enabled", return_value=False), mock.patch.object(
|
with mock.patch.object(http_handler, "debug_enabled", return_value=False), mock.patch.object(
|
||||||
|
|||||||
Reference in New Issue
Block a user