small tweeks

This commit is contained in:
minster586
2026-11-02 05:46:56 -05:00
parent b9a2cc584b
commit 86ab23a814
8 changed files with 1076 additions and 54 deletions
+208 -29
View File
@@ -93,6 +93,7 @@
<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>
@@ -107,7 +108,8 @@
</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
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>
@@ -132,6 +134,30 @@
</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">
@@ -194,9 +220,11 @@ const state = {
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;
@@ -409,29 +437,40 @@ function thumbHtml(r, selectedUrl) {
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">`
: 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>
: `<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">${(state.candidates.get(t.id) || []).map(r => thumbHtml(r, sel?.imageUrl)).join('')}</div>
<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.audioBase].join(' ').toLowerCase().includes(f));
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' : '';
@@ -442,6 +481,21 @@ function renderTracks() {
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
@@ -485,18 +539,21 @@ function renderPagination(totalPages) {
<button type="button" id="btnNext" class="btn-mini" ${currentPage >= totalPages ? 'disabled' : ''}>Next →</button>`;
}
async function loadTracks() {
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}`);
state.tracks = data.tracks;
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);
state.selections.clear();
state.candidates.clear();
clearArtworkChoices();
renderTracks();
toast(`${data.tracks.length} track(s) missing artwork loaded.`, 'success');
} catch (err) {
@@ -512,13 +569,10 @@ async function searchForRow(tr, track) {
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('');
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 {
@@ -527,7 +581,13 @@ async function searchForRow(tr, track) {
}
function setTrackSelection(tr, track, img) {
state.selections.set(track.id, { imageUrl: img.dataset.full, thumb: img.dataset.thumb, sourceLabel: img.dataset.sourceLabel || '' });
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">`;
@@ -544,7 +604,13 @@ function selectThumb(tr, track, img) {
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 || '' };
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)';
@@ -554,24 +620,90 @@ function openArtModal(trackId, imgData) {
openModal('artModal');
}
function confirmArtSelection() {
const { trackId, full, thumb, sourceLabel } = state.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) {
setTrackSelection(tr, track, { dataset: { full, thumb, sourceLabel } });
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 = thumbHtml(best, null);
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">`;
}
@@ -603,23 +735,36 @@ async function autoSearch() {
// ----------------------------- 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.imageUrl });
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',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ profileId: state.activeProfileId, updateDb, items }),
body: formData,
});
if (!res.ok) {
let msg = `Export failed (${res.status})`;
@@ -634,7 +779,14 @@ async function exportSelected() {
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');
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 {
@@ -698,6 +850,12 @@ function bindEvents() {
$('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();
});
@@ -715,7 +873,7 @@ function bindEvents() {
$('pfCancel').addEventListener('click', resetProfileForm);
$('profilesList').addEventListener('click', handleProfileAction);
$('btnLoadTracks').addEventListener('click', loadTracks);
$('btnLoadTracks').addEventListener('click', () => loadTracks({ resetMetadataEdits: true }));
$('btnAutoSearch').addEventListener('click', autoSearch);
$('filterInput').addEventListener('input', renderTracks);
$('btnExport').addEventListener('click', exportSelected);
@@ -734,12 +892,16 @@ function bindEvents() {
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);
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),
});
}
});
@@ -754,6 +916,21 @@ function bindEvents() {
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);
@@ -778,6 +955,8 @@ function bindEvents() {
$('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');
});