diff --git a/README.md b/README.md
index 94be70f..6f118e5 100644
--- a/README.md
+++ b/README.md
@@ -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` tab’s `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:
diff --git a/dashboard.html b/dashboard.html
index b8d8452..04445c3 100644
--- a/dashboard.html
+++ b/dashboard.html
@@ -566,6 +566,10 @@
API Key
+
+ Libraries
+
+
Auto-sync changes
@@ -655,7 +659,7 @@
let channels = { statusChannelId: "", mediaChannelId: "", catalogUrl: "" };
let mediaLibrary = { movies: [], shows: [] };
let activeMediaTab = "movies";
- let jellyfin = { url: "", configured: false, autoSync: false };
+ let jellyfin = { url: "", configured: false, libraryNames: [], autoSync: false };
function headers(method = "GET") {
const base = { "Content-Type": "application/json" };
@@ -878,6 +882,7 @@
jellyfin = payload.jellyfin || payload || jellyfin;
document.querySelector("#jellyfinUrl").value = jellyfin.url || "";
document.querySelector("#jellyfinApiKey").value = "";
+ document.querySelector("#jellyfinLibraries").value = Array.isArray(jellyfin.libraryNames) ? jellyfin.libraryNames.join(", ") : "";
document.querySelector("#jellyfinAutoSync").checked = Boolean(jellyfin.autoSync);
if (jellyfin.lastSyncError) {
setJellyfinMessage(`Last sync failed: ${jellyfin.lastSyncError}`, true);
@@ -901,6 +906,10 @@
return {
url: document.querySelector("#jellyfinUrl").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
};
}
diff --git a/status_bot.py b/status_bot.py
index b85d835..c20acd5 100644
--- a/status_bot.py
+++ b/status_bot.py
@@ -635,9 +635,13 @@ def save_media_channel_setting(runtime: BotRuntime, media_channel_id: str) -> di
def jellyfin_settings(runtime: BotRuntime) -> dict[str, Any]:
data = load_state(runtime.settings_path)
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 {
"url": str(data.get("jellyfin_url", "")).strip(),
"configured": bool(api_key),
+ "libraryNames": [str(name) for name in library_names if str(name).strip()],
"autoSync": bool(data.get("jellyfin_auto_sync", False)),
"lastSyncAt": data.get("jellyfin_last_sync_at"),
"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
elif data.get("clearApiKey"):
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["updated_at"] = datetime.now(timezone.utc).isoformat()
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
+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]]:
items: list[dict[str, Any]] = []
start_index = 0
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:
data = jellyfin_request(
settings,
@@ -893,6 +938,7 @@ def fetch_jellyfin_items(settings: dict[str, Any], item_type: str) -> list[dict[
"SortBy": "SortName",
"SortOrder": "Ascending",
"EnableImages": "false",
+ "ParentId": parent_id_value,
"StartIndex": start_index,
"Limit": limit,
},
@@ -960,6 +1006,9 @@ def fetch_jellyfin_library(runtime: BotRuntime) -> tuple[list[MediaItem], list[M
"url": str(state.get("jellyfin_url", "")).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")
show_items = dedupe_jellyfin_items(fetch_jellyfin_items(settings, "Series"), "Series")
movies = [jellyfin_movie_from_item(item) for item in movie_items]