From 326bb548ce518f19b4a41a5577f687188b0ba7e2 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Fri, 27 Mar 2026 15:36:08 -0700 Subject: [PATCH] Add per-artist Sync button on enhanced library view New "Sync" button in the enhanced view header validates an artist's library entries against files on disk. Removes stale tracks (missing files), cleans empty albums, and updates track counts. - POST /api/library/artist//sync endpoint - Checks each track's file_path via _resolve_library_file_path - Empty album cleanup checks ALL tracks (not just this artist's) to avoid deleting albums shared with other artists - Toast shows results: stale removed, albums cleaned, or "All files verified" if everything checks out - Auto-refreshes enhanced view when changes are made --- web_server.py | 71 ++++++++++++++++++++++++++++++++++++++++++ webui/static/script.js | 33 ++++++++++++++++++++ webui/static/style.css | 25 +++++++++++++++ 3 files changed, 129 insertions(+) diff --git a/web_server.py b/web_server.py index 18b3f8ef..59fc7d06 100644 --- a/web_server.py +++ b/web_server.py @@ -12474,6 +12474,77 @@ def library_delete_track(track_id): return jsonify({"success": False, "error": str(e)}), 500 +@app.route('/api/library/artist//sync', methods=['POST']) +def sync_artist_library(artist_id): + """Validate an artist's library entries — remove stale tracks/albums, recount.""" + try: + database = get_database() + with database._get_connection() as conn: + cursor = conn.cursor() + + # Get all tracks for this artist + cursor.execute(""" + SELECT t.id, t.file_path, t.title, t.album_id + FROM tracks t WHERE t.artist_id = ? + """, (artist_id,)) + tracks = cursor.fetchall() + + # Get artist name for logging + cursor.execute("SELECT name FROM artists WHERE id = ?", (artist_id,)) + artist_row = cursor.fetchone() + artist_name = artist_row['name'] if artist_row else f'ID {artist_id}' + + stale_tracks = [] + valid_tracks = 0 + + for track in tracks: + file_path = track['file_path'] + if not file_path: + stale_tracks.append(track['id']) + continue + + # Check if file exists on disk + resolved = _resolve_library_file_path(file_path) + if resolved and os.path.exists(resolved): + valid_tracks += 1 + else: + stale_tracks.append(track['id']) + + # Remove stale tracks + if stale_tracks: + placeholders = ','.join('?' for _ in stale_tracks) + cursor.execute(f"DELETE FROM tracks WHERE id IN ({placeholders})", stale_tracks) + + # Remove empty albums (no tracks left from ANY artist) + cursor.execute(""" + DELETE FROM albums WHERE artist_id = ? + AND id NOT IN (SELECT DISTINCT album_id FROM tracks) + """, (artist_id,)) + empty_albums_removed = cursor.rowcount + + # Update track_count on remaining albums + cursor.execute(""" + UPDATE albums SET track_count = ( + SELECT COUNT(*) FROM tracks WHERE tracks.album_id = albums.id + ) WHERE artist_id = ? + """, (artist_id,)) + + conn.commit() + + print(f"🔄 [Artist Sync] {artist_name}: {valid_tracks} valid, {len(stale_tracks)} stale removed, {empty_albums_removed} empty albums cleaned") + + return jsonify({ + "success": True, + "artist_name": artist_name, + "valid_tracks": valid_tracks, + "stale_removed": len(stale_tracks), + "empty_albums_removed": empty_albums_removed + }) + + except Exception as e: + print(f"❌ Error syncing artist {artist_id}: {e}") + return jsonify({"success": False, "error": str(e)}), 500 + @app.route('/api/library/album/', methods=['DELETE']) def library_delete_album(album_id): """Delete an album and all its tracks from the database (does NOT delete files on disk).""" diff --git a/webui/static/script.js b/webui/static/script.js index 4eb40b17..0d2a1584 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -40669,6 +40669,39 @@ function renderArtistMetaPanel(artist) { headerRight.appendChild(enrichWrap); } + // Sync / Validate button + const syncBtn = document.createElement('button'); + syncBtn.className = 'enhanced-sync-btn'; + syncBtn.innerHTML = '🔄 Sync'; + syncBtn.title = 'Validate files — removes stale entries for tracks no longer on disk'; + syncBtn.onclick = async (e) => { + e.stopPropagation(); + syncBtn.disabled = true; + syncBtn.textContent = 'Syncing...'; + try { + const res = await fetch(`/api/library/artist/${artist.id}/sync`, { method: 'POST' }); + const data = await res.json(); + if (data.success) { + const parts = []; + if (data.stale_removed > 0) parts.push(`${data.stale_removed} stale tracks removed`); + if (data.empty_albums_removed > 0) parts.push(`${data.empty_albums_removed} empty albums cleaned`); + if (parts.length === 0) parts.push('All files verified'); + showToast(`${data.artist_name}: ${parts.join(', ')}`, 'success'); + // Refresh enhanced view if anything changed + if (data.stale_removed > 0 || data.empty_albums_removed > 0) { + loadEnhancedViewData(artist.id); + } + } else { + showToast(`Sync failed: ${data.error}`, 'error'); + } + } catch (err) { + showToast(`Sync failed: ${err.message}`, 'error'); + } + syncBtn.disabled = false; + syncBtn.innerHTML = '🔄 Sync'; + }; + headerRight.appendChild(syncBtn); + header.appendChild(headerRight); panel.appendChild(header); diff --git a/webui/static/style.css b/webui/static/style.css index 907041c0..e9571e1f 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -40589,6 +40589,31 @@ textarea.enhanced-meta-field-input { background: rgba(var(--accent-rgb), 0.15); border-color: rgba(var(--accent-rgb), 0.35); } + +.enhanced-sync-btn { + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.1); + color: rgba(255, 255, 255, 0.7); + padding: 8px 16px; + border-radius: 8px; + font-size: 12px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; + font-family: inherit; +} + +.enhanced-sync-btn:hover { + background: rgba(255, 255, 255, 0.08); + border-color: rgba(255, 255, 255, 0.2); + color: #fff; +} + +.enhanced-sync-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + .enhanced-enrich-btn.small { padding: 5px 12px; font-size: 11px;