Sync media catalog from Jellyfin
This commit is contained in:
parent
08b40b0db0
commit
942c8c3a54
5 changed files with 412 additions and 2 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
121
dashboard.html
121
dashboard.html
|
|
@ -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));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
283
status_bot.py
283
status_bot.py
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue