Add editable media library dashboard
This commit is contained in:
parent
74445a5c86
commit
d998896872
5 changed files with 459 additions and 18 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
11
README.md
11
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:
|
||||
|
|
|
|||
266
dashboard.html
266
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 @@
|
|||
<h1>Media Catalog</h1>
|
||||
<div class="actions">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -498,6 +557,11 @@
|
|||
<input id="showsCsv" type="file" accept=".csv,text/csv">
|
||||
</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>
|
||||
<span>Movies</span>
|
||||
|
|
@ -517,6 +581,18 @@
|
|||
</div>
|
||||
</div>
|
||||
</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>
|
||||
</main>
|
||||
</div>
|
||||
|
|
@ -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"
|
||||
? `
|
||||
<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() {
|
||||
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) => {
|
||||
|
|
|
|||
198
status_bot.py
198
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)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue