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/<id>/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
pull/253/head
Broque Thomas 2 months ago
parent f94f043dc6
commit 326bb548ce

@ -12474,6 +12474,77 @@ def library_delete_track(track_id):
return jsonify({"success": False, "error": str(e)}), 500
@app.route('/api/library/artist/<int:artist_id>/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/<album_id>', methods=['DELETE'])
def library_delete_album(album_id):
"""Delete an album and all its tracks from the database (does NOT delete files on disk)."""

@ -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 = '&#x1f504; 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 = '&#x1f504; Sync';
};
headerRight.appendChild(syncBtn);
header.appendChild(headerRight);
panel.appendChild(header);

@ -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;

Loading…
Cancel
Save