diff --git a/database/music_database.py b/database/music_database.py index 24057b99..7538cc2b 100644 --- a/database/music_database.py +++ b/database/music_database.py @@ -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: diff --git a/web_server.py b/web_server.py index 19ecfebc..afa8c3ef 100644 --- a/web_server.py +++ b/web_server.py @@ -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/', 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//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: diff --git a/webui/static/script.js b/webui/static/script.js index 20820b26..79d3845e 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -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() {
Remove from library and delete the audio file from disk.
- + `; @@ -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 = '
Loading source info...
'; + + 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 = ` +
+ Source Info + +
+
No download source data available for this track. Source tracking starts with new downloads.
+ `; + 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 = ` +
+ Source Info + +
+
+
+ Service + ${icon} ${label} +
+ ${dl.source_service === 'soulseek' && dl.source_username ? `
+ User + ${_esc(dl.source_username)} +
` : ''} +
+ Original File + ${_esc(displayFile)} +
+ ${sizeStr ? `
+ Size + ${sizeStr} +
` : ''} + ${dl.audio_quality ? `
+ Quality + ${_esc(dl.audio_quality)} +
` : ''} + ${dateStr ? `
+ Downloaded + ${dateStr} +
` : ''} + ${dl.status !== 'completed' ? `
+ Status + ${dl.status} +
` : ''} +
+ ${dl.source_username && dl.source_filename ? ` +
+ +
` : ''} + ${data.downloads.length > 1 ? `
${data.downloads.length} download records for this track
` : ''} + `; + + // 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 = `
Error loading source info: ${_esc(e.message)}
`; + } +} + + // ================================================================================== // TRACK REDOWNLOAD MODAL — Multi-step: metadata selection → source selection → download // ================================================================================== diff --git a/webui/static/style.css b/webui/static/style.css index 5a050be0..287330a7 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -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; +}