Files
2026-11-02 05:46:56 -05:00

981 lines
46 KiB
HTML
Raw Permalink 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.
<!DOCTYPE html>
<html lang="en" class="h-full">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>RDJ Art Manager</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🎵</text></svg>">
<script src="https://cdn.tailwindcss.com"></script>
<style type="text/tailwindcss">
.btn-primary { @apply bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed text-white text-sm font-semibold px-3 py-2 rounded-lg transition whitespace-nowrap; }
.btn-ghost { @apply bg-slate-700/60 hover:bg-slate-600/60 disabled:opacity-50 text-slate-200 text-sm font-medium px-3 py-2 rounded-lg transition whitespace-nowrap; }
.btn-mini { @apply bg-slate-700 hover:bg-slate-600 disabled:opacity-50 text-slate-100 text-xs font-medium px-2 py-1 rounded transition; }
.btn-danger { @apply bg-rose-600/80 hover:bg-rose-500 text-white text-xs font-medium px-2 py-1 rounded transition; }
.inp { @apply bg-slate-900/70 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-100 placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 w-full; }
.modal { @apply fixed inset-0 z-50 items-start justify-center bg-black/60 p-4 overflow-y-auto; }
.modal-card { @apply bg-slate-800 border border-slate-700 rounded-xl p-6 w-full mt-10 shadow-2xl; }
.lbl { @apply block text-xs font-medium text-slate-400 mb-1; }
</style>
</head>
<body class="bg-slate-900 text-slate-200 min-h-full">
<!-- ============================== Header ============================== -->
<header class="sticky top-0 z-40 bg-slate-900/90 backdrop-blur border-b border-slate-700/60">
<div class="max-w-7xl mx-auto px-4 h-14 flex items-center gap-3">
<div class="flex items-center gap-2 font-bold text-lg text-white select-none">
<span class="text-2xl">🎵</span> RDJ Art Manager
</div>
<div id="navArea" class="ml-auto hidden items-center gap-2">
<label class="text-xs text-slate-400 hidden sm:block">Station</label>
<select id="stationSelect" class="inp !w-auto !py-1.5 max-w-[240px]"></select>
<label class="text-xs text-slate-400 hidden sm:block">Source</label>
<select id="apiSourceSelect" title="Primary artwork source" class="bg-slate-900 border border-slate-600 rounded px-3 py-1.5 text-sm text-slate-200">
<option value="auto">Auto (iTunes → Deezer → MusicBrainz)</option>
<option value="itunes">iTunes API Only</option>
<option value="deezer">Deezer API Only</option>
<option value="musicbrainz">MusicBrainz / CoverArtArchive Only</option>
</select>
<button id="btnStations" class="btn-ghost">Profiles</button>
<button id="btnAdmin" class="btn-ghost hidden">Admin</button>
<span id="userChip" class="text-xs px-2 py-1.5 rounded-lg bg-slate-700/80 text-slate-200"></span>
<button id="btnLogout" class="btn-ghost">Log out</button>
</div>
</div>
</header>
<!-- ============================== Auth ============================== -->
<section id="authView" class="max-w-md mx-auto mt-16 px-4 hidden">
<div class="bg-slate-800/70 border border-slate-700 rounded-xl p-6 shadow-xl">
<h1 class="text-xl font-bold text-white mb-1">Welcome</h1>
<p class="text-sm text-slate-400 mb-5">Sign in to manage RadioDJ album artwork.</p>
<div class="flex mb-6 rounded-lg overflow-hidden border border-slate-600">
<button id="tabLogin" type="button" class="flex-1 py-2 text-sm font-semibold bg-indigo-600 text-white transition">Sign in</button>
<button id="tabRegister" type="button" class="flex-1 py-2 text-sm font-semibold text-slate-300 hover:bg-slate-700/50 transition">Register</button>
</div>
<form id="authForm" class="space-y-4">
<div>
<label class="lbl" for="authUsername">Username</label>
<input id="authUsername" class="inp" autocomplete="username" required minlength="3" maxlength="32">
</div>
<div>
<label class="lbl" for="authPassword">Password</label>
<input id="authPassword" type="password" class="inp" autocomplete="current-password" required minlength="8">
</div>
<p id="authHint" class="text-xs text-slate-500">The first registered account becomes the administrator.</p>
<button id="authSubmit" type="submit" class="btn-primary w-full">Sign in</button>
<p id="authError" class="text-sm text-rose-400 hidden"></p>
</form>
</div>
</section>
<!-- ============================== App ============================== -->
<main id="appView" class="max-w-7xl mx-auto px-4 py-6 hidden">
<div class="flex flex-wrap items-center gap-2 mb-4">
<button id="btnLoadTracks" class="btn-primary">Load tracks missing artwork</button>
<button id="btnAutoSearch" class="btn-ghost" disabled>Fetch Art for Current Page</button>
<input id="filterInput" placeholder="Filter title / artist / file…" class="inp !w-56">
<span id="trackStats" class="text-xs text-slate-400"></span>
<div class="ml-auto flex items-center gap-3">
<label class="flex items-center gap-1.5 text-sm text-slate-300 select-none" title="When enabled, the export also runs UPDATE songs SET image = '<new file name>' on the selected station database.">
<input type="checkbox" id="chkUpdateDb" class="accent-indigo-500 w-4 h-4" checked>
Update RadioDJ DB
</label>
<button id="btnExport" class="btn-primary" disabled title="Only tracks whose checkbox is ticked are exported">Download ZIP &amp; Update Database</button>
</div>
</div>
<div class="bg-slate-800/60 border border-slate-700 rounded-xl overflow-hidden">
<div class="overflow-auto max-h-[72vh]">
<table class="w-full text-sm">
<thead class="sticky top-0 bg-slate-800 z-10 shadow">
<tr class="text-left text-xs uppercase tracking-wide text-slate-400">
<th class="p-3 w-10 text-center"><input type="checkbox" id="checkAll" class="accent-indigo-500 w-4 h-4"></th>
<th class="p-3 w-16">Artwork</th>
<th class="p-3">Title</th>
<th class="p-3">Artist</th>
<th class="p-3">Album</th>
<th class="p-3">Audio Filename</th>
<th class="p-3 w-80">Search &amp; Results</th>
</tr>
</thead>
<tbody id="tracksBody"></tbody>
</table>
<div id="paginationBar" class="hidden flex flex-wrap items-center gap-2 px-3 py-2 border-t border-slate-700/60 text-sm"></div>
<div id="emptyState" class="p-10 text-center text-slate-500 text-sm">
Select a station profile, then click “Load tracks missing artwork”.
</div>
</div>
</div>
<p class="text-xs text-slate-500 mt-3">
Artwork sources: iTunes Search API, MusicBrainz + Cover Art Archive — 100% free, no API keys.
Edit title, artist, or album in the grid to adjust artwork searches. These edits are temporary and do not update RadioDJ metadata.
The last option in each row lets you upload custom artwork or use a direct image URL. Hover any thumbnail to see its source; click it to inspect the full-size image. Exported images are
renamed to the audio file’s base name (e.g. <span class="font-mono">01 Track.mp3 → 01 Track.jpg</span>).
Nothing is exported until you tick the track’s checkbox to confirm its artwork.
</p>
</main>
<!-- ====================== Artwork preview overlay ===================== -->
<div id="artModal" class="modal hidden">
<div class="relative flex flex-col items-center justify-center min-h-[calc(100vh-2rem)] w-full">
<button id="artClose" class="absolute top-2 right-2 btn-ghost !text-lg" title="Close (Esc)">✕</button>
<img id="artImg" src="" alt="High resolution artwork"
class="max-h-[70vh] max-w-[90vw] rounded-xl shadow-2xl object-contain bg-slate-800 cursor-pointer"
title="Click to view the original image in a new tab">
<div class="mt-4 text-center">
<div id="artTitle" class="text-lg font-semibold text-white"></div>
<div id="artArtist" class="text-sm text-slate-400"></div>
<div id="artSource" class="text-xs text-slate-500 mt-1"></div>
</div>
<div class="mt-4 flex items-center gap-3">
<a id="artOriginal" href="#" target="_blank" rel="noopener noreferrer" class="btn-ghost">View Original Image ↗</a>
<button id="artConfirm" class="btn-primary">✓ Confirm &amp; Select This Art</button>
</div>
</div>
</div>
<!-- ======================= Custom artwork modal ======================= -->
<div id="customArtModal" class="modal hidden">
<div class="modal-card max-w-xl">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold text-white">Use custom artwork</h2>
<button type="button" class="btn-ghost" data-close="customArtModal">✕</button>
</div>
<p class="text-sm text-slate-400 mb-4">Choose an image file or provide a direct image URL. The image will be previewed for confirmation before export.</p>
<div class="space-y-4">
<label class="block text-sm text-slate-300">
Upload an image (PNG, JPEG, or WebP; up to 15 MB)
<input id="customArtFile" type="file" accept="image/png,image/jpeg,image/webp" class="inp mt-2">
</label>
<div>
<label for="customArtUrl" class="block text-sm text-slate-300">Or enter a direct image URL</label>
<div class="flex gap-2 mt-2">
<input id="customArtUrl" type="url" class="inp" placeholder="https://example.com/album-art.jpg">
<button id="btnUseArtUrl" type="button" class="btn-primary shrink-0">Use URL</button>
</div>
</div>
</div>
</div>
</div>
<!-- ========================= Profiles modal ========================= -->
<div id="profilesModal" class="modal hidden">
<div class="modal-card max-w-3xl">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold text-white">Station Profiles — RadioDJ MySQL</h2>
<button class="btn-ghost" data-close="profilesModal">✕</button>
</div>
<div id="profilesList" class="space-y-2 mb-6"></div>
<h3 id="profileFormTitle" class="text-sm font-semibold text-slate-300 mb-2">Add profile</h3>
<form id="profileForm" class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<input type="hidden" id="pfId">
<input id="pfName" class="inp" placeholder="Profile name (e.g. Main FM)" required maxlength="64">
<div class="flex gap-2">
<input id="pfHost" class="inp flex-1" placeholder="Host (e.g. 192.168.1.10)" required>
<input id="pfPort" class="inp !w-24" placeholder="3306" type="number" min="1" max="65535">
</div>
<input id="pfDb" class="inp" placeholder="Database name (e.g. radiodj)" required>
<input id="pfUser" class="inp" placeholder="DB user" required>
<input id="pfPass" class="inp sm:col-span-2" placeholder="DB password" type="password" autocomplete="new-password">
<div class="sm:col-span-2 flex items-center gap-2">
<button type="submit" class="btn-primary">Save profile</button>
<button type="button" id="pfCancel" class="btn-ghost hidden">Cancel edit</button>
<span id="pfMsg" class="text-xs"></span>
</div>
</form>
</div>
</div>
<!-- ========================== Admin modal =========================== -->
<div id="adminModal" class="modal hidden">
<div class="modal-card max-w-2xl">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold text-white">Administration</h2>
<button class="btn-ghost" data-close="adminModal">✕</button>
</div>
<h3 class="text-sm font-semibold text-slate-300 mb-2">Users</h3>
<div id="adminUsers" class="space-y-2 mb-6"></div>
<h3 class="text-sm font-semibold text-slate-300 mb-2">Copy station profiles between users</h3>
<div class="flex flex-wrap items-center gap-2">
<select id="cloneFrom" class="inp !w-auto"></select>
<span class="text-slate-400">→</span>
<select id="cloneTo" class="inp !w-auto"></select>
<button id="btnClone" class="btn-primary">Copy all profiles</button>
</div>
<p id="adminMsg" class="text-xs mt-3"></p>
</div>
</div>
<div id="toasts" class="fixed bottom-4 right-4 z-[60] space-y-2 max-w-sm"></div>
<script>
'use strict';
// ------------------------------ helpers -----------------------------------
const $ = id => document.getElementById(id);
const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
const sleep = ms => new Promise(r => setTimeout(r, ms));
const state = {
user: null,
profiles: [],
activeProfileId: null,
tracks: [],
metadataEdits: new Map(), // trackId -> edited artist/title/album for the current load
selections: new Map(), // trackId -> { imageUrl, thumb, sourceLabel }
candidates: new Map(), // trackId -> [{ full, thumb, source, sourceLabel, artist, album }] — persists across filter re-renders
artModal: { trackId: null, full: '' },
customArtTrackId: null,
authMode: 'login',
};
let currentPage = 1;
let currentLimit = 25;
let totalTracks = 0;
async function api(url, opts = {}) {
const res = await fetch(url, {
credentials: 'same-origin',
...opts,
headers: { 'Content-Type': 'application/json', ...(opts.headers || {}) },
body: opts.body && typeof opts.body !== 'string' ? JSON.stringify(opts.body) : opts.body,
});
if (res.status === 401 && !url.includes('/auth/')) { showAuth(); throw new Error('Session expired — please sign in again.'); }
let data = null;
if ((res.headers.get('content-type') || '').includes('application/json')) data = await res.json();
if (!res.ok) throw new Error((data && data.error) || `Request failed (${res.status})`);
return data;
}
function toast(msg, type = 'info') {
const colors = { info: 'bg-slate-700 text-slate-100', success: 'bg-emerald-600 text-white', error: 'bg-rose-600 text-white' };
const el = document.createElement('div');
el.className = `px-4 py-2.5 rounded-lg shadow-lg text-sm ${colors[type] || colors.info}`;
el.textContent = msg;
$('toasts').appendChild(el);
setTimeout(() => el.remove(), 4500);
}
function openModal(id) { $(id).classList.remove('hidden'); $(id).classList.add('flex'); }
function closeModal(id) { $(id).classList.add('hidden'); $(id).classList.remove('flex'); }
// ------------------------------ views -------------------------------------
function showAuth() {
state.user = null;
$('authView').classList.remove('hidden');
$('appView').classList.add('hidden');
$('navArea').classList.add('hidden');
$('navArea').classList.remove('flex');
}
function showApp() {
$('authView').classList.add('hidden');
$('appView').classList.remove('hidden');
$('navArea').classList.remove('hidden');
$('navArea').classList.add('flex');
$('userChip').textContent = `${state.user.username} · ${state.user.role}`;
$('btnAdmin').classList.toggle('hidden', state.user.role !== 'admin');
}
function setAuthMode(mode) {
state.authMode = mode;
const login = mode === 'login';
$('tabLogin').className = 'flex-1 py-2 text-sm font-semibold transition ' + (login ? 'bg-indigo-600 text-white' : 'text-slate-300 hover:bg-slate-700/50');
$('tabRegister').className = 'flex-1 py-2 text-sm font-semibold transition ' + (!login ? 'bg-indigo-600 text-white' : 'text-slate-300 hover:bg-slate-700/50');
$('authSubmit').textContent = login ? 'Sign in' : 'Create account';
$('authHint').classList.toggle('hidden', login);
$('authError').classList.add('hidden');
}
// ------------------------------ auth --------------------------------------
async function submitAuth(e) {
e.preventDefault();
$('authError').classList.add('hidden');
const username = $('authUsername').value.trim();
const password = $('authPassword').value;
try {
const data = await api(`/api/auth/${state.authMode}`, { method: 'POST', body: { username, password } });
state.user = data.user;
showApp();
await loadProfiles();
toast(`Welcome, ${data.user.username}${data.user.role === 'admin' ? ' (admin)' : ''}.`, 'success');
} catch (err) {
$('authError').textContent = err.message;
$('authError').classList.remove('hidden');
}
}
async function logout() {
try { await api('/api/auth/logout', { method: 'POST' }); } catch { /* ignore */ }
location.reload();
}
// ---------------------------- profiles ------------------------------------
async function loadProfiles() {
const data = await api('/api/profiles');
state.profiles = data.profiles;
if (state.activeProfileId && !state.profiles.some(p => p.id === state.activeProfileId)) state.activeProfileId = null;
if (!state.activeProfileId && state.profiles.length) state.activeProfileId = state.profiles[0].id;
renderStationSelect();
renderProfilesList();
}
function renderStationSelect() {
const sel = $('stationSelect');
sel.innerHTML = state.profiles.length
? state.profiles.map(p => `<option value="${p.id}">${esc(p.name)} — ${esc(p.host)}/${esc(p.database)}</option>`).join('')
: '<option value="">No profiles — open Profiles</option>';
sel.value = state.activeProfileId ?? '';
}
function renderProfilesList() {
const box = $('profilesList');
if (!state.profiles.length) {
box.innerHTML = '<p class="text-sm text-slate-500">No station profiles yet — add one below.</p>';
return;
}
box.innerHTML = state.profiles.map(p => `
<div class="flex flex-wrap items-center gap-2 bg-slate-900/50 border ${p.id === state.activeProfileId ? 'border-indigo-500/60' : 'border-slate-700'} rounded-lg px-3 py-2" data-pid="${p.id}">
<div class="min-w-0">
<div class="font-semibold text-slate-100">${esc(p.name)} ${p.id === state.activeProfileId ? '<span class="text-xs text-indigo-400">(active)</span>' : ''}</div>
<div class="text-xs text-slate-400 font-mono">${esc(p.dbUser)}@${esc(p.host)}:${p.port}/${esc(p.database)}</div>
</div>
<div class="ml-auto flex gap-1.5">
<button type="button" class="btn-mini act-use">Set active</button>
<button type="button" class="btn-mini act-test">Test</button>
<button type="button" class="btn-mini act-edit">Edit</button>
<button type="button" class="btn-danger act-del">Delete</button>
</div>
</div>`).join('');
}
function resetProfileForm() {
$('pfId').value = '';
$('profileForm').reset();
$('pfPort').value = '';
$('pfPass').placeholder = 'DB password';
$('profileFormTitle').textContent = 'Add profile';
$('pfCancel').classList.add('hidden');
$('pfMsg').textContent = '';
}
async function submitProfile(e) {
e.preventDefault();
const id = $('pfId').value;
const body = {
name: $('pfName').value.trim(),
host: $('pfHost').value.trim(),
port: $('pfPort').value || '3306',
database: $('pfDb').value.trim(),
dbUser: $('pfUser').value.trim(),
dbPassword: $('pfPass').value,
};
try {
if (id) await api(`/api/profiles/${id}`, { method: 'PUT', body });
else await api('/api/profiles', { method: 'POST', body });
resetProfileForm();
await loadProfiles();
toast('Profile saved.', 'success');
} catch (err) {
$('pfMsg').textContent = err.message;
$('pfMsg').className = 'text-xs text-rose-400';
}
}
async function handleProfileAction(e) {
const btn = e.target.closest('button');
if (!btn) return;
const wrap = e.target.closest('[data-pid]');
if (!wrap) return;
const pid = Number(wrap.dataset.pid);
const profile = state.profiles.find(p => p.id === pid);
if (!profile) return;
if (btn.classList.contains('act-use')) {
state.activeProfileId = pid;
renderStationSelect();
renderProfilesList();
toast(`Active station: ${profile.name}`, 'success');
} else if (btn.classList.contains('act-test')) {
btn.disabled = true; btn.textContent = '…';
try {
const r = await api(`/api/profiles/${pid}/test`, { method: 'POST' });
toast(`Connected — MySQL ${r.version}, ${r.missingArtwork} track(s) missing artwork.`, 'success');
} catch (err) {
toast(err.message, 'error');
} finally {
btn.disabled = false; btn.textContent = 'Test';
}
} else if (btn.classList.contains('act-edit')) {
$('pfId').value = profile.id;
$('pfName').value = profile.name;
$('pfHost').value = profile.host;
$('pfPort').value = profile.port;
$('pfDb').value = profile.database;
$('pfUser').value = profile.dbUser;
$('pfPass').value = '';
$('pfPass').placeholder = 'Leave blank to keep current password';
$('profileFormTitle').textContent = `Edit profile: ${profile.name}`;
$('pfCancel').classList.remove('hidden');
} else if (btn.classList.contains('act-del')) {
if (!confirm(`Delete profile "${profile.name}"?`)) return;
try {
await api(`/api/profiles/${pid}`, { method: 'DELETE' });
if (state.activeProfileId === pid) state.activeProfileId = null;
await loadProfiles();
toast('Profile deleted.', 'success');
} catch (err) { toast(err.message, 'error'); }
}
}
// ----------------------------- tracks -------------------------------------
function sourceLabel(r) {
return r.sourceLabel || ({ itunes: 'iTunes API', deezer: 'Deezer', musicbrainz: 'Cover Art Archive / MusicBrainz' }[r.source] || r.source || 'Unknown');
}
function thumbHtml(r, selectedUrl) {
return `<img class="thumb w-14 h-14 rounded object-cover cursor-zoom-in border-2 ${selectedUrl === r.full ? 'border-indigo-500' : 'border-transparent hover:border-slate-500'}"
src="${esc(r.thumb)}" data-full="${esc(r.full)}" data-thumb="${esc(r.thumb)}" data-source-label="${esc(sourceLabel(r))}"
title="Source: ${esc(sourceLabel(r))} — click to preview full size" loading="lazy" alt="artwork option">`;
}
function candidateOptionsHtml(trackId, results, selectedUrl) {
const candidates = results.map(r => thumbHtml(r, selectedUrl)).join('');
return `${candidates}
<button type="button" class="upload-art-btn w-14 h-14 rounded border-2 border-dashed border-slate-500 hover:border-indigo-400 hover:text-indigo-300 text-slate-300 flex items-center justify-center"
title="Upload an image or use a direct image URL" aria-label="Add custom artwork" data-track-id="${trackId}">
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 16V4m0 0L7 9m5-5 5 5M5 14v4a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-4"/>
</svg>
</button>`;
}
function trackRowHtml(t) {
const sel = state.selections.get(t.id);
const candidates = state.candidates.get(t.id) || [];
return `<tr data-id="${t.id}" class="border-b border-slate-700/50 hover:bg-slate-700/20">
<td class="p-3 text-center"><input type="checkbox" class="row-check accent-indigo-500 w-4 h-4" ${sel ? 'checked' : ''} title="Confirm this artwork for export"></td>
<td class="p-3"><div class="preview-slot w-14 h-14 rounded overflow-hidden bg-slate-700/60 flex items-center justify-center text-slate-500 text-xs">
${sel
? `<img src="${esc(sel.thumb)}" data-full="${esc(sel.imageUrl)}" data-source-label="${esc(sel.sourceLabel || '')}" class="preview-img w-full h-full object-cover cursor-zoom-in" title="Source: ${esc(sel.sourceLabel || 'Unknown')} — click to preview full size" alt="artwork">`
: `<img src="/Album-Art.png" class="w-full h-full object-contain" alt="Default album artwork">`}</div></td>
<td class="p-3"><input class="metadata-input inp !w-full !min-w-36 !py-1 font-medium text-slate-100" data-field="title" aria-label="Title" value="${esc(t.title)}"></td>
<td class="p-3"><input class="metadata-input inp !w-full !min-w-36 !py-1 text-slate-300" data-field="artist" aria-label="Artist" value="${esc(t.artist)}"></td>
<td class="p-3"><input class="metadata-input inp !w-full !min-w-36 !py-1 text-slate-300" data-field="album" aria-label="Album" value="${esc(t.album)}"></td>
<td class="p-3 font-mono text-xs text-slate-400 break-all">${esc(t.audioBase)}</td>
<td class="p-3">
<button type="button" class="search-btn btn-mini" title="Search iTunes / Cover Art Archive for artwork — review results, then tick the checkbox to confirm">Search artwork</button>
<div class="thumbs flex flex-wrap gap-1.5 mt-2">${candidateOptionsHtml(t.id, candidates, sel?.imageUrl)}</div>
</td>
</tr>`;
}
function renderTracks() {
const f = $('filterInput').value.trim().toLowerCase();
const rows = state.tracks.filter(t => !f || [t.title, t.artist, t.album, t.audioBase].join(' ').toLowerCase().includes(f));
$('tracksBody').innerHTML = rows.map(trackRowHtml).join('');
const empty = $('emptyState');
empty.style.display = rows.length ? 'none' : '';
empty.textContent = state.tracks.length
? 'No tracks match the filter.'
: 'No tracks loaded. Select a station profile, then click “Load tracks missing artwork”.';
$('checkAll').checked = false;
updateStats();
}
function clearArtworkChoices() {
const objectUrls = new Set();
for (const candidates of state.candidates.values()) {
for (const candidate of candidates) {
if (candidate.file && candidate.full.startsWith('blob:')) objectUrls.add(candidate.full);
}
}
for (const selection of state.selections.values()) {
if (selection.file && selection.imageUrl.startsWith('blob:')) objectUrls.add(selection.imageUrl);
}
for (const url of objectUrls) URL.revokeObjectURL(url);
state.selections.clear();
state.candidates.clear();
}
function updateStats() {
const checked = document.querySelectorAll('#tracksBody .row-check:checked').length;
$('trackStats').textContent = state.tracks.length
? `${state.tracks.length} track(s) on page (${totalTracks} total) · ${state.selections.size} with artwork · ${checked} selected`
: '';
$('btnExport').disabled = checked === 0;
$('btnAutoSearch').disabled = state.tracks.length === 0;
}
// --------------------------- pagination ------------------------------------
function renderPagination(totalPages) {
const bar = $('paginationBar');
if (!totalTracks) { bar.classList.add('hidden'); bar.innerHTML = ''; return; }
bar.classList.remove('hidden');
const cur = String(currentLimit);
let pages = '';
if (totalPages > 1) {
const lo = Math.max(1, Math.min(currentPage - 2, totalPages - 4));
const hi = Math.min(totalPages, Math.max(currentPage + 2, 5));
let html = '';
if (lo > 1) html += `<button type="button" data-page="1" class="btn-mini">1</button>` + (lo > 2 ? '<span class="px-1 text-slate-500">…</span>' : '');
for (let p = lo; p <= hi; p++) {
html += `<button type="button" data-page="${p}" class="btn-mini ${p === currentPage ? '!bg-indigo-600 !text-white' : ''}">${p}</button>`;
}
if (hi < totalPages) html += (hi < totalPages - 1 ? '<span class="px-1 text-slate-500">…</span>' : '') + `<button type="button" data-page="${totalPages}" class="btn-mini">${totalPages}</button>`;
pages = `<span class="flex items-center gap-1">${html}</span>`;
} else {
pages = '<span class="text-slate-400">Page 1 of 1</span>';
}
bar.innerHTML = `
<label class="text-slate-400">Per page</label>
<select id="limitSelect" class="inp !w-auto !py-1">
<option value="10" ${cur === '10' ? 'selected' : ''}>10</option>
<option value="25" ${cur === '25' ? 'selected' : ''}>25</option>
<option value="30" ${cur === '30' ? 'selected' : ''}>30</option>
<option value="50" ${cur === '50' ? 'selected' : ''}>50</option>
<option value="all" ${cur === 'all' ? 'selected' : ''}>all</option>
</select>
<button type="button" id="btnPrev" class="btn-mini" ${currentPage <= 1 ? 'disabled' : ''}>← Prev</button>
${pages}
<button type="button" id="btnNext" class="btn-mini" ${currentPage >= totalPages ? 'disabled' : ''}>Next →</button>`;
}
async function loadTracks({ resetMetadataEdits = false } = {}) {
if (!state.activeProfileId) return toast('Add and select a station profile first (Profiles button).', 'error');
const btn = $('btnLoadTracks');
btn.disabled = true; btn.textContent = 'Loading…';
try {
const data = await api(`/api/tracks?profileId=${state.activeProfileId}&page=${currentPage}&limit=${currentLimit}`);
if (resetMetadataEdits) state.metadataEdits.clear();
state.tracks = data.tracks.map(track => ({
...track,
...(state.metadataEdits.get(track.id) || {}),
}));
totalTracks = data.totalTracks || 0;
currentPage = data.page || 1;
renderPagination(data.totalPages || 1);
clearArtworkChoices();
renderTracks();
toast(`${data.tracks.length} track(s) missing artwork loaded.`, 'success');
} catch (err) {
toast(err.message, 'error');
} finally {
btn.disabled = false; btn.textContent = 'Load tracks missing artwork';
}
}
async function searchForRow(tr, track) {
const btn = tr.querySelector('.search-btn');
const box = tr.querySelector('.thumbs');
btn.disabled = true; btn.textContent = 'Searching…';
try {
const data = await api('/api/artwork/search', { method: 'POST', body: { artist: track.artist, title: track.title, album: track.album, source: $('apiSourceSelect').value } });
state.candidates.set(track.id, data.results);
const sel = state.selections.get(track.id);
box.innerHTML = candidateOptionsHtml(track.id, data.results, sel?.imageUrl);
if (!data.results.length) box.insertAdjacentHTML('afterbegin', '<span class="text-xs text-slate-500">No artwork found.</span>');
} catch (err) {
toast(err.message, 'error');
} finally {
btn.disabled = false; btn.textContent = 'Search artwork';
}
}
function setTrackSelection(tr, track, img) {
const candidate = (state.candidates.get(track.id) || []).find(item => item.full === img.dataset.full);
state.selections.set(track.id, {
imageUrl: img.dataset.full,
thumb: img.dataset.thumb,
sourceLabel: img.dataset.sourceLabel || '',
file: candidate?.file || null,
});
tr.querySelectorAll('.thumb').forEach(t => { t.classList.remove('border-indigo-500'); t.classList.add('border-transparent'); });
tr.querySelectorAll(`.thumb[data-full="${CSS.escape(img.dataset.full)}"]`).forEach(t => { t.classList.remove('border-transparent'); t.classList.add('border-indigo-500'); });
tr.querySelector('.preview-slot').innerHTML = `<img src="${esc(img.dataset.thumb)}" data-full="${esc(img.dataset.full)}" data-source-label="${esc(img.dataset.sourceLabel || '')}" class="preview-img w-full h-full object-cover cursor-zoom-in" title="Source: ${esc(img.dataset.sourceLabel || 'Unknown')} — click to preview full size" alt="selected artwork">`;
updateStats();
}
function selectThumb(tr, track, img) {
setTrackSelection(tr, track, img);
tr.querySelector('.row-check').checked = true;
updateStats();
}
// --------------------------- preview overlay -------------------------------
function openArtModal(trackId, imgData) {
const t = state.tracks.find(x => x.id === trackId);
if (!t) return;
state.artModal = {
trackId,
full: imgData.full,
thumb: imgData.thumb || imgData.full,
sourceLabel: imgData.sourceLabel || '',
file: imgData.file || null,
};
$('artImg').src = imgData.thumb || imgData.full; // thumb first, then upgrade to hi-res
$('artImg').src = imgData.full;
$('artTitle').textContent = t.title || '(untitled)';
$('artArtist').textContent = t.artist || '';
$('artSource').textContent = imgData.sourceLabel ? `Source: ${imgData.sourceLabel}` : '';
$('artOriginal').href = imgData.full;
openModal('artModal');
}
async function confirmArtSelection() {
const { trackId, full, thumb, sourceLabel, file } = state.artModal;
const tr = document.querySelector(`tr[data-id="${trackId}"]`);
const track = state.tracks.find(x => x.id === trackId);
if (tr && track) {
const candidate = (state.candidates.get(trackId) || []).find(item => item.full === full);
const selectedFile = candidate?.file || file || null;
if (selectedFile) {
state.selections.set(track.id, { imageUrl: full, thumb, sourceLabel, file: selectedFile });
tr.querySelector('.preview-slot').innerHTML = `<img src="${esc(thumb)}" data-full="${esc(full)}" data-source-label="${esc(sourceLabel)}" class="preview-img w-full h-full object-cover cursor-zoom-in" title="Source: ${esc(sourceLabel || 'Unknown')} — click to preview full size" alt="selected artwork">`;
updateStats();
} else {
setTrackSelection(tr, track, { dataset: { full, thumb, sourceLabel } });
}
tr.querySelector('.row-check').checked = true;
updateStats();
toast('Artwork confirmed for export.', 'success');
let source = 'unknown';
if (selectedFile) source = 'manual_upload';
else if (candidate?.source && ['itunes', 'deezer', 'musicbrainz'].includes(candidate.source)) source = candidate.source;
else if (sourceLabel === 'Custom image URL') source = 'url_upload';
try {
await api('/api/artwork/selection', { method: 'POST', body: { trackId: track.id, source } });
} catch (err) {
toast(`Artwork was selected, but selection logging failed: ${err.message}`, 'error');
}
}
closeModal('artModal');
}
function openCustomArtModal(trackId) {
state.customArtTrackId = trackId;
$('customArtFile').value = '';
$('customArtUrl').value = '';
openModal('customArtModal');
}
function addCustomArtworkCandidate({ full, thumb = full, sourceLabel, file = null }) {
const trackId = state.customArtTrackId;
const track = state.tracks.find(item => item.id === trackId);
const tr = document.querySelector(`tr[data-id="${trackId}"]`);
if (!track || !tr) return toast('Track is no longer visible. Reload it and try again.', 'error');
const candidate = { full, thumb, sourceLabel, artist: track.artist, album: track.album, file };
const candidates = state.candidates.get(trackId) || [];
candidates.push(candidate);
state.candidates.set(trackId, candidates);
tr.querySelector('.thumbs').innerHTML = candidateOptionsHtml(trackId, candidates, state.selections.get(trackId)?.imageUrl);
closeModal('customArtModal');
openArtModal(trackId, candidate);
}
function useCustomArtFile() {
const file = $('customArtFile').files[0];
if (!file) return toast('Choose an image file first.', 'error');
if (file.size > 15 * 1024 * 1024) return toast('Image exceeds 15 MB limit.', 'error');
if (!['image/png', 'image/jpeg', 'image/webp'].includes(file.type)) {
return toast('Choose a PNG, JPEG, or WebP image.', 'error');
}
addCustomArtworkCandidate({
full: URL.createObjectURL(file),
sourceLabel: `Uploaded image: ${file.name}`,
file,
});
}
function useCustomArtUrl() {
const value = $('customArtUrl').value.trim();
let url;
try {
url = new URL(value);
} catch {
return toast('Enter a valid direct image URL.', 'error');
}
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) {
return toast('Image URL must be a public HTTP or HTTPS URL.', 'error');
}
addCustomArtworkCandidate({ full: url.href, sourceLabel: 'Custom image URL' });
}
function fillRowFromBest(tr, track, best) {
// Suggest the top hit as a visual preview WITHOUT checking the confirmation box —
// the user must review it and tick the box manually before export.
state.candidates.set(track.id, [best]);
tr.querySelector('.thumbs').innerHTML = candidateOptionsHtml(track.id, [best], null);
tr.querySelector('.preview-slot').innerHTML = `<img src="${esc(best.thumb)}" data-full="${esc(best.full)}" data-source-label="${esc(sourceLabel(best))}" class="preview-img w-full h-full object-cover cursor-zoom-in" title="Source: ${esc(sourceLabel(best))} — suggested, click to preview">`;
}
async function autoSearch() {
const pending = state.tracks.filter(t => !state.selections.has(t.id));
if (!pending.length) return toast('Every loaded track already has artwork selected.');
const btn = $('btnAutoSearch');
btn.disabled = true;
let done = 0, found = 0;
for (const t of pending) {
done += 1;
btn.textContent = `Searching ${done}/${pending.length}…`;
try {
const data = await api('/api/artwork/search', { method: 'POST', body: { artist: t.artist, title: t.title, album: t.album, source: $('apiSourceSelect').value } });
if (data.results.length) {
found += 1;
const tr = document.querySelector(`tr[data-id="${t.id}"]`);
if (tr) fillRowFromBest(tr, t, data.results[0]);
}
} catch { /* keep going */ }
await sleep(250); // be polite to the free APIs
}
btn.textContent = 'Fetch Art for Current Page';
btn.disabled = false;
updateStats();
toast(`Fetched artwork suggestions for ${found}/${pending.length} track(s) — review each one and tick its checkbox to confirm.`, found ? 'success' : 'info');
}
// ----------------------------- export -------------------------------------
async function exportSelected() {
const items = [];
const uploadedFiles = [];
document.querySelectorAll('#tracksBody .row-check:checked').forEach(cb => {
const id = Number(cb.closest('tr').dataset.id);
const t = state.tracks.find(x => x.id === id);
const sel = state.selections.get(id);
if (t && sel) {
items.push({
id: t.id,
path: t.path,
audioBase: t.audioBase,
imageUrl: sel.file ? '' : sel.imageUrl,
upload: Boolean(sel.file),
});
if (sel.file) uploadedFiles.push({ id: t.id, file: sel.file });
}
});
if (!items.length) return toast('Nothing to export — select artwork for at least one track.', 'error');
if (uploadedFiles.length > 20) return toast('You can export up to 20 uploaded images at a time.', 'error');
const updateDb = $('chkUpdateDb').checked;
const btn = $('btnExport');
btn.disabled = true; btn.textContent = 'Working…';
try {
const formData = new FormData();
formData.append('payload', JSON.stringify({ profileId: state.activeProfileId, updateDb, items }));
for (const { id, file } of uploadedFiles) formData.append(`upload-${id}`, file, file.name);
const res = await fetch('/api/export', {
method: 'POST',
credentials: 'same-origin',
body: formData,
});
if (!res.ok) {
let msg = `Export failed (${res.status})`;
try { msg = (await res.json()).error || msg; } catch { /* ignore */ }
throw new Error(msg);
}
const blob = await res.blob();
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = `rdj-artwork-${new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-')}.zip`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(a.href);
const downloaded = Number(res.headers.get('X-Export-Downloaded') || 0);
const errors = Number(res.headers.get('X-Export-Errors') || 0);
toast(
errors
? `Exported ${downloaded} artwork file(s); ${errors} item(s) had errors. See export-report.json in the ZIP.`
: `Exported ${downloaded} artwork file(s)${updateDb ? ' and updated the RadioDJ database' : ''}.`,
errors ? 'error' : 'success',
);
} catch (err) {
toast(err.message, 'error');
} finally {
btn.disabled = false; btn.textContent = 'Download ZIP & Update Database';
updateStats();
}
}
// ------------------------------ admin -------------------------------------
async function loadAdmin() {
const data = await api('/api/admin/users');
$('adminUsers').innerHTML = data.users.map(u => `
<div class="flex items-center gap-2 bg-slate-900/50 border border-slate-700 rounded-lg px-3 py-2" data-uid="${u.id}">
<span class="font-medium text-slate-100">${esc(u.username)}</span>
<span class="text-xs text-slate-500">${u.profileCount} profile(s) · joined ${esc(String(u.createdAt).slice(0, 10))}</span>
<select class="role-sel inp !w-auto !py-1 ml-auto" ${u.id === state.user.id ? 'disabled title="You cannot change your own role"' : ''}>
<option value="user" ${u.role === 'user' ? 'selected' : ''}>user</option>
<option value="admin" ${u.role === 'admin' ? 'selected' : ''}>admin</option>
</select>
</div>`).join('');
const opts = data.users.map(u => `<option value="${u.id}">${esc(u.username)}</option>`).join('');
$('cloneFrom').innerHTML = opts;
$('cloneTo').innerHTML = opts;
if (data.users.length > 1) $('cloneTo').selectedIndex = 1;
}
async function handleRoleChange(e) {
if (!e.target.classList.contains('role-sel')) return;
const uid = Number(e.target.closest('[data-uid]').dataset.uid);
try {
await api(`/api/admin/users/${uid}/role`, { method: 'PUT', body: { role: e.target.value } });
toast('Role updated.', 'success');
} catch (err) {
toast(err.message, 'error');
await loadAdmin();
}
}
async function cloneProfiles() {
const fromUserId = Number($('cloneFrom').value);
const toUserId = Number($('cloneTo').value);
$('adminMsg').textContent = '';
try {
const r = await api('/api/admin/clone-profiles', { method: 'POST', body: { fromUserId, toUserId } });
$('adminMsg').className = 'text-xs mt-3 text-emerald-400';
$('adminMsg').textContent = `Copied ${r.cloned} profile(s).`;
await loadAdmin();
} catch (err) {
$('adminMsg').className = 'text-xs mt-3 text-rose-400';
$('adminMsg').textContent = err.message;
}
}
// ------------------------------ events ------------------------------------
function bindEvents() {
$('tabLogin').addEventListener('click', () => setAuthMode('login'));
$('tabRegister').addEventListener('click', () => setAuthMode('register'));
$('authForm').addEventListener('submit', submitAuth);
$('btnLogout').addEventListener('click', logout);
$('stationSelect').addEventListener('change', e => {
state.activeProfileId = Number(e.target.value) || null;
currentPage = 1;
state.metadataEdits.clear();
state.tracks = [];
clearArtworkChoices();
totalTracks = 0;
renderPagination(0);
renderTracks();
renderProfilesList();
});
$('btnStations').addEventListener('click', () => { renderProfilesList(); openModal('profilesModal'); });
$('btnAdmin').addEventListener('click', async () => {
openModal('adminModal');
try { await loadAdmin(); } catch (err) { toast(err.message, 'error'); }
});
document.querySelectorAll('[data-close]').forEach(b =>
b.addEventListener('click', () => closeModal(b.dataset.close)));
document.querySelectorAll('.modal').forEach(m =>
m.addEventListener('click', e => { if (e.target === m) closeModal(m.id); }));
$('profileForm').addEventListener('submit', submitProfile);
$('pfCancel').addEventListener('click', resetProfileForm);
$('profilesList').addEventListener('click', handleProfileAction);
$('btnLoadTracks').addEventListener('click', () => loadTracks({ resetMetadataEdits: true }));
$('btnAutoSearch').addEventListener('click', autoSearch);
$('filterInput').addEventListener('input', renderTracks);
$('btnExport').addEventListener('click', exportSelected);
$('checkAll').addEventListener('change', e => {
const on = e.target.checked;
document.querySelectorAll('#tracksBody .row-check').forEach(cb => {
const id = Number(cb.closest('tr').dataset.id);
cb.checked = on ? state.selections.has(id) : false;
});
updateStats();
});
$('tracksBody').addEventListener('click', async e => {
const tr = e.target.closest('tr[data-id]');
if (!tr) return;
const track = state.tracks.find(t => t.id === Number(tr.dataset.id));
if (!track) return;
if (e.target.closest('.upload-art-btn')) openCustomArtModal(track.id);
else if (e.target.classList.contains('search-btn')) await searchForRow(tr, track);
else if (e.target.classList.contains('thumb') || e.target.classList.contains('preview-img')) {
const candidate = (state.candidates.get(track.id) || []).find(item => item.full === (e.target.dataset.full || e.target.src));
const selected = state.selections.get(track.id);
openArtModal(track.id, {
full: e.target.dataset.full || e.target.src,
thumb: e.target.dataset.thumb || e.target.src,
sourceLabel: e.target.dataset.sourceLabel || '',
file: candidate?.file || (selected?.imageUrl === (e.target.dataset.full || e.target.src) ? selected.file : null),
});
}
});
$('tracksBody').addEventListener('change', e => {
if (!e.target.classList.contains('row-check')) return;
const id = Number(e.target.closest('tr').dataset.id);
if (e.target.checked && !state.selections.has(id)) {
e.target.checked = false;
toast('Choose an artwork result for this track first.', 'error');
}
updateStats();
});
$('tracksBody').addEventListener('input', e => {
if (!e.target.classList.contains('metadata-input')) return;
const tr = e.target.closest('tr[data-id]');
const track = state.tracks.find(t => t.id === Number(tr.dataset.id));
const field = e.target.dataset.field;
if (track && ['title', 'artist', 'album'].includes(field)) {
track[field] = e.target.value;
state.metadataEdits.set(track.id, {
title: track.title,
artist: track.artist,
album: track.album,
});
}
});
$('adminUsers').addEventListener('change', handleRoleChange);
$('btnClone').addEventListener('click', cloneProfiles);
// Pagination bar (delegated — contents re-render on every load)
$('paginationBar').addEventListener('change', e => {
if (e.target.id !== 'limitSelect') return;
currentLimit = e.target.value === 'all' ? 'all' : parseInt(e.target.value, 10);
currentPage = 1;
loadTracks();
});
$('paginationBar').addEventListener('click', e => {
const b = e.target.closest('button');
if (!b || b.disabled) return;
if (b.id === 'btnPrev') currentPage -= 1;
else if (b.id === 'btnNext') currentPage += 1;
else if (b.dataset.page) currentPage = parseInt(b.dataset.page, 10);
else return;
loadTracks();
});
// Artwork preview overlay
$('artClose').addEventListener('click', () => closeModal('artModal'));
$('artImg').addEventListener('click', () => { if (state.artModal.full) window.open(state.artModal.full, '_blank', 'noopener'); });
$('artConfirm').addEventListener('click', confirmArtSelection);
$('customArtFile').addEventListener('change', useCustomArtFile);
$('btnUseArtUrl').addEventListener('click', useCustomArtUrl);
document.addEventListener('keydown', e => {
if (e.key === 'Escape' && !$('artModal').classList.contains('hidden')) closeModal('artModal');
});
}
// ------------------------------- init -------------------------------------
(async function init() {
bindEvents();
setAuthMode('login');
try {
const { user } = await api('/api/auth/me');
state.user = user;
showApp();
await loadProfiles();
} catch {
showAuth();
}
})();
</script>
</body>
</html>