Filter Jellyfin media sync by library

This commit is contained in:
MiTHRAL 2026-05-15 15:46:17 -04:00
parent 8ac0973340
commit 8a0e4ec014
3 changed files with 60 additions and 2 deletions

View file

@ -204,7 +204,7 @@ Discord publishing uses one message with an attached `media-catalog.md` file so
The dashboard also serves a read-only catalog page at `/catalog`. Set the public reverse-proxy URL for that page in the `Media` tabs `Catalog URL` field, or with `PUBLIC_CATALOG_URL`, and Discord posts will include an `Open Catalog` button. The dashboard also serves a read-only catalog page at `/catalog`. Set the public reverse-proxy URL for that page in the `Media` tabs `Catalog URL` field, or with `PUBLIC_CATALOG_URL`, and Discord posts will include an `Open Catalog` button.
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 while comparing against the previously saved library. `Auto-sync changes` checks Jellyfin periodically and republishes only when the catalog fingerprint changes. Jellyfin results are deduplicated across libraries using provider IDs first, then normalized title and year. In Jellyfin, create an API key from the admin dashboard, then enter the Jellyfin URL and key in the `Media` tab. If you have duplicate free/premium libraries, enter only the premium Jellyfin library names in the `Libraries` field, separated by commas. `Sync now` replaces the editable library with the current Jellyfin movies and shows while comparing against the previously saved library. `Auto-sync changes` checks Jellyfin periodically and republishes only when the catalog fingerprint changes. Jellyfin results are deduplicated across included libraries using provider IDs first, then normalized title and year.
Channel selections are stored in: Channel selections are stored in:

View file

