Redesign download history with collapsible entries and full provenance

Entries are now compact cards that expand on click to reveal source
details. Shows expected vs downloaded title/artist with red mismatch
highlighting. Source artist column added to DB. Streaming track IDs
extracted from the id||name filename pattern. File and ID always on
their own line to avoid edge-case misplacement.
This commit is contained in:
Broque Thomas 2026-04-06 19:33:02 -07:00
parent f8fbcb507c
commit 410bddd102
4 changed files with 140 additions and 30 deletions

View file

@ -509,7 +509,7 @@ class MusicDatabase:
if 'download_source' not in lh_cols:
cursor.execute("ALTER TABLE library_history ADD COLUMN download_source TEXT")
logger.info("Added download_source column to library_history")
for _col in ['source_track_id', 'source_track_title', 'source_filename', 'acoustid_result']:
for _col in ['source_track_id', 'source_track_title', 'source_filename', 'acoustid_result', 'source_artist']:
if _col not in lh_cols:
cursor.execute(f"ALTER TABLE library_history ADD COLUMN {_col} TEXT")
logger.info(f"Added {_col} column to library_history")
@ -9622,7 +9622,7 @@ class MusicDatabase:
def add_library_history_entry(self, event_type, title, artist_name=None, album_name=None,
quality=None, server_source=None, file_path=None, thumb_url=None,
download_source=None, source_track_id=None, source_track_title=None,
source_filename=None, acoustid_result=None):
source_filename=None, acoustid_result=None, source_artist=None):
"""Record a download or import event to the library history table."""
try:
conn = self._get_connection()
@ -9630,10 +9630,12 @@ class MusicDatabase:
cursor.execute("""
INSERT INTO library_history (event_type, title, artist_name, album_name,
quality, server_source, file_path, thumb_url, download_source,
source_track_id, source_track_title, source_filename, acoustid_result)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
source_track_id, source_track_title, source_filename,
acoustid_result, source_artist)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (event_type, title, artist_name, album_name, quality, server_source, file_path, thumb_url,
download_source, source_track_id, source_track_title, source_filename, acoustid_result))
download_source, source_track_id, source_track_title, source_filename,
acoustid_result, source_artist))
conn.commit()
return True
except Exception as e:

View file

@ -1916,9 +1916,18 @@ def _record_library_history_download(context):
source_track_id = (search_result.get('track_id', '')
or search_result.get('id', '')
or ti.get('id', ''))
# Source track title: only save if it differs from expected title (otherwise it's just noise)
_src_title = search_result.get('title', '') or search_result.get('name', '')
source_track_title = _src_title if _src_title and _src_title != title else ''
# Source title/artist — what the download source said the track was.
# For Soulseek: parsed from the peer's filename by TrackResult._parse_filename_metadata()
# For Tidal/YouTube/Qobuz: from the streaming API's own metadata
# These live on the candidate's original fields, NOT the spotify_clean_* fields
source_track_title = search_result.get('title', '') or search_result.get('name', '')
source_artist = search_result.get('artist', '')
# For streaming sources, track ID is encoded in filename as "id||display_name"
if source_filename and '||' in source_filename and username in ('tidal', 'youtube', 'qobuz', 'hifi', 'deezer_dl'):
_stream_id = source_filename.split('||')[0]
if _stream_id and not source_track_id:
source_track_id = _stream_id
# AcoustID verification result
acoustid_result = context.get('_acoustid_result', '')
@ -1936,7 +1945,8 @@ def _record_library_history_download(context):
source_track_id=source_track_id,
source_track_title=source_track_title,
source_filename=source_filename,
acoustid_result=acoustid_result
acoustid_result=acoustid_result,
source_artist=source_artist
)
except Exception:
pass # Non-critical, never block download flow

View file

@ -21093,25 +21093,55 @@ function renderHistoryEntry(entry) {
const meta = [entry.artist_name, entry.album_name].filter(Boolean).join(' — ');
// Source provenance detail line
// Source provenance — expected vs downloaded
let sourceDetail = '';
if (entry.event_type === 'download' && (entry.source_filename || entry.source_track_title)) {
const parts = [];
if (entry.source_filename) parts.push(`File: ${escapeHtml(entry.source_filename)}`);
else if (entry.source_track_title) parts.push(`Source track: ${escapeHtml(entry.source_track_title)}`);
if (entry.source_track_id) parts.push(`ID: ${escapeHtml(entry.source_track_id)}`);
sourceDetail = `<div class="library-history-entry-source">${parts.join(' · ')}</div>`;
if (entry.event_type === 'download') {
const lines = [];
// Expected line (what we asked for)
if (entry.title || entry.artist_name) {
lines.push(`<span class="lh-prov-label">Expected:</span> ${escapeHtml(entry.title || '?')} <span class="lh-prov-dim">by</span> ${escapeHtml(entry.artist_name || '?')}`);
}
// Downloaded line (what the source provided)
const srcTitle = entry.source_track_title || '';
const srcArtist = entry.source_artist || '';
if (srcTitle || srcArtist) {
const isMismatch = (srcTitle && entry.title && srcTitle.toLowerCase() !== entry.title.toLowerCase())
|| (srcArtist && entry.artist_name && srcArtist.toLowerCase() !== entry.artist_name.toLowerCase());
const mismatchClass = isMismatch ? ' lh-prov-mismatch' : '';
lines.push(`<span class="lh-prov-label">Downloaded:</span> <span class="${mismatchClass}">${escapeHtml(srcTitle || '?')} <span class="lh-prov-dim">by</span> ${escapeHtml(srcArtist || '?')}</span>`);
}
// Source file + ID line
if (entry.source_filename || entry.source_track_id) {
const fileParts = [];
if (entry.source_filename) fileParts.push(`<span class="lh-prov-label">File:</span> ${escapeHtml(entry.source_filename)}`);
if (entry.source_track_id) fileParts.push(`<span class="lh-prov-label">${entry.source_filename ? '' : 'Source '}ID:</span> ${escapeHtml(entry.source_track_id)}`);
lines.push(fileParts.join(` <span class="lh-prov-dim">·</span> `));
}
if (lines.length > 0) {
sourceDetail = `<div class="library-history-entry-source">${lines.join('<br>')}</div>`;
}
}
return `<div class="library-history-entry">
const hasDetails = sourceDetail || acoustidBadge;
const expandIndicator = hasDetails ? `<span class="lh-expand-btn">&#x25BE;</span>` : '';
return `<div class="library-history-entry${hasDetails ? ' lh-expandable' : ''}" ${hasDetails ? 'onclick="this.classList.toggle(\'lh-expanded\')"' : ''}>
${thumb}
<div class="library-history-entry-text">
<div class="library-history-entry-title">${escapeHtml(entry.title || 'Unknown')}</div>
<div class="library-history-entry-meta">${escapeHtml(meta)}</div>
${sourceDetail}
<div class="library-history-entry-content">
<div class="library-history-entry-row1">
<div class="library-history-entry-text">
<div class="library-history-entry-title">${escapeHtml(entry.title || 'Unknown')}</div>
<div class="library-history-entry-meta">${escapeHtml(meta)}</div>
</div>
<div class="library-history-entry-badges">${badge}</div>
<div class="library-history-entry-time">${formatHistoryTime(entry.created_at)}</div>
${expandIndicator}
</div>
${hasDetails ? `<div class="library-history-entry-details">
${sourceDetail}
${acoustidBadge ? `<div class="library-history-entry-badges" style="margin-top:4px">${acoustidBadge}</div>` : ''}
</div>` : ''}
</div>
${badge}${acoustidBadge}
<div class="library-history-entry-time">${formatHistoryTime(entry.created_at)}</div>
</div>`;
}

View file

@ -9365,7 +9365,7 @@ body.helper-mode-active #dashboard-activity-feed:hover {
.library-history-entry {
display: flex;
align-items: center;
align-items: flex-start;
gap: 12px;
padding: 10px 12px;
border-left: 3px solid rgba(var(--accent-rgb), 0.15);
@ -9400,6 +9400,20 @@ body.helper-mode-active #dashboard-activity-feed:hover {
flex-shrink: 0;
}
.library-history-entry-content {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.library-history-entry-row1 {
display: flex;
align-items: flex-start;
gap: 12px;
}
.library-history-entry-text {
flex: 1;
min-width: 0;
@ -9423,14 +9437,67 @@ body.helper-mode-active #dashboard-activity-feed:hover {
margin-top: 2px;
}
.library-history-entry-details {
display: none;
padding: 6px 0 2px;
border-top: 1px solid rgba(255, 255, 255, 0.04);
margin-top: 4px;
animation: stgSlideIn 0.2s ease-out;
}
.library-history-entry.lh-expanded .library-history-entry-details {
display: block;
}
.library-history-entry-badges {
display: flex;
flex-wrap: wrap;
gap: 4px;
flex-shrink: 0;
}
.library-history-entry.lh-expandable {
cursor: pointer;
}
.lh-expand-btn {
color: rgba(255, 255, 255, 0.2);
font-size: 14px;
padding: 0 4px;
flex-shrink: 0;
transition: color 0.2s, transform 0.25s;
line-height: 1;
}
.library-history-entry.lh-expandable:hover .lh-expand-btn {
color: rgba(255, 255, 255, 0.5);
}
.library-history-entry.lh-expanded .lh-expand-btn {
transform: rotate(180deg);
color: var(--accent-color, #1db954);
}
.library-history-entry-source {
font-size: 10px;
color: rgba(255, 255, 255, 0.25);
white-space: nowrap;
font-size: 10.5px;
color: rgba(255, 255, 255, 0.35);
overflow: hidden;
text-overflow: ellipsis;
margin-top: 2px;
font-family: monospace;
font-family: 'SF Mono', 'Consolas', 'Monaco', monospace;
word-break: break-all;
line-height: 1.6;
padding: 4px 0 0;
}
.lh-prov-label {
color: rgba(255, 255, 255, 0.2);
font-weight: 600;
text-transform: uppercase;
font-size: 9px;
letter-spacing: 0.3px;
margin-right: 4px;
}
.lh-prov-dim {
color: rgba(255, 255, 255, 0.15);
}
.lh-prov-mismatch {
color: #ef5350;
}
.library-history-badge {
@ -9441,7 +9508,6 @@ body.helper-mode-active #dashboard-activity-feed:hover {
padding: 3px 8px;
border-radius: 4px;
white-space: nowrap;
flex-shrink: 0;
}
.library-history-badge.download {
@ -9477,6 +9543,8 @@ body.helper-mode-active #dashboard-activity-feed:hover {
color: rgba(255, 255, 255, 0.25);
white-space: nowrap;
flex-shrink: 0;
margin-left: auto;
padding-top: 1px;
}
.library-history-empty {