Files
Vocaluxe/VocaluxeSongServer/wwwroot/app.js
T
Simon DiesenreiterandClaude Opus 5.5 1e65864c8f Song server: search and download songs, mobile web UI
- Sources: "free" (Creative Commons songs with media from
  github.com/UltraStar-Deluxe/songs) and "usdb" (usdb.animux.de with an
  account; audio/video via yt-dlp from the song's #VIDEO hints or YouTube
  links in the comments, covers from fanart.tv/YouTube)
- Aggregated search (all sources in parallel, in-library flag), download
  queue into <library>/Downloads/<source>/<Artist - Title>, then rescan
- Web UI in wwwroot (plain JS, no build): search, downloads with progress,
  library; phone first, dark mode, installable
- Docker image with yt-dlp, ffmpeg and deno; library mounted writable
- Tests for matching, USDB parsing and song rewriting

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 23:15:48 +01:00

310 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use strict";
// Web UI of the Vocaluxe song server: search sources, queue downloads, browse the library.
// Plain JavaScript, no build step. API: /api/v1 (see README.md).
const $ = (sel) => document.querySelector(sel);
const state = {
tab: "search",
library: null, // LibraryDto
libraryVersion: "",
jobs: [], // DownloadJobDto[]
results: [],
searchSeq: 0,
};
function storageGet(key) {
try { return localStorage.getItem(key) || ""; } catch { return ""; }
}
function storageSet(key, value) {
try { localStorage.setItem(key, value); } catch { /* private mode */ }
}
// ---- API ------------------------------------------------------------------
async function api(path, options = {}) {
const headers = { ...(options.headers || {}) };
const key = storageGet("apiKey");
if (key) headers["X-Api-Key"] = key;
if (options.body) headers["Content-Type"] = "application/json";
const response = await fetch("api/v1/" + path, { ...options, headers });
if (response.status === 401) {
await askForKey();
return api(path, options);
}
if (!response.ok) {
let title = response.statusText;
try { title = (await response.json()).title || title; } catch { /* not JSON */ }
throw new Error(title);
}
return response.status === 204 ? null : response.json();
}
function askForKey() {
return new Promise((resolve) => {
const dialog = $("#keyDialog");
dialog.addEventListener("close", () => {
storageSet("apiKey", $("#keyInput").value.trim());
resolve();
}, { once: true });
dialog.showModal();
});
}
// ---- Rendering helpers ----------------------------------------------------
function el(tag, attrs = {}, ...children) {
const node = document.createElement(tag);
for (const [k, v] of Object.entries(attrs)) {
if (k === "class") node.className = v;
else if (k.startsWith("on")) node.addEventListener(k.slice(2), v);
else if (v !== false && v != null) node.setAttribute(k, v === true ? "" : v);
}
for (const child of children.flat()) {
if (child != null && child !== false) node.append(child);
}
return node;
}
function cover(url, title) {
const fallback = el("div", { class: "cover", "aria-hidden": "true" }, (title || "?").trim().charAt(0).toUpperCase());
if (!url) return fallback;
const img = el("img", { class: "cover", src: url, alt: "", loading: "lazy", referrerpolicy: "no-referrer" });
img.addEventListener("error", () => img.replaceWith(fallback), { once: true });
return img;
}
function songKey(artist, title) {
const norm = (s) => (s || "").normalize("NFD").replace(/[̀-ͯ]/g, "").toLowerCase().replace(/[^\p{L}\p{N}]+/gu, " ").trim();
return norm(artist) + "|" + norm(title);
}
// ---- Tabs -----------------------------------------------------------------
function showTab(tab) {
state.tab = tab;
document.querySelectorAll(".tab").forEach((t) => t.classList.toggle("active", t.id === "tab-" + tab));
document.querySelectorAll(".tabs button").forEach((b) => b.classList.toggle("active", b.dataset.tab === tab));
storageSet("tab", tab);
if (tab === "library") loadLibrary();
if (tab === "downloads") renderJobs();
if (tab === "search") $("#query").focus({ preventScroll: true });
}
document.querySelectorAll(".tabs button").forEach((b) => b.addEventListener("click", () => showTab(b.dataset.tab)));
// ---- Sources --------------------------------------------------------------
async function loadSources() {
try {
const sources = await api("sources");
$("#sources").replaceChildren(...sources.map((s) =>
el("span", { class: "chip" + (s.enabled && !s.hint ? "" : " off"), title: s.hint || s.description },
el("span", { class: "dot" }), s.name, s.hint && s.enabled ? " (limited)" : "", !s.enabled ? " (off)" : "")));
} catch (e) {
$("#sources").replaceChildren();
}
}
// ---- Search ---------------------------------------------------------------
let searchTimer = 0;
$("#query").addEventListener("input", () => {
clearTimeout(searchTimer);
searchTimer = setTimeout(search, 450);
});
$("#searchForm").addEventListener("submit", (e) => {
e.preventDefault();
clearTimeout(searchTimer);
$("#query").blur(); // hide the keyboard on phones
search();
});
async function search() {
const q = $("#query").value.trim();
const seq = ++state.searchSeq;
const status = $("#searchStatus");
status.className = "status";
if (q.length < 2) {
state.results = [];
status.textContent = "Type at least two letters. All song sources are searched at once.";
renderResults();
return;
}
status.textContent = "Searching…";
try {
const response = await api("search?q=" + encodeURIComponent(q));
if (seq !== state.searchSeq) return; // a newer search is running
state.results = response.results;
const errors = Object.entries(response.errors || {}).map(([source, msg]) => `${source}: ${msg}`);
status.textContent = (response.results.length ? `${response.results.length} songs found` : "Nothing found") +
(errors.length ? ` · not searched: ${errors.join("; ")}` : "");
status.classList.toggle("error", errors.length > 0 && response.results.length === 0);
renderResults();
} catch (e) {
if (seq !== state.searchSeq) return;
status.textContent = "Search failed: " + e.message;
status.classList.add("error");
}
}
function jobFor(result) {
return state.jobs.find((j) => j.source === result.source && j.sourceId === result.sourceId);
}
function resultButton(result) {
const job = jobFor(result);
if (result.inLibrary || (job && job.state === "done")) {
return el("button", { class: "action done", disabled: true, "aria-label": "In the library" }, "✓");
}
if (job && (job.state === "queued" || job.state === "running")) {
return el("button", { class: "action pending", disabled: true }, Math.round(job.progress * 100) + "%");
}
return el("button", {
class: "action",
"aria-label": `Download ${result.artist} – ${result.title}`,
onclick: (e) => download(result, e.currentTarget),
}, job && job.state === "failed" ? "Retry" : "Get");
}
function renderResults() {
$("#results").replaceChildren(...state.results.map((r) =>
el("li", { class: "item", "data-key": r.source + "/" + r.sourceId },
cover(r.coverUrl, r.title),
el("div", { class: "text" },
el("div", { class: "title" }, r.title),
el("div", { class: "artist" }, r.artist),
el("div", { class: "details" }, el("span", { class: "source" }, r.source), r.details)),
resultButton(r))));
}
async function download(result, button) {
button.disabled = true;
button.textContent = "…";
try {
const job = await api("downloads", {
method: "POST",
body: JSON.stringify({ source: result.source, sourceId: result.sourceId, artist: result.artist, title: result.title }),
});
state.jobs = [job, ...state.jobs.filter((j) => j.id !== job.id)];
renderResults();
renderBadge();
pollJobs(true);
} catch (e) {
button.disabled = false;
button.textContent = "Get";
alert("Download failed: " + e.message);
}
}
// ---- Downloads ------------------------------------------------------------
let pollTimer = 0;
async function pollJobs(soon) {
clearTimeout(pollTimer);
if (soon) {
pollTimer = setTimeout(() => pollJobs(false), 800);
return;
}
try {
const before = state.jobs.filter((j) => j.state === "done").length;
state.jobs = await api("downloads");
renderJobs();
renderBadge();
if (state.results.length) renderResults();
if (state.jobs.filter((j) => j.state === "done").length !== before) {
state.libraryVersion = ""; // a song was added: reload the library when it's shown
setTimeout(loadLibraryCount, 3000);
}
} catch { /* try again later */ }
const active = state.jobs.some((j) => j.state === "queued" || j.state === "running");
pollTimer = setTimeout(() => pollJobs(false), active ? 1500 : 10000);
}
function renderBadge() {
const active = state.jobs.filter((j) => j.state === "queued" || j.state === "running").length;
const badge = $("#activeBadge");
badge.hidden = active === 0;
badge.textContent = active;
}
const stateText = { queued: "Waiting", running: "Downloading", done: "Done", failed: "Failed" };
function renderJobs() {
$("#downloadsEmpty").hidden = state.jobs.length > 0;
$("#downloads").replaceChildren(...state.jobs.map((j) =>
el("li", { class: "item" },
cover("", j.title),
el("div", { class: "text" },
el("div", { class: "title" }, j.title),
el("div", { class: "artist" }, j.artist),
el("div", { class: "details" }, el("span", { class: "source" }, j.source),
j.message && j.state === "done" ? j.message : "")),
el("span", { class: "state " + j.state }, stateText[j.state] || j.state),
j.state === "running" || j.state === "queued"
? el("div", { class: "progress", role: "progressbar", "aria-valuenow": Math.round(j.progress * 100), "aria-valuemin": 0, "aria-valuemax": 100 },
el("span", { style: `width:${Math.round(j.progress * 100)}%` }))
: null,
j.state === "failed" ? el("div", { class: "message" }, j.message) : null)));
}
// ---- Library --------------------------------------------------------------
async function loadLibraryCount() {
try {
const health = await fetch("health").then((r) => r.json());
if (health.songs != null) $("#libraryCount").textContent = health.songs + " songs";
} catch { /* offline */ }
}
async function loadLibrary() {
const status = $("#libraryStatus");
if (state.library && state.libraryVersion) {
renderLibrary();
return;
}
status.textContent = "Loading…";
try {
state.library = await api("library");
state.libraryVersion = state.library.version;
$("#libraryCount").textContent = state.library.songs.length + " songs";
renderLibrary();
} catch (e) {
status.textContent = "Library not available: " + e.message;
}
}
$("#libraryFilter").addEventListener("input", () => renderLibrary());
function renderLibrary() {
if (!state.library) return;
const words = songKey($("#libraryFilter").value, "").replace("|", "").split(" ").filter(Boolean);
const songs = state.library.songs
.filter((s) => { const k = songKey(s.artist, s.title).replace("|", " "); return words.every((w) => k.includes(w)); })
.sort((a, b) => a.artist.localeCompare(b.artist) || a.title.localeCompare(b.title));
const shown = songs.slice(0, 300);
$("#libraryStatus").textContent = songs.length === 0
? (state.library.songs.length ? "No matching songs." : "The library is empty. Download songs under Search.")
: songs.length > shown.length ? `${songs.length} songs, showing the first ${shown.length}. Filter to narrow down.` : `${songs.length} songs`;
$("#library").replaceChildren(...shown.map((s) =>
el("li", { class: "item" },
el("div", { class: "text" }, el("div", { class: "title" }, s.title), el("div", { class: "artist" }, s.artist)))));
}
// ---- Start ----------------------------------------------------------------
// A link like "?q=queen" opens a search, "?tab=library" a tab.
const params = new URLSearchParams(location.search);
if (params.get("q")) $("#query").value = params.get("q");
const startTab = params.get("q") ? "search" : params.get("tab") || storageGet("tab");
loadSources();
loadLibraryCount();
pollJobs(false);
search();
showTab(["search", "downloads", "library"].includes(startTab) ? startTab : "search");