Sync media catalog from Jellyfin

This commit is contained in:
MiTHRAL 2026-05-15 15:30:04 -04:00
parent 08b40b0db0
commit 942c8c3a54
5 changed files with 412 additions and 2 deletions

View file

@ -5,6 +5,7 @@ ARCHIVE_STATUS_STATE=state/status-message.json
MEDIA_CATALOG_STATE=state/media-catalog.json MEDIA_CATALOG_STATE=state/media-catalog.json
MEDIA_LIBRARY_STATE=state/media-library.json MEDIA_LIBRARY_STATE=state/media-library.json
BOT_SETTINGS_STATE=state/bot-settings.json BOT_SETTINGS_STATE=state/bot-settings.json
JELLYFIN_SYNC_INTERVAL_SECONDS=900
CHECK_INTERVAL_SECONDS=60 CHECK_INTERVAL_SECONDS=60
HTTP_USER_AGENT=ArchiveStatusBot/1.0 HTTP_USER_AGENT=ArchiveStatusBot/1.0
DISCORD_DRY_RUN=false DISCORD_DRY_RUN=false

View file

@ -5,6 +5,7 @@ ARCHIVE_STATUS_STATE=state/status-message.json
MEDIA_CATALOG_STATE=state/media-catalog.json MEDIA_CATALOG_STATE=state/media-catalog.json
MEDIA_LIBRARY_STATE=state/media-library.json MEDIA_LIBRARY_STATE=state/media-library.json
BOT_SETTINGS_STATE=state/bot-settings.json BOT_SETTINGS_STATE=state/bot-settings.json
JELLYFIN_SYNC_INTERVAL_SECONDS=900
CHECK_INTERVAL_SECONDS=60 CHECK_INTERVAL_SECONDS=60
HTTP_USER_AGENT=ArchiveStatusBot/1.0 HTTP_USER_AGENT=ArchiveStatusBot/1.0
DISCORD_DRY_RUN=false DISCORD_DRY_RUN=false

View file

@ -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. 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: Channel selections are stored in:
```env ```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. 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 ## Service Config
Each service supports: Each service supports:

View file

@ -307,6 +307,20 @@
padding: 7px; 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 { .media-status {
display: grid; display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr)); grid-template-columns: repeat(4, minmax(0, 1fr));
@ -538,6 +552,32 @@
</div> </div>
</div> </div>
<section class="panel">
<div class="panel-header">
<div class="panel-title">Jellyfin Sync</div>
<div id="jellyfinState" class="message"></div>
</div>
<div class="media-form">
<div class="media-field">
<label for="jellyfinUrl">Jellyfin URL</label>
<input id="jellyfinUrl" placeholder="https://jellyfin.example.com">
</div>
<div class="media-field">
<label for="jellyfinApiKey">API Key</label>
<input id="jellyfinApiKey" type="password" placeholder="Leave blank to keep saved key">
</div>
<label class="check-field">
<input id="jellyfinAutoSync" type="checkbox">
Auto-sync changes
</label>
</div>
<div class="media-toolbar">
<button id="saveJellyfin" type="button">Save Jellyfin</button>
<button id="syncJellyfin" type="button">Sync now</button>
<button id="forceJellyfinPublish" type="button">Force publish</button>
</div>
</section>
<section class="panel"> <section class="panel">
<div class="panel-header"> <div class="panel-header">
<div class="panel-title">CSV Upload</div> <div class="panel-title">CSV Upload</div>
@ -607,6 +647,7 @@
const libraryCountEl = document.querySelector("#libraryCount"); const libraryCountEl = document.querySelector("#libraryCount");
const messageEl = document.querySelector("#message"); const messageEl = document.querySelector("#message");
const mediaMessageEl = document.querySelector("#mediaMessage"); const mediaMessageEl = document.querySelector("#mediaMessage");
const jellyfinStateEl = document.querySelector("#jellyfinState");
const loginMessageEl = document.querySelector("#loginMessage"); const loginMessageEl = document.querySelector("#loginMessage");
let services = []; let services = [];
@ -615,6 +656,7 @@
let channels = { statusChannelId: "", mediaChannelId: "" }; let channels = { statusChannelId: "", mediaChannelId: "" };
let mediaLibrary = { movies: [], shows: [] }; let mediaLibrary = { movies: [], shows: [] };
let activeMediaTab = "movies"; let activeMediaTab = "movies";
let jellyfin = { url: "", configured: false, autoSync: false };
function headers(method = "GET") { function headers(method = "GET") {
const base = { "Content-Type": "application/json" }; const base = { "Content-Type": "application/json" };
@ -651,6 +693,11 @@
mediaMessageEl.classList.toggle("error", isError); mediaMessageEl.classList.toggle("error", isError);
} }
function setJellyfinMessage(text, isError = false) {
jellyfinStateEl.textContent = text;
jellyfinStateEl.classList.toggle("error", isError);
}
function showView(name) { function showView(name) {
statusViewEl.hidden = name !== "status"; statusViewEl.hidden = name !== "status";
mediaViewEl.hidden = name !== "media"; mediaViewEl.hidden = name !== "media";
@ -822,6 +869,63 @@
return payload; 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 = {}) { function normalizeMediaLibrary(library = {}) {
return { return {
movies: Array.isArray(library.movies) ? library.movies.map((item) => normalizeMediaItem(item, "movie")) : [], movies: Array.isArray(library.movies) ? library.movies.map((item) => normalizeMediaItem(item, "movie")) : [],
@ -1093,11 +1197,23 @@
}); });
document.querySelector("#mediaRefresh").addEventListener("click", () => { document.querySelector("#mediaRefresh").addEventListener("click", () => {
loadMediaStatus() Promise.all([loadMediaStatus(), loadJellyfinStatus()])
.then(() => setMediaMessage("")) .then(() => setMediaMessage(""))
.catch((error) => setMediaMessage(error.message, true)); .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", () => { document.querySelector("#importMediaCsv").addEventListener("click", () => {
importMediaCsv().catch((error) => setMediaMessage(error.message, true)); importMediaCsv().catch((error) => setMediaMessage(error.message, true));
}); });
@ -1118,7 +1234,8 @@
item.addEventListener("click", () => { item.addEventListener("click", () => {
showView(item.dataset.view); showView(item.dataset.view);
if (item.dataset.view === "media") { if (item.dataset.view === "media") {
loadMediaStatus().catch((error) => setMediaMessage(error.message, true)); Promise.all([loadMediaStatus(), loadJellyfinStatus()])
.catch((error) => setMediaMessage(error.message, true));
} }
}); });
}); });

