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:
parent
f8fbcb507c
commit
410bddd102
4 changed files with 140 additions and 30 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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">▾</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>`;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue