- 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>
310 lines
11 KiB
JavaScript
310 lines
11 KiB
JavaScript
"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");
|