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 @@
scripts/ folder. Supported: .sh, .py, .bat, .ps1