Bidirectional artist sync, repair jobs grid, deezer column fix

Artist Sync button on enhanced library page now does true bidirectional
sync: Phase 1 pulls new albums/tracks from the media server using the
DatabaseUpdateWorker in deep scan mode (preserves enrichment), Phase 2
removes stale DB entries for files no longer on disk. Works for Plex,
Jellyfin, and Navidrome. Toast shows +albums, +tracks, -stale counts.

Repair jobs tab redesigned: 2-column grid layout with glass gradient
cards, accent top line on hover, hover lift effect, job description
text below name, running state with pulsing accent bar. Responsive
to single column under 900px.

Fixed deezer_artist_id → deezer_id column name on artists table lookup.
pull/306/head
Broque Thomas 1 month ago
parent 876c5665ad
commit a867bba18f

@ -15297,7 +15297,7 @@ def redownload_start(track_id):
@app.route('/api/library/artist/<artist_id>/sync', methods=['POST'])
def sync_artist_library(artist_id):
"""Validate an artist's library entries — remove stale tracks/albums, recount."""
"""Bidirectional sync: pull new content from media server AND remove stale entries."""
try:
database = get_database()
with database._get_connection() as conn:
@ -15307,13 +15307,11 @@ def sync_artist_library(artist_id):
db_artist_id = None
try:
candidate = int(artist_id)
# Verify this DB ID actually exists
cursor.execute("SELECT id FROM artists WHERE id = ?", (candidate,))
if cursor.fetchone():
db_artist_id = candidate
except (ValueError, TypeError):
pass
# If not found as DB ID, look up by source artist ID
if not db_artist_id:
for col in ('spotify_artist_id', 'itunes_artist_id', 'deezer_id'):
cursor.execute(f"SELECT id FROM artists WHERE {col} = ?", (artist_id,))
@ -15325,74 +15323,108 @@ def sync_artist_library(artist_id):
if not db_artist_id:
return jsonify({"success": False, "error": "Artist not found"}), 404
# 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 = ?
""", (db_artist_id,))
tracks = cursor.fetchall()
# Get current artist info
cursor.execute("SELECT name, server_source FROM artists WHERE id = ?", (db_artist_id,))
artist_row = cursor.fetchone()
artist_name = artist_row['name'] if artist_row else f'ID {db_artist_id}'
server_source = artist_row['server_source'] if artist_row else None
# Re-fetch artist name from media server (catches renames in Plex/Jellyfin/Navidrome)
name_updated = False
if server_source:
# ── Phase 1: Pull new content from media server ──
new_albums = 0
new_tracks = 0
name_updated = False
if server_source:
media_client = None
if server_source == 'plex' and plex_client and plex_client.server:
media_client = plex_client
elif server_source == 'jellyfin' and jellyfin_client:
media_client = jellyfin_client
elif server_source == 'navidrome' and navidrome_client:
media_client = navidrome_client
if media_client:
try:
from core.database_update_worker import DatabaseUpdateWorker
worker = DatabaseUpdateWorker(
media_client=media_client,
full_refresh=False,
server_type=server_source,
force_sequential=True,
)
worker.database = database # Use existing DB instance instead of creating new one
# Fetch the artist object from the server
server_artist = None
if server_source == 'plex' and plex_client and plex_client.server:
print(f"[Artist Sync] Fetching artist {db_artist_id} from {server_source}...")
if server_source == 'plex' and hasattr(media_client, 'server'):
try:
server_artist = media_client.server.fetchItem(int(db_artist_id))
print(f"[Artist Sync] Plex returned: {getattr(server_artist, 'title', 'None')}")
except Exception as e:
print(f"[Artist Sync] Plex fetchItem failed: {e}")
elif hasattr(media_client, 'get_artist_by_id'):
try:
server_artist = plex_client.server.fetchItem(int(db_artist_id))
except Exception:
pass
elif server_source in ('jellyfin', 'navidrome'):
media_client = {'jellyfin': jellyfin_client, 'navidrome': navidrome_client}.get(server_source)
if media_client and hasattr(media_client, 'get_artist_by_id'):
server_artist = media_client.get_artist_by_id(str(db_artist_id))
if server_artist and hasattr(server_artist, 'title') and server_artist.title:
new_name = server_artist.title
if new_name != artist_name:
cursor.execute("UPDATE artists SET name = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
(new_name, db_artist_id))
print(f"[Artist Sync] Server returned: {getattr(server_artist, 'title', None) or server_artist}")
except Exception as e:
print(f"[Artist Sync] get_artist_by_id failed: {e}")
else:
print(f"[Artist Sync] No get_artist_by_id method on {type(media_client).__name__}")
if not server_artist:
print(f"[Artist Sync] Could not fetch artist from server — skipping pull phase")
if server_artist:
# Check for name change
new_name = getattr(server_artist, 'title', None)
if new_name and new_name != artist_name:
database.execute_query(
"UPDATE artists SET name = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
(new_name, db_artist_id)
)
print(f"[Artist Sync] Name updated: '{artist_name}''{new_name}'")
artist_name = new_name
name_updated = True
# Process artist content (deep scan mode — skip existing, preserve enrichment)
success, details, new_albums, new_tracks = worker._process_artist_with_content(
server_artist, skip_existing_tracks=True
)
print(f"[Artist Sync] Server pull for {artist_name}: {details}")
except Exception as e:
print(f"[Artist Sync] Could not refresh name from {server_source}: {e}")
print(f"[Artist Sync] Server pull failed for {artist_name}: {e}")
stale_tracks = []
valid_tracks = 0
# ── Phase 2: Remove stale entries (files no longer on disk) ──
stale_removed = 0
empty_albums_removed = 0
with database._get_connection() as conn:
cursor = conn.cursor()
cursor.execute("SELECT id, file_path FROM tracks WHERE artist_id = ?", (db_artist_id,))
tracks = cursor.fetchall()
stale_ids = []
for track in tracks:
file_path = track['file_path']
if not file_path:
stale_tracks.append(track['id'])
fp = track['file_path']
if not fp:
stale_ids.append(track['id'])
continue
resolved = _resolve_library_file_path(fp)
if not resolved or not os.path.exists(resolved):
stale_ids.append(track['id'])
# 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)
if stale_ids:
placeholders = ','.join('?' for _ in stale_ids)
cursor.execute(f"DELETE FROM tracks WHERE id IN ({placeholders})", stale_ids)
stale_removed = len(stale_ids)
# 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)
""", (db_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
@ -15401,19 +15433,23 @@ def sync_artist_library(artist_id):
conn.commit()
print(f"[Artist Sync] {artist_name}: {valid_tracks} valid, {len(stale_tracks)} stale removed, {empty_albums_removed} empty albums cleaned")
print(f"[Artist Sync] {artist_name}: +{new_albums} albums, +{new_tracks} tracks, "
f"-{stale_removed} stale, -{empty_albums_removed} empty albums")
return jsonify({
"success": True,
"artist_name": artist_name,
"name_updated": name_updated,
"valid_tracks": valid_tracks,
"stale_removed": len(stale_tracks),
"empty_albums_removed": empty_albums_removed
})
return jsonify({
"success": True,
"artist_name": artist_name,
"name_updated": name_updated,
"new_albums": new_albums,
"new_tracks": new_tracks,
"stale_removed": stale_removed,
"empty_albums_removed": empty_albums_removed,
})
except Exception as e:
print(f"Error syncing artist {artist_id}: {e}")
import traceback
traceback.print_exc()
return jsonify({"success": False, "error": str(e)}), 500
@app.route('/api/library/album/<album_id>', methods=['DELETE'])

@ -45808,9 +45808,12 @@ function renderArtistMetaPanel(artist) {
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.new_albums > 0) parts.push(`+${data.new_albums} albums`);
if (data.new_tracks > 0) parts.push(`+${data.new_tracks} tracks`);
if (data.stale_removed > 0) parts.push(`${data.stale_removed} stale 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');
if (data.name_updated) parts.push('name updated');
if (parts.length === 0) parts.push('Already in sync');
showToast(`${data.artist_name}: ${parts.join(', ')}`, 'success');
// Refresh enhanced view if anything changed
if (data.stale_removed > 0 || data.empty_albums_removed > 0) {
@ -64014,6 +64017,7 @@ async function loadRepairJobs() {
<div class="repair-job-status ${dotClass}"></div>
<div class="repair-job-info">
<div class="repair-job-name">${job.display_name}</div>
<div class="repair-job-desc">${job.description || ''}</div>
<div class="repair-job-flow">${flowParts.join('')}</div>
<div class="repair-job-meta">${metaParts.join(' &middot; ')}</div>
</div>

@ -49359,66 +49359,92 @@ tr.tag-diff-same {
/* ── Jobs Tab ── */
.repair-jobs-list {
display: flex;
flex-direction: column;
gap: 8px;
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 10px;
}
@media (max-width: 900px) { .repair-jobs-list { grid-template-columns: 1fr; } }
.repair-job-card {
background: rgba(22, 22, 22, 0.95);
background: linear-gradient(135deg, rgba(22, 22, 22, 0.95), rgba(16, 16, 16, 0.98));
border: 1px solid rgba(255, 255, 255, 0.07);
border-radius: 10px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
overflow: hidden;
transition: all 0.2s ease;
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
}
.repair-job-card::before {
content: '';
position: absolute;
top: 0; left: 0; right: 0;
height: 2px;
background: linear-gradient(90deg, transparent, rgba(var(--accent-rgb), 0.3), transparent);
opacity: 0;
transition: opacity 0.3s;
}
.repair-job-card:hover {
background: rgba(28, 28, 28, 0.98);
border-color: rgba(var(--accent-rgb, 99, 102, 241), 0.2);
background: linear-gradient(135deg, rgba(28, 28, 28, 0.98), rgba(20, 20, 20, 0.99));
border-color: rgba(var(--accent-rgb), 0.15);
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
.repair-job-card:hover::before { opacity: 1; }
.repair-job-card.running {
border-color: rgba(var(--accent-rgb, 99, 102, 241), 0.35);
box-shadow: 0 0 12px rgba(var(--accent-rgb, 99, 102, 241), 0.08),
inset 0 0 20px rgba(var(--accent-rgb, 99, 102, 241), 0.03);
border-color: rgba(var(--accent-rgb), 0.3);
box-shadow: 0 0 16px rgba(var(--accent-rgb), 0.1);
}
.repair-job-card.running::before {
opacity: 1;
height: 3px;
background: linear-gradient(90deg, transparent, rgba(var(--accent-rgb), 0.6), transparent);
animation: scanPulse 2s ease-in-out infinite;
}
.repair-job-card.disabled {
opacity: 0.55;
opacity: 0.5;
}
.repair-job-card.disabled:hover {
opacity: 0.75;
opacity: 0.7;
transform: none;
}
.repair-job-main {
display: flex;
align-items: center;
padding: 10px 14px;
gap: 10px;
align-items: flex-start;
padding: 14px 16px;
gap: 12px;
}
/* Status dot — matches automation-status */
/* Status dot */
.repair-job-status {
width: 8px;
height: 8px;
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
margin-top: 4px;
}
.repair-job-status.enabled {
background: #4ade80;
box-shadow: 0 0 6px rgba(74, 222, 128, 0.4);
box-shadow: 0 0 8px rgba(74, 222, 128, 0.4);
}
.repair-job-status.disabled {
background: #555;
background: #444;
}
.repair-job-status.running {
background: rgb(var(--accent-rgb, 99, 102, 241));
box-shadow: 0 0 6px rgba(var(--accent-rgb, 99, 102, 241), 0.4);
background: rgb(var(--accent-rgb));
box-shadow: 0 0 8px rgba(var(--accent-rgb), 0.5);
animation: repair-status-pulse 1.5s ease-in-out infinite;
}
@keyframes repair-status-pulse {
0%, 100% { opacity: 1; box-shadow: 0 0 6px rgba(var(--accent-rgb, 99, 102, 241), 0.4); }
50% { opacity: 0.4; box-shadow: 0 0 2px rgba(var(--accent-rgb, 99, 102, 241), 0.2); }
0%, 100% { opacity: 1; box-shadow: 0 0 8px rgba(var(--accent-rgb), 0.5); }
50% { opacity: 0.4; box-shadow: 0 0 3px rgba(var(--accent-rgb), 0.2); }
}
.repair-job-info {
@ -49426,16 +49452,24 @@ tr.tag-diff-same {
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
gap: 4px;
}
.repair-job-name {
font-size: 13px;
font-weight: 600;
font-size: 14px;
font-weight: 700;
color: #fff;
white-space: nowrap;
letter-spacing: -0.2px;
}
.repair-job-desc {
font-size: 11px;
color: rgba(255, 255, 255, 0.35);
line-height: 1.3;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
}
/* Flow visualization — job type badges */
@ -49484,8 +49518,9 @@ tr.tag-diff-same {
.repair-job-actions {
display: flex;
align-items: center;
gap: 4px;
gap: 5px;
flex-shrink: 0;
margin-left: auto;
}
/* Toggle — matches automation-toggle (32x18) */

Loading…
Cancel
Save