Add editable media library dashboard

This commit is contained in:
MiTHRAL 2026-05-15 15:13:04 -04:00
parent 74445a5c86
commit d998896872
5 changed files with 459 additions and 18 deletions

View file

@ -3,6 +3,7 @@ DISCORD_GATEWAY_ENABLED=true
ARCHIVE_STATUS_CONFIG=services.json ARCHIVE_STATUS_CONFIG=services.json
ARCHIVE_STATUS_STATE=state/status-message.json 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
BOT_SETTINGS_STATE=state/bot-settings.json BOT_SETTINGS_STATE=state/bot-settings.json
MEDIA_CATALOG_MAX_ITEMS=240 MEDIA_CATALOG_MAX_ITEMS=240
CHECK_INTERVAL_SECONDS=60 CHECK_INTERVAL_SECONDS=60

View file

@ -3,6 +3,7 @@ DISCORD_GATEWAY_ENABLED=true
ARCHIVE_STATUS_CONFIG=services.json ARCHIVE_STATUS_CONFIG=services.json
ARCHIVE_STATUS_STATE=state/status-message.json 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
BOT_SETTINGS_STATE=state/bot-settings.json BOT_SETTINGS_STATE=state/bot-settings.json
MEDIA_CATALOG_MAX_ITEMS=240 MEDIA_CATALOG_MAX_ITEMS=240
CHECK_INTERVAL_SECONDS=60 CHECK_INTERVAL_SECONDS=60

View file

@ -187,13 +187,16 @@ The dashboard currently supports:
- saving `services.json` - saving `services.json`
- forcing an immediate check and Discord message update - forcing an immediate check and Discord message update
- uploading `Movies.csv` and `Shows.csv` - uploading `Movies.csv` and `Shows.csv`
- editing imported movies and shows before publishing
- publishing a paginated media catalog to a selected Discord channel - 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. The sidebar leaves room for future modules like polls, webhooks, automations, and service integrations without changing the bot shape later.
## Media Catalog ## Media Catalog
Open the dashboard and switch to `Media`. Set the target Discord channel ID, choose `Movies.csv`, `Shows.csv`, or both, then publish. Open the dashboard and switch to `Media`. Set the target Discord channel ID, choose `Movies.csv`, `Shows.csv`, or both, then import the CSVs into the library editor.
The editor supports adding, editing, and deleting movie/show rows before saving or publishing. Publishing sends the currently edited dashboard library, not the raw uploaded files.
Channel selections are stored in: Channel selections are stored in:
@ -209,6 +212,12 @@ The bot stores media catalog message IDs in:
MEDIA_CATALOG_STATE=state/media-catalog.json MEDIA_CATALOG_STATE=state/media-catalog.json
``` ```
The editable media library is stored in:
```env
MEDIA_LIBRARY_STATE=state/media-library.json
```
Republishing edits the previous catalog messages in place. If the catalog gets shorter, the bot attempts to delete old extra messages. 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: By default, Discord output is capped at 240 movies and 240 shows to avoid flooding a channel. Change this with:

View file

