Add catalog link page and Jellyfin dedupe
This commit is contained in:
parent
942c8c3a54
commit
4d82e0d55b
5 changed files with 250 additions and 26 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
|
||||||
|
PUBLIC_CATALOG_URL=
|
||||||
JELLYFIN_SYNC_INTERVAL_SECONDS=900
|
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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
PUBLIC_CATALOG_URL=
|
||||||
JELLYFIN_SYNC_INTERVAL_SECONDS=900
|
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
|
||||||
|
|
|
||||||
|
|
@ -201,7 +201,9 @@ 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.
|
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.
|
||||||
|
|
||||||
|
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. Jellyfin results are deduplicated across libraries using provider IDs first, then normalized title and year.
|
||||||
|
|
||||||
Channel selections are stored in:
|
Channel selections are stored in:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -588,6 +588,10 @@
|
||||||
<label for="mediaChannelId">Channel ID</label>
|
<label for="mediaChannelId">Channel ID</label>
|
||||||
<input id="mediaChannelId" inputmode="numeric" placeholder="Discord channel ID">
|
<input id="mediaChannelId" inputmode="numeric" placeholder="Discord channel ID">
|
||||||
</div>
|
</div>
|
||||||
|
<div class="media-field">
|
||||||
|
<label for="catalogUrl">Catalog URL</label>
|
||||||
|
<input id="catalogUrl" placeholder="https://archive.example.com/catalog">
|
||||||
|
</div>
|
||||||
<div class="media-field">
|
<div class="media-field">
|
||||||
<label for="moviesCsv">Movies.csv</label>
|
<label for="moviesCsv">Movies.csv</label>
|
||||||
<input id="moviesCsv" type="file" accept=".csv,text/csv">
|
<input id="moviesCsv" type="file" accept=".csv,text/csv">
|
||||||
|
|
@ -653,7 +657,7 @@
|
||||||
let services = [];
|
let services = [];
|
||||||
let results = new Map();
|
let results = new Map();
|
||||||
let csrfToken = "";
|
let csrfToken = "";
|
||||||
let channels = { statusChannelId: "", mediaChannelId: "" };
|
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, autoSync: false };
|
||||||
|
|
@ -738,7 +742,8 @@
|
||||||
function renderSummary(payload) {
|
function renderSummary(payload) {
|
||||||
channels = payload.channels || {
|
channels = payload.channels || {
|
||||||
statusChannelId: payload.channelId || channels.statusChannelId || "",
|
statusChannelId: payload.channelId || channels.statusChannelId || "",
|
||||||
mediaChannelId: channels.mediaChannelId || payload.channelId || ""
|
mediaChannelId: channels.mediaChannelId || payload.channelId || "",
|
||||||
|
catalogUrl: channels.catalogUrl || ""
|
||||||
};
|
};
|
||||||
document.querySelector("#statusChannelId").value = channels.statusChannelId || "";
|
document.querySelector("#statusChannelId").value = channels.statusChannelId || "";
|
||||||
const online = payload.results.filter((result) => result.ok).length;
|
const online = payload.results.filter((result) => result.ok).length;
|
||||||
|
|
@ -851,6 +856,10 @@
|
||||||
function renderMediaStatus(payload) {
|
function renderMediaStatus(payload) {
|
||||||
channels.mediaChannelId = payload.channelId || channels.mediaChannelId || "";
|
channels.mediaChannelId = payload.channelId || channels.mediaChannelId || "";
|
||||||
document.querySelector("#mediaChannelId").value = channels.mediaChannelId;
|
document.querySelector("#mediaChannelId").value = channels.mediaChannelId;
|
||||||
|
if (payload.channels) {
|
||||||
|
channels = payload.channels;
|
||||||
|
}
|
||||||
|
document.querySelector("#catalogUrl").value = channels.catalogUrl || "";
|
||||||
if (payload.library) {
|
if (payload.library) {
|
||||||
mediaLibrary = normalizeMediaLibrary(payload.library);
|
mediaLibrary = normalizeMediaLibrary(payload.library);
|
||||||
renderMediaLibrary();
|
renderMediaLibrary();
|
||||||
|
|
@ -912,6 +921,7 @@
|
||||||
|
|
||||||
async function syncJellyfin(forcePublish = false) {
|
async function syncJellyfin(forcePublish = false) {
|
||||||
setJellyfinMessage(forcePublish ? "Syncing and publishing..." : "Syncing Jellyfin...");
|
setJellyfinMessage(forcePublish ? "Syncing and publishing..." : "Syncing Jellyfin...");
|
||||||
|
await saveChannelSettings();
|
||||||
const settings = currentJellyfinSettings();
|
const settings = currentJellyfinSettings();
|
||||||
const payload = await api("/api/jellyfin/sync", {
|
const payload = await api("/api/jellyfin/sync", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|
@ -1029,7 +1039,8 @@
|
||||||
function currentChannelSettings() {
|
function currentChannelSettings() {
|
||||||
const statusChannelId = document.querySelector("#statusChannelId").value.trim() || channels.statusChannelId || "";
|
const statusChannelId = document.querySelector("#statusChannelId").value.trim() || channels.statusChannelId || "";
|
||||||
const mediaChannelId = document.querySelector("#mediaChannelId").value.trim() || channels.mediaChannelId || statusChannelId;
|
const mediaChannelId = document.querySelector("#mediaChannelId").value.trim() || channels.mediaChannelId || statusChannelId;
|
||||||
return { statusChannelId, mediaChannelId };
|
const catalogUrl = document.querySelector("#catalogUrl").value.trim() || channels.catalogUrl || "";
|
||||||
|
return { statusChannelId, mediaChannelId, catalogUrl };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveChannelSettings() {
|
async function saveChannelSettings() {
|
||||||
|
|
@ -1040,6 +1051,7 @@
|
||||||
channels = payload.channels || currentChannelSettings();
|
channels = payload.channels || currentChannelSettings();
|
||||||
document.querySelector("#statusChannelId").value = channels.statusChannelId || "";
|
document.querySelector("#statusChannelId").value = channels.statusChannelId || "";
|
||||||
document.querySelector("#mediaChannelId").value = channels.mediaChannelId || "";
|
document.querySelector("#mediaChannelId").value = channels.mediaChannelId || "";
|
||||||
|
document.querySelector("#catalogUrl").value = channels.catalogUrl || "";
|
||||||
return channels;
|
return channels;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1099,6 +1111,7 @@
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
channelId: document.querySelector("#mediaChannelId").value.trim() || channels.mediaChannelId,
|
channelId: document.querySelector("#mediaChannelId").value.trim() || channels.mediaChannelId,
|
||||||
|
catalogUrl: document.querySelector("#catalogUrl").value.trim() || channels.catalogUrl,
|
||||||
movies: mediaLibrary.movies,
|
movies: mediaLibrary.movies,
|
||||||
shows: mediaLibrary.shows
|
shows: mediaLibrary.shows
|
||||||
})
|
})
|
||||||
|
|
|
||||||
251
status_bot.py
251
status_bot.py
|
|
@ -8,6 +8,7 @@ import asyncio
|
||||||
import csv
|
import csv
|
||||||
import hashlib
|
import hashlib
|
||||||
import hmac
|
import hmac
|
||||||
|
import html
|
||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
|
@ -590,13 +591,25 @@ def channel_settings(runtime: BotRuntime) -> dict[str, str]:
|
||||||
data = load_state(runtime.settings_path)
|
data = load_state(runtime.settings_path)
|
||||||
status_channel = str(data.get("status_channel_id", "")).strip() or runtime.default_channel_id
|
status_channel = str(data.get("status_channel_id", "")).strip() or runtime.default_channel_id
|
||||||
media_channel = str(data.get("media_channel_id", "")).strip() or status_channel
|
media_channel = str(data.get("media_channel_id", "")).strip() or status_channel
|
||||||
|
catalog_url = str(data.get("catalog_url", "")).strip() or os.getenv("PUBLIC_CATALOG_URL", "").strip()
|
||||||
return {
|
return {
|
||||||
"statusChannelId": status_channel,
|
"statusChannelId": status_channel,
|
||||||
"mediaChannelId": media_channel,
|
"mediaChannelId": media_channel,
|
||||||
|
"catalogUrl": catalog_url,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def save_channel_settings(runtime: BotRuntime, status_channel_id: str, media_channel_id: str) -> dict[str, str]:
|
def validate_catalog_url(value: str) -> str:
|
||||||
|
catalog_url = value.strip()
|
||||||
|
if not catalog_url:
|
||||||
|
return ""
|
||||||
|
parsed = urllib.parse.urlparse(catalog_url)
|
||||||
|
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
|
||||||
|
raise ValueError("Catalog URL must be a valid http(s) URL")
|
||||||
|
return catalog_url
|
||||||
|
|
||||||
|
|
||||||
|
def save_channel_settings(runtime: BotRuntime, status_channel_id: str, media_channel_id: str, catalog_url: str = "") -> dict[str, str]:
|
||||||
status_channel = validate_channel_id(status_channel_id, "Status")
|
status_channel = validate_channel_id(status_channel_id, "Status")
|
||||||
media_channel = validate_channel_id(media_channel_id or status_channel, "Media")
|
media_channel = validate_channel_id(media_channel_id or status_channel, "Media")
|
||||||
state = load_state(runtime.settings_path)
|
state = load_state(runtime.settings_path)
|
||||||
|
|
@ -604,6 +617,7 @@ def save_channel_settings(runtime: BotRuntime, status_channel_id: str, media_cha
|
||||||
{
|
{
|
||||||
"status_channel_id": status_channel,
|
"status_channel_id": status_channel,
|
||||||
"media_channel_id": media_channel,
|
"media_channel_id": media_channel,
|
||||||
|
"catalog_url": validate_catalog_url(catalog_url),
|
||||||
"updated_at": datetime.now(timezone.utc).isoformat(),
|
"updated_at": datetime.now(timezone.utc).isoformat(),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
@ -655,6 +669,14 @@ def save_jellyfin_settings(runtime: BotRuntime, data: dict[str, Any]) -> dict[st
|
||||||
return jellyfin_settings(runtime)
|
return jellyfin_settings(runtime)
|
||||||
|
|
||||||
|
|
||||||
|
def save_catalog_url_setting(runtime: BotRuntime, catalog_url: str) -> dict[str, str]:
|
||||||
|
state = load_state(runtime.settings_path)
|
||||||
|
state["catalog_url"] = validate_catalog_url(catalog_url)
|
||||||
|
state["updated_at"] = datetime.now(timezone.utc).isoformat()
|
||||||
|
save_state(runtime.settings_path, state)
|
||||||
|
return channel_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())
|
||||||
|
|
||||||
|
|
@ -1042,7 +1064,7 @@ def fetch_jellyfin_items(settings: dict[str, Any], item_type: str) -> list[dict[
|
||||||
{
|
{
|
||||||
"Recursive": "true",
|
"Recursive": "true",
|
||||||
"IncludeItemTypes": item_type,
|
"IncludeItemTypes": item_type,
|
||||||
"Fields": "Genres,Overview,OfficialRating,RecursiveItemCount,ChildCount,PremiereDate,RunTimeTicks",
|
"Fields": "Genres,Overview,OfficialRating,RecursiveItemCount,ChildCount,PremiereDate,RunTimeTicks,ProviderIds",
|
||||||
"SortBy": "SortName",
|
"SortBy": "SortName",
|
||||||
"SortOrder": "Ascending",
|
"SortOrder": "Ascending",
|
||||||
"EnableImages": "false",
|
"EnableImages": "false",
|
||||||
|
|
@ -1060,14 +1082,63 @@ def fetch_jellyfin_items(settings: dict[str, Any], item_type: str) -> list[dict[
|
||||||
start_index += limit
|
start_index += limit
|
||||||
|
|
||||||
|
|
||||||
|
def jellyfin_item_dedupe_key(item: dict[str, Any], item_type: str) -> tuple[str, str]:
|
||||||
|
provider_ids = item.get("ProviderIds")
|
||||||
|
if isinstance(provider_ids, dict):
|
||||||
|
for provider in ("Tmdb", "Imdb", "Tvdb"):
|
||||||
|
value = str(provider_ids.get(provider, "")).strip().casefold()
|
||||||
|
if value:
|
||||||
|
return item_type.casefold(), f"{provider.casefold()}:{value}"
|
||||||
|
|
||||||
|
title = clean_media_text(str(item.get("Name", "")), 120) or ""
|
||||||
|
year = jellyfin_item_year(item) or ""
|
||||||
|
return item_type.casefold(), f"title:{title.casefold()}:{year}"
|
||||||
|
|
||||||
|
|
||||||
|
def dedupe_jellyfin_items(items: list[dict[str, Any]], item_type: str) -> list[dict[str, Any]]:
|
||||||
|
provider_deduped: dict[tuple[str, str], dict[str, Any]] = {}
|
||||||
|
for item in items:
|
||||||
|
key = jellyfin_item_dedupe_key(item, item_type)
|
||||||
|
current = provider_deduped.get(key)
|
||||||
|
if current is None:
|
||||||
|
provider_deduped[key] = item
|
||||||
|
continue
|
||||||
|
current_overview = str(current.get("Overview", "") or current.get("ShortOverview", ""))
|
||||||
|
next_overview = str(item.get("Overview", "") or item.get("ShortOverview", ""))
|
||||||
|
if len(next_overview) > len(current_overview):
|
||||||
|
provider_deduped[key] = item
|
||||||
|
|
||||||
|
title_deduped: dict[tuple[str, str], dict[str, Any]] = {}
|
||||||
|
for item in provider_deduped.values():
|
||||||
|
title = clean_media_text(str(item.get("Name", "")), 120) or ""
|
||||||
|
key = (title.casefold(), jellyfin_item_year(item) or "")
|
||||||
|
current = title_deduped.get(key)
|
||||||
|
if current is None:
|
||||||
|
title_deduped[key] = item
|
||||||
|
continue
|
||||||
|
current_has_provider = bool(current.get("ProviderIds"))
|
||||||
|
next_has_provider = bool(item.get("ProviderIds"))
|
||||||
|
if next_has_provider and not current_has_provider:
|
||||||
|
title_deduped[key] = item
|
||||||
|
continue
|
||||||
|
current_overview = str(current.get("Overview", "") or current.get("ShortOverview", ""))
|
||||||
|
next_overview = str(item.get("Overview", "") or item.get("ShortOverview", ""))
|
||||||
|
if len(next_overview) > len(current_overview):
|
||||||
|
title_deduped[key] = item
|
||||||
|
|
||||||
|
return sorted(title_deduped.values(), key=lambda item: (str(item.get("SortName", item.get("Name", ""))).casefold(), str(item.get("ProductionYear", ""))))
|
||||||
|
|
||||||
|
|
||||||
def fetch_jellyfin_library(runtime: BotRuntime) -> tuple[list[MediaItem], list[MediaItem]]:
|
def fetch_jellyfin_library(runtime: BotRuntime) -> tuple[list[MediaItem], list[MediaItem]]:
|
||||||
state = load_state(runtime.settings_path)
|
state = load_state(runtime.settings_path)
|
||||||
settings = {
|
settings = {
|
||||||
"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(),
|
||||||
}
|
}
|
||||||
movies = [jellyfin_movie_from_item(item) for item in fetch_jellyfin_items(settings, "Movie")]
|
movie_items = dedupe_jellyfin_items(fetch_jellyfin_items(settings, "Movie"), "Movie")
|
||||||
shows = [jellyfin_show_from_item(item) for item in fetch_jellyfin_items(settings, "Series")]
|
show_items = dedupe_jellyfin_items(fetch_jellyfin_items(settings, "Series"), "Series")
|
||||||
|
movies = [jellyfin_movie_from_item(item) for item in movie_items]
|
||||||
|
shows = [jellyfin_show_from_item(item) for item in show_items]
|
||||||
return (
|
return (
|
||||||
sorted(movies, key=lambda item: (item.title.casefold(), item.year or "")),
|
sorted(movies, key=lambda item: (item.title.casefold(), item.year or "")),
|
||||||
sorted(shows, key=lambda item: (item.title.casefold(), item.year or "")),
|
sorted(shows, key=lambda item: (item.title.casefold(), item.year or "")),
|
||||||
|
|
@ -1312,11 +1383,109 @@ def render_media_catalog_markdown(movies: list[MediaItem], shows: list[MediaItem
|
||||||
return "\n".join(lines).strip() + "\n"
|
return "\n".join(lines).strip() + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
def render_catalog_html(runtime: BotRuntime) -> bytes:
|
||||||
|
movies, shows = load_media_library(runtime)
|
||||||
|
updated_at = load_state(runtime.media_library_path).get("updated_at")
|
||||||
|
updated = "Never"
|
||||||
|
if updated_at:
|
||||||
|
try:
|
||||||
|
updated = datetime.fromisoformat(str(updated_at)).strftime("%Y-%m-%d %H:%M UTC")
|
||||||
|
except ValueError:
|
||||||
|
updated = str(updated_at)
|
||||||
|
|
||||||
|
def item_block(item: MediaItem) -> str:
|
||||||
|
meta = []
|
||||||
|
if item.year:
|
||||||
|
meta.append(item.year)
|
||||||
|
if item.genres:
|
||||||
|
meta.append(item.genres)
|
||||||
|
if item.rating:
|
||||||
|
meta.append(item.rating)
|
||||||
|
if item.runtime:
|
||||||
|
meta.append(item.runtime)
|
||||||
|
if item.media_type == "show":
|
||||||
|
if item.seasons:
|
||||||
|
meta.append(f"{item.seasons} season{'s' if item.seasons != 1 else ''}")
|
||||||
|
if item.episodes:
|
||||||
|
meta.append(f"{item.episodes} episode{'s' if item.episodes != 1 else ''}")
|
||||||
|
summary = f"<p>{html.escape(item.summary)}</p>" if item.summary else ""
|
||||||
|
return (
|
||||||
|
f'<article class="item" data-type="{html.escape(item.media_type)}" '
|
||||||
|
f'data-search="{html.escape((item.title + " " + (item.genres or "") + " " + (item.year or "")).casefold())}">'
|
||||||
|
f"<h2>{html.escape(item.title)}</h2>"
|
||||||
|
f'<div class="meta">{html.escape(" · ".join(meta))}</div>'
|
||||||
|
f"{summary}</article>"
|
||||||
|
)
|
||||||
|
|
||||||
|
items = "\n".join(item_block(item) for item in [*movies, *shows])
|
||||||
|
body = f"""<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>The Mithral Archive Media Catalog</title>
|
||||||
|
<style>
|
||||||
|
:root {{ color-scheme: dark; --bg: #111214; --panel: #1b1d21; --line: #30343b; --text: #f1f2f4; --muted: #a2a8b3; --action: #e7e9ed; --action-text: #15171a; }}
|
||||||
|
* {{ box-sizing: border-box; }}
|
||||||
|
body {{ margin: 0; background: var(--bg); color: var(--text); font: 14px/1.45 ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Helvetica Neue", sans-serif; }}
|
||||||
|
header {{ position: sticky; top: 0; z-index: 1; background: #15171a; border-bottom: 1px solid var(--line); padding: 14px 18px; }}
|
||||||
|
h1 {{ margin: 0 0 10px; font-size: 20px; letter-spacing: 0; }}
|
||||||
|
.controls {{ display: grid; grid-template-columns: minmax(180px, 1fr) auto auto auto; gap: 8px; align-items: center; }}
|
||||||
|
input, button {{ border: 1px solid var(--line); border-radius: 6px; background: #14161a; color: var(--text); padding: 8px 9px; font: inherit; }}
|
||||||
|
button.active {{ background: var(--action); color: var(--action-text); border-color: var(--action); }}
|
||||||
|
main {{ padding: 18px; display: grid; gap: 1px; background: var(--line); }}
|
||||||
|
.item {{ background: var(--panel); padding: 13px 14px; }}
|
||||||
|
.item[hidden] {{ display: none; }}
|
||||||
|
h2 {{ margin: 0 0 4px; font-size: 16px; }}
|
||||||
|
.meta {{ color: var(--muted); }}
|
||||||
|
p {{ margin: 8px 0 0; color: #d5d8de; }}
|
||||||
|
.stats {{ color: var(--muted); margin-bottom: 8px; }}
|
||||||
|
@media (max-width: 720px) {{ .controls {{ grid-template-columns: 1fr 1fr; }} .controls input {{ grid-column: 1 / -1; }} }}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<h1>The Mithral Archive Media Catalog</h1>
|
||||||
|
<div class="stats">{len(movies)} movies · {len(shows)} shows · Updated {html.escape(updated)}</div>
|
||||||
|
<div class="controls">
|
||||||
|
<input id="search" type="search" placeholder="Search title, genre, or year" autocomplete="off">
|
||||||
|
<button class="active" type="button" data-filter="all">All</button>
|
||||||
|
<button type="button" data-filter="movie">Movies</button>
|
||||||
|
<button type="button" data-filter="show">Shows</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main id="items">{items}</main>
|
||||||
|
<script>
|
||||||
|
const search = document.querySelector("#search");
|
||||||
|
const buttons = document.querySelectorAll("[data-filter]");
|
||||||
|
let filter = "all";
|
||||||
|
function applyFilter() {{
|
||||||
|
const query = search.value.trim().toLowerCase();
|
||||||
|
document.querySelectorAll(".item").forEach((item) => {{
|
||||||
|
const typeMatch = filter === "all" || item.dataset.type === filter;
|
||||||
|
const searchMatch = !query || item.dataset.search.includes(query);
|
||||||
|
item.hidden = !(typeMatch && searchMatch);
|
||||||
|
}});
|
||||||
|
}}
|
||||||
|
search.addEventListener("input", applyFilter);
|
||||||
|
buttons.forEach((button) => button.addEventListener("click", () => {{
|
||||||
|
filter = button.dataset.filter;
|
||||||
|
buttons.forEach((item) => item.classList.toggle("active", item === button));
|
||||||
|
applyFilter();
|
||||||
|
}}));
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
return body.encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
def publish_media_markdown_message(
|
def publish_media_markdown_message(
|
||||||
token: str,
|
token: str,
|
||||||
channel_id: str,
|
channel_id: str,
|
||||||
movies: list[MediaItem],
|
movies: list[MediaItem],
|
||||||
shows: list[MediaItem],
|
shows: list[MediaItem],
|
||||||
|
catalog_url: str = "",
|
||||||
) -> str:
|
) -> str:
|
||||||
markdown = render_media_catalog_markdown(movies, shows)
|
markdown = render_media_catalog_markdown(movies, shows)
|
||||||
payload = {
|
payload = {
|
||||||
|
|
@ -1327,6 +1496,20 @@ def publish_media_markdown_message(
|
||||||
),
|
),
|
||||||
"allowed_mentions": {"parse": []},
|
"allowed_mentions": {"parse": []},
|
||||||
}
|
}
|
||||||
|
if catalog_url:
|
||||||
|
payload["components"] = [
|
||||||
|
{
|
||||||
|
"type": 1,
|
||||||
|
"components": [
|
||||||
|
{
|
||||||
|
"type": 2,
|
||||||
|
"style": 5,
|
||||||
|
"label": "Open Catalog",
|
||||||
|
"url": catalog_url,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
]
|
||||||
message = discord_multipart_request(
|
message = discord_multipart_request(
|
||||||
"POST",
|
"POST",
|
||||||
token,
|
token,
|
||||||
|
|
@ -1368,7 +1551,13 @@ def publish_media_items(
|
||||||
for old_id in existing_ids:
|
for old_id in existing_ids:
|
||||||
discord_delete_message(runtime.token, delete_channel, old_id)
|
discord_delete_message(runtime.token, delete_channel, old_id)
|
||||||
|
|
||||||
message_id = publish_media_markdown_message(runtime.token, channel, movies_all, shows_all)
|
message_id = publish_media_markdown_message(
|
||||||
|
runtime.token,
|
||||||
|
channel,
|
||||||
|
movies_all,
|
||||||
|
shows_all,
|
||||||
|
catalog_url=settings.get("catalogUrl", ""),
|
||||||
|
)
|
||||||
|
|
||||||
save_state(
|
save_state(
|
||||||
runtime.media_state_path,
|
runtime.media_state_path,
|
||||||
|
|
@ -1421,6 +1610,7 @@ def media_catalog_status(runtime: BotRuntime) -> dict[str, Any]:
|
||||||
movies, shows = load_media_library(runtime)
|
movies, shows = load_media_library(runtime)
|
||||||
return {
|
return {
|
||||||
"channelId": str(state.get("channel_id", "")).strip() or settings["mediaChannelId"],
|
"channelId": str(state.get("channel_id", "")).strip() or settings["mediaChannelId"],
|
||||||
|
"channels": settings,
|
||||||
"messageIds": state.get("message_ids", []) if isinstance(state.get("message_ids"), list) else [],
|
"messageIds": state.get("message_ids", []) if isinstance(state.get("message_ids"), list) else [],
|
||||||
"movieCount": state.get("movie_count"),
|
"movieCount": state.get("movie_count"),
|
||||||
"showCount": state.get("show_count"),
|
"showCount": state.get("show_count"),
|
||||||
|
|
@ -1666,14 +1856,18 @@ def make_dashboard_handler(runtime: BotRuntime, auth: DashboardAuth | None) -> t
|
||||||
print(f"[dashboard] {self.address_string()} - {format % args}", flush=True)
|
print(f"[dashboard] {self.address_string()} - {format % args}", flush=True)
|
||||||
|
|
||||||
def do_GET(self) -> None:
|
def do_GET(self) -> None:
|
||||||
if self.path in {"/", "/dashboard"}:
|
path = urllib.parse.urlparse(self.path).path
|
||||||
|
if path in {"/", "/dashboard"}:
|
||||||
self.send_dashboard()
|
self.send_dashboard()
|
||||||
return
|
return
|
||||||
if self.path == "/favicon.ico":
|
if path == "/catalog":
|
||||||
|
self.send_catalog()
|
||||||
|
return
|
||||||
|
if path == "/favicon.ico":
|
||||||
self.send_response(HTTPStatus.NO_CONTENT)
|
self.send_response(HTTPStatus.NO_CONTENT)
|
||||||
self.end_headers()
|
self.end_headers()
|
||||||
return
|
return
|
||||||
if self.path == "/api/session":
|
if path == "/api/session":
|
||||||
session = self.require_auth()
|
session = self.require_auth()
|
||||||
if session is None:
|
if session is None:
|
||||||
return
|
return
|
||||||
|
|
@ -1686,22 +1880,22 @@ def make_dashboard_handler(runtime: BotRuntime, auth: DashboardAuth | None) -> t
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
if self.path == "/api/status":
|
if path == "/api/status":
|
||||||
if self.require_auth() is None:
|
if self.require_auth() is None:
|
||||||
return
|
return
|
||||||
self.send_json(HTTPStatus.OK, runtime_status(runtime))
|
self.send_json(HTTPStatus.OK, runtime_status(runtime))
|
||||||
return
|
return
|
||||||
if self.path == "/api/media":
|
if path == "/api/media":
|
||||||
if self.require_auth() is None:
|
if self.require_auth() is None:
|
||||||
return
|
return
|
||||||
self.send_json(HTTPStatus.OK, media_catalog_status(runtime))
|
self.send_json(HTTPStatus.OK, media_catalog_status(runtime))
|
||||||
return
|
return
|
||||||
if self.path == "/api/settings":
|
if path == "/api/settings":
|
||||||
if self.require_auth() is None:
|
if self.require_auth() is None:
|
||||||
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 path == "/api/jellyfin":
|
||||||
if self.require_auth() is None:
|
if self.require_auth() is None:
|
||||||
return
|
return
|
||||||
self.send_json(HTTPStatus.OK, {"jellyfin": jellyfin_settings(runtime)})
|
self.send_json(HTTPStatus.OK, {"jellyfin": jellyfin_settings(runtime)})
|
||||||
|
|
@ -1709,37 +1903,38 @@ def make_dashboard_handler(runtime: BotRuntime, auth: DashboardAuth | None) -> t
|
||||||
self.send_error(HTTPStatus.NOT_FOUND)
|
self.send_error(HTTPStatus.NOT_FOUND)
|
||||||
|
|
||||||
def do_POST(self) -> None:
|
def do_POST(self) -> None:
|
||||||
if self.path == "/api/login":
|
path = urllib.parse.urlparse(self.path).path
|
||||||
|
if path == "/api/login":
|
||||||
self.handle_login()
|
self.handle_login()
|
||||||
return
|
return
|
||||||
session = self.require_auth(require_csrf=True)
|
session = self.require_auth(require_csrf=True)
|
||||||
if session is None:
|
if session is None:
|
||||||
return
|
return
|
||||||
if self.path == "/api/logout":
|
if path == "/api/logout":
|
||||||
self.handle_logout(session[0])
|
self.handle_logout(session[0])
|
||||||
return
|
return
|
||||||
if self.path == "/api/check":
|
if path == "/api/check":
|
||||||
self.handle_check()
|
self.handle_check()
|
||||||
return
|
return
|
||||||
if self.path == "/api/services":
|
if path == "/api/services":
|
||||||
self.handle_services()
|
self.handle_services()
|
||||||
return
|
return
|
||||||
if self.path == "/api/media":
|
if path == "/api/media":
|
||||||
self.handle_media_catalog()
|
self.handle_media_catalog()
|
||||||
return
|
return
|
||||||
if self.path == "/api/media/import":
|
if path == "/api/media/import":
|
||||||
self.handle_media_import()
|
self.handle_media_import()
|
||||||
return
|
return
|
||||||
if self.path == "/api/media/library":
|
if path == "/api/media/library":
|
||||||
self.handle_media_library()
|
self.handle_media_library()
|
||||||
return
|
return
|
||||||
if self.path == "/api/settings":
|
if path == "/api/settings":
|
||||||
self.handle_settings()
|
self.handle_settings()
|
||||||
return
|
return
|
||||||
if self.path == "/api/jellyfin/settings":
|
if path == "/api/jellyfin/settings":
|
||||||
self.handle_jellyfin_settings()
|
self.handle_jellyfin_settings()
|
||||||
return
|
return
|
||||||
if self.path == "/api/jellyfin/sync":
|
if path == "/api/jellyfin/sync":
|
||||||
self.handle_jellyfin_sync()
|
self.handle_jellyfin_sync()
|
||||||
return
|
return
|
||||||
self.send_error(HTTPStatus.NOT_FOUND)
|
self.send_error(HTTPStatus.NOT_FOUND)
|
||||||
|
|
@ -1798,6 +1993,15 @@ def make_dashboard_handler(runtime: BotRuntime, auth: DashboardAuth | None) -> t
|
||||||
self.end_headers()
|
self.end_headers()
|
||||||
self.wfile.write(body)
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
def send_catalog(self) -> None:
|
||||||
|
body = render_catalog_html(runtime)
|
||||||
|
self.send_response(HTTPStatus.OK)
|
||||||
|
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||||
|
self.send_header("Cache-Control", "no-store")
|
||||||
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
|
||||||
def read_json(self) -> dict[str, Any]:
|
def read_json(self) -> dict[str, Any]:
|
||||||
raw_length = self.headers.get("Content-Length", "0")
|
raw_length = self.headers.get("Content-Length", "0")
|
||||||
try:
|
try:
|
||||||
|
|
@ -1918,6 +2122,8 @@ def make_dashboard_handler(runtime: BotRuntime, auth: DashboardAuth | None) -> t
|
||||||
try:
|
try:
|
||||||
data = self.read_json()
|
data = self.read_json()
|
||||||
if "movies" in data or "shows" in data:
|
if "movies" in data or "shows" in data:
|
||||||
|
if "catalogUrl" in data:
|
||||||
|
save_catalog_url_setting(runtime, str(data.get("catalogUrl", "")))
|
||||||
movies = media_items_from_data(data.get("movies", []), "movie")
|
movies = media_items_from_data(data.get("movies", []), "movie")
|
||||||
shows = media_items_from_data(data.get("shows", []), "show")
|
shows = media_items_from_data(data.get("shows", []), "show")
|
||||||
save_media_library(runtime, movies, shows)
|
save_media_library(runtime, movies, shows)
|
||||||
|
|
@ -1992,6 +2198,7 @@ def make_dashboard_handler(runtime: BotRuntime, auth: DashboardAuth | None) -> t
|
||||||
runtime,
|
runtime,
|
||||||
str(channels.get("statusChannelId", "")),
|
str(channels.get("statusChannelId", "")),
|
||||||
str(channels.get("mediaChannelId", "")),
|
str(channels.get("mediaChannelId", "")),
|
||||||
|
str(channels.get("catalogUrl", "")),
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self.send_json(HTTPStatus.BAD_REQUEST, {"error": str(exc)})
|
self.send_json(HTTPStatus.BAD_REQUEST, {"error": str(exc)})
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue