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 @@
    - +
    No track
    Unknown Artist
    diff --git a/webui/static/script.js b/webui/static/script.js index 2ceec24c..bb6c3b25 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -4284,9 +4284,9 @@ function updateNpTrackInfo() { if (artUrl) { sidebarArt.src = artUrl; sidebarArt.style.display = ''; - sidebarArt.onerror = () => { sidebarArt.src = ''; }; + sidebarArt.onerror = () => { sidebarArt.src = '/static/trans2.png'; }; } else { - sidebarArt.src = ''; + sidebarArt.src = '/static/trans2.png'; } } @@ -4332,7 +4332,7 @@ function updateNpTrackInfo() { artistEl.textContent = 'Unknown Artist'; albumEl.textContent = 'Unknown Album'; if (artImg) artImg.classList.add('hidden'); - if (sidebarArt) sidebarArt.src = ''; + if (sidebarArt) sidebarArt.src = '/static/trans2.png'; if (badgesEl) badgesEl.innerHTML = ''; if (actionBtns) actionBtns.classList.add('hidden'); npResetAmbientGlow(); From 959bca2b8d1e478d71095d986cf2a71e9606ef03 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Thu, 9 Apr 2026 18:20:29 -0700 Subject: [PATCH 6/7] Add Run Script action to automation engine New automation action that executes user scripts from a dedicated scripts/ directory. Available as both a DO action and THEN action. Scripts are selected from a dropdown populated by /api/scripts. Security: only scripts in the scripts dir can run, path traversal blocked, no shell=True, stdout/stderr capped, configurable timeout (max 300s). Scripts receive SOULSYNC_EVENT, SOULSYNC_AUTOMATION, and SOULSYNC_SCRIPTS_DIR environment variables. Includes Dockerfile + docker-compose.yml changes for the scripts volume mount, and three example scripts (hello_world.sh, system_info.py, notify_ntfy.sh). --- Dockerfile | 4 +- config/settings.py | 4 ++ core/automation_engine.py | 8 +++ docker-compose.yml | 1 + scripts/hello_world.sh | 6 +++ scripts/notify_ntfy.sh | 12 +++++ scripts/system_info.py | 17 ++++++ web_server.py | 105 ++++++++++++++++++++++++++++++++++++++ webui/static/script.js | 37 +++++++++++++- 9 files changed, 191 insertions(+), 3 deletions(-) create mode 100644 scripts/hello_world.sh create mode 100644 scripts/notify_ntfy.sh create mode 100644 scripts/system_info.py diff --git a/Dockerfile b/Dockerfile index 1b90257f..a8c246b9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -35,7 +35,7 @@ COPY . . # Create necessary directories with proper permissions # NOTE: /app/data is for database FILES, /app/database is the Python package -RUN mkdir -p /app/config /app/data /app/logs /app/downloads /app/Transfer && \ +RUN mkdir -p /app/config /app/data /app/logs /app/downloads /app/Transfer /app/scripts && \ chown -R soulsync:soulsync /app # Create defaults directory and copy template files @@ -47,7 +47,7 @@ RUN mkdir -p /defaults && \ # Create volume mount points # NOTE: Changed /app/database to /app/data to avoid overwriting Python package -VOLUME ["/app/config", "/app/data", "/app/logs", "/app/downloads", "/app/Transfer"] +VOLUME ["/app/config", "/app/data", "/app/logs", "/app/downloads", "/app/Transfer", "/app/scripts"] # Copy and set up entrypoint script COPY entrypoint.sh /entrypoint.sh diff --git a/config/settings.py b/config/settings.py index 6820b9fc..85272c93 100644 --- a/config/settings.py +++ b/config/settings.py @@ -463,6 +463,10 @@ class ConfigManager: "library": { "music_paths": [] }, + "scripts": { + "path": "./scripts", + "timeout": 60 + }, "import": { "staging_path": "./Staging", "replace_lower_quality": False diff --git a/core/automation_engine.py b/core/automation_engine.py index 48a77715..9964e474 100644 --- a/core/automation_engine.py +++ b/core/automation_engine.py @@ -846,6 +846,14 @@ class AutomationEngine: emit_data['signal_name'] = sig logger.info(f"Automation '{automation.get('name')}' firing signal: {sig} (depth={chain_depth + 1})") self.emit('signal:' + sig, emit_data) + elif t == 'run_script': + handler = self._action_handlers.get('run_script') + if handler: + script_config = dict(c) + # Pass action result as environment context + script_config['_automation_name'] = automation.get('name', '') + script_config['_event_data'] = {'type': 'then_action', 'result': {k: str(v) for k, v in action_result.items() if not k.startswith('_')}} + handler['handler'](script_config) except Exception as e: logger.error(f"Then-action '{item.get('type')}' failed for automation {automation.get('id')}: {e}") diff --git a/docker-compose.yml b/docker-compose.yml index 9b4327b2..15f0a61d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -30,6 +30,7 @@ services: - ./logs:/app/logs - ./downloads:/app/downloads - ./Staging:/app/Staging + - ./scripts:/app/scripts # Use named volume for database persistence (separate from host database) # NOTE: Changed from /app/database to /app/data to avoid overwriting Python package - soulsync_database:/app/data diff --git a/scripts/hello_world.sh b/scripts/hello_world.sh new file mode 100644 index 00000000..20d41224 --- /dev/null +++ b/scripts/hello_world.sh @@ -0,0 +1,6 @@ +#!/bin/bash +# Simple test script — verifies script execution is working +echo "Hello from SoulSync Scripts!" +echo "Automation: $SOULSYNC_AUTOMATION" +echo "Event: $SOULSYNC_EVENT" +echo "Time: $(date)" diff --git a/scripts/notify_ntfy.sh b/scripts/notify_ntfy.sh new file mode 100644 index 00000000..b7c47440 --- /dev/null +++ b/scripts/notify_ntfy.sh @@ -0,0 +1,12 @@ +#!/bin/bash +# Send a notification via ntfy.sh (self-hosted or public) +# Configure: set NTFY_URL and NTFY_TOPIC environment variables +# Example: NTFY_URL=https://ntfy.sh NTFY_TOPIC=soulsync + +NTFY_URL="${NTFY_URL:-https://ntfy.sh}" +NTFY_TOPIC="${NTFY_TOPIC:-soulsync}" + +curl -s -d "SoulSync automation '${SOULSYNC_AUTOMATION}' completed" \ + "${NTFY_URL}/${NTFY_TOPIC}" > /dev/null 2>&1 + +echo "Notification sent to ${NTFY_URL}/${NTFY_TOPIC}" diff --git a/scripts/system_info.py b/scripts/system_info.py new file mode 100644 index 00000000..d3229caf --- /dev/null +++ b/scripts/system_info.py @@ -0,0 +1,17 @@ +#!/usr/bin/env python3 +"""Reports basic system info — useful for debugging Docker setups.""" +import os +import platform +import shutil + +print(f"Platform: {platform.system()} {platform.release()}") +print(f"Python: {platform.python_version()}") +print(f"Working Dir: {os.getcwd()}") + +# Disk usage for common SoulSync paths +for path in ['/app/downloads', '/app/Transfer', '/app/data', './downloads', './Transfer']: + if os.path.exists(path): + usage = shutil.disk_usage(path) + free_gb = usage.free / (1024**3) + total_gb = usage.total / (1024**3) + print(f"Disk {path}: {free_gb:.1f} GB free / {total_gb:.1f} GB total") diff --git a/web_server.py b/web_server.py index 6b4d5356..1b519105 100644 --- a/web_server.py +++ b/web_server.py @@ -1737,6 +1737,79 @@ def _register_automation_handlers(): '_manages_own_progress': True, } + def _auto_run_script(config): + """Execute a user script from the scripts directory.""" + import subprocess as _sp + script_name = config.get('script_name', '') + timeout = min(int(config.get('timeout', 60)), 300) + automation_id = config.get('_automation_id') + + if not script_name: + return {'status': 'error', 'error': 'No script selected'} + + scripts_dir = docker_resolve_path(config_manager.get('scripts.path', './scripts')) + if not scripts_dir or not os.path.isdir(scripts_dir): + os.makedirs(scripts_dir, exist_ok=True) + return {'status': 'error', 'error': 'Scripts directory is empty. Add scripts to the scripts/ folder.'} + + script_path = os.path.join(scripts_dir, script_name) + script_path = os.path.realpath(script_path) + + # Security: block path traversal + if not script_path.startswith(os.path.realpath(scripts_dir)): + return {'status': 'error', 'error': 'Script path traversal blocked'} + + if not os.path.isfile(script_path): + return {'status': 'error', 'error': f'Script not found: {script_name}'} + + _update_automation_progress(automation_id, phase=f'Running {script_name}...', progress=10) + + # Build environment with SoulSync context + env = os.environ.copy() + event_data = config.get('_event_data') or {} + env['SOULSYNC_EVENT'] = str(event_data.get('type', '')) + env['SOULSYNC_AUTOMATION'] = config.get('_automation_name', '') + env['SOULSYNC_SCRIPTS_DIR'] = scripts_dir + + try: + # Determine how to run the script + if script_path.endswith('.py'): + cmd = ['python', script_path] + elif script_path.endswith('.sh'): + cmd = ['bash', script_path] + else: + cmd = [script_path] + + result = _sp.run( + cmd, + capture_output=True, text=True, timeout=timeout, + cwd=scripts_dir, env=env + ) + + _update_automation_progress(automation_id, phase='Script completed', progress=100) + + stdout = result.stdout[:2000] if result.stdout else '' + stderr = result.stderr[:1000] if result.stderr else '' + + if result.returncode == 0: + logger.info(f"Script '{script_name}' completed (exit 0)") + else: + logger.warning(f"Script '{script_name}' exited with code {result.returncode}") + + return { + 'status': 'completed' if result.returncode == 0 else 'error', + 'exit_code': str(result.returncode), + 'stdout': stdout, + 'stderr': stderr, + 'script': script_name, + } + except _sp.TimeoutExpired: + _update_automation_progress(automation_id, phase='Script timed out', progress=100) + return {'status': 'error', 'error': f'Script timed out after {timeout}s', 'script': script_name} + except Exception as e: + return {'status': 'error', 'error': str(e), 'script': script_name} + + automation_engine.register_action_handler('run_script', _auto_run_script) automation_engine.register_action_handler('full_cleanup', _auto_full_cleanup) automation_engine.register_action_handler('start_database_update', _auto_start_database_update, @@ -5789,6 +5862,30 @@ def _collect_known_signals(): pass return sorted(signals) +@app.route('/api/scripts', methods=['GET']) +def list_available_scripts(): + """List executable scripts in the scripts directory.""" + try: + scripts_dir = docker_resolve_path(config_manager.get('scripts.path', './scripts')) + if not scripts_dir or not os.path.isdir(scripts_dir): + return jsonify({'scripts': []}) + + allowed_ext = {'.sh', '.py', '.bat', '.ps1', '.rb', '.pl', '.js'} + scripts = [] + for fname in sorted(os.listdir(scripts_dir)): + ext = os.path.splitext(fname)[1].lower() + fpath = os.path.join(scripts_dir, fname) + if os.path.isfile(fpath) and (ext in allowed_ext or os.access(fpath, os.X_OK)): + scripts.append({ + 'name': fname, + 'extension': ext, + 'size': os.path.getsize(fpath), + }) + return jsonify({'scripts': scripts}) + except Exception as e: + return jsonify({'scripts': [], 'error': str(e)}) + + @app.route('/api/automations/blocks', methods=['GET']) def get_automation_blocks(): """Return available block types for the automation builder sidebar.""" @@ -5953,6 +6050,8 @@ def get_automation_blocks(): "description": "Clear quarantine, download queue, staging folder, and search history in one sweep", "available": True}, {"type": "deep_scan_library", "label": "Deep Scan Library", "icon": "search", "description": "Full library comparison without losing enrichment data", "available": True}, + {"type": "run_script", "label": "Run Script", "icon": "terminal", + "description": "Execute a script from the scripts folder", "available": True}, ], "notifications": [ {"type": "discord_webhook", "label": "Discord Webhook", "icon": "message", "description": "Send a Discord notification", "available": True, @@ -5969,6 +6068,12 @@ def get_automation_blocks(): "config_fields": [ {"key": "signal_name", "type": "signal_input", "label": "Signal Name"} ]}, + # Run script then-action + {"type": "run_script", "label": "Run Script", "icon": "terminal", + "description": "Execute a script after the action completes", "available": True, + "config_fields": [ + {"key": "script_name", "type": "script_select", "label": "Script"} + ]}, ], "known_signals": _collect_known_signals(), }) diff --git a/webui/static/script.js b/webui/static/script.js index bb6c3b25..c016253d 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -66398,7 +66398,7 @@ const _autoIcons = { scan_library: '\uD83D\uDD04', refresh_mirrored: '\uD83D\uDCC2', sync_playlist: '\uD83D\uDD01', discover_playlist: '\uD83D\uDD0D', discovery_completed: '\uD83D\uDD0D', notify_only: '\uD83D\uDD14', discord_webhook: '\uD83D\uDCAC', pushbullet: '\uD83D\uDD14', telegram: '\u2709\uFE0F', webhook: '\uD83C\uDF10', - signal_received: '\u26A1', fire_signal: '\u26A1', + signal_received: '\u26A1', fire_signal: '\u26A1', run_script: '\uD83D\uDCBB', // Phase 3 wishlist_processing_completed: '\u2705', watchlist_scan_completed: '\u2705', database_update_completed: '\uD83D\uDDC4\uFE0F', download_failed: '\u274C', @@ -67662,6 +67662,7 @@ function _autoFormatNotify(type) { if (type === 'pushbullet') return 'Pushbullet'; if (type === 'telegram') return 'Telegram'; if (type === 'fire_signal') return '\u26A1 Signal'; + if (type === 'run_script') return '\uD83D\uDCBB Script'; return type || ''; } function _autoParseUTC(ts) { @@ -68342,6 +68343,34 @@ function _renderBlockConfigFields(slotKey, blockType, config) {
    Other automations with "Signal Received" trigger will wake up
    `; } + if (blockType === 'run_script') { + const scriptName = _escAttr(config.script_name || ''); + const timeout = config.timeout || 60; + // Fetch scripts list and populate + const selectId = `cfg-${slotKey}-script_name`; + setTimeout(async () => { + try { + const resp = await fetch('/api/scripts'); + const data = await resp.json(); + const sel = document.getElementById(selectId); + if (sel && data.scripts) { + sel.innerHTML = '' + + data.scripts.map(s => ``).join(''); + } + } catch (e) { console.warn('Failed to load scripts:', e); } + }, 100); + return `
    + + +
    +
    + + seconds +
    +
    Place scripts in the scripts/ folder. Supported: .sh, .py, .bat, .ps1
    `; + } if (blockType === 'scan_watchlist' || blockType === 'scan_library' || blockType === 'notify_only') { return '
    No configuration needed
    '; } @@ -68701,6 +68730,12 @@ function _readPlacedConfig(slotKey) { if (type === 'signal_received' || type === 'fire_signal') { return { signal_name: document.getElementById('cfg-' + slotKey + '-signal_name')?.value?.trim() || '' }; } + if (type === 'run_script') { + return { + script_name: document.getElementById('cfg-' + slotKey + '-script_name')?.value || '', + timeout: parseInt(document.getElementById('cfg-' + slotKey + '-timeout')?.value || '60') || 60, + }; + } if (type === 'discord_webhook') { return { webhook_url: document.getElementById('cfg-' + slotKey + '-webhook_url')?.value?.trim() || '', From d9e2e129b0fee34e0cd44e7462fa529a7dcd8dba Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Thu, 9 Apr 2026 18:46:49 -0700 Subject: [PATCH 7/7] Allow selecting which duplicate to keep in duplicate detector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Duplicate finding detail now shows each version as clickable — user can choose which to keep instead of relying on auto-selection. Added track_number as tiebreaker in auto-pick (higher track number wins over 01, catching leftover duplicates from the playlist sync track number bug). Track number displayed in the detail view for clarity. --- core/repair_worker.py | 17 +++++++++++++---- webui/static/script.js | 39 +++++++++++++++++++++++++++++++++------ 2 files changed, 46 insertions(+), 10 deletions(-) diff --git a/core/repair_worker.py b/core/repair_worker.py index 0d2fc573..2b75f763 100644 --- a/core/repair_worker.py +++ b/core/repair_worker.py @@ -1174,14 +1174,23 @@ class RepairWorker: conn.close() def _fix_duplicates(self, entity_type, entity_id, file_path, details): - """Keep the best quality duplicate and remove the rest from the database.""" + """Keep the selected or best quality duplicate and remove the rest from the database.""" tracks = details.get('tracks', []) if len(tracks) < 2: return {'success': False, 'error': 'Not enough duplicate info to determine best copy'} - # Pick best: highest bitrate, then longest duration - best = max(tracks, key=lambda t: (t.get('bitrate', 0) or 0, t.get('duration', 0) or 0)) - best_id = best.get('track_id') or best.get('id') + # If user specified which track to keep, use that + keep_id = details.get('_fix_action') + if keep_id: + best = next((t for t in tracks if str(t.get('track_id') or t.get('id')) == str(keep_id)), None) + if not best: + return {'success': False, 'error': f'Selected track ID {keep_id} not found in duplicates'} + best_id = keep_id + else: + # Auto-pick: highest bitrate, then longest duration, then highest track number (correct > 01) + best = max(tracks, key=lambda t: (t.get('bitrate', 0) or 0, t.get('duration', 0) or 0, t.get('track_number', 0) or 0)) + best_id = best.get('track_id') or best.get('id') + if not best_id: return {'success': False, 'error': 'Could not determine best track ID'} diff --git a/webui/static/script.js b/webui/static/script.js index c016253d..cf3a08b9 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -62757,23 +62757,27 @@ function _renderFindingDetail(f) { case 'duplicate_tracks': if (!d.tracks || !d.tracks.length) return _gridRows([['Count', d.count || '?']]); - // Determine best copy (same logic as backend: highest bitrate, then duration) + // Determine best copy (same logic as backend: highest bitrate, then duration, then track number) const bestDup = d.tracks.reduce((best, t) => { const bBr = best.bitrate || 0, tBr = t.bitrate || 0; const bDur = best.duration || 0, tDur = t.duration || 0; - return (tBr > bBr || (tBr === bBr && tDur > bDur)) ? t : best; + const bTn = best.track_number || 0, tTn = t.track_number || 0; + return (tBr > bBr || (tBr === bBr && tDur > bDur) || (tBr === bBr && tDur === bDur && tTn > bTn)) ? t : best; }, d.tracks[0]); + const findingId = f.id; return media + `
    ${d.tracks.map((t, i) => { - const isBest = t.id === bestDup.id; - return `
    + const tid = t.track_id || t.id; + const isBest = (t.id === bestDup.id); + return `
    ${isBest ? 'KEEP' : 'REMOVE'} ${_escFinding(t.title)} by ${_escFinding(t.artist)} - Album: ${_escFinding(t.album || 'Unknown')}${t.bitrate ? ` · ${t.bitrate} kbps` : ''}${t.duration ? ` · ${Math.round(t.duration)}s` : ''} + Album: ${_escFinding(t.album || 'Unknown')}${t.bitrate ? ` · ${t.bitrate} kbps` : ''}${t.duration ? ` · ${Math.round(t.duration)}s` : ''}${t.track_number ? ` · Track #${t.track_number}` : ''} ${t.file_path ? `${_escFinding(t.file_path)}` : ''}
    `; - }).join('')}
    `; + }).join('')}
    +
    Click on a version to keep it, or use "Keep Best" for auto-selection
    `; case 'incomplete_album': if (d.artist) rows.push(['Artist', d.artist]); @@ -63048,6 +63052,29 @@ function renderRepairFindingsPagination(total, currentPage) { container.innerHTML = html; } +async function selectDuplicateToKeep(findingId, keepTrackId) { + if (!await showConfirmDialog({ title: 'Keep This Version', message: 'Keep this version and remove the other duplicate(s)?', confirmText: 'Keep', destructive: true })) return; + try { + const response = await fetch(`/api/repair/findings/${findingId}/fix`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ fix_action: keepTrackId }), + }); + const result = await response.json(); + if (result.success) { + showToast(result.message || 'Duplicate resolved', 'success'); + } else { + showToast(result.error || 'Failed to resolve duplicate', 'error'); + } + loadRepairFindingsDashboard(); + loadRepairFindings(); + updateRepairStatus(); + } catch (error) { + console.error('Error fixing duplicate:', error); + showToast('Error resolving duplicate', 'error'); + } +} + async function fixRepairFinding(id, findingType) { // Orphan files require user to choose an action let fixAction = null;