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_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

View file

@ -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

View file

@ -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:

View file

@ -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) => {

View file

@ -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)