Files
RadioDJ-Album-art-downloader/public/index.html
T
minster586 b9a2cc584b Main Upload
This is the start upload I figured this is a way for me to easily tweak with radio DJ and get the album art
2026-09-09 05:03:17 -04:00

802 lines
38 KiB
HTML
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.
<!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">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.
Hover any thumbnail to see its source; click it to inspect the full-size image. Exported images are
renamed to the audio files base name (e.g. <span class="font-mono">01 Track.mp3 → 01 Track.jpg</span>).
Nothing is exported until you tick the tracks 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>
<!-- ========================= 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: [],
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: '' },
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 trackRowHtml(t) {
const sel = state.selections.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">`
: t.placeholder
? `<span class="text-amber-400 text-[10px] leading-tight text-center px-0.5" title="RadioDJ placeholder: ${esc(t.image)}">placeholder art</span>`
: '<span class="text-slate-500">missing</span>'}</div></td>
<td class="p-3 font-medium text-slate-100">${esc(t.title)}</td>
<td class="p-3 text-slate-300">${esc(t.artist)}</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">${(state.candidates.get(t.id) || []).map(r => thumbHtml(r, sel?.imageUrl)).join('')}</div>
</td>
</tr>`;
}
function renderTracks() {
const f = $('filterInput').value.trim().toLowerCase();
const rows = state.tracks.filter(t => !f || [t.title, t.artist, 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 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() {
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}`);
state.tracks = data.tracks;
totalTracks = data.totalTracks || 0;
currentPage = data.page || 1;
renderPagination(data.totalPages || 1);
state.selections.clear();
state.candidates.clear();
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 } });
if (!data.results.length) {
box.innerHTML = '<span class="text-xs text-slate-500">No artwork found.</span>';
return;
}
state.candidates.set(track.id, data.results);
const sel = state.selections.get(track.id);
box.innerHTML = data.results.map(r => thumbHtml(r, sel?.imageUrl)).join('');
} catch (err) {
toast(err.message, 'error');
} finally {
btn.disabled = false; btn.textContent = 'Search artwork';
}
}
function setTrackSelection(tr, track, img) {
state.selections.set(track.id, { imageUrl: img.dataset.full, thumb: img.dataset.thumb, sourceLabel: img.dataset.sourceLabel || '' });
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 || '' };
$('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');
}
function confirmArtSelection() {
const { trackId, full, thumb, sourceLabel } = state.artModal;
const tr = document.querySelector(`tr[data-id="${trackId}"]`);
const track = state.tracks.find(x => x.id === trackId);
if (tr && track) {
setTrackSelection(tr, track, { dataset: { full, thumb, sourceLabel } });
tr.querySelector('.row-check').checked = true;
updateStats();
toast('Artwork confirmed for export.', 'success');
}
closeModal('artModal');
}
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 = thumbHtml(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 = [];
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.imageUrl });
});
if (!items.length) return toast('Nothing to export — select artwork for at least one track.', 'error');
const updateDb = $('chkUpdateDb').checked;
const btn = $('btnExport');
btn.disabled = true; btn.textContent = 'Working…';
try {
const res = await fetch('/api/export', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ profileId: state.activeProfileId, updateDb, items }),
});
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);
toast(`Exported ${items.length} artwork file(s)${updateDb ? ' and updated the RadioDJ database' : ''}. See export-report.json in the ZIP.`, '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;
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);
$('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.classList.contains('search-btn')) await searchForRow(tr, track);
else if (e.target.classList.contains('thumb') || e.target.classList.contains('preview-img')) {
openArtModal(track.id, {
full: e.target.dataset.full || e.target.src,
thumb: e.target.dataset.thumb || e.target.src,
sourceLabel: e.target.dataset.sourceLabel || '',
});
}
});
$('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();
});
$('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);
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>