From 942c8c3a54f299d51540b20bd141309612f1ae87 Mon Sep 17 00:00:00 2001 From: MiTHRAL Date: Fri, 15 May 2026 15:30:04 -0400 Subject: [PATCH] Sync media catalog from Jellyfin --- .env.deploy.example | 1 + .env.example | 1 + README.md | 8 ++ dashboard.html | 121 ++++++++++++++++++- status_bot.py | 283 ++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 412 insertions(+), 2 deletions(-) diff --git a/.env.deploy.example b/.env.deploy.example index b0f6f91..9424ba2 100644 --- a/.env.deploy.example +++ b/.env.deploy.example @@ -5,6 +5,7 @@ ARCHIVE_STATUS_STATE=state/status-message.json MEDIA_CATALOG_STATE=state/media-catalog.json MEDIA_LIBRARY_STATE=state/media-library.json BOT_SETTINGS_STATE=state/bot-settings.json +JELLYFIN_SYNC_INTERVAL_SECONDS=900 CHECK_INTERVAL_SECONDS=60 HTTP_USER_AGENT=ArchiveStatusBot/1.0 DISCORD_DRY_RUN=false diff --git a/.env.example b/.env.example index e3cb2b4..4022e75 100644 --- a/.env.example +++ b/.env.example @@ -5,6 +5,7 @@ ARCHIVE_STATUS_STATE=state/status-message.json MEDIA_CATALOG_STATE=state/media-catalog.json MEDIA_LIBRARY_STATE=state/media-library.json BOT_SETTINGS_STATE=state/bot-settings.json +JELLYFIN_SYNC_INTERVAL_SECONDS=900 CHECK_INTERVAL_SECONDS=60 HTTP_USER_AGENT=ArchiveStatusBot/1.0 DISCORD_DRY_RUN=false diff --git a/README.md b/README.md index 2a85ee3..b4e008d 100644 --- a/README.md +++ b/README.md @@ -201,6 +201,8 @@ The editor supports adding, editing, and deleting movie/show rows before saving Discord publishing uses one message with an attached `media-catalog.md` file so the channel does not get flooded by a long embed wall. +You can also sync directly from Jellyfin instead of using CSVs. In Jellyfin, create an API key from the admin dashboard, then enter the Jellyfin URL and key in the `Media` tab. `Sync now` replaces the editable library with the current Jellyfin movies and shows. `Auto-sync changes` checks Jellyfin periodically and republishes only when the catalog fingerprint changes. + Channel selections are stored in: ```env @@ -223,6 +225,12 @@ MEDIA_LIBRARY_STATE=state/media-library.json Republishing deletes the previous media catalog message and posts a fresh compact Markdown attachment. +The auto-sync interval defaults to 15 minutes: + +```env +JELLYFIN_SYNC_INTERVAL_SECONDS=900 +``` + ## Service Config Each service supports: diff --git a/dashboard.html b/dashboard.html index fdb4a8b..ff0df48 100644 --- a/dashboard.html +++ b/dashboard.html @@ -307,6 +307,20 @@ padding: 7px; } + .check-field { + display: flex; + align-items: center; + gap: 8px; + padding-top: 25px; + color: var(--muted); + font-weight: 650; + } + + .check-field input { + width: auto; + min-height: 0; + } + .media-status { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); @@ -538,6 +552,32 @@ +
+
+
Jellyfin Sync
+
+
+
+
+ + +
+
+ + +
+ +
+
+ + + +
+
+
CSV Upload
@@ -607,6 +647,7 @@ const libraryCountEl = document.querySelector("#libraryCount"); const messageEl = document.querySelector("#message"); const mediaMessageEl = document.querySelector("#mediaMessage"); + const jellyfinStateEl = document.querySelector("#jellyfinState"); const loginMessageEl = document.querySelector("#loginMessage"); let services = []; @@ -615,6 +656,7 @@ let channels = { statusChannelId: "", mediaChannelId: "" }; let mediaLibrary = { movies: [], shows: [] }; let activeMediaTab = "movies"; + let jellyfin = { url: "", configured: false, autoSync: false }; function headers(method = "GET") { const base = { "Content-Type": "application/json" }; @@ -651,6 +693,11 @@ mediaMessageEl.classList.toggle("error", isError); } + function setJellyfinMessage(text, isError = false) { + jellyfinStateEl.textContent = text; + jellyfinStateEl.classList.toggle("error", isError); + } + function showView(name) { statusViewEl.hidden = name !== "status"; mediaViewEl.hidden = name !== "media"; @@ -822,6 +869,63 @@ return payload; } + function renderJellyfinStatus(payload = {}) { + jellyfin = payload.jellyfin || payload || jellyfin; + document.querySelector("#jellyfinUrl").value = jellyfin.url || ""; + document.querySelector("#jellyfinApiKey").value = ""; + document.querySelector("#jellyfinAutoSync").checked = Boolean(jellyfin.autoSync); + if (jellyfin.lastSyncError) { + setJellyfinMessage(`Last sync failed: ${jellyfin.lastSyncError}`, true); + return; + } + if (jellyfin.lastSyncAt) { + const synced = new Date(jellyfin.lastSyncAt).toLocaleString([], { dateStyle: "short", timeStyle: "short" }); + setJellyfinMessage(`Last synced ${synced}`); + return; + } + setJellyfinMessage(jellyfin.configured ? "Ready to sync." : "Add a Jellyfin URL and API key."); + } + + async function loadJellyfinStatus() { + const payload = await api("/api/jellyfin"); + renderJellyfinStatus(payload); + return payload; + } + + function currentJellyfinSettings() { + return { + url: document.querySelector("#jellyfinUrl").value.trim(), + apiKey: document.querySelector("#jellyfinApiKey").value.trim(), + autoSync: document.querySelector("#jellyfinAutoSync").checked + }; + } + + async function saveJellyfinSettings() { + const payload = await api("/api/jellyfin/settings", { + method: "POST", + body: JSON.stringify(currentJellyfinSettings()) + }); + renderJellyfinStatus(payload); + setJellyfinMessage("Saved Jellyfin settings."); + return payload; + } + + async function syncJellyfin(forcePublish = false) { + setJellyfinMessage(forcePublish ? "Syncing and publishing..." : "Syncing Jellyfin..."); + const settings = currentJellyfinSettings(); + const payload = await api("/api/jellyfin/sync", { + method: "POST", + body: JSON.stringify({ ...settings, forcePublish }) + }); + mediaLibrary = normalizeMediaLibrary(payload.library); + renderMediaLibrary(); + document.querySelector("#mediaMovieCount").textContent = payload.movieCount; + document.querySelector("#mediaShowCount").textContent = payload.showCount; + renderJellyfinStatus(payload); + const action = payload.published ? "published" : payload.changed ? "updated library" : "no changes"; + setJellyfinMessage(`Synced ${payload.movieCount} movies and ${payload.showCount} shows; ${action}.`); + } + function normalizeMediaLibrary(library = {}) { return { movies: Array.isArray(library.movies) ? library.movies.map((item) => normalizeMediaItem(item, "movie")) : [], @@ -1093,11 +1197,23 @@ }); document.querySelector("#mediaRefresh").addEventListener("click", () => { - loadMediaStatus() + Promise.all([loadMediaStatus(), loadJellyfinStatus()]) .then(() => setMediaMessage("")) .catch((error) => setMediaMessage(error.message, true)); }); + document.querySelector("#saveJellyfin").addEventListener("click", () => { + saveJellyfinSettings().catch((error) => setJellyfinMessage(error.message, true)); + }); + + document.querySelector("#syncJellyfin").addEventListener("click", () => { + syncJellyfin(false).catch((error) => setJellyfinMessage(error.message, true)); + }); + + document.querySelector("#forceJellyfinPublish").addEventListener("click", () => { + syncJellyfin(true).catch((error) => setJellyfinMessage(error.message, true)); + }); + document.querySelector("#importMediaCsv").addEventListener("click", () => { importMediaCsv().catch((error) => setMediaMessage(error.message, true)); }); @@ -1118,7 +1234,8 @@ item.addEventListener("click", () => { showView(item.dataset.view); if (item.dataset.view === "media") { - loadMediaStatus().catch((error) => setMediaMessage(error.message, true)); + Promise.all([loadMediaStatus(), loadJellyfinStatus()]) + .catch((error) => setMediaMessage(error.message, true)); } }); }); diff --git a/status_bot.py b/status_bot.py index 3c695c4..53ea8fa 100644 --- a/status_bot.py +++ b/status_bot.py @@ -39,6 +39,7 @@ MAX_REQUEST_BYTES = 8_000_000 SESSION_COOKIE = "archive_bot_session" PBKDF2_ITERATIONS = 390_000 MEDIA_ITEMS_PER_EMBED = 10 +DEFAULT_JELLYFIN_SYNC_INTERVAL_SECONDS = 900 @dataclass(frozen=True) @@ -619,6 +620,41 @@ def save_media_channel_setting(runtime: BotRuntime, media_channel_id: str) -> di return channel_settings(runtime) +def jellyfin_settings(runtime: BotRuntime) -> dict[str, Any]: + data = load_state(runtime.settings_path) + api_key = str(data.get("jellyfin_api_key", "")).strip() + return { + "url": str(data.get("jellyfin_url", "")).strip(), + "configured": bool(api_key), + "autoSync": bool(data.get("jellyfin_auto_sync", False)), + "lastSyncAt": data.get("jellyfin_last_sync_at"), + "lastSyncError": data.get("jellyfin_last_sync_error"), + "lastPublishedAt": data.get("jellyfin_last_published_at"), + "lastFingerprint": data.get("jellyfin_last_fingerprint"), + "lastPublishedFingerprint": data.get("jellyfin_last_published_fingerprint"), + } + + +def save_jellyfin_settings(runtime: BotRuntime, data: dict[str, Any]) -> dict[str, Any]: + state = load_state(runtime.settings_path) + url = str(data.get("url", "")).strip().rstrip("/") + if url: + parsed = urllib.parse.urlparse(url) + if parsed.scheme not in {"http", "https"} or not parsed.netloc: + raise ValueError("Jellyfin URL must be a valid http(s) URL") + + api_key = str(data.get("apiKey", "")).strip() + state["jellyfin_url"] = url + if api_key: + state["jellyfin_api_key"] = api_key + elif data.get("clearApiKey"): + state["jellyfin_api_key"] = "" + state["jellyfin_auto_sync"] = bool(data.get("autoSync", False)) + state["updated_at"] = datetime.now(timezone.utc).isoformat() + save_state(runtime.settings_path, state) + return jellyfin_settings(runtime) + + def normalize_csv_key(value: str) -> str: return "".join(character for character in value.lower() if character.isalnum()) @@ -912,6 +948,212 @@ def import_media_csvs( } +def jellyfin_runtime(runtime_ticks: Any) -> str | None: + try: + ticks = int(runtime_ticks or 0) + except (TypeError, ValueError): + return None + if ticks <= 0: + return None + minutes = round(ticks / 10_000_000 / 60) + if minutes <= 0: + return None + hours, remainder = divmod(minutes, 60) + if hours and remainder: + return f"{hours}h {remainder}m" + if hours: + return f"{hours}h" + return f"{remainder}m" + + +def jellyfin_item_year(item: dict[str, Any]) -> str | None: + production_year = item.get("ProductionYear") + if production_year: + return parse_year_text(str(production_year)) + return parse_year_text(str(item.get("PremiereDate", ""))) + + +def jellyfin_item_summary(item: dict[str, Any]) -> str | None: + return clean_media_text(str(item.get("Overview", "") or item.get("ShortOverview", ""))) + + +def jellyfin_movie_from_item(item: dict[str, Any]) -> MediaItem: + return MediaItem( + title=clean_media_text(str(item.get("Name", "")), 120) or "Untitled Movie", + media_type="movie", + year=jellyfin_item_year(item), + genres=clean_media_text(", ".join(str(genre) for genre in item.get("Genres", []) if genre), 120), + rating=clean_media_text(str(item.get("OfficialRating", "")), 32), + runtime=jellyfin_runtime(item.get("RunTimeTicks")), + summary=jellyfin_item_summary(item), + ) + + +def jellyfin_show_from_item(item: dict[str, Any]) -> MediaItem: + return MediaItem( + title=clean_media_text(str(item.get("Name", "")), 120) or "Untitled Show", + media_type="show", + year=jellyfin_item_year(item), + genres=clean_media_text(", ".join(str(genre) for genre in item.get("Genres", []) if genre), 120), + rating=clean_media_text(str(item.get("OfficialRating", "")), 32), + summary=jellyfin_item_summary(item), + seasons=parse_int_text(str(item.get("ChildCount", ""))), + episodes=parse_int_text(str(item.get("RecursiveItemCount", ""))), + ) + + +def jellyfin_request(settings: dict[str, Any], path: str, params: dict[str, Any]) -> dict[str, Any]: + base_url = str(settings.get("url", "")).strip().rstrip("/") + api_key = str(settings.get("apiKey", "")).strip() + if not base_url or not api_key: + raise ValueError("Jellyfin URL and API key are required") + + query = urllib.parse.urlencode({key: value for key, value in params.items() if value is not None}) + url = f"{base_url}{path}" + if query: + url = f"{url}?{query}" + request = urllib.request.Request( + url, + headers={ + "Accept": "application/json", + "User-Agent": "ArchiveStatusBot/1.0", + "X-Emby-Token": api_key, + }, + method="GET", + ) + try: + with urllib.request.urlopen(request, timeout=30) as response: + return json.loads(response.read().decode("utf-8")) + except urllib.error.HTTPError as exc: + detail = exc.read().decode("utf-8", errors="ignore") + raise RuntimeError(f"Jellyfin API failed: HTTP {exc.code} {detail}") from exc + except (urllib.error.URLError, TimeoutError, socket.timeout) as exc: + raise RuntimeError(f"Jellyfin API failed: {clean_error(exc)}") from exc + + +def fetch_jellyfin_items(settings: dict[str, Any], item_type: str) -> list[dict[str, Any]]: + items: list[dict[str, Any]] = [] + start_index = 0 + limit = 200 + while True: + data = jellyfin_request( + settings, + "/Items", + { + "Recursive": "true", + "IncludeItemTypes": item_type, + "Fields": "Genres,Overview,OfficialRating,RecursiveItemCount,ChildCount,PremiereDate,RunTimeTicks", + "SortBy": "SortName", + "SortOrder": "Ascending", + "EnableImages": "false", + "StartIndex": start_index, + "Limit": limit, + }, + ) + page = data.get("Items", []) + if not isinstance(page, list): + raise RuntimeError("Jellyfin returned an invalid Items payload") + items.extend(item for item in page if isinstance(item, dict)) + total = int(data.get("TotalRecordCount", len(items)) or len(items)) + if not page or len(items) >= total: + return items + start_index += limit + + +def fetch_jellyfin_library(runtime: BotRuntime) -> tuple[list[MediaItem], list[MediaItem]]: + state = load_state(runtime.settings_path) + settings = { + "url": str(state.get("jellyfin_url", "")).strip(), + "apiKey": str(state.get("jellyfin_api_key", "")).strip(), + } + movies = [jellyfin_movie_from_item(item) for item in fetch_jellyfin_items(settings, "Movie")] + shows = [jellyfin_show_from_item(item) for item in fetch_jellyfin_items(settings, "Series")] + return ( + sorted(movies, key=lambda item: (item.title.casefold(), item.year or "")), + sorted(shows, key=lambda item: (item.title.casefold(), item.year or "")), + ) + + +def media_library_fingerprint(movies: list[MediaItem], shows: list[MediaItem]) -> str: + payload = media_library_to_jsonable(movies, shows) + body = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8") + return hashlib.sha256(body).hexdigest() + + +def update_jellyfin_sync_state( + runtime: BotRuntime, + *, + fingerprint: str | None = None, + error: str | None = None, + published: bool = False, +) -> None: + state = load_state(runtime.settings_path) + now = datetime.now(timezone.utc).isoformat() + state["jellyfin_last_sync_at"] = now + state["jellyfin_last_sync_error"] = error + if fingerprint is not None: + state["jellyfin_last_fingerprint"] = fingerprint + if published: + state["jellyfin_last_published_at"] = now + if fingerprint is not None: + state["jellyfin_last_published_fingerprint"] = fingerprint + save_state(runtime.settings_path, state) + + +def sync_jellyfin_library(runtime: BotRuntime, force_publish: bool = False) -> dict[str, Any]: + movies, shows = fetch_jellyfin_library(runtime) + fingerprint = media_library_fingerprint(movies, shows) + settings_state = load_state(runtime.settings_path) + changed = fingerprint != str(settings_state.get("jellyfin_last_fingerprint", "")) + publish_changed = fingerprint != str(settings_state.get("jellyfin_last_published_fingerprint", "")) + save_media_library(runtime, movies, shows) + + published = False + result: dict[str, Any] = {} + if (publish_changed or force_publish) and not runtime.dry_run: + result = publish_media_items( + runtime=runtime, + channel_id=channel_settings(runtime)["mediaChannelId"], + movies_all=movies, + shows_all=shows, + ) + published = True + + update_jellyfin_sync_state(runtime, fingerprint=fingerprint, published=published) + return { + "changed": changed, + "published": published, + "movieCount": len(movies), + "showCount": len(shows), + "library": media_library_to_jsonable(movies, shows), + "publishResult": result, + "jellyfin": jellyfin_settings(runtime), + } + + +def maybe_run_jellyfin_sync(runtime: BotRuntime) -> dict[str, Any] | None: + settings = jellyfin_settings(runtime) + if not settings["configured"] or not settings["autoSync"]: + return None + + interval = int(os.getenv("JELLYFIN_SYNC_INTERVAL_SECONDS", str(DEFAULT_JELLYFIN_SYNC_INTERVAL_SECONDS))) + state = load_state(runtime.settings_path) + last_sync = str(state.get("jellyfin_last_sync_at", "")).strip() + if last_sync: + try: + last = datetime.fromisoformat(last_sync) + if (datetime.now(timezone.utc) - last).total_seconds() < interval: + return None + except ValueError: + pass + + try: + return sync_jellyfin_library(runtime) + except Exception as exc: + update_jellyfin_sync_state(runtime, error=str(exc)[:240]) + raise + + def media_item_heading(item: MediaItem) -> str: if item.year: return f"{item.title} ({item.year})" @@ -1459,6 +1701,11 @@ def make_dashboard_handler(runtime: BotRuntime, auth: DashboardAuth | None) -> t return self.send_json(HTTPStatus.OK, {"channels": channel_settings(runtime)}) return + if self.path == "/api/jellyfin": + if self.require_auth() is None: + return + self.send_json(HTTPStatus.OK, {"jellyfin": jellyfin_settings(runtime)}) + return self.send_error(HTTPStatus.NOT_FOUND) def do_POST(self) -> None: @@ -1489,6 +1736,12 @@ def make_dashboard_handler(runtime: BotRuntime, auth: DashboardAuth | None) -> t if self.path == "/api/settings": self.handle_settings() return + if self.path == "/api/jellyfin/settings": + self.handle_jellyfin_settings() + return + if self.path == "/api/jellyfin/sync": + self.handle_jellyfin_sync() + return self.send_error(HTTPStatus.NOT_FOUND) def require_auth(self, require_csrf: bool = False) -> tuple[str, DashboardSession] | None: @@ -1746,6 +1999,29 @@ def make_dashboard_handler(runtime: BotRuntime, auth: DashboardAuth | None) -> t self.send_json(HTTPStatus.OK, {"channels": result}) + def handle_jellyfin_settings(self) -> None: + try: + data = self.read_json() + result = save_jellyfin_settings(runtime, data) + except Exception as exc: + self.send_json(HTTPStatus.BAD_REQUEST, {"error": str(exc)}) + return + + self.send_json(HTTPStatus.OK, {"jellyfin": result}) + + def handle_jellyfin_sync(self) -> None: + try: + data = self.read_json() + if data: + save_jellyfin_settings(runtime, data) + result = sync_jellyfin_library(runtime, force_publish=bool(data.get("forcePublish", False))) + except Exception as exc: + update_jellyfin_sync_state(runtime, error=str(exc)[:240]) + self.send_json(HTTPStatus.BAD_REQUEST, {"error": str(exc), "jellyfin": jellyfin_settings(runtime)}) + return + + self.send_json(HTTPStatus.OK, result) + return DashboardHandler @@ -1817,6 +2093,13 @@ def main() -> int: message_id, results = run_check_cycle(runtime) online = sum(1 for result in results if result.ok) print(f"Updated Discord status message {message_id}: {online}/{len(results)} online", flush=True) + sync_result = maybe_run_jellyfin_sync(runtime) + if sync_result is not None: + action = "published" if sync_result["published"] else "checked" + print( + f"Jellyfin sync {action}: {sync_result['movieCount']} movies, {sync_result['showCount']} shows", + flush=True, + ) except Exception as exc: with runtime.lock: runtime.last_error = str(exc)