View file

@ -39,6 +39,7 @@ MAX_REQUEST_BYTES = 8_000_000
SESSION_COOKIE = "archive_bot_session" SESSION_COOKIE = "archive_bot_session"
PBKDF2_ITERATIONS = 390_000 PBKDF2_ITERATIONS = 390_000
MEDIA_ITEMS_PER_EMBED = 10 MEDIA_ITEMS_PER_EMBED = 10
DEFAULT_JELLYFIN_SYNC_INTERVAL_SECONDS = 900
@dataclass(frozen=True) @dataclass(frozen=True)
@ -619,6 +620,41 @@ def save_media_channel_setting(runtime: BotRuntime, media_channel_id: str) -> di
return channel_settings(runtime) 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: def normalize_csv_key(value: str) -> str:
return "".join(character for character in value.lower() if character.isalnum()) 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: def media_item_heading(item: MediaItem) -> str:
if item.year: if item.year:
return f"{item.title} ({item.year})" return f"{item.title} ({item.year})"
@ -1459,6 +1701,11 @@ def make_dashboard_handler(runtime: BotRuntime, auth: DashboardAuth | None) -> t
return return
self.send_json(HTTPStatus.OK, {"channels": channel_settings(runtime)}) self.send_json(HTTPStatus.OK, {"channels": channel_settings(runtime)})
return 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) self.send_error(HTTPStatus.NOT_FOUND)
def do_POST(self) -> None: def do_POST(self) -> None:
@ -1489,6 +1736,12 @@ def make_dashboard_handler(runtime: BotRuntime, auth: DashboardAuth | None) -> t
if self.path == "/api/settings": if self.path == "/api/settings":
self.handle_settings() self.handle_settings()
return 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) self.send_error(HTTPStatus.NOT_FOUND)
def require_auth(self, require_csrf: bool = False) -> tuple[str, DashboardSession] | None: 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}) 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 return DashboardHandler
@ -1817,6 +2093,13 @@ def main() -> int:
message_id, results = run_check_cycle(runtime) message_id, results = run_check_cycle(runtime)
online = sum(1 for result in results if result.ok) online = sum(1 for result in results if result.ok)
print(f"Updated Discord status message {message_id}: {online}/{len(results)} online", flush=True) 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: except Exception as exc:
with runtime.lock: with runtime.lock:
runtime.last_error = str(exc) runtime.last_error = str(exc)