Filter Jellyfin media sync by library
This commit is contained in:
parent
8ac0973340
commit
8a0e4ec014
3 changed files with 60 additions and 2 deletions
|
|
@ -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.
|
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:
|
Channel selections are stored in:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue