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_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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
11
README.md
11
README.md
|
|
@ -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:
|
||||||
|
|
|
||||||
266
dashboard.html
266
dashboard.html
|
|
@ -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) => {
|
||||||
|
|
|
||||||
198
status_bot.py
198
status_bot.py
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue