From d998896872cc34667062aad2372d405eb4b1fd48 Mon Sep 17 00:00:00 2001 From: MiTHRAL Date: Fri, 15 May 2026 15:13:04 -0400 Subject: [PATCH] Add editable media library dashboard --- .env.deploy.example | 1 + .env.example | 1 + README.md | 11 +- dashboard.html | 266 +++++++++++++++++++++++++++++++++++++++++++- status_bot.py | 198 +++++++++++++++++++++++++++++++-- 5 files changed, 459 insertions(+), 18 deletions(-) diff --git a/.env.deploy.example b/.env.deploy.example index 44707ff..45f8aa1 100644 --- a/.env.deploy.example +++ b/.env.deploy.example @@ -3,6 +3,7 @@ DISCORD_GATEWAY_ENABLED=true ARCHIVE_STATUS_CONFIG=services.json ARCHIVE_STATUS_STATE=state/status-message.json MEDIA_CATALOG_STATE=state/media-catalog.json +MEDIA_LIBRARY_STATE=state/media-library.json BOT_SETTINGS_STATE=state/bot-settings.json MEDIA_CATALOG_MAX_ITEMS=240 CHECK_INTERVAL_SECONDS=60 diff --git a/.env.example b/.env.example index 56c6665..5b3f073 100644 --- a/.env.example +++ b/.env.example @@ -3,6 +3,7 @@ DISCORD_GATEWAY_ENABLED=true ARCHIVE_STATUS_CONFIG=services.json ARCHIVE_STATUS_STATE=state/status-message.json MEDIA_CATALOG_STATE=state/media-catalog.json +MEDIA_LIBRARY_STATE=state/media-library.json BOT_SETTINGS_STATE=state/bot-settings.json MEDIA_CATALOG_MAX_ITEMS=240 CHECK_INTERVAL_SECONDS=60 diff --git a/README.md b/README.md index d84754c..bf63d51 100644 --- a/README.md +++ b/README.md @@ -187,13 +187,16 @@ The dashboard currently supports: - saving `services.json` - forcing an immediate check and Discord message update - uploading `Movies.csv` and `Shows.csv` +- editing imported movies and shows before publishing - 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. +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: @@ -209,6 +212,12 @@ The bot stores media catalog message IDs in: 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. By default, Discord output is capped at 240 movies and 240 shows to avoid flooding a channel. Change this with: diff --git a/dashboard.html b/dashboard.html index 2087cfb..fdb4a8b 100644 --- a/dashboard.html +++ b/dashboard.html @@ -332,6 +332,60 @@ 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 { max-width: 420px; margin: 80px auto; @@ -369,6 +423,8 @@ .service-row, .channel-form, + .library-row, + .library-row.movies, .media-form { grid-template-columns: 1fr 1fr; } @@ -392,6 +448,8 @@ nav, .service-row, .channel-form, + .library-row, + .library-row.movies, .media-form, .media-status { grid-template-columns: 1fr; @@ -475,6 +533,7 @@

Media Catalog

+
@@ -498,6 +557,11 @@ +
+ + + +
Movies @@ -517,6 +581,18 @@
+ +
+
+
Library Editor
+
+
+
+ + +
+
+
@@ -527,6 +603,8 @@ const statusViewEl = document.querySelector("#statusView"); const mediaViewEl = document.querySelector("#mediaView"); const servicesEl = document.querySelector("#services"); + const mediaLibraryEl = document.querySelector("#mediaLibrary"); + const libraryCountEl = document.querySelector("#libraryCount"); const messageEl = document.querySelector("#message"); const mediaMessageEl = document.querySelector("#mediaMessage"); const loginMessageEl = document.querySelector("#loginMessage"); @@ -535,6 +613,8 @@ let results = new Map(); let csrfToken = ""; let channels = { statusChannelId: "", mediaChannelId: "" }; + let mediaLibrary = { movies: [], shows: [] }; + let activeMediaTab = "movies"; function headers(method = "GET") { const base = { "Content-Type": "application/json" }; @@ -724,6 +804,10 @@ function renderMediaStatus(payload) { channels.mediaChannelId = payload.channelId || 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("#mediaShowCount").textContent = payload.showCount == null ? "Not published" : payload.showCount; document.querySelector("#mediaMessageCount").textContent = Array.isArray(payload.messageIds) ? payload.messageIds.length : 0; @@ -738,6 +822,106 @@ 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" + ? ` +
+ + +
+
+ + +
+ ` + : ` +
+ + +
+ `; + + row.innerHTML = ` +
+ + +
+
+ + +
+
+ + +
+
+ + +
+ ${countFields} +
+ + +
+ + `; + 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() { const statusChannelId = document.querySelector("#statusChannelId").value.trim() || channels.statusChannelId || ""; const mediaChannelId = document.querySelector("#mediaChannelId").value.trim() || channels.mediaChannelId || statusChannelId; @@ -761,7 +945,7 @@ return file.text().then((text) => ({ name: file.name, text })); } - async function publishMedia() { + async function importMediaCsv() { const moviesInput = document.querySelector("#moviesCsv"); const showsInput = document.querySelector("#showsCsv"); const [movies, shows] = await Promise.all([ @@ -770,27 +954,59 @@ ]); 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..."); - const payload = await api("/api/media", { + setMediaMessage("Importing CSV files..."); + const payload = await api("/api/media/import", { 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" }) }); + 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({ channelId: payload.channelId, messageIds: payload.messageIds, movieCount: payload.movieCount, showCount: payload.showCount, - publishedAt: new Date().toISOString() + publishedAt: new Date().toISOString(), + library: mediaLibrary }); setMediaMessage(`Published ${payload.movieCount} movies and ${payload.showCount} shows.`); } @@ -882,6 +1098,14 @@ .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", () => { 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", () => { services.push(normalizeService({ name: "New Service", url: "https://example.com" })); renderServices(); @@ -911,6 +1157,14 @@ 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; servicesEl.addEventListener("dragstart", (e) => { diff --git a/status_bot.py b/status_bot.py index f4a66b8..0867e42 100644 --- a/status_bot.py +++ b/status_bot.py @@ -167,6 +167,7 @@ class BotRuntime: config_path: Path, state_path: Path, media_state_path: Path, + media_library_path: Path, settings_path: Path, dry_run: bool = False, ) -> None: @@ -175,6 +176,7 @@ class BotRuntime: self.config_path = config_path self.state_path = state_path self.media_state_path = media_state_path + self.media_library_path = media_library_path self.settings_path = settings_path self.dry_run = dry_run self.lock = threading.Lock() @@ -769,6 +771,101 @@ def parse_media_csv(csv_text: str, media_type: str, filename: str) -> list[Media 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: if item.year: return f"{item.title} ({item.year})" @@ -878,13 +975,13 @@ def render_media_catalog_payloads( return payloads -def publish_media_catalog( +def publish_media_items( runtime: BotRuntime, channel_id: str, - movies_csv: str, - shows_csv: str, - movie_filename: str, - show_filename: str, + movies_all: list[MediaItem], + shows_all: list[MediaItem], + movie_source: str = "Dashboard library", + show_source: str = "Dashboard library", ) -> dict[str, Any]: if runtime.dry_run: 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) 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") + 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))) movies = movies_all[:limit] @@ -905,8 +1000,8 @@ def publish_media_catalog( 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", + movie_source=movie_source, + show_source=show_source, ) 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]: state = load_state(runtime.media_state_path) settings = channel_settings(runtime) + movies, shows = load_media_library(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 [], @@ -968,6 +1085,7 @@ def media_catalog_status(runtime: BotRuntime) -> dict[str, Any]: "showCount": state.get("show_count"), "publishedAt": state.get("published_at"), "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": self.handle_media_catalog() 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": self.handle_settings() return @@ -1441,6 +1565,19 @@ def make_dashboard_handler(runtime: BotRuntime, auth: DashboardAuth | None) -> t def handle_media_catalog(self) -> None: try: 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( runtime=runtime, 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) + 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: try: data = self.read_json() @@ -1508,9 +1683,10 @@ def main() -> int: 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")) + media_library_path = Path(env("MEDIA_LIBRARY_STATE", "state/media-library.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, 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 if bool_env("DISCORD_GATEWAY_ENABLED", True) and not runtime.dry_run: gateway = DiscordGatewayManager(token)