feat: runtime config, Matomo analytics, chat accounts, DM history/search, and Prometheus retention

This commit is contained in:
zwitschi committed 2026-08-13 13:12:35 +02:00
1 parent 06ab5b4655
commit 121fdbacdc
27 files changed
+1841 -161

No files matched your search

+95 -6
View File
@@ -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";
+185 -1
View File
@@ -15,7 +15,13 @@ let hls: InstanceType<HlsClass> | null = null;
let ws: WebSocket | null = null;
let currentRoom = "main";
let username = "viewer" + Math.floor(Math.random() * 1000);
let authenticated = false;
let currentDMTarget = "";
let chatVisible = true;
let usersVisible = true;
let typingUser = "";
let lastTypingSent = 0;
let typingTimeout: number | undefined;
const onlineUsers = new Set<string>();
// Player error recovery state (STREAMING.md contract)
@@ -28,7 +34,11 @@ export function resetViewerStateForTests() {
ws = null;
currentRoom = "main";
username = "viewer";
authenticated = false;
chatVisible = true;
usersVisible = true;
typingUser = "";
lastTypingSent = 0;
onlineUsers.clear();
playerErrorCount = 0;
}
@@ -81,6 +91,11 @@ function connectWebSocket(room: string) {
ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
if (msg.type === "typing") {
showTyping(msg.username);
return;
}
hideTyping(msg.username);
addMessage(msg.type || "public", msg.text, msg.username, msg.timestamp);
// Track presence from system join/leave messages
@@ -183,7 +198,9 @@ export async function initPlayerAndChat(loadHlsModule: () => Promise<{ default:
case Hls.ErrorTypes.NETWORK_ERROR:
if (playerErrorCount <= PLAYER_MAX_RETRIES) {
const delay = PLAYER_BASE_DELAY_MS * Math.pow(2, playerErrorCount - 1);
console.warn(`HLS network error (attempt ${playerErrorCount}/${PLAYER_MAX_RETRIES}), retrying in ${delay}ms...`);
console.warn(
`HLS network error (attempt ${playerErrorCount}/${PLAYER_MAX_RETRIES}), retrying in ${delay}ms...`,
);
showReconnecting("Reconnecting...");
setTimeout(() => {
hls?.startLoad();
@@ -282,25 +299,192 @@ function sendMessage() {
function switchRoom(room: string) {
currentRoom = room;
const isDM = room.startsWith("dm:");
document.getElementById("room-name")!.textContent =
room === "main" ? "Main Channel" : room.charAt(0).toUpperCase() + room.slice(1) + " Room";
addMessage("system", `Switched to ${room} room`);
const searchRow = document.getElementById("dm-search-row");
if (searchRow) searchRow.hidden = !isDM;
connectWebSocket(room);
}
function togglePrivateDM() {
const target = prompt("Enter username to start private chat with:");
if (target && target !== username) {
currentDMTarget = target;
const dmRoom = `dm:${[username, target].sort().join("-")}`;
switchRoom(dmRoom);
}
}
async function searchDM() {
const input = document.getElementById("dm-search-input") as HTMLInputElement;
const q = input.value.trim();
if (!q || !currentDMTarget) return;
try {
const res = await fetch(`/api/dm/search?with=${encodeURIComponent(currentDMTarget)}&q=${encodeURIComponent(q)}`);
if (!res.ok) return;
const results = await res.json();
addMessage("system", `Search results (${results.length}) for "${q}":`);
for (const m of results) {
addMessage(m.type || "private", m.text, m.username, m.timestamp);
}
} catch {
/* ignore */
}
}
window.switchRoom = switchRoom;
window.sendMessage = sendMessage;
window.togglePrivateDM = togglePrivateDM;
window.toggleChatPanel = toggleChatPanel;
function updateAuthUI() {
const label = document.getElementById("chat-user-label");
const toggle = document.getElementById("auth-toggle-btn");
const logoutBtn = document.getElementById("auth-logout-btn");
const panel = document.getElementById("auth-panel");
if (label) {
label.textContent = authenticated ? username : "";
label.hidden = !authenticated;
}
if (toggle) toggle.hidden = authenticated;
if (logoutBtn) logoutBtn.hidden = !authenticated;
if (panel) panel.hidden = true;
}
async function refreshUserSession() {
try {
const res = await fetch("/api/auth/session");
if (!res.ok) return;
const data = await res.json();
const wasAnonymous = username.startsWith("viewer");
if (data.authenticated && data.username) {
authenticated = true;
username = data.username;
updateAuthUI();
if (wasAnonymous) connectWebSocket(currentRoom);
} else {
authenticated = false;
updateAuthUI();
}
} catch {
/* non-critical */
}
}
async function submitAuth(path: string) {
const u = (document.getElementById("auth-username") as HTMLInputElement).value.trim();
const p = (document.getElementById("auth-password") as HTMLInputElement).value;
const status = document.getElementById("auth-status");
if (!u || !p) {
if (status) {
status.textContent = "Username and password required.";
status.className = "status-line status-error";
}
return;
}
try {
const res = await fetch(path, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: u, password: p }),
});
if (!res.ok) {
const text = (await res.text()).trim();
throw new Error(text || `HTTP ${res.status}`);
}
const data = await res.json();
username = data.username || u;
authenticated = true;
updateAuthUI();
if (status) {
status.textContent = `Signed in as ${username}.`;
status.className = "status-line";
}
connectWebSocket(currentRoom);
} catch (err) {
if (status) {
status.textContent = `Failed: ${err instanceof Error ? err.message : String(err)}`;
status.className = "status-line status-error";
}
}
}
function login() {
void submitAuth("/api/auth/login");
}
function register() {
void submitAuth("/api/auth/register");
}
async function logout() {
try {
await fetch("/api/auth/logout", { method: "POST" });
} catch {
/* ignore */
}
authenticated = false;
username = "viewer" + Math.floor(Math.random() * 1000);
updateAuthUI();
connectWebSocket(currentRoom);
}
function toggleUserList() {
const panel = document.getElementById("presence-panel");
const btn = document.getElementById("users-toggle-btn");
if (!panel) return;
usersVisible = !usersVisible;
panel.hidden = !usersVisible;
if (btn) btn.setAttribute("aria-expanded", String(usersVisible));
}
function showTyping(name: string) {
if (name === username) return;
const el = document.getElementById("typing-indicator");
if (!el) return;
typingUser = name;
el.textContent = `${name} is typing...`;
el.hidden = false;
window.clearTimeout(typingTimeout);
typingTimeout = window.setTimeout(() => {
typingUser = "";
el.hidden = true;
el.textContent = "";
}, 3000);
}
function hideTyping(name: string) {
if (name !== typingUser) return;
const el = document.getElementById("typing-indicator");
typingUser = "";
window.clearTimeout(typingTimeout);
if (el) {
el.hidden = true;
el.textContent = "";
}
}
function sendTyping() {
if (!ws || ws.readyState !== WebSocket.OPEN) return;
const now = Date.now();
if (now - lastTypingSent < 2000) return;
lastTypingSent = now;
ws.send(JSON.stringify({ room: currentRoom, username, text: "", type: "typing" }));
}
document.addEventListener("DOMContentLoaded", () => {
void initPlayerAndChat();
void refreshUserSession();
document.getElementById("auth-toggle-btn")?.addEventListener("click", () => {
const panel = document.getElementById("auth-panel");
if (panel) panel.hidden = !panel.hidden;
});
document.getElementById("auth-login-btn")?.addEventListener("click", login);
document.getElementById("auth-register-btn")?.addEventListener("click", register);
document.getElementById("auth-logout-btn")?.addEventListener("click", logout);
document.getElementById("dm-search-btn")?.addEventListener("click", searchDM);
document.getElementById("users-toggle-btn")?.addEventListener("click", toggleUserList);
document.getElementById("message-input")?.addEventListener("input", sendTyping);
});
+76 -1
View File
@@ -253,6 +253,79 @@ select {
line-height: 1.5;
}
.badge {
display: inline-block;
padding: 0.1rem 0.5rem;
border-radius: 999px;
font-size: 0.72rem;
font-weight: 600;
line-height: 1.5;
white-space: nowrap;
}
.badge-ok {
color: var(--ok);
background: rgba(4, 101, 13, 0.12);
}
.badge-warn {
color: var(--warn);
background: rgba(212, 150, 36, 0.12);
}
.badge-info {
color: var(--brand);
background: rgba(88, 119, 146, 0.14);
}
.badge-danger {
color: var(--danger);
background: rgba(196, 74, 94, 0.12);
}
.badge-muted {
color: var(--ink-soft);
background: var(--bg-2);
}
.chat-header-actions {
display: flex;
align-items: center;
gap: 0.5rem;
}
.chat-user-label {
font-size: 0.78rem;
font-weight: 600;
color: var(--ink-soft);
max-width: 10rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.auth-panel {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.75rem;
border-top: 1px solid var(--bg-2);
}
.dm-search-row {
display: flex;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
border-top: 1px solid var(--bg-2);
}
.typing-indicator {
font-size: 0.78rem;
font-style: italic;
color: var(--ink-soft);
padding: 0.25rem 0.75rem 0;
}
.btn {
border: 1px solid transparent;
border-radius: var(--radius-1);
@@ -1085,7 +1158,9 @@ select {
}
@keyframes lv-spin {
to { transform: rotate(360deg); }
to {
transform: rotate(360deg);
}
}
#reconnecting-text {