Add track download provenance tracking and source info UI (#234)

New track_downloads table records every download with full source data:
service type (soulseek/youtube/tidal/etc), username, remote filename,
file size, and audio quality. Recorded at all 3 post-processing
completion points.

Source Info button (ℹ) on each track in the enhanced library view shows
a popover with download provenance: service, username, original filename,
size, quality, download date. Includes "Blacklist This Source" button
that stores the real username+filename (not guessed local filenames).

Removed broken "Delete & Blacklist" option from Smart Delete since it
had no access to real source data. Blacklisting now done exclusively
from the Source Info popover where actual provenance data exists.

Added blacklist CRUD API endpoints (GET/POST/DELETE /api/library/blacklist).
This commit is contained in:
Broque Thomas 2026-04-01 11:37:21 -07:00
parent f8f87e0e38
commit 04f01d36e1
4 changed files with 460 additions and 9 deletions

View file

@ -1134,6 +1134,28 @@ class MusicDatabase:
""")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_blacklist_user_file ON download_blacklist (blocked_username, blocked_filename)")
# Track download provenance — where each library track came from
cursor.execute("""
CREATE TABLE IF NOT EXISTS track_downloads (
id INTEGER PRIMARY KEY AUTOINCREMENT,
track_id TEXT,
file_path TEXT,
source_service TEXT NOT NULL,
source_username TEXT,
source_filename TEXT,
source_size INTEGER,
audio_quality TEXT,
track_title TEXT,
track_artist TEXT,
track_album TEXT,
status TEXT DEFAULT 'completed',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_td_track_id ON track_downloads (track_id)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_td_file_path ON track_downloads (file_path)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_td_source ON track_downloads (source_username, source_filename)")
logger.info("Discovery tables created successfully")
except Exception as e:
@ -8508,6 +8530,69 @@ class MusicDatabase:
logger.error(f"Error removing from blacklist: {e}")
return False
# ==================== Track Download Provenance Methods ====================
def record_track_download(self, file_path: str, source_service: str, source_username: str,
source_filename: str, source_size: int = 0, audio_quality: str = '',
track_title: str = '', track_artist: str = '', track_album: str = '',
status: str = 'completed', track_id: str = None) -> Optional[int]:
"""Record a download with full source provenance. Returns the record ID."""
try:
conn = self._get_connection()
cursor = conn.cursor()
# Try to link to existing library track by file path if track_id not given
if not track_id and file_path:
cursor.execute("SELECT id FROM tracks WHERE file_path = ? LIMIT 1", (file_path,))
row = cursor.fetchone()
if row:
track_id = str(row[0])
cursor.execute("""
INSERT INTO track_downloads
(track_id, file_path, source_service, source_username, source_filename,
source_size, audio_quality, track_title, track_artist, track_album, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (track_id, file_path, source_service, source_username, source_filename,
source_size, audio_quality, track_title, track_artist, track_album, status))
conn.commit()
return cursor.lastrowid
except Exception as e:
logger.error(f"Error recording track download: {e}")
return None
def get_track_downloads(self, track_id: str) -> list:
"""Get all download records for a library track."""
try:
conn = self._get_connection()
cursor = conn.cursor()
cursor.execute("""
SELECT * FROM track_downloads
WHERE track_id = ?
ORDER BY created_at DESC
""", (str(track_id),))
return [dict(row) for row in cursor.fetchall()]
except Exception as e:
logger.error(f"Error getting track downloads: {e}")
return []
def get_download_by_file_path(self, file_path: str) -> Optional[dict]:
"""Find the most recent download record for a file path."""
try:
conn = self._get_connection()
cursor = conn.cursor()
cursor.execute("""
SELECT * FROM track_downloads
WHERE file_path = ?
ORDER BY created_at DESC
LIMIT 1
""", (file_path,))
row = cursor.fetchone()
return dict(row) if row else None
except Exception as e:
logger.error(f"Error getting download by file path: {e}")
return None
# ==================== Discovery Pool Methods ====================
def get_discovery_pool_matched(self, limit: int = 500) -> list:

View file

@ -1674,6 +1674,53 @@ def _record_library_history_download(context):
except Exception:
pass # Non-critical, never block download flow
def _record_download_provenance(context):
"""Record download source provenance for track lineage tracking. Non-blocking."""
try:
# Extract source info
search_result = context.get('original_search_result') or context.get('search_result') or {}
username = search_result.get('username', context.get('_download_username', ''))
filename = search_result.get('filename', '')
# Determine source service from username
service_map = {'youtube': 'youtube', 'tidal': 'tidal', 'qobuz': 'qobuz', 'hifi': 'hifi', 'deezer_dl': 'deezer'}
source_service = service_map.get(username, 'soulseek')
# Track metadata
ti = context.get('track_info') or context.get('search_result') or {}
artist_name = ''
artists = ti.get('artists', [])
if artists:
a = artists[0]
artist_name = a.get('name', str(a)) if isinstance(a, dict) else str(a)
if not artist_name:
artist_name = ti.get('artist', '')
album_raw = ti.get('album', '')
album_name = album_raw.get('name', '') if isinstance(album_raw, dict) else str(album_raw or '')
title = ti.get('name', ti.get('title', ''))
file_path = context.get('_final_processed_path', context.get('_final_path', ''))
quality = context.get('_audio_quality', '')
size = search_result.get('size', 0)
db = get_database()
db.record_track_download(
file_path=file_path,
source_service=source_service,
source_username=username,
source_filename=filename,
source_size=size or 0,
audio_quality=quality,
track_title=title,
track_artist=artist_name,
track_album=album_name,
)
except Exception:
pass # Non-critical, never block download flow
# --- Register Public REST API Blueprint (v1) ---
try:
from api import create_api_blueprint, limiter
@ -12853,6 +12900,79 @@ def library_delete_track(track_id):
return jsonify({"success": False, "error": str(e)}), 500
# ==================================================================================
# DOWNLOAD BLACKLIST API
# ==================================================================================
@app.route('/api/library/blacklist', methods=['GET'])
def get_blacklist():
"""Get all blacklisted download sources."""
try:
database = get_database()
entries = database.get_blacklist(limit=200)
return jsonify({"success": True, "entries": entries})
except Exception as e:
return jsonify({"success": False, "error": str(e)}), 500
@app.route('/api/library/blacklist', methods=['POST'])
def add_to_blacklist():
"""Add a download source to the blacklist."""
try:
data = request.get_json()
database = get_database()
result = database.add_to_blacklist(
track_title=data.get('track_title', ''),
track_artist=data.get('track_artist', ''),
blocked_filename=data.get('blocked_filename', ''),
blocked_username=data.get('blocked_username', ''),
reason=data.get('reason', 'user_rejected'),
)
return jsonify({"success": result})
except Exception as e:
return jsonify({"success": False, "error": str(e)}), 500
@app.route('/api/library/blacklist/<int:blacklist_id>', methods=['DELETE'])
def remove_from_blacklist(blacklist_id):
"""Remove an entry from the blacklist."""
try:
database = get_database()
result = database.remove_from_blacklist(blacklist_id)
return jsonify({"success": result})
except Exception as e:
return jsonify({"success": False, "error": str(e)}), 500
# ==================================================================================
# TRACK SOURCE INFO & PROVENANCE
# ==================================================================================
@app.route('/api/library/track/<int:track_id>/source-info', methods=['GET'])
def get_track_source_info(track_id):
"""Get download provenance info for a library track."""
try:
database = get_database()
downloads = database.get_track_downloads(str(track_id))
if not downloads:
# Try matching by file path as fallback
conn = database._get_connection()
cursor = conn.cursor()
cursor.execute("SELECT file_path FROM tracks WHERE id = ?", (track_id,))
row = cursor.fetchone()
conn.close()
if row and row['file_path']:
dl = database.get_download_by_file_path(row['file_path'])
if dl:
downloads = [dl]
return jsonify({"success": True, "downloads": downloads})
except Exception as e:
logger.error(f"Error getting track source info: {e}")
return jsonify({"success": False, "error": str(e)}), 500
# ==================================================================================
# TRACK REDOWNLOAD — Search metadata, search download sources, start redownload
# ==================================================================================
@ -18544,6 +18664,7 @@ def _post_process_matched_download(context_key, context, file_path):
context['_final_path'] = str(destination)
_emit_track_downloaded(context)
_record_library_history_download(context)
_record_download_provenance(context)
return
# --- END SIMPLE DOWNLOAD HANDLING ---
@ -18634,6 +18755,7 @@ def _post_process_matched_download(context_key, context, file_path):
_emit_track_downloaded(context)
_record_library_history_download(context)
_record_download_provenance(context)
# Mark as stream processed so the verification worker doesn't search
# for the file by its original Soulseek name (which no longer exists after rename)
@ -18984,6 +19106,7 @@ def _post_process_matched_download(context_key, context, file_path):
_emit_track_downloaded(context)
_record_library_history_download(context)
_record_download_provenance(context)
# RETAG DATA CAPTURE: Record completed album/single downloads for retag tool
try:

View file

@ -42191,6 +42191,16 @@ function _buildTrackRow(track, album, admin) {
}
tr.appendChild(tagTd);
// Source info button (admin only)
const srcTd = document.createElement('td');
srcTd.className = 'col-source-info';
const srcBtn = document.createElement('button');
srcBtn.className = 'enhanced-source-info-btn';
srcBtn.innerHTML = '';
srcBtn.title = 'View download source info';
srcTd.appendChild(srcBtn);
tr.appendChild(srcTd);
// Redownload button (admin only)
const rdTd = document.createElement('td');
rdTd.className = 'col-redownload';
@ -42360,6 +42370,13 @@ function _attachTableDelegation(table, album) {
return;
}
// Source info button (admin)
if (target.closest('.enhanced-source-info-btn')) {
e.stopPropagation();
showTrackSourceInfo(track, target.closest('.enhanced-source-info-btn'));
return;
}
// Redownload button (admin)
if (target.closest('.enhanced-redownload-btn')) {
e.stopPropagation();
@ -42423,6 +42440,7 @@ function _showMobileTrackActions(track, album) {
actions.push({ icon: '✎', label: 'Write Tags', action: () => showTagPreview(track.id) });
}
if (admin) {
actions.push({ icon: '', label: 'Source Info', action: () => showTrackSourceInfo(track, null) });
actions.push({ icon: '↻', label: 'Redownload Track', action: () => showTrackRedownloadModal(track, album) });
actions.push({ icon: '✕', label: 'Delete Track', cls: 'popover-delete', action: () => deleteLibraryTrack(track.id, album.id) });
}
@ -42585,8 +42603,7 @@ async function deleteLibraryTrack(trackId, albumId) {
if (!choice) return;
const params = new URLSearchParams();
if (choice === 'delete_file' || choice === 'delete_and_blacklist') params.set('delete_file', 'true');
if (choice === 'delete_and_blacklist') params.set('blacklist', 'true');
if (choice === 'delete_file') params.set('delete_file', 'true');
try {
const response = await fetch(`/api/library/track/${trackId}?${params}`, { method: 'DELETE' });
@ -42643,13 +42660,7 @@ function _showSmartDeleteDialog() {
<div class="smart-delete-option-desc">Remove from library and delete the audio file from disk.</div>
</div>
</button>
<button class="smart-delete-option destructive" data-choice="delete_and_blacklist">
<div class="smart-delete-option-icon"></div>
<div class="smart-delete-option-info">
<div class="smart-delete-option-title">Delete & Blacklist</div>
<div class="smart-delete-option-desc">Delete file and blacklist the source so it won't be downloaded again.</div>
</div>
</button>
<!-- Blacklisting is done from Source Info () where real download source data is available -->
</div>
</div>
`;
@ -42667,6 +42678,158 @@ function _showSmartDeleteDialog() {
});
}
// ==================================================================================
// TRACK SOURCE INFO — View download provenance and blacklist sources
// ==================================================================================
async function showTrackSourceInfo(track, anchorEl) {
// Remove existing popover
const existing = document.getElementById('source-info-popover');
if (existing) existing.remove();
const popover = document.createElement('div');
popover.id = 'source-info-popover';
popover.className = 'source-info-popover';
popover.innerHTML = '<div class="source-info-loading"><div class="server-search-spinner"></div>Loading source info...</div>';
document.body.appendChild(popover);
// Position near the button or center on mobile
if (anchorEl) {
const rect = anchorEl.getBoundingClientRect();
const popW = 360;
let left = rect.left - popW - 8;
if (left < 10) left = rect.right + 8;
let top = rect.top - 20;
if (top + 300 > window.innerHeight) top = window.innerHeight - 310;
popover.style.left = `${left}px`;
popover.style.top = `${Math.max(10, top)}px`;
} else {
popover.style.left = '50%';
popover.style.top = '50%';
popover.style.transform = 'translate(-50%, -50%)';
}
requestAnimationFrame(() => popover.classList.add('visible'));
// Close on outside click
const closeHandler = e => {
if (!popover.contains(e.target) && e.target !== anchorEl) {
popover.remove();
document.removeEventListener('click', closeHandler);
}
};
setTimeout(() => document.addEventListener('click', closeHandler), 100);
// Escape to close
const escH = e => { if (e.key === 'Escape') { popover.remove(); document.removeEventListener('keydown', escH); document.removeEventListener('click', closeHandler); } };
document.addEventListener('keydown', escH);
try {
const res = await fetch(`/api/library/track/${track.id}/source-info`);
const data = await res.json();
if (!data.success || !data.downloads || data.downloads.length === 0) {
popover.innerHTML = `
<div class="source-info-header">
<span class="source-info-title">Source Info</span>
<button class="source-info-close" onclick="document.getElementById('source-info-popover')?.remove()">&times;</button>
</div>
<div class="source-info-empty">No download source data available for this track. Source tracking starts with new downloads.</div>
`;
return;
}
const serviceIcons = { soulseek: '🔍', youtube: '▶️', tidal: '🌊', qobuz: '🎵', hifi: '🎧', deezer: '💜' };
const serviceLabels = { soulseek: 'Soulseek', youtube: 'YouTube', tidal: 'Tidal', qobuz: 'Qobuz', hifi: 'HiFi', deezer: 'Deezer' };
const dl = data.downloads[0]; // Most recent download
const icon = serviceIcons[dl.source_service] || '📦';
const label = serviceLabels[dl.source_service] || dl.source_service;
const displayFile = dl.source_filename ? dl.source_filename.replace(/\\/g, '/').split('/').pop() : 'Unknown';
const sizeStr = dl.source_size ? `${(dl.source_size / 1048576).toFixed(1)} MB` : '';
const dateStr = dl.created_at ? timeAgo(dl.created_at) : '';
popover.innerHTML = `
<div class="source-info-header">
<span class="source-info-title">Source Info</span>
<button class="source-info-close" onclick="document.getElementById('source-info-popover')?.remove()">&times;</button>
</div>
<div class="source-info-body">
<div class="source-info-row">
<span class="source-info-label">Service</span>
<span class="source-info-value">${icon} ${label}</span>
</div>
${dl.source_service === 'soulseek' && dl.source_username ? `<div class="source-info-row">
<span class="source-info-label">User</span>
<span class="source-info-value source-info-mono">${_esc(dl.source_username)}</span>
</div>` : ''}
<div class="source-info-row">
<span class="source-info-label">Original File</span>
<span class="source-info-value source-info-mono source-info-ellipsis" title="${_esc(dl.source_filename || '')}">${_esc(displayFile)}</span>
</div>
${sizeStr ? `<div class="source-info-row">
<span class="source-info-label">Size</span>
<span class="source-info-value">${sizeStr}</span>
</div>` : ''}
${dl.audio_quality ? `<div class="source-info-row">
<span class="source-info-label">Quality</span>
<span class="source-info-value">${_esc(dl.audio_quality)}</span>
</div>` : ''}
${dateStr ? `<div class="source-info-row">
<span class="source-info-label">Downloaded</span>
<span class="source-info-value">${dateStr}</span>
</div>` : ''}
${dl.status !== 'completed' ? `<div class="source-info-row">
<span class="source-info-label">Status</span>
<span class="source-info-value" style="color:#ef5350">${dl.status}</span>
</div>` : ''}
</div>
${dl.source_username && dl.source_filename ? `
<div class="source-info-actions">
<button class="source-info-blacklist-btn" id="source-info-blacklist-btn"> Blacklist This Source</button>
</div>` : ''}
${data.downloads.length > 1 ? `<div class="source-info-history">${data.downloads.length} download records for this track</div>` : ''}
`;
// Blacklist button handler
const blBtn = document.getElementById('source-info-blacklist-btn');
if (blBtn) {
blBtn.addEventListener('click', async () => {
if (!await showConfirmDialog({ title: 'Blacklist Source', message: `Blacklist "${displayFile}" from ${dl.source_service === 'soulseek' ? dl.source_username : label}? This source will be skipped in future downloads.`, confirmText: 'Blacklist', destructive: true })) return;
try {
const db_res = await fetch('/api/library/blacklist', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
track_title: dl.track_title || track.title,
track_artist: dl.track_artist || '',
blocked_filename: dl.source_filename,
blocked_username: dl.source_username,
reason: 'user_rejected'
})
});
const result = await db_res.json();
if (result.success) {
showToast('Source blacklisted', 'success');
blBtn.disabled = true;
blBtn.textContent = '⛔ Blacklisted';
} else {
showToast(result.error || 'Failed to blacklist', 'error');
}
} catch (e) {
showToast('Error: ' + e.message, 'error');
}
});
}
} catch (e) {
popover.innerHTML = `<div class="source-info-empty">Error loading source info: ${_esc(e.message)}</div>`;
}
}
// ==================================================================================
// TRACK REDOWNLOAD MODAL — Multi-step: metadata selection → source selection → download
// ==================================================================================

View file

@ -52198,3 +52198,83 @@ tr.tag-diff-same {
}
.enhanced-redownload-btn:hover { color: var(--accent); background: rgba(var(--accent-rgb),0.1); }
.col-redownload { width: 30px; text-align: center; }
/* Source Info button */
.enhanced-source-info-btn {
background: none; border: none; color: rgba(255,255,255,0.15); cursor: pointer;
font-size: 14px; padding: 2px 4px; border-radius: 4px; transition: all 0.2s;
}
.enhanced-source-info-btn:hover { color: rgba(100,181,246,0.8); background: rgba(100,181,246,0.1); }
.col-source-info { width: 28px; text-align: center; }
/* Source Info Popover */
.source-info-popover {
position: fixed;
width: 360px;
max-width: 95vw;
background: rgba(20, 20, 28, 0.98);
backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 16px;
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.5);
z-index: 9500;
opacity: 0;
transform: scale(0.96);
transition: opacity 0.2s, transform 0.2s;
}
.source-info-popover.visible { opacity: 1; transform: scale(1); }
.source-info-header {
display: flex; justify-content: space-between; align-items: center;
padding: 14px 16px 10px;
}
.source-info-title { font-size: 13px; font-weight: 700; color: #fff; }
.source-info-close {
width: 24px; height: 24px; border: none; background: rgba(255,255,255,0.06);
color: rgba(255,255,255,0.4); border-radius: 50%; font-size: 16px;
cursor: pointer; display: flex; align-items: center; justify-content: center;
}
.source-info-close:hover { background: rgba(255,255,255,0.12); color: #fff; }
.source-info-body { padding: 0 16px 12px; }
.source-info-row {
display: flex; justify-content: space-between; align-items: center;
padding: 6px 0; border-bottom: 1px solid rgba(255,255,255,0.03);
}
.source-info-row:last-child { border-bottom: none; }
.source-info-label {
font-size: 11px; color: rgba(255,255,255,0.35); font-weight: 600;
text-transform: uppercase; letter-spacing: 0.04em; flex-shrink: 0;
}
.source-info-value {
font-size: 12px; color: rgba(255,255,255,0.8); text-align: right;
max-width: 220px;
}
.source-info-mono { font-family: monospace; font-size: 11px; }
.source-info-ellipsis { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.source-info-empty {
padding: 20px 16px; text-align: center; font-size: 11px;
color: rgba(255,255,255,0.25);
}
.source-info-loading {
padding: 24px 16px; text-align: center; font-size: 11px;
color: rgba(255,255,255,0.25);
}
.source-info-actions { padding: 4px 16px 14px; }
.source-info-blacklist-btn {
width: 100%; padding: 8px 14px; border-radius: 8px; border: 1px solid rgba(239,83,80,0.2);
background: rgba(239,83,80,0.06); color: #ef5350; font-size: 11px; font-weight: 600;
cursor: pointer; transition: all 0.2s;
}
.source-info-blacklist-btn:hover { background: rgba(239,83,80,0.12); border-color: rgba(239,83,80,0.35); }
.source-info-blacklist-btn:disabled { opacity: 0.5; cursor: default; }
.source-info-history {
padding: 8px 16px 14px; font-size: 10px; color: rgba(255,255,255,0.2); text-align: center;
}