Add dashboard media catalog and channel settings

This commit is contained in:
MiTHRAL 2026-05-15 15:05:41 -04:00
parent 65842e6d33
commit 74445a5c86
5 changed files with 857 additions and 50 deletions

View file

@ -1,8 +1,10 @@
DISCORD_BOT_TOKEN=replace-with-your-discord-bot-token
DISCORD_CHANNEL_ID=1504278732070981683
DISCORD_GATEWAY_ENABLED=true
ARCHIVE_STATUS_CONFIG=services.json
ARCHIVE_STATUS_STATE=state/status-message.json
MEDIA_CATALOG_STATE=state/media-catalog.json
BOT_SETTINGS_STATE=state/bot-settings.json
MEDIA_CATALOG_MAX_ITEMS=240
CHECK_INTERVAL_SECONDS=60
HTTP_USER_AGENT=ArchiveStatusBot/1.0
DISCORD_DRY_RUN=false

View file

@ -1,8 +1,10 @@
DISCORD_BOT_TOKEN=replace-with-your-discord-bot-token
DISCORD_CHANNEL_ID=1504278732070981683
DISCORD_GATEWAY_ENABLED=true
ARCHIVE_STATUS_CONFIG=services.json
ARCHIVE_STATUS_STATE=state/status-message.json
MEDIA_CATALOG_STATE=state/media-catalog.json
BOT_SETTINGS_STATE=state/bot-settings.json
MEDIA_CATALOG_MAX_ITEMS=240
CHECK_INTERVAL_SECONDS=60
HTTP_USER_AGENT=ArchiveStatusBot/1.0
DISCORD_DRY_RUN=false

View file

