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.
This commit is contained in:
parent
876c5665ad
commit
a867bba18f
3 changed files with 166 additions and 91 deletions
152
web_server.py
152
web_server.py
|
|
@ -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:
|
||||
try:
|
||||
server_artist = None
|
||||
if server_source == 'plex' and plex_client and plex_client.server:
|
||||
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))
|
||||
# ── Phase 1: Pull new content from media server ──
|
||||
new_albums = 0
|
||||
new_tracks = 0
|
||||
name_updated = False
|
||||
|
||||
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))
|
||||
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
|
||||
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 = media_client.get_artist_by_id(str(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'])
|
||||
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 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)
|
||||
""", (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(' · ')}</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…
Reference in a new issue