diff --git a/database/music_database.py b/database/music_database.py index 930ea223..5abcd165 100644 --- a/database/music_database.py +++ b/database/music_database.py @@ -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: diff --git a/web_server.py b/web_server.py index f780e6d5..6b6f0efb 100644 --- a/web_server.py +++ b/web_server.py @@ -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 diff --git a/webui/static/script.js b/webui/static/script.js index 784d03c1..fdd17bc4 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -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 = `
${parts.join(' · ')}
`; + if (entry.event_type === 'download') { + const lines = []; + // Expected line (what we asked for) + if (entry.title || entry.artist_name) { + lines.push(`Expected: ${escapeHtml(entry.title || '?')} by ${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(`Downloaded: ${escapeHtml(srcTitle || '?')} by ${escapeHtml(srcArtist || '?')}`); + } + // Source file + ID line + if (entry.source_filename || entry.source_track_id) { + const fileParts = []; + if (entry.source_filename) fileParts.push(`File: ${escapeHtml(entry.source_filename)}`); + if (entry.source_track_id) fileParts.push(`${entry.source_filename ? '' : 'Source '}ID: ${escapeHtml(entry.source_track_id)}`); + lines.push(fileParts.join(` · `)); + } + if (lines.length > 0) { + sourceDetail = `
${lines.join('
')}
`; + } } - return `
+ const hasDetails = sourceDetail || acoustidBadge; + const expandIndicator = hasDetails ? `` : ''; + + return `
${thumb} -
-
${escapeHtml(entry.title || 'Unknown')}
- - ${sourceDetail} +
+
+
+
${escapeHtml(entry.title || 'Unknown')}
+ +
+
${badge}
+
${formatHistoryTime(entry.created_at)}
+ ${expandIndicator} +
+ ${hasDetails ? `
+ ${sourceDetail} + ${acoustidBadge ? `
${acoustidBadge}
` : ''} +
` : ''}
- ${badge}${acoustidBadge} -
${formatHistoryTime(entry.created_at)}
`; } diff --git a/webui/static/style.css b/webui/static/style.css index 69228b96..8f57e2dc 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -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 {