feat: runtime config, Matomo analytics, chat accounts, DM history/search, and Prometheus retention
This commit is contained in:
1 parent
06ab5b4655
commit
121fdbacdc
27 files changed
+1841
-161
No files matched your search
+95
-6
@@ -16,6 +16,7 @@ type MediaEntry = {
|
||||
runtime?: string;
|
||||
modifiedAt?: string;
|
||||
tags?: string;
|
||||
path?: string;
|
||||
};
|
||||
|
||||
const bodyDataset = document.body.dataset;
|
||||
@@ -246,7 +247,8 @@ async function refreshPlaylist() {
|
||||
const content: string = payload.content || "";
|
||||
const textarea = document.getElementById("playlist-content") as HTMLTextAreaElement | null;
|
||||
if (textarea) textarea.value = content;
|
||||
renderPlaylistSortable(content);
|
||||
const statusMap = await fetchMediaStatusMap();
|
||||
renderPlaylistSortable(content, statusMap);
|
||||
await showPlaylistWarnings(content);
|
||||
setStatus("playlist-status", "Playlist loaded.");
|
||||
} catch (error) {
|
||||
@@ -255,7 +257,7 @@ async function refreshPlaylist() {
|
||||
}
|
||||
}
|
||||
|
||||
export function renderPlaylistSortable(content: string) {
|
||||
export function renderPlaylistSortable(content: string, statusMap?: Map<string, string>) {
|
||||
const list = document.getElementById("playlist-sortable");
|
||||
if (!list) return;
|
||||
const lines = content.split("\n").filter((l) => l.trim() && !l.startsWith("#"));
|
||||
@@ -265,10 +267,12 @@ export function renderPlaylistSortable(content: string) {
|
||||
.replace(/^file\s+/, "")
|
||||
.replace(/['"]/g, "")
|
||||
.trim();
|
||||
const badge = statusMap ? statusBadge(statusMap.get(path)) : "";
|
||||
return `<li class="sortable-item" draggable="true" data-index="${i}">
|
||||
<span class="drag-handle" aria-hidden="true">⋮⋮</span>
|
||||
<span class="sortable-index">${i + 1}</span>
|
||||
<span class="sortable-path">${path}</span>
|
||||
${badge}
|
||||
<button type="button" class="btn btn-secondary btn-sm" data-remove="${i}">✕</button>
|
||||
</li>`;
|
||||
})
|
||||
@@ -352,6 +356,80 @@ async function showPlaylistWarnings(content: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function statusBadge(status: string | undefined): string {
|
||||
if (!status) return `<span class="badge badge-muted">unknown</span>`;
|
||||
switch (status) {
|
||||
case "ready":
|
||||
return `<span class="badge badge-ok">ready</span>`;
|
||||
case "needs_normalization":
|
||||
return `<span class="badge badge-warn">needs normalization</span>`;
|
||||
case "normalizing":
|
||||
return `<span class="badge badge-info">normalizing</span>`;
|
||||
case "normalization_failed":
|
||||
return `<span class="badge badge-danger">failed</span>`;
|
||||
default:
|
||||
return `<span class="badge badge-muted">${status}</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchMediaStatusMap(): Promise<Map<string, string>> {
|
||||
const map = new Map<string, string>();
|
||||
try {
|
||||
const resp = await fetch("/api/admin/media");
|
||||
if (!resp.ok) return map;
|
||||
const payload = await resp.json();
|
||||
for (const f of payload.files || []) {
|
||||
if (f.path) map.set(f.path, f.status || "unknown");
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
async function addMediaToPlaylist(file: MediaEntry) {
|
||||
if (file.status !== "ready") {
|
||||
setStatus("media-status", "Only ready media can be added to the playlist.", true);
|
||||
return;
|
||||
}
|
||||
if (!file.path) {
|
||||
setStatus("media-status", "Cannot add media without a path.", true);
|
||||
return;
|
||||
}
|
||||
setStatus("media-status", `Adding ${file.name} to playlist...`);
|
||||
try {
|
||||
const getResp = await fetch("/api/admin/playlist");
|
||||
if (!getResp.ok) throw new Error(await readErrorMessage(getResp));
|
||||
const payload = await getResp.json();
|
||||
const content: string = payload.content || "";
|
||||
const lines = content.split("\n").filter((l) => l.trim());
|
||||
const entry = `file '${file.path}'`;
|
||||
const exists = lines.some(
|
||||
(l) =>
|
||||
l
|
||||
.replace(/^file\s+/, "")
|
||||
.replace(/['"]/g, "")
|
||||
.trim() === file.path,
|
||||
);
|
||||
if (exists) {
|
||||
setStatus("media-status", `${file.name} is already in the playlist.`, true);
|
||||
return;
|
||||
}
|
||||
lines.push(entry);
|
||||
const putResp = await fetch("/api/admin/playlist", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ content: lines.join("\n") + "\n" }),
|
||||
});
|
||||
if (!putResp.ok) throw new Error(await readErrorMessage(putResp));
|
||||
setStatus("media-status", `${file.name} added to playlist.`);
|
||||
} catch (error) {
|
||||
console.error("Failed to add media to playlist", error);
|
||||
const details = error instanceof Error ? error.message : "Unknown error.";
|
||||
setStatus("media-status", `Failed to add ${file.name} to playlist. (${details})`, true);
|
||||
}
|
||||
}
|
||||
|
||||
export async function savePlaylist() {
|
||||
if (state.playlistSaving) return;
|
||||
state.playlistSaving = true;
|
||||
@@ -556,16 +634,18 @@ function renderMediaRows(files: MediaEntry[]) {
|
||||
files.forEach((file) => {
|
||||
const tr = document.createElement("tr");
|
||||
const canNormalize = file.status === "needs_normalization" || file.status === "normalization_failed";
|
||||
const canAdd = file.status === "ready";
|
||||
tr.innerHTML = `
|
||||
<td>${file.name}</td>
|
||||
<td>${file.title || "-"}</td>
|
||||
<td>${file.runtime || "00:00:00"}</td>
|
||||
<td>${file.status || "unknown"}</td>
|
||||
<td>${statusBadge(file.status)}</td>
|
||||
<td>${fmtSize(file.size)}</td>
|
||||
<td>${file.modifiedAt || "-"}</td>
|
||||
<td class="action-cell">
|
||||
<button type="button" class="btn btn-secondary btn-small" data-action="edit" data-id="${file.id || ""}">Edit</button>
|
||||
${canNormalize ? `<button type="button" class="btn btn-secondary btn-small" data-action="normalize" data-id="${file.id || ""}">Normalize</button>` : ""}
|
||||
${canAdd ? `<button type="button" class="btn btn-secondary btn-small" data-action="add" data-id="${file.id || ""}">Add to playlist</button>` : ""}
|
||||
<button type="button" class="btn btn-danger btn-small" data-action="delete" data-id="${file.id || ""}">Delete</button>
|
||||
</td>
|
||||
`;
|
||||
@@ -573,6 +653,9 @@ function renderMediaRows(files: MediaEntry[]) {
|
||||
if (canNormalize) {
|
||||
tr.querySelector('[data-action="normalize"]')?.addEventListener("click", () => normalizeMedia(file));
|
||||
}
|
||||
if (canAdd) {
|
||||
tr.querySelector('[data-action="add"]')?.addEventListener("click", () => addMediaToPlaylist(file));
|
||||
}
|
||||
tr.querySelector('[data-action="delete"]')?.addEventListener("click", () => deleteMedia(file));
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
@@ -1143,6 +1226,10 @@ async function loadConfig() {
|
||||
const cfg = await resp.json();
|
||||
(document.getElementById("config-rate-max") as HTMLInputElement).value = cfg.rateLimitMax;
|
||||
(document.getElementById("config-rate-window") as HTMLInputElement).value = cfg.rateLimitWindowSec;
|
||||
(document.getElementById("config-hls-duration") as HTMLInputElement).value = cfg.hlsSegmentDuration;
|
||||
(document.getElementById("config-hls-window") as HTMLInputElement).value = cfg.hlsWindowSize;
|
||||
(document.getElementById("config-matomo-url") as HTMLInputElement).value = cfg.matomoUrl || "";
|
||||
(document.getElementById("config-matomo-site-id") as HTMLInputElement).value = cfg.matomoSiteId || "";
|
||||
} catch {
|
||||
/* non-critical */
|
||||
}
|
||||
@@ -1157,11 +1244,13 @@ async function saveConfig(e: Event) {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
rateLimitMax: Number((document.getElementById("config-rate-max") as HTMLInputElement).value),
|
||||
rateLimitWindowSec: Number((document.getElementById("config-rate-window") as HTMLInputElement).value),
|
||||
hlsSegmentDuration: Number((document.getElementById("config-hls-duration") as HTMLInputElement).value),
|
||||
hlsWindowSize: Number((document.getElementById("config-hls-window") as HTMLInputElement).value),
|
||||
matomoUrl: (document.getElementById("config-matomo-url") as HTMLInputElement).value.trim(),
|
||||
matomoSiteId: (document.getElementById("config-matomo-site-id") as HTMLInputElement).value.trim(),
|
||||
}),
|
||||
});
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
if (!resp.ok) throw new Error(await readErrorMessage(resp));
|
||||
if (status) {
|
||||
status.textContent = "Config saved.";
|
||||
status.className = "status-line";
|
||||
|
||||
Reference in new issue
Block a user