@ -566,6 +566,10 @@
<label for="jellyfinApiKey">API Key</label> <label for="jellyfinApiKey">API Key</label>
<input id="jellyfinApiKey" type="password" placeholder="Leave blank to keep saved key"> <input id="jellyfinApiKey" type="password" placeholder="Leave blank to keep saved key">
</div> </div>
<div class="media-field">
<label for="jellyfinLibraries">Libraries</label>
<input id="jellyfinLibraries" placeholder="Premium Movies, Premium Shows">
</div>
<label class="check-field"> <label class="check-field">
<input id="jellyfinAutoSync" type="checkbox"> <input id="jellyfinAutoSync" type="checkbox">
Auto-sync changes Auto-sync changes
@ -655,7 +659,7 @@
let channels = { statusChannelId: "", mediaChannelId: "", catalogUrl: "" }; let channels = { statusChannelId: "", mediaChannelId: "", catalogUrl: "" };
let mediaLibrary = { movies: [], shows: [] }; let mediaLibrary = { movies: [], shows: [] };
let activeMediaTab = "movies"; let activeMediaTab = "movies";
let jellyfin = { url: "", configured: false, autoSync: false }; let jellyfin = { url: "", configured: false, libraryNames: [], autoSync: false };
function headers(method = "GET") { function headers(method = "GET") {
const base = { "Content-Type": "application/json" }; const base = { "Content-Type": "application/json" };
@ -878,6 +882,7 @@
jellyfin = payload.jellyfin || payload || jellyfin; jellyfin = payload.jellyfin || payload || jellyfin;
document.querySelector("#jellyfinUrl").value = jellyfin.url || ""; document.querySelector("#jellyfinUrl").value = jellyfin.url || "";
document.querySelector("#jellyfinApiKey").value = ""; document.querySelector("#jellyfinApiKey").value = "";
document.querySelector("#jellyfinLibraries").value = Array.isArray(jellyfin.libraryNames) ? jellyfin.libraryNames.join(", ") : "";
document.querySelector("#jellyfinAutoSync").checked = Boolean(jellyfin.autoSync); document.querySelector("#jellyfinAutoSync").checked = Boolean(jellyfin.autoSync);
if (jellyfin.lastSyncError) { if (jellyfin.lastSyncError) {
setJellyfinMessage(`Last sync failed: ${jellyfin.lastSyncError}`, true); setJellyfinMessage(`Last sync failed: ${jellyfin.lastSyncError}`, true);
@ -901,6 +906,10 @@
return { return {
url: document.querySelector("#jellyfinUrl").value.trim(), url: document.querySelector("#jellyfinUrl").value.trim(),
apiKey: document.querySelector("#jellyfinApiKey").value.trim(), apiKey: document.querySelector("#jellyfinApiKey").value.trim(),
libraryNames: document.querySelector("#jellyfinLibraries").value
.split(",")
.map((name) => name.trim())
.filter(Boolean),
autoSync: document.querySelector("#jellyfinAutoSync").checked autoSync: document.querySelector("#jellyfinAutoSync").checked
}; };
} }

View file

@ -635,9 +635,13 @@ def save_media_channel_setting(runtime: BotRuntime, media_channel_id: str) -> di
def jellyfin_settings(runtime: BotRuntime) -> dict[str, Any]: def jellyfin_settings(runtime: BotRuntime) -> dict[str, Any]:
data = load_state(runtime.settings_path) data = load_state(runtime.settings_path)
api_key = str(data.get("jellyfin_api_key", "")).strip() api_key = str(data.get("jellyfin_api_key", "")).strip()
library_names = data.get("jellyfin_library_names", [])
if not isinstance(library_names, list):
library_names = []
return { return {
"url": str(data.get("jellyfin_url", "")).strip(), "url": str(data.get("jellyfin_url", "")).strip(),
"configured": bool(api_key), "configured": bool(api_key),
"libraryNames": [str(name) for name in library_names if str(name).strip()],
"autoSync": bool(data.get("jellyfin_auto_sync", False)), "autoSync": bool(data.get("jellyfin_auto_sync", False)),
"lastSyncAt": data.get("jellyfin_last_sync_at"), "lastSyncAt": data.get("jellyfin_last_sync_at"),
"lastSyncError": data.get("jellyfin_last_sync_error"), "lastSyncError": data.get("jellyfin_last_sync_error"),
@ -662,6 +666,14 @@ def save_jellyfin_settings(runtime: BotRuntime, data: dict[str, Any]) -> dict[st
state["jellyfin_api_key"] = api_key state["jellyfin_api_key"] = api_key
elif data.get("clearApiKey"): elif data.get("clearApiKey"):
state["jellyfin_api_key"] = "" state["jellyfin_api_key"] = ""
raw_library_names = data.get("libraryNames", [])
if isinstance(raw_library_names, str):
library_names = [name.strip() for name in raw_library_names.split(",") if name.strip()]
elif isinstance(raw_library_names, list):
library_names = [str(name).strip() for name in raw_library_names if str(name).strip()]
else:
raise ValueError("Jellyfin libraries must be a list or comma-separated string")
state["jellyfin_library_names"] = library_names
state["jellyfin_auto_sync"] = bool(data.get("autoSync", False)) state["jellyfin_auto_sync"] = bool(data.get("autoSync", False))
state["updated_at"] = datetime.now(timezone.utc).isoformat() state["updated_at"] = datetime.now(timezone.utc).isoformat()
save_state(runtime.settings_path, state) save_state(runtime.settings_path, state)
@ -878,10 +890,43 @@ def jellyfin_request(settings: dict[str, Any], path: str, params: dict[str, Any]
raise RuntimeError(f"Jellyfin API failed: {clean_error(exc)}") from exc raise RuntimeError(f"Jellyfin API failed: {clean_error(exc)}") from exc
def fetch_jellyfin_library_ids(settings: dict[str, Any], library_names: list[str]) -> list[str]:
if not library_names:
return []
requested = {name.casefold() for name in library_names}
data = jellyfin_request(settings, "/Library/MediaFolders", {})
items = data.get("Items", [])
if not isinstance(items, list):
raise RuntimeError("Jellyfin returned an invalid library folder payload")
matched: list[str] = []
available: list[str] = []
for item in items:
if not isinstance(item, dict):
continue
name = str(item.get("Name", "")).strip()
folder_id = str(item.get("Id", "")).strip()
if name:
available.append(name)
if name.casefold() in requested and folder_id:
matched.append(folder_id)
missing = sorted(set(library_names) - {name for name in available if name.casefold() in requested})
if missing:
raise ValueError(f"Jellyfin library not found: {', '.join(missing)}")
return matched
def fetch_jellyfin_items(settings: dict[str, Any], item_type: str) -> list[dict[str, Any]]: def fetch_jellyfin_items(settings: dict[str, Any], item_type: str) -> list[dict[str, Any]]:
items: list[dict[str, Any]] = [] items: list[dict[str, Any]] = []
start_index = 0 start_index = 0
limit = 200 limit = 200
parent_ids = settings.get("ParentIds")
if isinstance(parent_ids, list) and parent_ids:
parent_id_value = ",".join(str(parent_id) for parent_id in parent_ids)
else:
parent_id_value = None
while True: while True:
data = jellyfin_request( data = jellyfin_request(
settings, settings,
@ -893,6 +938,7 @@ def fetch_jellyfin_items(settings: dict[str, Any], item_type: str) -> list[dict[
"SortBy": "SortName", "SortBy": "SortName",
"SortOrder": "Ascending", "SortOrder": "Ascending",
"EnableImages": "false", "EnableImages": "false",
"ParentId": parent_id_value,
"StartIndex": start_index, "StartIndex": start_index,
"Limit": limit, "Limit": limit,
}, },
@ -960,6 +1006,9 @@ def fetch_jellyfin_library(runtime: BotRuntime) -> tuple[list[MediaItem], list[M
"url": str(state.get("jellyfin_url", "")).strip(), "url": str(state.get("jellyfin_url", "")).strip(),
"apiKey": str(state.get("jellyfin_api_key", "")).strip(), "apiKey": str(state.get("jellyfin_api_key", "")).strip(),
} }
library_names = jellyfin_settings(runtime).get("libraryNames", [])
if isinstance(library_names, list) and library_names:
settings["ParentIds"] = fetch_jellyfin_library_ids(settings, [str(name) for name in library_names])
movie_items = dedupe_jellyfin_items(fetch_jellyfin_items(settings, "Movie"), "Movie") movie_items = dedupe_jellyfin_items(fetch_jellyfin_items(settings, "Movie"), "Movie")
show_items = dedupe_jellyfin_items(fetch_jellyfin_items(settings, "Series"), "Series") show_items = dedupe_jellyfin_items(fetch_jellyfin_items(settings, "Series"), "Series")
movies = [jellyfin_movie_from_item(item) for item in movie_items] movies = [jellyfin_movie_from_item(item) for item in movie_items]