Polish finding detail renderers with richer visuals

- Cover art findings: show artwork image thumbnail preview
- Duplicate findings: KEEP/REMOVE badges highlighting best quality copy
- Incomplete album: completion progress bar with percentage
- Fake lossless: spectral analysis bar comparing detected vs expected cutoff
- Resolved findings: show descriptive action label (e.g. "Entry Removed")
- Fix operator precedence bug in fake lossless nyquist calculation
This commit is contained in:
Broque Thomas 2026-03-15 13:00:44 -07:00
parent 7a706e8c11
commit 0594673cad
2 changed files with 187 additions and 18 deletions

View file

@ -50438,8 +50438,17 @@ async function loadRepairFindings() {
container.innerHTML = items.map(f => {
const icon = severityIcons[f.severity] || '';
const age = formatCacheAge(f.created_at);
const statusBadge = f.status !== 'pending' ?
`<span class="repair-finding-status-badge ${f.status}">${f.status}</span>` : '';
const actionLabels = {
removed_db_entry: 'Entry Removed', deleted_file: 'File Deleted',
already_gone: 'Already Gone', fixed_track_number: 'Track # Fixed',
applied_cover_art: 'Art Applied', applied_metadata: 'Metadata Applied',
removed_duplicates: 'Duplicates Removed',
};
let statusBadge = '';
if (f.status !== 'pending') {
const actionText = actionLabels[f.user_action] || f.status;
statusBadge = `<span class="repair-finding-status-badge ${f.status}">${actionText}</span>`;
}
const typeLabel = typeLabels[f.finding_type] || f.finding_type.replace(/_/g, ' ');
const d = f.details || {};
const filePath = f.file_path || d.original_path || d.file_path || '';
@ -50555,41 +50564,76 @@ function _renderFindingDetail(f) {
if (f.file_path) rows.push(['File', f.file_path, 'path']);
return _gridRows(rows);
case 'fake_lossless':
case 'fake_lossless': {
const cutoff = d.detected_cutoff_khz || 0;
const expectedMin = d.expected_min_khz || 0;
const nyquist = d.nyquist_khz || (d.sample_rate ? d.sample_rate / 2000 : 22.05);
let flHtml = '';
if (cutoff && expectedMin) {
const cutoffPct = Math.min(100, Math.round((cutoff / nyquist) * 100));
const expectedPct = Math.min(100, Math.round((expectedMin / nyquist) * 100));
flHtml += `<div class="repair-spectrum-bar">
<div class="repair-spectrum-label">Spectral Analysis</div>
<div class="repair-spectrum-track">
<div class="repair-spectrum-detected" style="width:${cutoffPct}%"></div>
<div class="repair-spectrum-expected" style="left:${expectedPct}%"></div>
</div>
<div class="repair-spectrum-legend">
<span class="repair-spectrum-legend-detected">${cutoff} kHz detected</span>
<span class="repair-spectrum-legend-expected">${expectedMin} kHz expected min</span>
</div>
</div>`;
}
if (d.format) rows.push(['Format', d.format.toUpperCase()]);
rows.push(['Spectral Cutoff', `${d.detected_cutoff_khz || '?'} kHz`]);
rows.push(['Expected Min', `${d.expected_min_khz || '?'} kHz`]);
if (d.sample_rate) rows.push(['Sample Rate', `${d.sample_rate} Hz`]);
if (d.nyquist_khz) rows.push(['Nyquist', `${d.nyquist_khz} kHz`]);
if (d.bit_depth) rows.push(['Bit Depth', `${d.bit_depth}-bit`]);
if (d.bitrate) rows.push(['Bitrate', `${d.bitrate} kbps`]);
if (d.file_size) rows.push(['File Size', _formatFileSize(d.file_size)]);
if (f.file_path) rows.push(['File', f.file_path, 'path']);
return _gridRows(rows);
return flHtml + _gridRows(rows);
}
case 'duplicate_tracks':
if (!d.tracks || !d.tracks.length) return _gridRows([['Count', d.count || '?']]);
return `<div class="repair-detail-sublist">${d.tracks.map((t, i) => `
<div class="repair-detail-subitem">
<strong>Copy ${i + 1}: ${_escFinding(t.title)} by ${_escFinding(t.artist)}</strong>
<span>Album: ${_escFinding(t.album || 'Unknown')}</span>
${t.bitrate ? `<span>Bitrate: ${t.bitrate} kbps${t.duration ? ` &middot; Duration: ${Math.round(t.duration)}s` : ''}</span>` : ''}
// Determine best copy (same logic as backend: highest bitrate, then duration)
const bestDup = d.tracks.reduce((best, t) => {
const bBr = best.bitrate || 0, tBr = t.bitrate || 0;
const bDur = best.duration || 0, tDur = t.duration || 0;
return (tBr > bBr || (tBr === bBr && tDur > bDur)) ? t : best;
}, d.tracks[0]);
return `<div class="repair-detail-sublist">${d.tracks.map((t, i) => {
const isBest = t.id === bestDup.id;
return `<div class="repair-detail-subitem ${isBest ? 'best' : 'removable'}">
<strong>
${isBest ? '<span class="repair-keep-badge">KEEP</span>' : '<span class="repair-remove-badge">REMOVE</span>'}
${_escFinding(t.title)} by ${_escFinding(t.artist)}
</strong>
<span>Album: ${_escFinding(t.album || 'Unknown')}${t.bitrate ? ` &middot; ${t.bitrate} kbps` : ''}${t.duration ? ` &middot; ${Math.round(t.duration)}s` : ''}</span>
${t.file_path ? `<span class="mono">${_escFinding(t.file_path)}</span>` : ''}
</div>`).join('')}</div>`;
</div>`;
}).join('')}</div>`;
case 'incomplete_album':
if (d.artist) rows.push(['Artist', d.artist]);
if (d.album_title) rows.push(['Album', d.album_title]);
rows.push(['Tracks Found', `${d.actual_tracks || '?'} of ${d.expected_tracks || '?'}`]);
if (d.spotify_album_id) rows.push(['Spotify ID', d.spotify_album_id]);
let incHtml = _gridRows(rows);
const actual = d.actual_tracks || 0, expected = d.expected_tracks || 0;
if (expected > 0) {
const pct = Math.round((actual / expected) * 100);
incHtml += `<div class="repair-completion-bar">
<div class="repair-completion-label">${actual} of ${expected} tracks (${pct}%)</div>
<div class="repair-completion-track"><div class="repair-completion-fill" style="width:${pct}%"></div></div>
</div>`;
}
if (d.missing_tracks && d.missing_tracks.length) {
return _gridRows(rows) + `<div class="repair-detail-sublist">${d.missing_tracks.map(t => `
incHtml += `<div class="repair-detail-sublist">${d.missing_tracks.map(t => `
<div class="repair-detail-subitem">
<strong>#${t.track_number || '?'} ${_escFinding(t.name || t.title || 'Unknown')}</strong>
${t.duration_ms ? `<span>Duration: ${Math.round(t.duration_ms / 1000)}s</span>` : ''}
</div>`).join('')}</div>`;
}
return _gridRows(rows);
return incHtml;
case 'path_mismatch':
if (d.from) rows.push(['Current Path', d.from, 'path']);
@ -50612,8 +50656,15 @@ function _renderFindingDetail(f) {
if (d.artist) rows.push(['Artist', d.artist]);
if (d.album_title) rows.push(['Album', d.album_title]);
if (d.spotify_album_id) rows.push(['Spotify ID', d.spotify_album_id]);
if (d.found_artwork_url) rows.push(['Artwork URL', d.found_artwork_url, 'success']);
return _gridRows(rows);
let artHtml = _gridRows(rows);
if (d.found_artwork_url) {
artHtml += `<div class="repair-artwork-preview">
<img src="${_escFinding(d.found_artwork_url)}" alt="Album artwork"
onerror="this.parentElement.innerHTML='<span>Image failed to load</span>'" />
<span class="repair-artwork-label">Found artwork</span>
</div>`;
}
return artHtml;
case 'track_number_mismatch':
if (d.matched_title) rows.push(['Matched To', d.matched_title]);

View file

@ -40580,6 +40580,124 @@ tr.tag-diff-same {
color: rgba(255, 255, 255, 0.35);
}
/* Duplicate keep/remove badges */
.repair-detail-subitem.best {
border-color: rgba(34, 197, 94, 0.2);
background: rgba(34, 197, 94, 0.04);
}
.repair-detail-subitem.removable {
opacity: 0.65;
}
.repair-keep-badge {
display: inline-block;
font-size: 9px;
font-weight: 700;
padding: 1px 5px;
border-radius: 3px;
background: rgba(34, 197, 94, 0.15);
color: #22c55e;
margin-right: 4px;
letter-spacing: 0.04em;
}
.repair-remove-badge {
display: inline-block;
font-size: 9px;
font-weight: 700;
padding: 1px 5px;
border-radius: 3px;
background: rgba(239, 68, 68, 0.12);
color: #ef4444;
margin-right: 4px;
letter-spacing: 0.04em;
}
/* Artwork preview (for missing cover art) */
.repair-artwork-preview {
margin-top: 8px;
display: flex;
align-items: flex-end;
gap: 10px;
}
.repair-artwork-preview img {
width: 80px;
height: 80px;
border-radius: 6px;
object-fit: cover;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.repair-artwork-label {
font-size: 10px;
color: rgba(255, 255, 255, 0.35);
}
/* Completion bar (for incomplete albums) */
.repair-completion-bar {
margin: 8px 0 4px;
}
.repair-completion-label {
font-size: 11px;
color: rgba(255, 255, 255, 0.5);
margin-bottom: 4px;
}
.repair-completion-track {
height: 6px;
background: rgba(255, 255, 255, 0.06);
border-radius: 3px;
overflow: hidden;
}
.repair-completion-fill {
height: 100%;
background: linear-gradient(90deg, #f59e0b, #f97316);
border-radius: 3px;
transition: width 0.3s ease;
}
/* Spectrum bar (for fake lossless) */
.repair-spectrum-bar {
margin-bottom: 10px;
}
.repair-spectrum-label {
font-size: 11px;
color: rgba(255, 255, 255, 0.5);
margin-bottom: 4px;
}
.repair-spectrum-track {
position: relative;
height: 10px;
background: rgba(255, 255, 255, 0.06);
border-radius: 5px;
overflow: visible;
}
.repair-spectrum-detected {
position: absolute;
top: 0;
left: 0;
height: 100%;
background: linear-gradient(90deg, #ef4444, #f97316);
border-radius: 5px;
opacity: 0.7;
}
.repair-spectrum-expected {
position: absolute;
top: -2px;
width: 2px;
height: 14px;
background: #22c55e;
border-radius: 1px;
}
.repair-spectrum-legend {
display: flex;
justify-content: space-between;
margin-top: 4px;
font-size: 10px;
}
.repair-spectrum-legend-detected {
color: #f97316;
}
.repair-spectrum-legend-expected {
color: #22c55e;
}
/* Score bars (for AcoustID) */
.repair-score-bar {
display: flex;