@ -8,6 +8,8 @@ It does not need Discord gateway intents or slash commands for the status module
The bot also includes a small web dashboard for editing monitored services and forcing immediate Discord refreshes.
It also has a media catalog module. From the dashboard you can upload `Movies.csv` and/or `Shows.csv`, choose a Discord channel ID, and publish a formatted catalog embed set.
## Discord Bot Setup
Create a Discord application and bot in the Discord Developer Portal, then invite it with:
@ -16,18 +18,14 @@ Create a Discord application and bot in the Discord Developer Portal, then invit
https://discord.com/oauth2/authorize?client_id=YOUR_CLIENT_ID&permissions=84992&scope=bot
```
Required channel permissions:
Required channel permissions for each dashboard-selected channel:
- View Channel
- Send Messages
- Embed Links
- Read Message History
The current `status` channel ID is:
```text
1504278732070981683
```
Channel IDs are configured from the dashboard and stored in `state/bot-settings.json`.
## Local Setup
@ -95,6 +93,8 @@ http://127.0.0.1:8787
Sign in with `DASHBOARD_USERNAME` and the password you used when generating `DASHBOARD_PASSWORD_HASH`.
Set the Status and Media channel IDs from the dashboard before publishing Discord updates.
## Docker Setup
```sh
@ -127,7 +127,7 @@ If `state/` or `services.json` were created by a previous container as another u
sudo chown -R "$(id -u):$(id -g)" services.json state
```
The bot stores the Discord message ID in `state/status-message.json`. Keep that file mounted so the bot edits the same message after restarts.
The bot stores channel settings in `state/bot-settings.json` and Discord message IDs in `state/status-message.json` and `state/media-catalog.json`. Keep `state/` mounted so the bot edits the same messages after restarts.
The deploy compose joins your existing reverse-proxy network:
@ -181,13 +181,42 @@ http://127.0.0.1:8787
The dashboard currently supports:
- viewing monitored services
- selecting the Discord channel used by the status updater
- adding/removing service rows
- editing check URL, display URL, expected statuses, timeout, and keyword
- saving `services.json`
- forcing an immediate check and Discord message update
- uploading `Movies.csv` and `Shows.csv`
- publishing a paginated media catalog to a selected Discord channel
The sidebar leaves room for future modules like polls, webhooks, automations, and service integrations without changing the bot shape later.
## Media Catalog
Open the dashboard and switch to `Media`. Set the target Discord channel ID, choose `Movies.csv`, `Shows.csv`, or both, then publish.
Channel selections are stored in:
```env
BOT_SETTINGS_STATE=state/bot-settings.json
```
The parser accepts common column names such as `title`, `name`, `year`, `genre`, `genres`, `rating`, `runtime`, `summary`, `overview`, `season`, and `episode`. Show exports that contain one row per episode are grouped by show title where possible.
The bot stores media catalog message IDs in:
```env
MEDIA_CATALOG_STATE=state/media-catalog.json
```
Republishing edits the previous catalog messages in place. If the catalog gets shorter, the bot attempts to delete old extra messages.
By default, Discord output is capped at 240 movies and 240 shows to avoid flooding a channel. Change this with:
```env
MEDIA_CATALOG_MAX_ITEMS=240
```
## Service Config
Each service supports:

View file

@ -107,9 +107,19 @@
display: flex;
justify-content: space-between;
gap: 12px;
width: 100%;
border: 0;
border-radius: 6px;
background: transparent;
padding: 8px 9px;
color: var(--muted);
font-weight: 650;
text-align: left;
}
.nav-item:hover {
background: #1b1d21;
border-color: transparent;
}
.nav-item.active {
@ -265,6 +275,63 @@
color: #fca5a5;
}
.view[hidden] {
display: none;
}
.media-form {
display: grid;
grid-template-columns: minmax(220px, 0.8fr) minmax(220px, 1fr) minmax(220px, 1fr);
gap: 12px;
padding: 14px;
border-bottom: 1px solid var(--line);
}
.channel-form {
display: grid;
grid-template-columns: minmax(220px, 360px);
gap: 12px;
padding: 14px;
margin-bottom: 16px;
}
.media-field label {
display: block;
color: var(--muted);
font-size: 12px;
font-weight: 650;
margin-bottom: 5px;
}
.media-field input[type="file"] {
padding: 7px;
}
.media-status {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 1px;
background: var(--line);
}
.media-status div {
background: var(--panel);
padding: 14px;
min-height: 74px;
}
.media-status span {
display: block;
color: var(--muted);
margin-bottom: 5px;
}
.media-status strong {
display: block;
font-size: 16px;
overflow-wrap: anywhere;
}
.token-screen {
max-width: 420px;
margin: 80px auto;
@ -300,7 +367,9 @@
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.service-row {
.service-row,
.channel-form,
.media-form {
grid-template-columns: 1fr 1fr;
}
}
@ -321,7 +390,10 @@
}
nav,
.service-row {
.service-row,
.channel-form,
.media-form,
.media-status {
grid-template-columns: 1fr;
}
}
@ -343,14 +415,15 @@
<aside>
<div class="brand">Archive Bot</div>
<nav aria-label="Bot modules">
<div class="nav-item active"><span>Status</span><span>Ready</span></div>
<button class="nav-item active" type="button" data-view="status"><span>Status</span><span>Ready</span></button>
<button class="nav-item" type="button" data-view="media"><span>Media</span><span>CSV</span></button>
<div class="nav-item disabled"><span>Polls</span><span>Later</span></div>
<div class="nav-item disabled"><span>Webhooks</span><span>Later</span></div>
<div class="nav-item disabled"><span>Automations</span><span>Later</span></div>
</nav>
</aside>
<main>
<section id="statusView" class="view">
<div class="topbar">
<h1>Status Services</h1>
<div class="actions">
@ -362,6 +435,13 @@
</div>
</div>
<section class="panel channel-form" aria-label="Status channel">
<div class="media-field">
<label for="statusChannelId">Status Channel ID</label>
<input id="statusChannelId" inputmode="numeric" placeholder="Discord channel ID">
</div>
</section>
<section class="summary panel" aria-label="Current status summary">
<div class="summary-item">
<span>Services</span>
@ -388,19 +468,73 @@
</div>
<div id="services" class="service-list"></div>
</section>
</section>
<section id="mediaView" class="view" hidden>
<div class="topbar">
<h1>Media Catalog</h1>
<div class="actions">
<button id="mediaRefresh" type="button">Refresh</button>
<button id="publishMedia" class="primary" type="button">Publish to Discord</button>
</div>
</div>
<section class="panel">
<div class="panel-header">
<div class="panel-title">CSV Upload</div>
<div id="mediaMessage" class="message"></div>
</div>
<div class="media-form">
<div class="media-field">
<label for="mediaChannelId">Channel ID</label>
<input id="mediaChannelId" inputmode="numeric" placeholder="Discord channel ID">
</div>
<div class="media-field">
<label for="moviesCsv">Movies.csv</label>
<input id="moviesCsv" type="file" accept=".csv,text/csv">
</div>
<div class="media-field">
<label for="showsCsv">Shows.csv</label>
<input id="showsCsv" type="file" accept=".csv,text/csv">
</div>
</div>
<div class="media-status" aria-label="Media catalog status">
<div>
<span>Movies</span>
<strong id="mediaMovieCount">Not published</strong>
</div>
<div>
<span>Shows</span>
<strong id="mediaShowCount">Not published</strong>
</div>
<div>
<span>Messages</span>
<strong id="mediaMessageCount">0</strong>
</div>
<div>
<span>Published</span>
<strong id="mediaPublishedAt">Never</strong>
</div>
</div>
</section>
</section>
</main>
</div>
<script>
const appEl = document.querySelector("#app");
const loginEl = document.querySelector("#login");
const statusViewEl = document.querySelector("#statusView");
const mediaViewEl = document.querySelector("#mediaView");
const servicesEl = document.querySelector("#services");
const messageEl = document.querySelector("#message");
const mediaMessageEl = document.querySelector("#mediaMessage");
const loginMessageEl = document.querySelector("#loginMessage");
let services = [];
let results = new Map();
let csrfToken = "";
let channels = { statusChannelId: "", mediaChannelId: "" };
function headers(method = "GET") {
const base = { "Content-Type": "application/json" };
@ -432,6 +566,19 @@
messageEl.classList.toggle("error", isError);
}
function setMediaMessage(text, isError = false) {
mediaMessageEl.textContent = text;
mediaMessageEl.classList.toggle("error", isError);
}
function showView(name) {
statusViewEl.hidden = name !== "status";
mediaViewEl.hidden = name !== "media";
document.querySelectorAll("[data-view]").forEach((item) => {
item.classList.toggle("active", item.dataset.view === name);
});
}
function showLogin(message = "") {
appEl.hidden = true;
loginEl.hidden = false;
@ -462,6 +609,11 @@
}
function renderSummary(payload) {
channels = payload.channels || {
statusChannelId: payload.channelId || channels.statusChannelId || "",
mediaChannelId: channels.mediaChannelId || payload.channelId || ""
};
document.querySelector("#statusChannelId").value = channels.statusChannelId || "";
const online = payload.results.filter((result) => result.ok).length;
document.querySelector("#serviceCount").textContent = payload.services.length;
document.querySelector("#onlineCount").textContent = online;
@ -569,6 +721,80 @@
showApp();
}
function renderMediaStatus(payload) {
channels.mediaChannelId = payload.channelId || channels.mediaChannelId || "";
document.querySelector("#mediaChannelId").value = channels.mediaChannelId;
document.querySelector("#mediaMovieCount").textContent = payload.movieCount == null ? "Not published" : payload.movieCount;
document.querySelector("#mediaShowCount").textContent = payload.showCount == null ? "Not published" : payload.showCount;
document.querySelector("#mediaMessageCount").textContent = Array.isArray(payload.messageIds) ? payload.messageIds.length : 0;
document.querySelector("#mediaPublishedAt").textContent = payload.publishedAt
? new Date(payload.publishedAt).toLocaleString([], { dateStyle: "short", timeStyle: "short" })
: "Never";
}
async function loadMediaStatus() {
const payload = await api("/api/media");
renderMediaStatus(payload);
return payload;
}
function currentChannelSettings() {
const statusChannelId = document.querySelector("#statusChannelId").value.trim() || channels.statusChannelId || "";
const mediaChannelId = document.querySelector("#mediaChannelId").value.trim() || channels.mediaChannelId || statusChannelId;
return { statusChannelId, mediaChannelId };
}
async function saveChannelSettings() {
const payload = await api("/api/settings", {
method: "POST",
body: JSON.stringify({ channels: currentChannelSettings() })
});
channels = payload.channels || currentChannelSettings();
document.querySelector("#statusChannelId").value = channels.statusChannelId || "";
document.querySelector("#mediaChannelId").value = channels.mediaChannelId || "";
return channels;
}
function readFileText(input) {
const file = input.files && input.files[0];
if (!file) return Promise.resolve({ name: "", text: "" });
return file.text().then((text) => ({ name: file.name, text }));
}
async function publishMedia() {
const moviesInput = document.querySelector("#moviesCsv");
const showsInput = document.querySelector("#showsCsv");
const [movies, shows] = await Promise.all([
readFileText(moviesInput),
readFileText(showsInput)
]);
if (!movies.text.trim() && !shows.text.trim()) {
throw new Error("Choose a Movies.csv or Shows.csv file before publishing.");
}
setMediaMessage("Uploading CSV files...");
const payload = await api("/api/media", {
method: "POST",
body: JSON.stringify({
channelId: document.querySelector("#mediaChannelId").value.trim() || channels.mediaChannelId,
moviesCsv: movies.text,
showsCsv: shows.text,
movieFileName: movies.name || "Movies.csv",
showFileName: shows.name || "Shows.csv"
})
});
renderMediaStatus({
channelId: payload.channelId,
messageIds: payload.messageIds,
movieCount: payload.movieCount,
showCount: payload.showCount,
publishedAt: new Date().toISOString()
});
setMediaMessage(`Published ${payload.movieCount} movies and ${payload.showCount} shows.`);
}
async function loadSession() {
const session = await api("/api/session");
csrfToken = session.csrfToken || "";
@ -604,6 +830,7 @@
async function saveServices() {
services = collectServices().map(normalizeService);
await saveChannelSettings();
const payload = await api("/api/services", {
method: "POST",
body: JSON.stringify({ services })
@ -615,6 +842,7 @@
}
async function checkNow() {
await saveChannelSettings();
const payload = await api("/api/check", { method: "POST", body: "{}" });
results = new Map(payload.results.map((result) => [result.name, result]));
renderSummary({ services, results: payload.results, lastCheckedAt: new Date().toISOString() });
@ -648,10 +876,29 @@
checkNow().catch((error) => setMessage(error.message, true));
});
document.querySelector("#mediaRefresh").addEventListener("click", () => {
loadMediaStatus()
.then(() => setMediaMessage(""))
.catch((error) => setMediaMessage(error.message, true));
});
document.querySelector("#publishMedia").addEventListener("click", () => {
publishMedia().catch((error) => setMediaMessage(error.message, true));
});
document.querySelector("#logout").addEventListener("click", () => {
logout();
});
document.querySelectorAll("[data-view]").forEach((item) => {
item.addEventListener("click", () => {
showView(item.dataset.view);
if (item.dataset.view === "media") {
loadMediaStatus().catch((error) => setMediaMessage(error.message, true));
}
});
});
document.querySelector("#addService").addEventListener("click", () => {
services.push(normalizeService({ name: "New Service", url: "https://example.com" }));
renderServices();

View file

@ -5,10 +5,13 @@ from __future__ import annotations
import base64
import asyncio
import csv
import hashlib
import hmac
import io
import json
import os
import re
import secrets
import signal
import socket
@ -32,9 +35,11 @@ DISCORD_API = "https://discord.com/api/v10"
DEFAULT_INTERVAL_SECONDS = 60
DEFAULT_TIMEOUT_SECONDS = 10
MAX_DISCORD_EMBEDS = 10
MAX_REQUEST_BYTES = 1_000_000
MAX_REQUEST_BYTES = 8_000_000
SESSION_COOKIE = "archive_bot_session"
PBKDF2_ITERATIONS = 390_000
MEDIA_ITEMS_PER_EMBED = 10
MAX_MEDIA_ITEMS_PER_CATEGORY = 240
@dataclass(frozen=True)
@ -60,6 +65,19 @@ class CheckResult:
error: str | None
@dataclass(frozen=True)
class MediaItem:
title: str
media_type: str
year: str | None = None
genres: str | None = None
rating: str | None = None
runtime: str | None = None
summary: str | None = None
seasons: int | None = None
episodes: int | None = None
@dataclass(frozen=True)
class DashboardAuthConfig:
username: str
@ -148,12 +166,16 @@ class BotRuntime:
channel_id: str,
config_path: Path,
state_path: Path,
media_state_path: Path,
settings_path: Path,
dry_run: bool = False,
) -> None:
self.token = token
self.channel_id = channel_id
self.default_channel_id = channel_id
self.config_path = config_path
self.state_path = state_path
self.media_state_path = media_state_path
self.settings_path = settings_path
self.dry_run = dry_run
self.lock = threading.Lock()
self.last_results: list[CheckResult] = []
@ -475,6 +497,13 @@ def discord_request(
raise RuntimeError(f"Discord API {method} {path} failed: {exc.code} {detail}") from exc
def discord_delete_message(token: str, channel_id: str, message_id: str) -> None:
try:
discord_request("DELETE", token, f"/channels/{channel_id}/messages/{message_id}")
except RuntimeError as exc:
print(f"Could not delete old media catalog message {message_id}: {exc}", file=sys.stderr)
def discord_bot_identity(token: str) -> dict[str, Any]:
return discord_request("GET", token, "/users/@me")
@ -499,6 +528,449 @@ def save_state(path: Path, state: dict[str, Any]) -> None:
temporary.replace(path)
def validate_channel_id(value: str, label: str) -> str:
channel_id = value.strip()
if not channel_id:
raise ValueError(f"{label} channel ID is required")
if not channel_id.isdigit():
raise ValueError(f"{label} channel ID must be a Discord numeric channel ID")
return channel_id
def channel_settings(runtime: BotRuntime) -> dict[str, str]:
data = load_state(runtime.settings_path)
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
return {
"statusChannelId": status_channel,
"mediaChannelId": media_channel,
}
def save_channel_settings(runtime: BotRuntime, status_channel_id: str, media_channel_id: str) -> dict[str, str]:
status_channel = validate_channel_id(status_channel_id, "Status")
media_channel = validate_channel_id(media_channel_id or status_channel, "Media")
state = load_state(runtime.settings_path)
state.update(
{
"status_channel_id": status_channel,
"media_channel_id": media_channel,
"updated_at": datetime.now(timezone.utc).isoformat(),
}
)
save_state(runtime.settings_path, state)
return channel_settings(runtime)
def save_media_channel_setting(runtime: BotRuntime, media_channel_id: str) -> dict[str, str]:
media_channel = validate_channel_id(media_channel_id, "Media")
state = load_state(runtime.settings_path)
state["media_channel_id"] = media_channel
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:
return "".join(character for character in value.lower() if character.isalnum())
def value_from_row(row: dict[str, str], aliases: list[str]) -> str:
for alias in aliases:
value = row.get(alias, "").strip()
if value:
return value
return ""
def parse_int_text(value: str) -> int | None:
digits = "".join(character for character in value if character.isdigit())
if not digits:
return None
try:
return int(digits)
except ValueError:
return None
def parse_year_text(value: str) -> str | None:
match = re.search(r"(18|19|20|21)\d{2}", value)
return match.group(0) if match else None
def clean_media_text(value: str, limit: int = 220) -> str | None:
cleaned = " ".join(str(value).replace("\r", " ").replace("\n", " ").split())
if not cleaned:
return None
if len(cleaned) <= limit:
return cleaned
return cleaned[: limit - 1].rstrip() + ""
def format_runtime(value: str) -> str | None:
cleaned = clean_media_text(value, 48)
if not cleaned:
return None
if not cleaned.isdigit():
return cleaned
minutes = int(cleaned)
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 parse_media_csv(csv_text: str, media_type: str, filename: str) -> list[MediaItem]:
text = csv_text.lstrip("\ufeff")
if not text.strip():
return []
try:
dialect = csv.Sniffer().sniff(text[:4096], delimiters=",;\t|")
except csv.Error:
dialect = csv.excel
reader = csv.DictReader(io.StringIO(text), dialect=dialect)
if not reader.fieldnames:
raise ValueError(f"{filename} does not have a header row")
title_aliases = [
"title",
"name",
"movie",
"movietitle",
"sorttitle",
"originaltitle",
]
if media_type == "show":
title_aliases = [
"showtitle",
"seriestitle",
"series",
"show",
"grandparenttitle",
"parenttitle",
"title",
"name",
]
year_aliases = ["year", "releaseyear", "productionyear", "releasedate", "premiered", "date"]
genre_aliases = ["genres", "genre", "tags", "categories"]
rating_aliases = ["rating", "contentrating", "agerating", "certification", "mpaarating"]
runtime_aliases = ["runtime", "duration", "runtimeminutes", "length", "durationminutes"]
summary_aliases = ["summary", "overview", "description", "plot", "tagline"]
season_count_aliases = ["seasoncount", "seasons"]
season_number_aliases = ["season", "seasonnumber", "seasonindex", "parentindex"]
episode_count_aliases = ["episodecount", "episodes"]
episode_number_aliases = ["episode", "episodenumber", "episodeindex", "index"]
items: list[MediaItem] = []
show_groups: dict[tuple[str, str], dict[str, Any]] = {}
for raw_row in reader:
row = {
normalize_csv_key(str(key)): str(value or "")
for key, value in raw_row.items()
if key is not None
}
title = clean_media_text(value_from_row(row, title_aliases), 120)
if not title:
continue
year = parse_year_text(value_from_row(row, year_aliases))
genres = clean_media_text(value_from_row(row, genre_aliases), 120)
rating = clean_media_text(value_from_row(row, rating_aliases), 32)
runtime = format_runtime(value_from_row(row, runtime_aliases))
summary = clean_media_text(value_from_row(row, summary_aliases))
season_count = parse_int_text(value_from_row(row, season_count_aliases))
season_number = parse_int_text(value_from_row(row, season_number_aliases))
episode_count = parse_int_text(value_from_row(row, episode_count_aliases))
episode_number = parse_int_text(value_from_row(row, episode_number_aliases))
if media_type == "show":
key = (title.casefold(), year or "")
group = show_groups.setdefault(
key,
{
"title": title,
"year": year,
"genres": genres,
"rating": rating,
"runtime": runtime,
"summary": summary,
"seasons": set(),
"episodes": 0,
"explicit_season_count": None,
"explicit_episode_count": None,
},
)
if not group.get("genres") and genres:
group["genres"] = genres
if not group.get("rating") and rating:
group["rating"] = rating
if not group.get("runtime") and runtime:
group["runtime"] = runtime
if not group.get("summary") and summary:
group["summary"] = summary
if season_number is not None:
group["seasons"].add(season_number)
if episode_number is not None:
group["episodes"] += 1
if season_count is not None:
current = group.get("explicit_season_count")
group["explicit_season_count"] = max(current or 0, season_count)
if episode_count is not None:
current = group.get("explicit_episode_count")
group["explicit_episode_count"] = max(current or 0, episode_count)
continue
items.append(
MediaItem(
title=title,
media_type=media_type,
year=year,
genres=genres,
rating=rating,
runtime=runtime,
summary=summary,
)
)
if media_type == "show":
for group in show_groups.values():
seasons = group.get("explicit_season_count") or len(group["seasons"]) or None
episodes = group.get("explicit_episode_count") or group["episodes"] or None
items.append(
MediaItem(
title=group["title"],
media_type=media_type,
year=group["year"],
genres=group["genres"],
rating=group["rating"],
runtime=group["runtime"],
summary=group["summary"],
seasons=seasons,
episodes=episodes,
)
)
deduped: dict[tuple[str, str], MediaItem] = {}
for item in items:
deduped.setdefault((item.title.casefold(), item.year or ""), item)
parsed = sorted(deduped.values(), key=lambda item: (item.title.casefold(), item.year or ""))
if not parsed:
raise ValueError(f"{filename} did not contain any rows with a recognizable title/name column")
return parsed
def media_item_heading(item: MediaItem) -> str:
if item.year:
return f"{item.title} ({item.year})"
return item.title
def media_item_value(item: MediaItem) -> str:
details: list[str] = []
if item.genres:
details.append(item.genres)
if item.rating:
details.append(item.rating)
if item.runtime and item.media_type == "movie":
details.append(item.runtime)
if item.media_type == "show":
counts = []
if item.seasons:
counts.append(f"{item.seasons} season{'s' if item.seasons != 1 else ''}")
if item.episodes:
counts.append(f"{item.episodes} episode{'s' if item.episodes != 1 else ''}")
if counts:
details.append(" · ".join(counts))
lines = []
if details:
lines.append("".join(details))
if item.summary:
lines.append(item.summary)
return "\n".join(lines)[:1024] or "No details provided."
def media_category_embeds(
title: str,
items: list[MediaItem],
color: int,
total_count: int,
source_name: str,
) -> list[dict[str, Any]]:
embeds: list[dict[str, Any]] = []
omitted = max(total_count - len(items), 0)
page_count = max((len(items) + MEDIA_ITEMS_PER_EMBED - 1) // MEDIA_ITEMS_PER_EMBED, 1)
for page_index in range(page_count):
start = page_index * MEDIA_ITEMS_PER_EMBED
chunk = items[start : start + MEDIA_ITEMS_PER_EMBED]
description = f"{total_count} total from `{source_name}`"
if omitted:
description += f" · showing first {len(items)}"
if page_count > 1:
description += f" · page {page_index + 1}/{page_count}"
fields = [
{
"name": media_item_heading(item)[:256],
"value": media_item_value(item),
"inline": False,
}
for item in chunk
]
embeds.append(
{
"title": title,
"description": description,
"color": color,
"fields": fields,
}
)
return embeds
def render_media_catalog_payloads(
movies: list[MediaItem],
shows: list[MediaItem],
movie_total: int,
show_total: int,
movie_source: str,
show_source: str,
) -> list[dict[str, Any]]:
now = datetime.now(timezone.utc)
embeds: list[dict[str, Any]] = [
{
"title": "The Mithral Archive Media Catalog",
"description": "Current movies and shows available in the archive.",
"color": 0x5865F2,
"fields": [
{"name": "Movies", "value": str(movie_total), "inline": True},
{"name": "Shows", "value": str(show_total), "inline": True},
{"name": "Updated", "value": now.strftime("%Y-%m-%d %H:%M UTC"), "inline": True},
],
"timestamp": now.isoformat(),
}
]
if movies:
embeds.extend(media_category_embeds("Movies", movies, 0xF59E0B, movie_total, movie_source))
if shows:
embeds.extend(media_category_embeds("Shows", shows, 0x10B981, show_total, show_source))
payloads = []
for index in range(0, len(embeds), MAX_DISCORD_EMBEDS):
payloads.append(
{
"content": "**The Mithral Archive Media Catalog**" if index == 0 else "",
"embeds": embeds[index : index + MAX_DISCORD_EMBEDS],
}
)
return payloads
def publish_media_catalog(
runtime: BotRuntime,
channel_id: str,
movies_csv: str,
shows_csv: str,
movie_filename: str,
show_filename: str,
) -> dict[str, Any]:
if runtime.dry_run:
raise RuntimeError("Discord dry run is enabled; media catalog was parsed but not sent")
settings = channel_settings(runtime)
channel = validate_channel_id(channel_id.strip() or settings["mediaChannelId"], "Media")
movies_all = parse_media_csv(movies_csv, "movie", movie_filename) if movies_csv.strip() else []
shows_all = parse_media_csv(shows_csv, "show", show_filename) if shows_csv.strip() else []
if not movies_all and not shows_all:
raise ValueError("Upload at least one Movies.csv or Shows.csv file")
limit = int(os.getenv("MEDIA_CATALOG_MAX_ITEMS", str(MAX_MEDIA_ITEMS_PER_CATEGORY)))
movies = movies_all[:limit]
shows = shows_all[:limit]
payloads = render_media_catalog_payloads(
movies=movies,
shows=shows,
movie_total=len(movies_all),
show_total=len(shows_all),
movie_source=movie_filename or "Movies.csv",
show_source=show_filename or "Shows.csv",
)
state = load_state(runtime.media_state_path)
old_channel = str(state.get("channel_id", "")).strip()
existing_ids = [
str(message_id)
for message_id in state.get("message_ids", [])
if str(message_id).strip()
]
if old_channel and old_channel != channel:
for old_id in existing_ids:
discord_delete_message(runtime.token, old_channel, old_id)
existing_ids = []
message_ids: list[str] = []
for index, payload in enumerate(payloads):
if index < len(existing_ids):
discord_request("PATCH", runtime.token, f"/channels/{channel}/messages/{existing_ids[index]}", payload)
message_ids.append(existing_ids[index])
continue
message = discord_request("POST", runtime.token, f"/channels/{channel}/messages", payload)
message_id = str(message.get("id", "")).strip()
if not message_id:
raise RuntimeError("Discord did not return a message id for the media catalog")
message_ids.append(message_id)
for old_id in existing_ids[len(payloads) :]:
discord_delete_message(runtime.token, channel, old_id)
save_state(
runtime.media_state_path,
{
"channel_id": channel,
"message_ids": message_ids,
"movie_count": len(movies_all),
"show_count": len(shows_all),
"published_at": datetime.now(timezone.utc).isoformat(),
},
)
save_media_channel_setting(runtime, channel)
return {
"channelId": channel,
"messageIds": message_ids,
"movieCount": len(movies_all),
"showCount": len(shows_all),
"displayedMovieCount": len(movies),
"displayedShowCount": len(shows),
}
def media_catalog_status(runtime: BotRuntime) -> dict[str, Any]:
state = load_state(runtime.media_state_path)
settings = channel_settings(runtime)
return {
"channelId": str(state.get("channel_id", "")).strip() or settings["mediaChannelId"],
"messageIds": state.get("message_ids", []) if isinstance(state.get("message_ids"), list) else [],
"movieCount": state.get("movie_count"),
"showCount": state.get("show_count"),
"publishedAt": state.get("published_at"),
"maxItemsPerCategory": int(os.getenv("MEDIA_CATALOG_MAX_ITEMS", str(MAX_MEDIA_ITEMS_PER_CATEGORY))),
}
def render_embeds(results: list[CheckResult]) -> list[dict[str, Any]]:
checked_at = datetime.now(timezone.utc)
online = sum(1 for result in results if result.ok)
@ -631,7 +1103,8 @@ def run_check_cycle(runtime: BotRuntime) -> tuple[str, list[CheckResult]]:
if runtime.dry_run:
message_id = "dry-run"
else:
message_id = upsert_status_message(runtime.token, runtime.channel_id, runtime.state_path, results)
channel_id = validate_channel_id(channel_settings(runtime)["statusChannelId"], "Status")
message_id = upsert_status_message(runtime.token, channel_id, runtime.state_path, results)
with runtime.lock:
runtime.last_results = results
@ -644,6 +1117,7 @@ def run_check_cycle(runtime: BotRuntime) -> tuple[str, list[CheckResult]]:
def runtime_status(runtime: BotRuntime) -> dict[str, Any]:
services = load_services(runtime.config_path)
settings = channel_settings(runtime)
with runtime.lock:
results = list(runtime.last_results)
return {
@ -652,7 +1126,8 @@ def runtime_status(runtime: BotRuntime) -> dict[str, Any]:
"lastError": runtime.last_error,
"lastMessageId": runtime.last_message_id,
"lastCheckedAt": runtime.last_checked_at.isoformat() if runtime.last_checked_at else None,
"channelId": runtime.channel_id,
"channelId": settings["statusChannelId"],
"channels": settings,
}
@ -757,6 +1232,16 @@ def make_dashboard_handler(runtime: BotRuntime, auth: DashboardAuth | None) -> t
return
self.send_json(HTTPStatus.OK, runtime_status(runtime))
return
if self.path == "/api/media":
if self.require_auth() is None:
return
self.send_json(HTTPStatus.OK, media_catalog_status(runtime))
return
if self.path == "/api/settings":
if self.require_auth() is None:
return
self.send_json(HTTPStatus.OK, {"channels": channel_settings(runtime)})
return
self.send_error(HTTPStatus.NOT_FOUND)
def do_POST(self) -> None:
@ -775,6 +1260,12 @@ def make_dashboard_handler(runtime: BotRuntime, auth: DashboardAuth | None) -> t
if self.path == "/api/services":
self.handle_services()
return
if self.path == "/api/media":
self.handle_media_catalog()
return
if self.path == "/api/settings":
self.handle_settings()
return
self.send_error(HTTPStatus.NOT_FOUND)
def require_auth(self, require_csrf: bool = False) -> tuple[str, DashboardSession] | None:
@ -947,6 +1438,40 @@ def make_dashboard_handler(runtime: BotRuntime, auth: DashboardAuth | None) -> t
},
)
def handle_media_catalog(self) -> None:
try:
data = self.read_json()
result = publish_media_catalog(
runtime=runtime,
channel_id=str(data.get("channelId", "")),
movies_csv=str(data.get("moviesCsv", "")),
shows_csv=str(data.get("showsCsv", "")),
movie_filename=str(data.get("movieFileName", "Movies.csv") or "Movies.csv"),
show_filename=str(data.get("showFileName", "Shows.csv") or "Shows.csv"),
)
except Exception as exc:
self.send_json(HTTPStatus.BAD_REQUEST, {"error": str(exc)})
return
self.send_json(HTTPStatus.OK, result)
def handle_settings(self) -> None:
try:
data = self.read_json()
channels = data.get("channels", data)
if not isinstance(channels, dict):
raise ValueError("Settings payload must include a channels object")
result = save_channel_settings(
runtime,
str(channels.get("statusChannelId", "")),
str(channels.get("mediaChannelId", "")),
)
except Exception as exc:
self.send_json(HTTPStatus.BAD_REQUEST, {"error": str(exc)})
return
self.send_json(HTTPStatus.OK, {"channels": result})
return DashboardHandler
@ -979,11 +1504,13 @@ def main() -> int:
token = env("DISCORD_BOT_TOKEN")
token = normalize_discord_token(token)
channel_id = env("DISCORD_CHANNEL_ID")
channel_id = os.getenv("DISCORD_CHANNEL_ID", "").strip()
config_path = Path(env("ARCHIVE_STATUS_CONFIG", "services.json"))
state_path = Path(env("ARCHIVE_STATUS_STATE", "state/status-message.json"))
media_state_path = Path(env("MEDIA_CATALOG_STATE", "state/media-catalog.json"))
settings_path = Path(env("BOT_SETTINGS_STATE", "state/bot-settings.json"))
interval = int(env("CHECK_INTERVAL_SECONDS", str(DEFAULT_INTERVAL_SECONDS)))
runtime = BotRuntime(token, channel_id, config_path, state_path, dry_run=bool_env("DISCORD_DRY_RUN", False))
runtime = BotRuntime(token, channel_id, config_path, state_path, media_state_path, settings_path, dry_run=bool_env("DISCORD_DRY_RUN", False))
gateway = None
if bool_env("DISCORD_GATEWAY_ENABLED", True) and not runtime.dry_run:
gateway = DiscordGatewayManager(token)