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/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/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/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 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/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 fbf56eb2..747b3f47 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(), }) @@ -21055,7 +21160,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" ] }, { @@ -36139,7 +36247,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 @@ -36474,6 +36582,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) @@ -36572,10 +36692,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") @@ -36592,7 +36713,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/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/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.
  • diff --git a/webui/static/script.js b/webui/static/script.js index b8a9bfb6..cf3a08b9 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(); @@ -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; @@ -15958,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 || '' }) }); @@ -26570,6 +26596,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'); @@ -62710,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]); @@ -62894,9 +62945,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 @@ -62998,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; @@ -66348,7 +66425,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', @@ -67612,6 +67689,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) { @@ -68292,6 +68370,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
    '; } @@ -68651,6 +68757,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() || '',