From 4178c1eb56621b0cddbbc86eaa90f78fd2d195b4 Mon Sep 17 00:00:00 2001
From: Broque Thomas <26755000+Nezreka@users.noreply.github.com>
Date: Thu, 9 Apr 2026 09:38:13 -0700
Subject: [PATCH 1/7] Fix Deezer ARL sync/download rehydration and add album
data caching
Sync rehydration: after loading Deezer ARL playlists, checks each
for active syncs via /api/sync/status and re-attaches polling with
live card updates. Download rehydration: rehydrateModal now handles
deezer_arl_ playlist IDs, and openDownloadMissingModal routes cache
misses to the correct ARL endpoint. Fix All now prompts for dead
file action.
Album data caching: get_playlist_tracks now checks the metadata
cache before fetching album release dates from the Deezer API.
Cache hits are instant, misses are fetched and stored for future
use across all playlists. Import fixed from core.metadata_cache
instead of web_server to avoid circular dependency.
---
core/deezer_download_client.py | 23 +++++++++++++-
webui/static/script.js | 57 +++++++++++++++++++++++++++++++---
2 files changed, 75 insertions(+), 5 deletions(-)
diff --git a/core/deezer_download_client.py b/core/deezer_download_client.py
index f20a963d..82d7dce4 100644
--- a/core/deezer_download_client.py
+++ b/core/deezer_download_client.py
@@ -315,20 +315,41 @@ class DeezerDownloadClient:
break
raw_tracks.extend(page_tracks)
- # Batch-fetch release dates for unique albums
+ # Batch-fetch release dates for unique albums (cache-first)
album_ids = set()
for t in raw_tracks:
aid = t.get('album', {}).get('id')
if aid:
album_ids.add(str(aid))
album_release_dates = {}
+ try:
+ from core.metadata_cache import get_metadata_cache
+ cache = get_metadata_cache()
+ except Exception:
+ cache = None
for aid in album_ids:
+ # Check metadata cache first
+ if cache:
+ try:
+ cached = cache.get_entity('deezer', 'album', aid)
+ if cached and cached.get('release_date'):
+ album_release_dates[aid] = cached['release_date']
+ continue
+ except Exception:
+ pass
+ # Cache miss — fetch from API
try:
time.sleep(0.3) # Respect rate limits
a_resp = self._session.get(f'https://api.deezer.com/album/{aid}', timeout=10)
if a_resp.ok:
a_data = a_resp.json()
album_release_dates[aid] = a_data.get('release_date', '')
+ # Store in metadata cache for future use
+ if cache:
+ try:
+ cache.store_entity('deezer', 'album', aid, a_data)
+ except Exception:
+ pass
except Exception:
pass
diff --git a/webui/static/script.js b/webui/static/script.js
index b8a9bfb6..3eb63314 100644
--- a/webui/static/script.js
+++ b/webui/static/script.js
@@ -10195,7 +10195,29 @@ async function rehydrateModal(processInfo, userRequested = false) {
return;
}
- // Handle regular Spotify playlist processes
+ // Handle Deezer ARL playlist processes — ensure playlist data is in spotifyPlaylists for modal reuse
+ if (playlist_id.startsWith('deezer_arl_') && !spotifyPlaylists.find(p => p.id === playlist_id)) {
+ const rawId = playlist_id.replace('deezer_arl_', '');
+ const deezerPlaylist = deezerArlPlaylists.find(p => String(p.id) === rawId);
+ if (deezerPlaylist) {
+ spotifyPlaylists.push({
+ id: playlist_id,
+ name: deezerPlaylist.name,
+ track_count: deezerPlaylist.track_count || 0,
+ image_url: deezerPlaylist.image_url || '',
+ owner: deezerPlaylist.owner || '',
+ });
+ } else {
+ // Playlists not loaded yet — use process info as fallback
+ spotifyPlaylists.push({
+ id: playlist_id,
+ name: playlist_name || 'Deezer Playlist',
+ track_count: 0,
+ });
+ }
+ }
+
+ // Handle regular Spotify / Deezer ARL playlist processes
let playlistData = spotifyPlaylists.find(p => p.id === playlist_id);
if (!playlistData) {
console.warn(`Cannot rehydrate modal: Playlist data for ${playlist_id} not loaded.`);
@@ -11707,7 +11729,10 @@ async function openDownloadMissingModal(playlistId) {
let tracks = playlistTrackCache[playlistId];
if (!tracks) {
try {
- const response = await fetch(`/api/spotify/playlist/${playlistId}`);
+ const fetchUrl = playlistId.startsWith('deezer_arl_')
+ ? `/api/deezer/arl-playlist/${playlistId.replace('deezer_arl_', '')}`
+ : `/api/spotify/playlist/${playlistId}`;
+ const response = await fetch(fetchUrl);
const fullPlaylist = await response.json();
if (fullPlaylist.error) throw new Error(fullPlaylist.error);
tracks = fullPlaylist.tracks;
@@ -26570,6 +26595,27 @@ async function loadDeezerArlPlaylists() {
renderDeezerArlPlaylists();
deezerArlPlaylistsLoaded = true;
+ // Check for active syncs or downloads and rehydrate UI
+ await checkForActiveProcesses();
+ for (const p of deezerArlPlaylists) {
+ const arlId = `deezer_arl_${p.id}`;
+ try {
+ const syncResp = await fetch(`/api/sync/status/${arlId}`);
+ if (syncResp.ok) {
+ const syncState = await syncResp.json();
+ if (syncState.status === 'syncing') {
+ // Re-attach sync polling and update card UI
+ if (!spotifyPlaylists.find(sp => sp.id === arlId)) {
+ spotifyPlaylists.push({ id: arlId, name: p.name, track_count: p.track_count || 0, image_url: p.image_url || '', owner: p.owner || '' });
+ }
+ updateCardToSyncing(arlId, syncState.progress?.progress || 0, syncState.progress);
+ startSyncPolling(arlId);
+ console.log(`🔄 Rehydrated active sync for Deezer ARL playlist: ${p.name}`);
+ }
+ }
+ } catch (e) { /* No active sync — normal */ }
+ }
+
} catch (error) {
container.innerHTML = `
❌ Error: ${error.message}
`;
showToast(`Error loading Deezer playlists: ${error.message}`, 'error');
@@ -62894,9 +62940,12 @@ async function fixAllMatchingFindings() {
const jobId = jobFilter ? jobFilter.value : '';
const severity = severityFilter ? severityFilter.value : '';
- // If fixing orphan files, prompt for action FIRST (staging vs delete)
+ // If fixing orphan files or dead files, prompt for action FIRST
let fixAction = null;
- if (jobId === 'orphan_file_detector' || _isMassOrphanFix(jobId, _repairFindingsTotal)) {
+ if (jobId === 'dead_file_cleaner') {
+ fixAction = await _promptDeadFileAction();
+ if (!fixAction) return;
+ } else if (jobId === 'orphan_file_detector' || _isMassOrphanFix(jobId, _repairFindingsTotal)) {
fixAction = await _promptOrphanAction();
if (!fixAction) return;
// Confirm before proceeding
From 1e69d813e6a4cbcf96111a256f1eca326fee890b Mon Sep 17 00:00:00 2001
From: Broque Thomas <26755000+Nezreka@users.noreply.github.com>
Date: Thu, 9 Apr 2026 09:44:15 -0700
Subject: [PATCH 2/7] Update What's New and Help docs with recent changes
Added dead file fix options, Deezer ARL rehydration, and album
data caching to the Fixes & Improvements section and Help docs.
---
web_server.py | 5 ++++-
webui/static/docs.js | 1 +
2 files changed, 5 insertions(+), 1 deletion(-)
diff --git a/web_server.py b/web_server.py
index 4dfa8b01..163cc1ed 100644
--- a/web_server.py
+++ b/web_server.py
@@ -21040,7 +21040,10 @@ def get_version_info():
"• Replace lower quality files on import — opt-in toggle in Settings > Library",
"• HiFi API instance health check in Settings > Downloads",
"• Debug test activity feed message removed from startup",
- "• Global search downloads now create bubble snapshots on Dashboard and Search page"
+ "• Global search downloads now create bubble snapshots on Dashboard and Search page",
+ "• Dead file findings now offer 'Remove from DB' option alongside 'Re-download' — works in bulk fix too",
+ "• Deezer ARL sync and download modals rehydrate after page refresh",
+ "• Deezer album data (release dates, cover art) cached in metadata cache — subsequent playlist loads are near-instant"
]
},
{
diff --git a/webui/static/docs.js b/webui/static/docs.js
index 59c1ba58..a1ba1ded 100644
--- a/webui/static/docs.js
+++ b/webui/static/docs.js
@@ -1463,6 +1463,7 @@ const DOCS_SECTIONS = [
Music Library Paths — In Settings > Library, add folder paths where your music files live. Required for tag writing, streaming, and file detection when your media server stores files at a different path than SoulSync can see. Docker users: mount your music folder(s) with read-write access, then add the container-side path.
Replace Lower Quality on Import — Opt-in toggle in Settings > Library. When importing from Staging, if a track already exists at lower quality (e.g. MP3), it gets replaced with the higher quality version (e.g. FLAC). Disabled by default.
HiFi Instance Health — In Settings > Downloads > HiFi, click "Check All Instances" to see which community API instances are online, searchable, or able to download.
+ Dead File Fix Options — Dead file findings in Library Maintenance now prompt with two choices: "Re-download" (adds to wishlist) or "Remove from DB" (just deletes the stale record). Works for single and bulk fix.
From 963a003ca0c590d7ef7fe635984d797d134008d4 Mon Sep 17 00:00:00 2001
From: Broque Thomas <26755000+Nezreka@users.noreply.github.com>
Date: Thu, 9 Apr 2026 10:08:18 -0700
Subject: [PATCH 3/7] Set playlist poster image on Plex/Jellyfin/Emby after
sync
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
After a successful playlist sync, if the source playlist has cover
art (Spotify, Tidal, Deezer, etc.), the image is downloaded and
uploaded as the playlist poster on the media server. Plex uses
uploadPoster(), Jellyfin/Emby uses POST /Items/{id}/Images/Primary.
Navidrome skipped (no playlist image API). Failure is silent — sync
result unchanged. Automation-triggered syncs and playlists without
images are unaffected.
---
core/jellyfin_client.py | 31 +++++++++++++++++++++++++++++++
core/plex_client.py | 16 ++++++++++++++++
web_server.py | 19 ++++++++++++++++---
webui/static/script.js | 3 ++-
4 files changed, 65 insertions(+), 4 deletions(-)
diff --git a/core/jellyfin_client.py b/core/jellyfin_client.py
index e51d3d56..f46b24b7 100644
--- a/core/jellyfin_client.py
+++ b/core/jellyfin_client.py
@@ -1496,6 +1496,37 @@ class JellyfinClient:
logger.error(f"Error getting tracks for playlist {playlist_id}: {e}")
return []
+ def set_playlist_image(self, playlist_name: str, image_url: str) -> bool:
+ """Set the poster image for a playlist by downloading from a URL."""
+ if not self.ensure_connection() or not image_url:
+ return False
+ try:
+ playlist = self.get_playlist_by_name(playlist_name)
+ if not playlist:
+ return False
+ playlist_id = playlist.get('Id') or playlist.get('id')
+ if not playlist_id:
+ return False
+ import requests as _req
+ img_resp = _req.get(image_url, timeout=15)
+ if img_resp.ok and img_resp.content:
+ content_type = img_resp.headers.get('Content-Type', 'image/jpeg')
+ upload_url = f"{self.base_url}/Items/{playlist_id}/Images/Primary"
+ upload_resp = _req.post(
+ upload_url,
+ headers={'X-Emby-Token': self.api_key, 'Content-Type': content_type},
+ data=img_resp.content,
+ timeout=15
+ )
+ if upload_resp.ok:
+ logger.info(f"Set playlist poster for '{playlist_name}'")
+ return True
+ else:
+ logger.debug(f"Playlist image upload returned {upload_resp.status_code}")
+ except Exception as e:
+ logger.debug(f"Could not set playlist poster for '{playlist_name}': {e}")
+ return False
+
def update_playlist(self, playlist_name: str, tracks) -> bool:
"""Update an existing playlist or create it if it doesn't exist"""
if not self.ensure_connection():
diff --git a/core/plex_client.py b/core/plex_client.py
index fd3de9ce..b31d2ae8 100644
--- a/core/plex_client.py
+++ b/core/plex_client.py
@@ -479,6 +479,22 @@ class PlexClient:
logger.error(f"Error updating playlist '{playlist_name}': {e}")
return False
+ def set_playlist_image(self, playlist_name: str, image_url: str) -> bool:
+ """Set the poster image for a playlist by downloading from a URL."""
+ if not self.ensure_connection() or not image_url:
+ return False
+ try:
+ playlist = self.server.playlist(playlist_name)
+ import requests as _req
+ img_resp = _req.get(image_url, timeout=15)
+ if img_resp.ok and img_resp.content:
+ playlist.uploadPoster(data=img_resp.content)
+ logger.info(f"Set playlist poster for '{playlist_name}'")
+ return True
+ except Exception as e:
+ logger.debug(f"Could not set playlist poster for '{playlist_name}': {e}")
+ return False
+
def _find_track(self, title: str, artist: str, album: str) -> Optional[PlexTrack]:
if not self.music_library:
return None
diff --git a/web_server.py b/web_server.py
index 163cc1ed..6b4d5356 100644
--- a/web_server.py
+++ b/web_server.py
@@ -36103,7 +36103,7 @@ def convert_youtube_results_to_spotify_tracks(discovery_results):
# Add these new endpoints to the end of web_server.py
-def _run_sync_task(playlist_id, playlist_name, tracks_json, automation_id=None, profile_id=1):
+def _run_sync_task(playlist_id, playlist_name, tracks_json, automation_id=None, profile_id=1, playlist_image_url=''):
"""The actual sync function that runs in the background thread."""
global sync_states, sync_service
@@ -36438,6 +36438,18 @@ def _run_sync_task(playlist_id, playlist_name, tracks_json, automation_id=None,
}
print(f"🏁 Sync finished for {playlist_id} - state updated")
+ # Set playlist poster image if available (Plex, Jellyfin, Emby)
+ if playlist_image_url and getattr(result, 'synced_tracks', 0) > 0:
+ try:
+ active_server = config_manager.get_active_media_server()
+ if active_server == 'plex' and plex_client:
+ plex_client.set_playlist_image(playlist_name, playlist_image_url)
+ elif active_server in ('jellyfin', 'emby') and jellyfin_client:
+ jellyfin_client.set_playlist_image(playlist_name, playlist_image_url)
+ # Navidrome doesn't support custom playlist images
+ except Exception as img_err:
+ print(f"⚠️ Could not set playlist image: {img_err}")
+
# Record sync history completion with per-track data
try:
matched = getattr(result, 'matched_tracks', 0)
@@ -36536,10 +36548,11 @@ def start_playlist_sync():
playlist_id = data.get('playlist_id')
playlist_name = data.get('playlist_name')
tracks_json = data.get('tracks') # Pass the full track list
+ playlist_image_url = data.get('image_url', '')
if not all([playlist_id, playlist_name, tracks_json]):
return jsonify({"success": False, "error": "Missing playlist_id, name, or tracks."}), 400
-
+
# Add activity for sync start
add_activity_item("🔄", "Spotify Sync Started", f"'{playlist_name}' - {len(tracks_json)} tracks", "Now")
@@ -36556,7 +36569,7 @@ def start_playlist_sync():
# Submit the task to the thread pool (capture profile_id while still in request context)
_sync_profile_id = get_current_profile_id()
thread_submit_time = time.time()
- future = sync_executor.submit(_run_sync_task, playlist_id, playlist_name, tracks_json, None, _sync_profile_id)
+ future = sync_executor.submit(_run_sync_task, playlist_id, playlist_name, tracks_json, None, _sync_profile_id, playlist_image_url)
active_sync_workers[playlist_id] = future
thread_submit_duration = (time.time() - thread_submit_time) * 1000
print(f"⏱️ [TIMING] Thread submitted at {time.strftime('%H:%M:%S')} (took {thread_submit_duration:.1f}ms)")
diff --git a/webui/static/script.js b/webui/static/script.js
index 3eb63314..2ceec24c 100644
--- a/webui/static/script.js
+++ b/webui/static/script.js
@@ -15983,7 +15983,8 @@ async function startPlaylistSync(playlistId) {
body: JSON.stringify({
playlist_id: playlist.id,
playlist_name: playlist.name,
- tracks: tracks // Send the full track list
+ tracks: tracks, // Send the full track list
+ image_url: playlist.image_url || ''
})
});
From 7cfd1cae3fb62d5f1741b0ac384959c374043e49 Mon Sep 17 00:00:00 2001
From: Broque Thomas <26755000+Nezreka@users.noreply.github.com>
Date: Thu, 9 Apr 2026 10:16:53 -0700
Subject: [PATCH 4/7] Fix AcoustID scanner creating thousands of no-match
findings
The scanner was creating a finding for every file that couldn't be
identified by AcoustID, flooding the findings list with non-actionable
entries. Users saw the scanner "stuck scanning the same files over
and over" because the no-match findings were dismissed but recreated
on every run. Now only genuine mismatches (AcoustID identifies a
different track) create findings. Errors are counted and shown in
the job log with actual error messages for debugging.
---
core/repair_jobs/acoustid_scanner.py | 28 +++++++++-------------------
1 file changed, 9 insertions(+), 19 deletions(-)
diff --git a/core/repair_jobs/acoustid_scanner.py b/core/repair_jobs/acoustid_scanner.py
index 438fa2f8..dad6ae60 100644
--- a/core/repair_jobs/acoustid_scanner.py
+++ b/core/repair_jobs/acoustid_scanner.py
@@ -175,33 +175,23 @@ class AcoustIDScannerJob(RepairJob):
fp_result = acoustid_client.fingerprint_and_lookup(fpath)
except Exception as e:
logger.debug("Fingerprint failed for %s: %s", fname, e)
+ result.errors += 1
+ if context.report_progress:
+ context.report_progress(
+ log_line=f'Error: {fname} — {e}',
+ log_type='error'
+ )
return
if not fp_result or not fp_result.get('recordings'):
- # No match — could be a very rare/new track
+ # No match — could be API error, rare track, or invalid key
+ # Don't create findings for "no match" — these flood the list
+ # and are usually not actionable. Only log for visibility.
if context.report_progress:
context.report_progress(
log_line=f'No match: {fname}',
log_type='skip'
)
- if context.create_finding:
- context.create_finding(
- job_id=self.job_id,
- finding_type='acoustid_no_match',
- severity='info',
- entity_type='track',
- entity_id=str(expected.get('track_id') or ''),
- file_path=fpath,
- title=f'No AcoustID match: {fname}',
- description='File could not be identified by AcoustID fingerprint',
- details={
- 'expected_title': expected['title'],
- 'expected_artist': expected['artist'],
- 'album_thumb_url': expected.get('album_thumb_url'),
- 'artist_thumb_url': expected.get('artist_thumb_url'),
- }
- )
- result.findings_created += 1
return
# Check best recording match
From 5ed819a062c434f6d6e4cbb5383113597b4b41f4 Mon Sep 17 00:00:00 2001
From: Broque Thomas <26755000+Nezreka@users.noreply.github.com>
Date: Thu, 9 Apr 2026 10:51:48 -0700
Subject: [PATCH 5/7] Show SoulSync logo as fallback for missing sidebar album
art
Sidebar media player now shows the SoulSync logo instead of a broken
image icon when no album art is available or when no track is playing.
Default src, onerror fallback, and clear-player paths all use
/static/trans2.png.
---
webui/index.html | 2 +-
webui/static/script.js | 6 +++---
2 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/webui/index.html b/webui/index.html
index 8344ca8a..47ec78aa 100644
--- a/webui/index.html
+++ b/webui/index.html
@@ -259,7 +259,7 @@