@ -332,6 +332,60 @@
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
.media-editor {
margin-top: 16px;
}
.media-toolbar {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 12px 14px;
border-bottom: 1px solid var(--line);
}
.media-tabs {
display: flex;
gap: 6px;
padding: 10px 14px;
border-bottom: 1px solid var(--line);
}
.media-tab {
min-width: 86px;
}
.media-tab.active {
background: var(--action);
border-color: var(--action);
color: var(--action-text);
}
.library-list {
display: grid;
gap: 1px;
background: var(--line);
}
.library-row {
display: grid;
grid-template-columns: minmax(170px, 1fr) 82px minmax(130px, 0.9fr) 90px 96px 96px minmax(220px, 1.3fr) 70px;
gap: 10px;
align-items: end;
background: var(--panel);
padding: 12px 14px;
}
.library-row.movies {
grid-template-columns: minmax(170px, 1fr) 82px minmax(130px, 0.9fr) 90px 96px minmax(220px, 1.3fr) 70px;
}
.empty-library {
background: var(--panel);
color: var(--muted);
padding: 18px 14px;
}
.token-screen { .token-screen {
max-width: 420px; max-width: 420px;
margin: 80px auto; margin: 80px auto;
@ -369,6 +423,8 @@
.service-row, .service-row,
.channel-form, .channel-form,
.library-row,
.library-row.movies,
.media-form { .media-form {
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
} }
@ -392,6 +448,8 @@
nav, nav,
.service-row, .service-row,
.channel-form, .channel-form,
.library-row,
.library-row.movies,
.media-form, .media-form,
.media-status { .media-status {
grid-template-columns: 1fr; grid-template-columns: 1fr;
@ -475,6 +533,7 @@
<h1>Media Catalog</h1> <h1>Media Catalog</h1>
<div class="actions"> <div class="actions">
<button id="mediaRefresh" type="button">Refresh</button> <button id="mediaRefresh" type="button">Refresh</button>
<button id="saveMediaLibrary" type="button">Save library</button>
<button id="publishMedia" class="primary" type="button">Publish to Discord</button> <button id="publishMedia" class="primary" type="button">Publish to Discord</button>
</div> </div>
</div> </div>
@ -498,6 +557,11 @@
<input id="showsCsv" type="file" accept=".csv,text/csv"> <input id="showsCsv" type="file" accept=".csv,text/csv">
</div> </div>
</div> </div>
<div class="media-toolbar">
<button id="importMediaCsv" type="button">Import CSV</button>
<button id="addMovie" type="button">Add movie</button>
<button id="addShow" type="button">Add show</button>
</div>
<div class="media-status" aria-label="Media catalog status"> <div class="media-status" aria-label="Media catalog status">
<div> <div>
<span>Movies</span> <span>Movies</span>
@ -517,6 +581,18 @@
</div> </div>
</div> </div>
</section> </section>
<section class="panel media-editor">
<div class="panel-header">
<div class="panel-title">Library Editor</div>
<div id="libraryCount" class="message"></div>
</div>
<div class="media-tabs">
<button id="moviesTab" class="media-tab active" type="button" data-media-tab="movies">Movies</button>
<button id="showsTab" class="media-tab" type="button" data-media-tab="shows">Shows</button>
</div>
<div id="mediaLibrary" class="library-list"></div>
</section>
</section> </section>
</main> </main>
</div> </div>
@ -527,6 +603,8 @@
const statusViewEl = document.querySelector("#statusView"); const statusViewEl = document.querySelector("#statusView");
const mediaViewEl = document.querySelector("#mediaView"); const mediaViewEl = document.querySelector("#mediaView");
const servicesEl = document.querySelector("#services"); const servicesEl = document.querySelector("#services");
const mediaLibraryEl = document.querySelector("#mediaLibrary");
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 loginMessageEl = document.querySelector("#loginMessage"); const loginMessageEl = document.querySelector("#loginMessage");
@ -535,6 +613,8 @@
let results = new Map(); let results = new Map();
let csrfToken = ""; let csrfToken = "";
let channels = { statusChannelId: "", mediaChannelId: "" }; let channels = { statusChannelId: "", mediaChannelId: "" };
let mediaLibrary = { movies: [], shows: [] };
let activeMediaTab = "movies";
function headers(method = "GET") { function headers(method = "GET") {
const base = { "Content-Type": "application/json" }; const base = { "Content-Type": "application/json" };
@ -724,6 +804,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.library) {
mediaLibrary = normalizeMediaLibrary(payload.library);
renderMediaLibrary();
}
document.querySelector("#mediaMovieCount").textContent = payload.movieCount == null ? "Not published" : payload.movieCount; document.querySelector("#mediaMovieCount").textContent = payload.movieCount == null ? "Not published" : payload.movieCount;
document.querySelector("#mediaShowCount").textContent = payload.showCount == null ? "Not published" : payload.showCount; 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("#mediaMessageCount").textContent = Array.isArray(payload.messageIds) ? payload.messageIds.length : 0;
@ -738,6 +822,106 @@
return payload; return payload;
} }
function normalizeMediaLibrary(library = {}) {
return {
movies: Array.isArray(library.movies) ? library.movies.map((item) => normalizeMediaItem(item, "movie")) : [],
shows: Array.isArray(library.shows) ? library.shows.map((item) => normalizeMediaItem(item, "show")) : []
};
}
function normalizeMediaItem(item = {}, mediaType = "movie") {
return {
title: item.title || "",
mediaType,
year: item.year || "",
genres: item.genres || "",
rating: item.rating || "",
runtime: mediaType === "movie" ? item.runtime || "" : "",
seasons: mediaType === "show" ? item.seasons || "" : "",
episodes: mediaType === "show" ? item.episodes || "" : "",
summary: item.summary || ""
};
}
function collectMediaLibrary() {
const currentItems = [...mediaLibraryEl.querySelectorAll(".library-row")].map((row) => {
const item = {};
row.querySelectorAll("[data-media-key]").forEach((input) => {
item[input.dataset.mediaKey] = input.value.trim();
});
return normalizeMediaItem(item, activeMediaTab === "movies" ? "movie" : "show");
});
mediaLibrary[activeMediaTab] = currentItems;
return mediaLibrary;
}
function mediaRow(item, index) {
const type = activeMediaTab === "movies" ? "movie" : "show";
const row = document.createElement("div");
row.className = `library-row ${activeMediaTab}`;
row.dataset.index = String(index);
const countFields = type === "show"
? `
<div class="field">
<label>Seasons</label>
<input data-media-key="seasons" type="number" min="0" value="${escapeAttr(item.seasons)}">
</div>
<div class="field">
<label>Episodes</label>
<input data-media-key="episodes" type="number" min="0" value="${escapeAttr(item.episodes)}">
</div>
`
: `
<div class="field">
<label>Runtime</label>
<input data-media-key="runtime" value="${escapeAttr(item.runtime)}">
</div>
`;
row.innerHTML = `
<div class="field">
<label>Title</label>
<input data-media-key="title" value="${escapeAttr(item.title)}">
</div>
<div class="field">
<label>Year</label>
<input data-media-key="year" inputmode="numeric" value="${escapeAttr(item.year)}">
</div>
<div class="field">
<label>Genres</label>
<input data-media-key="genres" value="${escapeAttr(item.genres)}">
</div>
<div class="field">
<label>Rating</label>
<input data-media-key="rating" value="${escapeAttr(item.rating)}">
</div>
${countFields}
<div class="field">
<label>Summary</label>
<input data-media-key="summary" value="${escapeAttr(item.summary)}">
</div>
<button class="danger" type="button" data-remove-media="${index}">Del</button>
`;
return row;
}
function renderMediaLibrary() {
mediaLibraryEl.innerHTML = "";
const items = mediaLibrary[activeMediaTab] || [];
libraryCountEl.textContent = `${mediaLibrary.movies.length} movies · ${mediaLibrary.shows.length} shows`;
document.querySelectorAll("[data-media-tab]").forEach((button) => {
button.classList.toggle("active", button.dataset.mediaTab === activeMediaTab);
});
if (!items.length) {
const empty = document.createElement("div");
empty.className = "empty-library";
empty.textContent = activeMediaTab === "movies" ? "No movies loaded." : "No shows loaded.";
mediaLibraryEl.append(empty);
return;
}
items.forEach((item, index) => mediaLibraryEl.append(mediaRow(item, index)));
}
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;
@ -761,7 +945,7 @@
return file.text().then((text) => ({ name: file.name, text })); return file.text().then((text) => ({ name: file.name, text }));
} }
async function publishMedia() { async function importMediaCsv() {
const moviesInput = document.querySelector("#moviesCsv"); const moviesInput = document.querySelector("#moviesCsv");
const showsInput = document.querySelector("#showsCsv"); const showsInput = document.querySelector("#showsCsv");
const [movies, shows] = await Promise.all([ const [movies, shows] = await Promise.all([
@ -770,27 +954,59 @@
]); ]);
if (!movies.text.trim() && !shows.text.trim()) { if (!movies.text.trim() && !shows.text.trim()) {
throw new Error("Choose a Movies.csv or Shows.csv file before publishing."); throw new Error("Choose a Movies.csv or Shows.csv file before importing.");
} }
setMediaMessage("Uploading CSV files..."); setMediaMessage("Importing CSV files...");
const payload = await api("/api/media", { const payload = await api("/api/media/import", {
method: "POST", method: "POST",
body: JSON.stringify({ body: JSON.stringify({
channelId: document.querySelector("#mediaChannelId").value.trim() || channels.mediaChannelId,
moviesCsv: movies.text, moviesCsv: movies.text,
showsCsv: shows.text, showsCsv: shows.text,
movieFileName: movies.name || "Movies.csv", movieFileName: movies.name || "Movies.csv",
showFileName: shows.name || "Shows.csv" showFileName: shows.name || "Shows.csv"
}) })
}); });
mediaLibrary = normalizeMediaLibrary(payload.library);
renderMediaLibrary();
document.querySelector("#mediaMovieCount").textContent = payload.movieCount;
document.querySelector("#mediaShowCount").textContent = payload.showCount;
setMediaMessage(`Imported ${payload.movieCount} movies and ${payload.showCount} shows.`);
}
async function saveMediaLibrary() {
collectMediaLibrary();
const payload = await api("/api/media/library", {
method: "POST",
body: JSON.stringify(mediaLibrary)
});
mediaLibrary = normalizeMediaLibrary(payload.library);
renderMediaLibrary();
document.querySelector("#mediaMovieCount").textContent = payload.movieCount;
document.querySelector("#mediaShowCount").textContent = payload.showCount;
setMediaMessage("Saved library edits.");
return payload;
}
async function publishMedia() {
collectMediaLibrary();
setMediaMessage("Publishing library...");
const payload = await api("/api/media", {
method: "POST",
body: JSON.stringify({
channelId: document.querySelector("#mediaChannelId").value.trim() || channels.mediaChannelId,
movies: mediaLibrary.movies,
shows: mediaLibrary.shows
})
});
renderMediaStatus({ renderMediaStatus({
channelId: payload.channelId, channelId: payload.channelId,
messageIds: payload.messageIds, messageIds: payload.messageIds,
movieCount: payload.movieCount, movieCount: payload.movieCount,
showCount: payload.showCount, showCount: payload.showCount,
publishedAt: new Date().toISOString() publishedAt: new Date().toISOString(),
library: mediaLibrary
}); });
setMediaMessage(`Published ${payload.movieCount} movies and ${payload.showCount} shows.`); setMediaMessage(`Published ${payload.movieCount} movies and ${payload.showCount} shows.`);
} }
@ -882,6 +1098,14 @@
.catch((error) => setMediaMessage(error.message, true)); .catch((error) => setMediaMessage(error.message, true));
}); });
document.querySelector("#importMediaCsv").addEventListener("click", () => {
importMediaCsv().catch((error) => setMediaMessage(error.message, true));
});
document.querySelector("#saveMediaLibrary").addEventListener("click", () => {
saveMediaLibrary().catch((error) => setMediaMessage(error.message, true));
});
document.querySelector("#publishMedia").addEventListener("click", () => { document.querySelector("#publishMedia").addEventListener("click", () => {
publishMedia().catch((error) => setMediaMessage(error.message, true)); publishMedia().catch((error) => setMediaMessage(error.message, true));
}); });
@ -899,6 +1123,28 @@
}); });
}); });
document.querySelectorAll("[data-media-tab]").forEach((button) => {
button.addEventListener("click", () => {
collectMediaLibrary();
activeMediaTab = button.dataset.mediaTab;
renderMediaLibrary();
});
});
document.querySelector("#addMovie").addEventListener("click", () => {
collectMediaLibrary();
activeMediaTab = "movies";
mediaLibrary.movies.push(normalizeMediaItem({ title: "New Movie" }, "movie"));
renderMediaLibrary();
});
document.querySelector("#addShow").addEventListener("click", () => {
collectMediaLibrary();
activeMediaTab = "shows";
mediaLibrary.shows.push(normalizeMediaItem({ title: "New Show" }, "show"));
renderMediaLibrary();
});
document.querySelector("#addService").addEventListener("click", () => { document.querySelector("#addService").addEventListener("click", () => {
services.push(normalizeService({ name: "New Service", url: "https://example.com" })); services.push(normalizeService({ name: "New Service", url: "https://example.com" }));
renderServices(); renderServices();
@ -911,6 +1157,14 @@
renderServices(); renderServices();
}); });
mediaLibraryEl.addEventListener("click", (event) => {
const button = event.target.closest("[data-remove-media]");
if (!button) return;
collectMediaLibrary();
mediaLibrary[activeMediaTab].splice(Number(button.dataset.removeMedia), 1);
renderMediaLibrary();
});
let draggedRow = null; let draggedRow = null;
servicesEl.addEventListener("dragstart", (e) => { servicesEl.addEventListener("dragstart", (e) => {

View file

@ -167,6 +167,7 @@ class BotRuntime:
config_path: Path, config_path: Path,
state_path: Path, state_path: Path,
media_state_path: Path, media_state_path: Path,
media_library_path: Path,
settings_path: Path, settings_path: Path,
dry_run: bool = False, dry_run: bool = False,
) -> None: ) -> None:
@ -175,6 +176,7 @@ class BotRuntime:
self.config_path = config_path self.config_path = config_path
self.state_path = state_path self.state_path = state_path
self.media_state_path = media_state_path self.media_state_path = media_state_path
self.media_library_path = media_library_path
self.settings_path = settings_path self.settings_path = settings_path
self.dry_run = dry_run self.dry_run = dry_run
self.lock = threading.Lock() self.lock = threading.Lock()
@ -769,6 +771,101 @@ def parse_media_csv(csv_text: str, media_type: str, filename: str) -> list[Media
return parsed return parsed
def media_item_to_jsonable(item: MediaItem) -> dict[str, Any]:
data: dict[str, Any] = {
"title": item.title,
"mediaType": item.media_type,
"year": item.year or "",
"genres": item.genres or "",
"rating": item.rating or "",
"runtime": item.runtime or "",
"summary": item.summary or "",
}
if item.media_type == "show":
data["seasons"] = item.seasons if item.seasons is not None else ""
data["episodes"] = item.episodes if item.episodes is not None else ""
return data
def media_item_from_data(data: Any, media_type: str) -> MediaItem:
if not isinstance(data, dict):
raise ValueError(f"{media_type.title()} entries must be objects")
title = clean_media_text(str(data.get("title", "")), 120)
if not title:
raise ValueError(f"{media_type.title()} entries must include a title")
year = parse_year_text(str(data.get("year", "")))
seasons = parse_int_text(str(data.get("seasons", ""))) if media_type == "show" else None
episodes = parse_int_text(str(data.get("episodes", ""))) if media_type == "show" else None
return MediaItem(
title=title,
media_type=media_type,
year=year,
genres=clean_media_text(str(data.get("genres", "")), 120),
rating=clean_media_text(str(data.get("rating", "")), 32),
runtime=format_runtime(str(data.get("runtime", ""))) if media_type == "movie" else None,
summary=clean_media_text(str(data.get("summary", ""))),
seasons=seasons,
episodes=episodes,
)
def media_items_from_data(raw_items: Any, media_type: str) -> list[MediaItem]:
if raw_items is None:
return []
if not isinstance(raw_items, list):
raise ValueError(f"{media_type.title()} library must be a list")
deduped: dict[tuple[str, str], MediaItem] = {}
for raw_item in raw_items:
item = media_item_from_data(raw_item, media_type)
deduped[(item.title.casefold(), item.year or "")] = item
return sorted(deduped.values(), key=lambda item: (item.title.casefold(), item.year or ""))
def media_library_to_jsonable(movies: list[MediaItem], shows: list[MediaItem]) -> dict[str, Any]:
return {
"movies": [media_item_to_jsonable(item) for item in movies],
"shows": [media_item_to_jsonable(item) for item in shows],
}
def load_media_library(runtime: BotRuntime) -> tuple[list[MediaItem], list[MediaItem]]:
data = load_state(runtime.media_library_path)
return (
media_items_from_data(data.get("movies", []), "movie"),
media_items_from_data(data.get("shows", []), "show"),
)
def save_media_library(runtime: BotRuntime, movies: list[MediaItem], shows: list[MediaItem]) -> dict[str, Any]:
payload = media_library_to_jsonable(movies, shows)
payload["updated_at"] = datetime.now(timezone.utc).isoformat()
save_state(runtime.media_library_path, payload)
return payload
def import_media_csvs(
runtime: BotRuntime,
movies_csv: str,
shows_csv: str,
movie_filename: str,
show_filename: str,
) -> dict[str, Any]:
current_movies, current_shows = load_media_library(runtime)
movies = parse_media_csv(movies_csv, "movie", movie_filename) if movies_csv.strip() else current_movies
shows = parse_media_csv(shows_csv, "show", show_filename) if shows_csv.strip() else current_shows
save_media_library(runtime, movies, shows)
return {
"library": media_library_to_jsonable(movies, shows),
"movieCount": len(movies),
"showCount": len(shows),
}
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})"
@ -878,13 +975,13 @@ def render_media_catalog_payloads(
return payloads return payloads
def publish_media_catalog( def publish_media_items(
runtime: BotRuntime, runtime: BotRuntime,
channel_id: str, channel_id: str,
movies_csv: str, movies_all: list[MediaItem],
shows_csv: str, shows_all: list[MediaItem],
movie_filename: str, movie_source: str = "Dashboard library",
show_filename: str, show_source: str = "Dashboard library",
) -> dict[str, Any]: ) -> dict[str, Any]:
if runtime.dry_run: if runtime.dry_run:
raise RuntimeError("Discord dry run is enabled; media catalog was parsed but not sent") raise RuntimeError("Discord dry run is enabled; media catalog was parsed but not sent")
@ -892,10 +989,8 @@ def publish_media_catalog(
settings = channel_settings(runtime) settings = channel_settings(runtime)
channel = validate_channel_id(channel_id.strip() or settings["mediaChannelId"], "Media") 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: if not movies_all and not shows_all:
raise ValueError("Upload at least one Movies.csv or Shows.csv file") raise ValueError("Add at least one movie or show before publishing")
limit = int(os.getenv("MEDIA_CATALOG_MAX_ITEMS", str(MAX_MEDIA_ITEMS_PER_CATEGORY))) limit = int(os.getenv("MEDIA_CATALOG_MAX_ITEMS", str(MAX_MEDIA_ITEMS_PER_CATEGORY)))
movies = movies_all[:limit] movies = movies_all[:limit]
@ -905,8 +1000,8 @@ def publish_media_catalog(
shows=shows, shows=shows,
movie_total=len(movies_all), movie_total=len(movies_all),
show_total=len(shows_all), show_total=len(shows_all),
movie_source=movie_filename or "Movies.csv", movie_source=movie_source,
show_source=show_filename or "Shows.csv", show_source=show_source,
) )
state = load_state(runtime.media_state_path) state = load_state(runtime.media_state_path)
@ -958,9 +1053,31 @@ def publish_media_catalog(
} }
def publish_media_catalog(
runtime: BotRuntime,
channel_id: str,
movies_csv: str,
shows_csv: str,
movie_filename: str,
show_filename: str,
) -> dict[str, Any]:
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 []
save_media_library(runtime, movies_all, shows_all)
return publish_media_items(
runtime=runtime,
channel_id=channel_id,
movies_all=movies_all,
shows_all=shows_all,
movie_source=movie_filename or "Movies.csv",
show_source=show_filename or "Shows.csv",
)
def media_catalog_status(runtime: BotRuntime) -> dict[str, Any]: def media_catalog_status(runtime: BotRuntime) -> dict[str, Any]:
state = load_state(runtime.media_state_path) state = load_state(runtime.media_state_path)
settings = channel_settings(runtime) settings = channel_settings(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"],
"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 [],
@ -968,6 +1085,7 @@ def media_catalog_status(runtime: BotRuntime) -> dict[str, Any]:
"showCount": state.get("show_count"), "showCount": state.get("show_count"),
"publishedAt": state.get("published_at"), "publishedAt": state.get("published_at"),
"maxItemsPerCategory": int(os.getenv("MEDIA_CATALOG_MAX_ITEMS", str(MAX_MEDIA_ITEMS_PER_CATEGORY))), "maxItemsPerCategory": int(os.getenv("MEDIA_CATALOG_MAX_ITEMS", str(MAX_MEDIA_ITEMS_PER_CATEGORY))),
"library": media_library_to_jsonable(movies, shows),
} }
@ -1263,6 +1381,12 @@ def make_dashboard_handler(runtime: BotRuntime, auth: DashboardAuth | None) -> t
if self.path == "/api/media": if self.path == "/api/media":
self.handle_media_catalog() self.handle_media_catalog()
return return
if self.path == "/api/media/import":
self.handle_media_import()
return
if self.path == "/api/media/library":
self.handle_media_library()
return
if self.path == "/api/settings": if self.path == "/api/settings":
self.handle_settings() self.handle_settings()
return return
@ -1441,6 +1565,19 @@ def make_dashboard_handler(runtime: BotRuntime, auth: DashboardAuth | None) -> t
def handle_media_catalog(self) -> None: def handle_media_catalog(self) -> None:
try: try:
data = self.read_json() data = self.read_json()
if "movies" in data or "shows" in data:
movies = media_items_from_data(data.get("movies", []), "movie")
shows = media_items_from_data(data.get("shows", []), "show")
save_media_library(runtime, movies, shows)
result = publish_media_items(
runtime=runtime,
channel_id=str(data.get("channelId", "")),
movies_all=movies,
shows_all=shows,
)
self.send_json(HTTPStatus.OK, result)
return
result = publish_media_catalog( result = publish_media_catalog(
runtime=runtime, runtime=runtime,
channel_id=str(data.get("channelId", "")), channel_id=str(data.get("channelId", "")),
@ -1455,6 +1592,44 @@ def make_dashboard_handler(runtime: BotRuntime, auth: DashboardAuth | None) -> t
self.send_json(HTTPStatus.OK, result) self.send_json(HTTPStatus.OK, result)
def handle_media_import(self) -> None:
try:
data = self.read_json()
result = import_media_csvs(
runtime=runtime,
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_media_library(self) -> None:
try:
data = self.read_json()
movies = media_items_from_data(data.get("movies", []), "movie")
shows = media_items_from_data(data.get("shows", []), "show")
saved = save_media_library(runtime, movies, shows)
except Exception as exc:
self.send_json(HTTPStatus.BAD_REQUEST, {"error": str(exc)})
return
self.send_json(
HTTPStatus.OK,
{
"library": {
"movies": saved.get("movies", []),
"shows": saved.get("shows", []),
},
"movieCount": len(movies),
"showCount": len(shows),
},
)
def handle_settings(self) -> None: def handle_settings(self) -> None:
try: try:
data = self.read_json() data = self.read_json()
@ -1508,9 +1683,10 @@ def main() -> int:
config_path = Path(env("ARCHIVE_STATUS_CONFIG", "services.json")) config_path = Path(env("ARCHIVE_STATUS_CONFIG", "services.json"))
state_path = Path(env("ARCHIVE_STATUS_STATE", "state/status-message.json")) state_path = Path(env("ARCHIVE_STATUS_STATE", "state/status-message.json"))
media_state_path = Path(env("MEDIA_CATALOG_STATE", "state/media-catalog.json")) media_state_path = Path(env("MEDIA_CATALOG_STATE", "state/media-catalog.json"))
media_library_path = Path(env("MEDIA_LIBRARY_STATE", "state/media-library.json"))
settings_path = Path(env("BOT_SETTINGS_STATE", "state/bot-settings.json")) settings_path = Path(env("BOT_SETTINGS_STATE", "state/bot-settings.json"))
interval = int(env("CHECK_INTERVAL_SECONDS", str(DEFAULT_INTERVAL_SECONDS))) interval = int(env("CHECK_INTERVAL_SECONDS", str(DEFAULT_INTERVAL_SECONDS)))
runtime = BotRuntime(token, channel_id, config_path, state_path, media_state_path, settings_path, dry_run=bool_env("DISCORD_DRY_RUN", False)) runtime = BotRuntime(token, channel_id, config_path, state_path, media_state_path, media_library_path, settings_path, dry_run=bool_env("DISCORD_DRY_RUN", False))
gateway = None gateway = None
if bool_env("DISCORD_GATEWAY_ENABLED", True) and not runtime.dry_run: if bool_env("DISCORD_GATEWAY_ENABLED", True) and not runtime.dry_run:
gateway = DiscordGatewayManager(token) gateway = DiscordGatewayManager(token)