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_LIBRARY_STATE=state/media-library.json
|
||||
BOT_SETTINGS_STATE=state/bot-settings.json
|
||||
JELLYFIN_SYNC_INTERVAL_SECONDS=900
|
||||
CHECK_INTERVAL_SECONDS=60
|
||||
HTTP_USER_AGENT=ArchiveStatusBot/1.0
|
||||
DISCORD_DRY_RUN=false
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ ARCHIVE_STATUS_STATE=state/status-message.json
|
|||
MEDIA_CATALOG_STATE=state/media-catalog.json
|
||||
MEDIA_LIBRARY_STATE=state/media-library.json
|
||||
BOT_SETTINGS_STATE=state/bot-settings.json
|
||||
JELLYFIN_SYNC_INTERVAL_SECONDS=900
|
||||
CHECK_INTERVAL_SECONDS=60
|
||||
HTTP_USER_AGENT=ArchiveStatusBot/1.0
|
||||
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.
|
||||
|
||||
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:
|
||||
|
||||
```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.
|
||||
|
||||
The auto-sync interval defaults to 15 minutes:
|
||||
|
||||
```env
|
||||
JELLYFIN_SYNC_INTERVAL_SECONDS=900
|
||||
```
|
||||
|
||||
## Service Config
|
||||
|
||||
Each service supports:
|
||||
|
|
|
|||
121
dashboard.html
121
dashboard.html
|
|
@ -307,6 +307,20 @@
|
|||
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 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
|
|
@ -538,6 +552,32 @@
|
|||
</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">
|
||||
<div class="panel-header">
|
||||
<div class="panel-title">CSV Upload</div>
|
||||
|
|
@ -607,6 +647,7 @@
|
|||
const libraryCountEl = document.querySelector("#libraryCount");
|
||||
const messageEl = document.querySelector("#message");
|
||||
const mediaMessageEl = document.querySelector("#mediaMessage");
|
||||
const jellyfinStateEl = document.querySelector("#jellyfinState");
|
||||
const loginMessageEl = document.querySelector("#loginMessage");
|
||||
|
||||
let services = [];
|
||||
|
|
@ -615,6 +656,7 @@
|
|||
let channels = { statusChannelId: "", mediaChannelId: "" };
|
||||
let mediaLibrary = { movies: [], shows: [] };
|
||||
let activeMediaTab = "movies";
|
||||
let jellyfin = { url: "", configured: false, autoSync: false };
|
||||
|
||||
function headers(method = "GET") {
|
||||
const base = { "Content-Type": "application/json" };
|
||||
|
|
@ -651,6 +693,11 @@
|
|||
mediaMessageEl.classList.toggle("error", isError);
|
||||
}
|
||||
|
||||
function setJellyfinMessage(text, isError = false) {
|
||||
jellyfinStateEl.textContent = text;
|
||||
jellyfinStateEl.classList.toggle("error", isError);
|
||||
}
|
||||
|
||||
function showView(name) {
|
||||
statusViewEl.hidden = name !== "status";
|
||||
mediaViewEl.hidden = name !== "media";
|
||||
|
|
@ -822,6 +869,63 @@
|
|||
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 = {}) {
|
||||
return {
|
||||
movies: Array.isArray(library.movies) ? library.movies.map((item) => normalizeMediaItem(item, "movie")) : [],
|
||||
|
|
@ -1093,11 +1197,23 @@
|
|||
});
|
||||
|
||||
document.querySelector("#mediaRefresh").addEventListener("click", () => {
|
||||
loadMediaStatus()
|
||||
Promise.all([loadMediaStatus(), loadJellyfinStatus()])
|
||||
.then(() => setMediaMessage(""))
|
||||
.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", () => {
|
||||
importMediaCsv().catch((error) => setMediaMessage(error.message, true));
|
||||
});
|
||||
|
|
@ -1118,7 +1234,8 @@
|
|||
item.addEventListener("click", () => {
|
||||
showView(item.dataset.view);
|
||||
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"
|
||||
PBKDF2_ITERATIONS = 390_000
|
||||
MEDIA_ITEMS_PER_EMBED = 10
|
||||
DEFAULT_JELLYFIN_SYNC_INTERVAL_SECONDS = 900
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
|
@ -619,6 +620,41 @@ def save_media_channel_setting(runtime: BotRuntime, media_channel_id: str) -> di
|
|||
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:
|
||||
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:
|
||||
if item.year:
|
||||
return f"{item.title} ({item.year})"
|
||||
|
|
@ -1459,6 +1701,11 @@ def make_dashboard_handler(runtime: BotRuntime, auth: DashboardAuth | None) -> t
|
|||
return
|
||||
self.send_json(HTTPStatus.OK, {"channels": channel_settings(runtime)})
|
||||
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)
|
||||
|
||||
def do_POST(self) -> None:
|
||||
|
|
@ -1489,6 +1736,12 @@ def make_dashboard_handler(runtime: BotRuntime, auth: DashboardAuth | None) -> t
|
|||
if self.path == "/api/settings":
|
||||
self.handle_settings()
|
||||
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)
|
||||
|
||||
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})
|
||||
|
||||
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
|
||||
|
||||
|
||||
|
|
@ -1817,6 +2093,13 @@ def main() -> int:
|
|||
message_id, results = run_check_cycle(runtime)
|
||||
online = sum(1 for result in results if result.ok)
|
||||
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:
|
||||
with runtime.lock:
|
||||
runtime.last_error = str(exc)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue