Track-detail modal: click any download row for a rich, status-aware view
Clicking a track row in the download modal now opens a polished detail modal (its own template, webui/track-detail-modal.html, included into index.html; behavior in static/track-detail.js): cover, title/artist/album, status badge, in-app play, source, quality, AcoustID verdict, file location, and the expected-vs-downloaded provenance — backed by /api/downloads/task/<id>/detail. It adapts by status: - completed -> play (library stream) + full provenance - quarantined-> reason + Listen (quarantine stream) + Accept & Import + Search - failed/not_found -> reason + Search This absorbs the standalone quarantine chooser, which is removed (its Listen/Accept/Search live here now, with the same Windows file-handle release before Accept and the thin-sidecar -> Recover-to-Staging fallback). Plain failed/not-found rows still go straight to the search modal; sync-import modal unaffected. Status cells clear their clickable/detail state each render so a row that flips to completed isn't left with a stale handler.
This commit is contained in:
parent
e4bbcfda1b
commit
134d306511
5 changed files with 401 additions and 149 deletions
|
|
@ -8012,6 +8012,7 @@
|
||||||
<script src="{{ url_for('static', filename='search.js', v=static_v) }}"></script>
|
<script src="{{ url_for('static', filename='search.js', v=static_v) }}"></script>
|
||||||
<script src="{{ url_for('static', filename='sync-spotify.js', v=static_v) }}"></script>
|
<script src="{{ url_for('static', filename='sync-spotify.js', v=static_v) }}"></script>
|
||||||
<script src="{{ url_for('static', filename='downloads.js', v=static_v) }}"></script>
|
<script src="{{ url_for('static', filename='downloads.js', v=static_v) }}"></script>
|
||||||
|
<script src="{{ url_for('static', filename='track-detail.js', v=static_v) }}"></script>
|
||||||
<script src="{{ url_for('static', filename='wishlist-tools.js', v=static_v) }}"></script>
|
<script src="{{ url_for('static', filename='wishlist-tools.js', v=static_v) }}"></script>
|
||||||
<script src="{{ url_for('static', filename='sync-services.js', v=static_v) }}"></script>
|
<script src="{{ url_for('static', filename='sync-services.js', v=static_v) }}"></script>
|
||||||
<script src="{{ url_for('static', filename='sync-listenbrainz.js', v=static_v) }}"></script>
|
<script src="{{ url_for('static', filename='sync-listenbrainz.js', v=static_v) }}"></script>
|
||||||
|
|
@ -8045,6 +8046,8 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="gsearch-results" id="gsearch-results"></div>
|
<div class="gsearch-results" id="gsearch-results"></div>
|
||||||
|
|
||||||
|
{% include 'track-detail-modal.html' %}
|
||||||
|
|
||||||
<!-- Floating Mini Media Player — fixed bottom-right, above bell/help -->
|
<!-- Floating Mini Media Player — fixed bottom-right, above bell/help -->
|
||||||
<div class="media-player idle" id="media-player">
|
<div class="media-player idle" id="media-player">
|
||||||
<!-- Thin interactive progress bar spanning full width at top -->
|
<!-- Thin interactive progress bar spanning full width at top -->
|
||||||
|
|
|
||||||
|
|
@ -2982,21 +2982,17 @@ function _ensureCandidatesClickListener(statusEl) {
|
||||||
statusEl.addEventListener('click', function (e) {
|
statusEl.addEventListener('click', function (e) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
_hideErrorTooltip();
|
_hideErrorTooltip();
|
||||||
// Single click handler routes by dataset set at render time. A
|
|
||||||
// quarantined task opens the chooser; everything else opens the search
|
|
||||||
// modal. Deciding at click-time (not bind-time) means a task that fails
|
|
||||||
// first and only quarantines on a later retry can't end up double-bound.
|
|
||||||
if (this.dataset.quarantineEntryId) {
|
|
||||||
showQuarantineChooser({
|
|
||||||
entryId: this.dataset.quarantineEntryId,
|
|
||||||
taskId: this.dataset.taskId,
|
|
||||||
reason: this.dataset.quarantineReason || '',
|
|
||||||
trackName: this.dataset.quarantineTrack || 'this track',
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const taskId = this.dataset.taskId;
|
const taskId = this.dataset.taskId;
|
||||||
if (taskId) showCandidatesModal(taskId);
|
if (!taskId) return;
|
||||||
|
// Decide at click-time from dataset set each render: completed and
|
||||||
|
// quarantined rows open the rich track-detail modal (it carries the
|
||||||
|
// play/listen + Accept/Search actions); plain failed/not-found go
|
||||||
|
// straight to the search modal.
|
||||||
|
if (this.dataset.detailOpen && typeof openTrackDetail === 'function') {
|
||||||
|
openTrackDetail(taskId);
|
||||||
|
} else {
|
||||||
|
showCandidatesModal(taskId);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -3395,125 +3391,9 @@ async function approveQuarantineFromDownloadRow(button) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Quarantine chooser (download-modal status click) ──────────────────────
|
// Quarantine actions (Listen / Accept & Import / Search) now live in the
|
||||||
// Clicking a 🛡️ Quarantined status opens this instead of the search modal:
|
// track-detail modal (static/track-detail.js), which a quarantined row opens
|
||||||
// listen to the file, accept & import it (bypassing the failed check), or fall
|
// via _ensureCandidatesClickListener + dataset.detailOpen.
|
||||||
// back to searching for a different result. (Routing is handled inside
|
|
||||||
// _ensureCandidatesClickListener so a single listener covers both cases.)
|
|
||||||
|
|
||||||
// Stop and unload the chooser's <audio> so the server closes its read handle
|
|
||||||
// on the quarantined file. On Windows an open handle blocks the subsequent
|
|
||||||
// Approve/Recover move (WinError 32 "file in use") — listening then accepting
|
|
||||||
// would otherwise fail. Returns a short promise so callers can let the
|
|
||||||
// streaming connection actually tear down before moving the file.
|
|
||||||
function _qcReleaseAudio() {
|
|
||||||
const aud = document.getElementById('qc-audio');
|
|
||||||
if (!aud) return Promise.resolve();
|
|
||||||
try {
|
|
||||||
aud.pause();
|
|
||||||
aud.removeAttribute('src');
|
|
||||||
aud.load(); // forces the browser to drop the current stream connection
|
|
||||||
} catch (e) { /* ignore */ }
|
|
||||||
return new Promise((r) => setTimeout(r, 400));
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeQuarantineChooser() {
|
|
||||||
const o = document.getElementById('quarantine-chooser-overlay');
|
|
||||||
if (o) o.remove();
|
|
||||||
}
|
|
||||||
|
|
||||||
function showQuarantineChooser({ entryId, taskId, reason, trackName }) {
|
|
||||||
closeQuarantineChooser();
|
|
||||||
const streamUrl = entryId ? `/api/quarantine/${encodeURIComponent(entryId)}/stream` : '';
|
|
||||||
const overlay = document.createElement('div');
|
|
||||||
overlay.id = 'quarantine-chooser-overlay';
|
|
||||||
overlay.className = 'candidates-modal-overlay';
|
|
||||||
overlay.addEventListener('mousedown', (e) => { if (e.target === overlay) closeQuarantineChooser(); });
|
|
||||||
overlay.innerHTML = `
|
|
||||||
<div class="candidates-modal quarantine-chooser-modal">
|
|
||||||
<div class="candidates-modal-header">
|
|
||||||
<div>
|
|
||||||
<h2>🛡️ Quarantined File</h2>
|
|
||||||
<div class="quarantine-chooser-track">${escapeHtml(trackName)}</div>
|
|
||||||
</div>
|
|
||||||
<button class="candidates-modal-close" onclick="closeQuarantineChooser()">×</button>
|
|
||||||
</div>
|
|
||||||
<div class="quarantine-chooser-body">
|
|
||||||
<div class="quarantine-chooser-reason"><strong>Why it was quarantined:</strong><br>${escapeHtml(reason) || 'Unknown reason'}</div>
|
|
||||||
<div class="quarantine-chooser-listen">
|
|
||||||
<label>Listen before deciding</label>
|
|
||||||
${streamUrl ? `<audio id="qc-audio" controls preload="none" src="${streamUrl}"></audio>` : '<span class="quarantine-chooser-muted">Playback unavailable for this entry.</span>'}
|
|
||||||
</div>
|
|
||||||
<div class="quarantine-chooser-actions">
|
|
||||||
<button class="quarantine-chooser-btn quarantine-chooser-accept" id="qc-accept-btn">✓ Accept & Import</button>
|
|
||||||
<button class="quarantine-chooser-btn quarantine-chooser-search" id="qc-search-btn">🔍 Search for a different result</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
document.body.appendChild(overlay);
|
|
||||||
requestAnimationFrame(() => overlay.classList.add('visible'));
|
|
||||||
|
|
||||||
const acceptBtn = overlay.querySelector('#qc-accept-btn');
|
|
||||||
if (acceptBtn) acceptBtn.addEventListener('click', () => acceptQuarantineFromChooser(acceptBtn, entryId, taskId));
|
|
||||||
const searchBtn = overlay.querySelector('#qc-search-btn');
|
|
||||||
if (searchBtn) searchBtn.addEventListener('click', () => {
|
|
||||||
closeQuarantineChooser();
|
|
||||||
if (taskId) showCandidatesModal(taskId);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function acceptQuarantineFromChooser(button, entryId, taskId) {
|
|
||||||
if (!entryId) { showToast('Cannot accept — missing quarantine id.', 'error'); return; }
|
|
||||||
const confirmed = await showConfirmDialog({
|
|
||||||
title: 'Accept Quarantined File',
|
|
||||||
message: 'Import this file and skip the quarantine checks for this approved pass?',
|
|
||||||
confirmText: 'Accept & Import',
|
|
||||||
cancelText: 'Cancel',
|
|
||||||
});
|
|
||||||
if (!confirmed) return;
|
|
||||||
const original = button.textContent;
|
|
||||||
button.disabled = true;
|
|
||||||
button.textContent = 'Importing…';
|
|
||||||
const restore = () => { button.disabled = false; button.textContent = original; };
|
|
||||||
// Release the preview stream first so the file isn't locked during the move.
|
|
||||||
await _qcReleaseAudio();
|
|
||||||
try {
|
|
||||||
const resp = await fetch(`/api/quarantine/${encodeURIComponent(entryId)}/approve`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ task_id: taskId || '' }),
|
|
||||||
});
|
|
||||||
const data = await resp.json();
|
|
||||||
if (data.success) {
|
|
||||||
showToast('Accepted. Re-running post-processing.', 'success');
|
|
||||||
closeQuarantineChooser();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// One-click approve needs the saved import context. Older / orphaned
|
|
||||||
// quarantine entries (thin or missing sidecar) can't be re-imported in
|
|
||||||
// place — fall back to moving the file into Staging so the user can
|
|
||||||
// finish via the Import flow, same as the Quarantine manager does.
|
|
||||||
const needsRecover = /thin sidecar|recover to staging|embedded context|missing file or sidecar/i.test(data.error || '');
|
|
||||||
if (needsRecover) {
|
|
||||||
button.textContent = 'Recovering…';
|
|
||||||
const rec = await fetch(`/api/quarantine/${encodeURIComponent(entryId)}/recover`, { method: 'POST' });
|
|
||||||
const recData = await rec.json();
|
|
||||||
if (recData.success) {
|
|
||||||
showToast('Older entry — moved to Staging. Finish it from the Import page.', 'success');
|
|
||||||
closeQuarantineChooser();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
showToast(`Recover failed: ${recData.error || 'Unknown error'}`, 'error');
|
|
||||||
restore();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
showToast(`Accept failed: ${data.error || 'Unknown error'}`, 'error');
|
|
||||||
restore();
|
|
||||||
} catch (err) {
|
|
||||||
showToast(`Accept failed: ${err.message}`, 'error');
|
|
||||||
restore();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeCandidatesModal() {
|
function closeCandidatesModal() {
|
||||||
const overlay = document.getElementById('candidates-modal-overlay');
|
const overlay = document.getElementById('candidates-modal-overlay');
|
||||||
|
|
@ -3787,6 +3667,7 @@ function processModalStatusUpdate(playlistId, data) {
|
||||||
delete statusEl.dataset.quarantineEntryId;
|
delete statusEl.dataset.quarantineEntryId;
|
||||||
delete statusEl.dataset.quarantineReason;
|
delete statusEl.dataset.quarantineReason;
|
||||||
delete statusEl.dataset.quarantineTrack;
|
delete statusEl.dataset.quarantineTrack;
|
||||||
|
delete statusEl.dataset.detailOpen;
|
||||||
statusEl.textContent = statusText;
|
statusEl.textContent = statusText;
|
||||||
|
|
||||||
if ((task.status === 'failed' || task.status === 'cancelled' || task.status === 'not_found') && task.error_message) {
|
if ((task.status === 'failed' || task.status === 'cancelled' || task.status === 'not_found') && task.error_message) {
|
||||||
|
|
@ -3794,25 +3675,22 @@ function processModalStatusUpdate(playlistId, data) {
|
||||||
statusEl.dataset.errorMsg = task.error_message;
|
statusEl.dataset.errorMsg = task.error_message;
|
||||||
_ensureErrorTooltipListeners(statusEl);
|
_ensureErrorTooltipListeners(statusEl);
|
||||||
}
|
}
|
||||||
// Make not_found / failed / cancelled cells clickable to open
|
// Completed rows are clickable into the rich track-detail modal
|
||||||
// the candidates modal. Always bind — even when no auto-search
|
// (play, location, AcoustID verdict, provenance).
|
||||||
// candidates were cached — because the modal carries the manual
|
if (task.status === 'completed') {
|
||||||
// search bar, which is the user's recourse for empty results.
|
statusEl.classList.add('has-candidates');
|
||||||
if (task.status === 'not_found' || task.status === 'failed' || task.status === 'cancelled') {
|
statusEl.dataset.taskId = task.task_id;
|
||||||
|
statusEl.dataset.detailOpen = '1';
|
||||||
|
_ensureCandidatesClickListener(statusEl);
|
||||||
|
} else if (task.status === 'not_found' || task.status === 'failed' || task.status === 'cancelled') {
|
||||||
|
// Clickable to recover: quarantined -> track-detail modal
|
||||||
|
// (Listen / Accept / Search); plain failed/not-found ->
|
||||||
|
// straight to the search modal. detailOpen is set/cleared
|
||||||
|
// each render so a row that changes kind stays correct.
|
||||||
statusEl.classList.add('has-candidates');
|
statusEl.classList.add('has-candidates');
|
||||||
statusEl.dataset.taskId = task.task_id;
|
statusEl.dataset.taskId = task.task_id;
|
||||||
// Quarantined files route to the chooser (Listen / Accept /
|
|
||||||
// Search); everything else to the search modal. Set or CLEAR
|
|
||||||
// the quarantine flags every render so a row that flips
|
|
||||||
// between failure kinds always reflects its current state.
|
|
||||||
if (isQuarantinedTask && task.quarantine_entry_id) {
|
if (isQuarantinedTask && task.quarantine_entry_id) {
|
||||||
statusEl.dataset.quarantineEntryId = task.quarantine_entry_id;
|
statusEl.dataset.detailOpen = '1';
|
||||||
statusEl.dataset.quarantineReason = task.error_message || '';
|
|
||||||
statusEl.dataset.quarantineTrack = (task.track_info && task.track_info.name) || '';
|
|
||||||
} else {
|
|
||||||
delete statusEl.dataset.quarantineEntryId;
|
|
||||||
delete statusEl.dataset.quarantineReason;
|
|
||||||
delete statusEl.dataset.quarantineTrack;
|
|
||||||
}
|
}
|
||||||
_ensureCandidatesClickListener(statusEl);
|
_ensureCandidatesClickListener(statusEl);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -42468,6 +42468,100 @@ div.artist-hero-badge {
|
||||||
background: rgba(var(--accent-rgb), 0.28);
|
background: rgba(var(--accent-rgb), 0.28);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Track Detail modal ─────────────────────────────────────────────────── */
|
||||||
|
.td-overlay {
|
||||||
|
position: fixed; inset: 0;
|
||||||
|
background: rgba(18, 18, 18, 0.85);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
z-index: 100002;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
opacity: 0; pointer-events: none;
|
||||||
|
transition: opacity 0.25s ease;
|
||||||
|
}
|
||||||
|
.td-overlay.visible { opacity: 1; pointer-events: auto; }
|
||||||
|
.td-modal {
|
||||||
|
background: linear-gradient(135deg, #1c1c1c 0%, #131313 100%);
|
||||||
|
border: 1px solid rgba(var(--accent-rgb), 0.22);
|
||||||
|
border-radius: 16px;
|
||||||
|
width: 90%; max-width: 560px; max-height: 86vh;
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
box-shadow: 0 25px 80px rgba(0, 0, 0, 0.7);
|
||||||
|
transform: scale(0.96);
|
||||||
|
transition: transform 0.25s ease;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.td-overlay.visible .td-modal { transform: scale(1); }
|
||||||
|
.td-header {
|
||||||
|
display: flex; align-items: center; gap: 14px;
|
||||||
|
padding: 18px 20px;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
.td-thumb-wrap { flex: 0 0 auto; width: 56px; height: 56px; }
|
||||||
|
.td-thumb, .td-thumb-ph {
|
||||||
|
width: 56px; height: 56px; border-radius: 10px; object-fit: cover;
|
||||||
|
}
|
||||||
|
.td-thumb-ph {
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
font-size: 24px; background: rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
.td-headtext { flex: 1 1 auto; min-width: 0; }
|
||||||
|
.td-title { margin: 0; font-size: 17px; color: #fff; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.td-artist { color: rgba(255, 255, 255, 0.75); font-size: 13px; margin-top: 2px; }
|
||||||
|
.td-album { color: rgba(255, 255, 255, 0.45); font-size: 12px; margin-top: 1px; }
|
||||||
|
.td-status-badge {
|
||||||
|
flex: 0 0 auto; padding: 4px 10px; border-radius: 999px;
|
||||||
|
font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em;
|
||||||
|
border: 1px solid currentColor;
|
||||||
|
}
|
||||||
|
.td-badge-ok { color: #4caf50; }
|
||||||
|
.td-badge-warn { color: #ff9800; }
|
||||||
|
.td-badge-bad { color: #ef5350; }
|
||||||
|
.td-badge-muted { color: #888; }
|
||||||
|
.td-badge-info { color: rgb(var(--accent-rgb)); }
|
||||||
|
.td-close {
|
||||||
|
flex: 0 0 auto; width: 30px; height: 30px; border: none; border-radius: 8px;
|
||||||
|
background: rgba(255, 255, 255, 0.08); color: rgba(255, 255, 255, 0.7);
|
||||||
|
font-size: 20px; line-height: 1; cursor: pointer;
|
||||||
|
}
|
||||||
|
.td-close:hover { background: rgba(255, 255, 255, 0.18); color: #fff; }
|
||||||
|
.td-body { padding: 18px 20px 22px; overflow-y: auto; display: flex; flex-direction: column; gap: 16px; }
|
||||||
|
.td-audio { width: 100%; }
|
||||||
|
.td-reason {
|
||||||
|
background: rgba(250, 204, 21, 0.08);
|
||||||
|
border: 1px solid rgba(250, 204, 21, 0.25);
|
||||||
|
border-radius: 10px; padding: 12px 14px;
|
||||||
|
color: rgba(255, 255, 255, 0.85); font-size: 13px; line-height: 1.5;
|
||||||
|
}
|
||||||
|
.td-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px 18px; }
|
||||||
|
.td-field { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
|
||||||
|
.td-field-wide { grid-column: 1 / -1; }
|
||||||
|
.td-label { color: rgba(255, 255, 255, 0.5); font-size: 11px; text-transform: uppercase; letter-spacing: 0.04em; }
|
||||||
|
.td-value { color: rgba(255, 255, 255, 0.9); font-size: 14px; word-break: break-word; }
|
||||||
|
.td-mono { font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; font-size: 12px; color: rgba(255, 255, 255, 0.7); }
|
||||||
|
.td-aid-ok { color: #4caf50; }
|
||||||
|
.td-aid-bad { color: #ef5350; }
|
||||||
|
.td-aid-muted { color: rgba(255, 255, 255, 0.55); }
|
||||||
|
.td-provenance {
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08); border-radius: 10px;
|
||||||
|
padding: 10px 14px; display: flex; flex-direction: column; gap: 6px;
|
||||||
|
}
|
||||||
|
.td-prov-row { display: flex; gap: 10px; font-size: 13px; }
|
||||||
|
.td-prov-k { flex: 0 0 92px; color: rgba(255, 255, 255, 0.5); }
|
||||||
|
.td-prov-v { color: rgba(255, 255, 255, 0.85); word-break: break-word; }
|
||||||
|
.td-actions { display: flex; flex-direction: column; gap: 10px; }
|
||||||
|
.td-action-btn {
|
||||||
|
padding: 12px 16px; border-radius: 10px; border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
background: rgba(255, 255, 255, 0.05); color: #fff;
|
||||||
|
font-size: 14px; font-weight: 600; cursor: pointer;
|
||||||
|
transition: background 0.15s ease, border-color 0.15s ease;
|
||||||
|
}
|
||||||
|
.td-action-btn:hover:not(:disabled) { background: rgba(255, 255, 255, 0.12); }
|
||||||
|
.td-action-btn:disabled { opacity: 0.6; cursor: default; }
|
||||||
|
.td-action-primary {
|
||||||
|
border-color: rgba(var(--accent-rgb), 0.5); background: rgba(var(--accent-rgb), 0.15);
|
||||||
|
}
|
||||||
|
.td-action-primary:hover:not(:disabled) { background: rgba(var(--accent-rgb), 0.28); }
|
||||||
|
|
||||||
.candidates-modal-body {
|
.candidates-modal-body {
|
||||||
padding: 20px 28px;
|
padding: 20px 28px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
|
|
||||||
233
webui/static/track-detail.js
Normal file
233
webui/static/track-detail.js
Normal file
|
|
@ -0,0 +1,233 @@
|
||||||
|
/* Track Detail modal — opens from any track row in the download modal and
|
||||||
|
* shows a rich, status-aware view: cover, title/artist/album, play/listen,
|
||||||
|
* source, quality, AcoustID verdict, file location, expected-vs-downloaded,
|
||||||
|
* and the right actions for the row's state (Accept/Search for quarantined,
|
||||||
|
* Search for failed). Backed by /api/downloads/task/<id>/detail.
|
||||||
|
*
|
||||||
|
* Globals it leans on (defined in downloads.js, loaded first): showToast,
|
||||||
|
* showConfirmDialog, showCandidatesModal, escapeHtml.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const _TD_STATUS = {
|
||||||
|
completed: { label: 'Completed', cls: 'td-badge-ok' },
|
||||||
|
quarantined: { label: 'Quarantined', cls: 'td-badge-warn' },
|
||||||
|
failed: { label: 'Failed', cls: 'td-badge-bad' },
|
||||||
|
not_found: { label: 'Not Found', cls: 'td-badge-muted' },
|
||||||
|
in_progress: { label: 'In Progress', cls: 'td-badge-info' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const _TD_ACOUSTID = {
|
||||||
|
pass: { label: 'Verified', cls: 'td-aid-ok' },
|
||||||
|
fail: { label: 'Failed', cls: 'td-aid-bad' },
|
||||||
|
error: { label: 'Error', cls: 'td-aid-bad' },
|
||||||
|
skip: { label: 'No match', cls: 'td-aid-muted' },
|
||||||
|
disabled: { label: 'Off', cls: 'td-aid-muted' },
|
||||||
|
};
|
||||||
|
|
||||||
|
function _tdEsc(s) {
|
||||||
|
if (typeof escapeHtml === 'function') return escapeHtml(s == null ? '' : s);
|
||||||
|
return String(s == null ? '' : s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
}
|
||||||
|
|
||||||
|
function _tdSetText(id, value, fallback = '—') {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (el) el.textContent = (value && String(value).trim()) ? value : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Release the preview <audio> so the OS file handle is freed before any move
|
||||||
|
// (Windows locks an open file — same reason the quarantine chooser does this).
|
||||||
|
function _tdReleaseAudio() {
|
||||||
|
const a = document.getElementById('td-audio');
|
||||||
|
if (!a) return Promise.resolve();
|
||||||
|
try { a.pause(); a.removeAttribute('src'); a.load(); } catch (e) { /* ignore */ }
|
||||||
|
return new Promise((r) => setTimeout(r, 400));
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeTrackDetail() {
|
||||||
|
const o = document.getElementById('track-detail-overlay');
|
||||||
|
if (!o) return;
|
||||||
|
const a = document.getElementById('td-audio');
|
||||||
|
if (a) { try { a.pause(); a.removeAttribute('src'); a.load(); } catch (e) { /* ignore */ } }
|
||||||
|
o.classList.remove('visible');
|
||||||
|
o.setAttribute('aria-hidden', 'true');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openTrackDetail(taskId) {
|
||||||
|
if (!taskId) return;
|
||||||
|
const overlay = document.getElementById('track-detail-overlay');
|
||||||
|
if (!overlay) { console.warn('track-detail modal not present'); return; }
|
||||||
|
|
||||||
|
let detail;
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/api/downloads/task/${encodeURIComponent(taskId)}/detail`);
|
||||||
|
const data = await resp.json();
|
||||||
|
if (!data.success) { showToast(data.error || 'Could not load track detail', 'error'); return; }
|
||||||
|
detail = data.detail;
|
||||||
|
} catch (err) {
|
||||||
|
showToast(`Could not load track detail: ${err.message}`, 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_tdRender(detail, taskId);
|
||||||
|
overlay.classList.add('visible');
|
||||||
|
overlay.setAttribute('aria-hidden', 'false');
|
||||||
|
}
|
||||||
|
|
||||||
|
function _tdRender(d, taskId) {
|
||||||
|
const kind = d.status_kind || 'in_progress';
|
||||||
|
|
||||||
|
// Header
|
||||||
|
_tdSetText('td-title', d.title, 'Unknown Track');
|
||||||
|
_tdSetText('td-artist', d.artist, '');
|
||||||
|
_tdSetText('td-album', d.album, '');
|
||||||
|
|
||||||
|
const thumb = document.getElementById('td-thumb');
|
||||||
|
const thumbPh = document.getElementById('td-thumb-ph');
|
||||||
|
if (thumb && thumbPh) {
|
||||||
|
if (d.thumb_url && /^https?:\/\//.test(d.thumb_url)) {
|
||||||
|
thumb.src = d.thumb_url; thumb.hidden = false; thumbPh.hidden = true;
|
||||||
|
thumb.onerror = () => { thumb.hidden = true; thumbPh.hidden = false; };
|
||||||
|
} else {
|
||||||
|
thumb.hidden = true; thumbPh.hidden = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const badge = document.getElementById('td-status-badge');
|
||||||
|
if (badge) {
|
||||||
|
const s = _TD_STATUS[kind] || _TD_STATUS.in_progress;
|
||||||
|
badge.textContent = s.label;
|
||||||
|
badge.className = `td-status-badge ${s.cls}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Info grid
|
||||||
|
_tdSetText('td-f-source', d.source);
|
||||||
|
_tdSetText('td-f-quality', d.quality);
|
||||||
|
_tdSetText('td-f-location', d.file_path);
|
||||||
|
const aidEl = document.getElementById('td-f-acoustid');
|
||||||
|
if (aidEl) {
|
||||||
|
const a = _TD_ACOUSTID[d.acoustid_result];
|
||||||
|
aidEl.textContent = a ? a.label : '—';
|
||||||
|
aidEl.className = `td-value ${a ? a.cls : ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expected vs downloaded (only when we have provenance)
|
||||||
|
const prov = document.getElementById('td-provenance');
|
||||||
|
const exp = (d.expected && (d.expected.title || d.expected.artist));
|
||||||
|
const dl = (d.downloaded && (d.downloaded.title || d.downloaded.artist));
|
||||||
|
if (prov) {
|
||||||
|
if (exp || dl) {
|
||||||
|
prov.hidden = false;
|
||||||
|
_tdSetText('td-exp', exp ? `${d.expected.title}${d.expected.artist ? ' — ' + d.expected.artist : ''}` : '', '—');
|
||||||
|
_tdSetText('td-dl', dl ? `${d.downloaded.title}${d.downloaded.artist ? ' — ' + d.downloaded.artist : ''}` : '', '—');
|
||||||
|
} else {
|
||||||
|
prov.hidden = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reason banner (quarantined / failed)
|
||||||
|
const reason = document.getElementById('td-reason');
|
||||||
|
if (reason) {
|
||||||
|
if ((kind === 'quarantined' || kind === 'failed') && d.reason) {
|
||||||
|
reason.hidden = false;
|
||||||
|
reason.innerHTML = `<strong>${kind === 'quarantined' ? 'Why it was quarantined' : 'Why it failed'}:</strong><br>${_tdEsc(d.reason)}`;
|
||||||
|
} else {
|
||||||
|
reason.hidden = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Audio: completed -> library stream; quarantined -> quarantine stream.
|
||||||
|
const audio = document.getElementById('td-audio');
|
||||||
|
if (audio) {
|
||||||
|
let src = '';
|
||||||
|
if (kind === 'completed' && d.file_path) {
|
||||||
|
src = `/stream/library-audio?path=${encodeURIComponent(d.file_path)}`;
|
||||||
|
} else if (kind === 'quarantined' && d.quarantine_entry_id) {
|
||||||
|
src = `/api/quarantine/${encodeURIComponent(d.quarantine_entry_id)}/stream`;
|
||||||
|
}
|
||||||
|
if (src) { audio.src = src; audio.hidden = false; } else { audio.removeAttribute('src'); audio.hidden = true; }
|
||||||
|
}
|
||||||
|
|
||||||
|
_tdRenderActions(d, taskId, kind);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _tdRenderActions(d, taskId, kind) {
|
||||||
|
const el = document.getElementById('td-actions');
|
||||||
|
if (!el) return;
|
||||||
|
el.innerHTML = '';
|
||||||
|
const add = (label, cls, onClick) => {
|
||||||
|
const b = document.createElement('button');
|
||||||
|
b.className = `td-action-btn ${cls}`;
|
||||||
|
b.textContent = label;
|
||||||
|
b.addEventListener('click', onClick);
|
||||||
|
el.appendChild(b);
|
||||||
|
return b;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (kind === 'quarantined') {
|
||||||
|
add('✓ Accept & Import', 'td-action-primary', (e) => _tdAccept(e.currentTarget, d.quarantine_entry_id, taskId));
|
||||||
|
add('🔍 Search for a different result', 'td-action-secondary', () => { closeTrackDetail(); if (taskId) showCandidatesModal(taskId); });
|
||||||
|
} else if (kind === 'failed' || kind === 'not_found') {
|
||||||
|
add('🔍 Search for a different result', 'td-action-secondary', () => { closeTrackDetail(); if (taskId) showCandidatesModal(taskId); });
|
||||||
|
}
|
||||||
|
// completed / in_progress: no destructive actions — the player + info is it.
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _tdAccept(button, entryId, taskId) {
|
||||||
|
if (!entryId) { showToast('Cannot accept — missing quarantine id.', 'error'); return; }
|
||||||
|
const confirmed = await showConfirmDialog({
|
||||||
|
title: 'Accept Quarantined File',
|
||||||
|
message: 'Import this file and skip the quarantine checks for this approved pass?',
|
||||||
|
confirmText: 'Accept & Import',
|
||||||
|
cancelText: 'Cancel',
|
||||||
|
});
|
||||||
|
if (!confirmed) return;
|
||||||
|
const original = button.textContent;
|
||||||
|
button.disabled = true;
|
||||||
|
button.textContent = 'Importing…';
|
||||||
|
await _tdReleaseAudio(); // free the file handle before the move (Windows lock)
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/api/quarantine/${encodeURIComponent(entryId)}/approve`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ task_id: taskId || '' }),
|
||||||
|
});
|
||||||
|
const data = await resp.json();
|
||||||
|
if (data.success) {
|
||||||
|
showToast('Accepted. Re-running post-processing.', 'success');
|
||||||
|
closeTrackDetail();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const needsRecover = /thin sidecar|recover to staging|embedded context|missing file or sidecar/i.test(data.error || '');
|
||||||
|
if (needsRecover) {
|
||||||
|
button.textContent = 'Recovering…';
|
||||||
|
const rec = await fetch(`/api/quarantine/${encodeURIComponent(entryId)}/recover`, { method: 'POST' });
|
||||||
|
const recData = await rec.json();
|
||||||
|
if (recData.success) {
|
||||||
|
showToast('Older entry — moved to Staging. Finish it from the Import page.', 'success');
|
||||||
|
closeTrackDetail();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
showToast(`Recover failed: ${recData.error || 'Unknown error'}`, 'error');
|
||||||
|
} else {
|
||||||
|
showToast(`Accept failed: ${data.error || 'Unknown error'}`, 'error');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
showToast(`Accept failed: ${err.message}`, 'error');
|
||||||
|
}
|
||||||
|
button.disabled = false;
|
||||||
|
button.textContent = original;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close on backdrop click + Escape.
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
const overlay = document.getElementById('track-detail-overlay');
|
||||||
|
if (overlay) {
|
||||||
|
overlay.addEventListener('mousedown', (e) => { if (e.target === overlay) closeTrackDetail(); });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
const o = document.getElementById('track-detail-overlay');
|
||||||
|
if (o && o.classList.contains('visible')) closeTrackDetail();
|
||||||
|
}
|
||||||
|
});
|
||||||
44
webui/track-detail-modal.html
Normal file
44
webui/track-detail-modal.html
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
{# Track Detail modal — click any track row in the download modal to inspect it.
|
||||||
|
Populated by static/track-detail.js (openTrackDetail). One shell that adapts
|
||||||
|
by status_kind: completed shows play + provenance; quarantined adds the
|
||||||
|
reason + Listen / Accept / Search; failed/not-found offer Search. #}
|
||||||
|
<div id="track-detail-overlay" class="td-overlay" aria-hidden="true">
|
||||||
|
<div class="td-modal" role="dialog" aria-modal="true" aria-labelledby="td-title">
|
||||||
|
<div class="td-header">
|
||||||
|
<div class="td-thumb-wrap">
|
||||||
|
<img id="td-thumb" class="td-thumb" alt="" hidden>
|
||||||
|
<div id="td-thumb-ph" class="td-thumb-ph">🎵</div>
|
||||||
|
</div>
|
||||||
|
<div class="td-headtext">
|
||||||
|
<h2 id="td-title" class="td-title">Track</h2>
|
||||||
|
<div id="td-artist" class="td-artist"></div>
|
||||||
|
<div id="td-album" class="td-album"></div>
|
||||||
|
</div>
|
||||||
|
<span id="td-status-badge" class="td-status-badge"></span>
|
||||||
|
<button type="button" class="td-close" aria-label="Close" onclick="closeTrackDetail()">×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="td-body">
|
||||||
|
<audio id="td-audio" class="td-audio" controls preload="none" hidden></audio>
|
||||||
|
|
||||||
|
<div id="td-reason" class="td-reason" hidden></div>
|
||||||
|
|
||||||
|
<div class="td-grid">
|
||||||
|
<div class="td-field"><span class="td-label">Source</span><span id="td-f-source" class="td-value">—</span></div>
|
||||||
|
<div class="td-field"><span class="td-label">Quality</span><span id="td-f-quality" class="td-value">—</span></div>
|
||||||
|
<div class="td-field"><span class="td-label">AcoustID</span><span id="td-f-acoustid" class="td-value">—</span></div>
|
||||||
|
<div class="td-field td-field-wide">
|
||||||
|
<span class="td-label">Location</span>
|
||||||
|
<span id="td-f-location" class="td-value td-mono">—</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="td-provenance" class="td-provenance" hidden>
|
||||||
|
<div class="td-prov-row"><span class="td-prov-k">Expected</span><span id="td-exp" class="td-prov-v"></span></div>
|
||||||
|
<div class="td-prov-row"><span class="td-prov-k">Downloaded</span><span id="td-dl" class="td-prov-v"></span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="td-actions" class="td-actions"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
Loading…
Reference in a new issue