Merge branch 'Nezreka:main' into main

This commit is contained in:
FelixClements 2026-04-10 11:34:18 +02:00 committed by GitHub
commit 985afb7c2a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 395 additions and 46 deletions

View file

@ -35,7 +35,7 @@ COPY . .
# Create necessary directories with proper permissions # Create necessary directories with proper permissions
# NOTE: /app/data is for database FILES, /app/database is the Python package # 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 chown -R soulsync:soulsync /app
# Create defaults directory and copy template files # Create defaults directory and copy template files
@ -47,7 +47,7 @@ RUN mkdir -p /defaults && \
# Create volume mount points # Create volume mount points
# NOTE: Changed /app/database to /app/data to avoid overwriting Python package # 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 and set up entrypoint script
COPY entrypoint.sh /entrypoint.sh COPY entrypoint.sh /entrypoint.sh

View file

@ -463,6 +463,10 @@ class ConfigManager:
"library": { "library": {
"music_paths": [] "music_paths": []
}, },
"scripts": {
"path": "./scripts",
"timeout": 60
},
"import": { "import": {
"staging_path": "./Staging", "staging_path": "./Staging",
"replace_lower_quality": False "replace_lower_quality": False

View file

@ -846,6 +846,14 @@ class AutomationEngine:
emit_data['signal_name'] = sig emit_data['signal_name'] = sig
logger.info(f"Automation '{automation.get('name')}' firing signal: {sig} (depth={chain_depth + 1})") logger.info(f"Automation '{automation.get('name')}' firing signal: {sig} (depth={chain_depth + 1})")
self.emit('signal:' + sig, emit_data) 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: except Exception as e:
logger.error(f"Then-action '{item.get('type')}' failed for automation {automation.get('id')}: {e}") logger.error(f"Then-action '{item.get('type')}' failed for automation {automation.get('id')}: {e}")

View file

@ -315,20 +315,41 @@ class DeezerDownloadClient:
break break
raw_tracks.extend(page_tracks) raw_tracks.extend(page_tracks)
# Batch-fetch release dates for unique albums # Batch-fetch release dates for unique albums (cache-first)
album_ids = set() album_ids = set()
for t in raw_tracks: for t in raw_tracks:
aid = t.get('album', {}).get('id') aid = t.get('album', {}).get('id')
if aid: if aid:
album_ids.add(str(aid)) album_ids.add(str(aid))
album_release_dates = {} 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: 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: try:
time.sleep(0.3) # Respect rate limits time.sleep(0.3) # Respect rate limits
a_resp = self._session.get(f'https://api.deezer.com/album/{aid}', timeout=10) a_resp = self._session.get(f'https://api.deezer.com/album/{aid}', timeout=10)
if a_resp.ok: if a_resp.ok:
a_data = a_resp.json() a_data = a_resp.json()
album_release_dates[aid] = a_data.get('release_date', '') 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: except Exception:
pass pass

View file

@ -1496,6 +1496,37 @@ class JellyfinClient:
logger.error(f"Error getting tracks for playlist {playlist_id}: {e}") logger.error(f"Error getting tracks for playlist {playlist_id}: {e}")
return [] 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: def update_playlist(self, playlist_name: str, tracks) -> bool:
"""Update an existing playlist or create it if it doesn't exist""" """Update an existing playlist or create it if it doesn't exist"""
if not self.ensure_connection(): if not self.ensure_connection():

View file

@ -479,6 +479,22 @@ class PlexClient:
logger.error(f"Error updating playlist '{playlist_name}': {e}") logger.error(f"Error updating playlist '{playlist_name}': {e}")
return False 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]: def _find_track(self, title: str, artist: str, album: str) -> Optional[PlexTrack]:
if not self.music_library: if not self.music_library:
return None return None

View file

@ -175,33 +175,23 @@ class AcoustIDScannerJob(RepairJob):
fp_result = acoustid_client.fingerprint_and_lookup(fpath) fp_result = acoustid_client.fingerprint_and_lookup(fpath)
except Exception as e: except Exception as e:
logger.debug("Fingerprint failed for %s: %s", fname, 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 return
if not fp_result or not fp_result.get('recordings'): 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: if context.report_progress:
context.report_progress( context.report_progress(
log_line=f'No match: {fname}', log_line=f'No match: {fname}',
log_type='skip' 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 return
# Check best recording match # Check best recording match

View file

@ -1174,14 +1174,23 @@ class RepairWorker:
conn.close() conn.close()
def _fix_duplicates(self, entity_type, entity_id, file_path, details): 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', []) tracks = details.get('tracks', [])
if len(tracks) < 2: if len(tracks) < 2:
return {'success': False, 'error': 'Not enough duplicate info to determine best copy'} return {'success': False, 'error': 'Not enough duplicate info to determine best copy'}
# Pick best: highest bitrate, then longest duration # If user specified which track to keep, use that
best = max(tracks, key=lambda t: (t.get('bitrate', 0) or 0, t.get('duration', 0) or 0)) keep_id = details.get('_fix_action')
best_id = best.get('track_id') or best.get('id') 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: if not best_id:
return {'success': False, 'error': 'Could not determine best track ID'} return {'success': False, 'error': 'Could not determine best track ID'}

View file

@ -30,6 +30,7 @@ services:
- ./logs:/app/logs - ./logs:/app/logs
- ./downloads:/app/downloads - ./downloads:/app/downloads
- ./Staging:/app/Staging - ./Staging:/app/Staging
- ./scripts:/app/scripts
# Use named volume for database persistence (separate from host database) # Use named volume for database persistence (separate from host database)
# NOTE: Changed from /app/database to /app/data to avoid overwriting Python package # NOTE: Changed from /app/database to /app/data to avoid overwriting Python package
- soulsync_database:/app/data - soulsync_database:/app/data

6
scripts/hello_world.sh Normal file
View file

@ -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)"

12
scripts/notify_ntfy.sh Normal file
View file

@ -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}"

17
scripts/system_info.py Normal file
View file

@ -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")

View file

@ -1737,6 +1737,79 @@ def _register_automation_handlers():
'_manages_own_progress': True, '_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('full_cleanup', _auto_full_cleanup)
automation_engine.register_action_handler('start_database_update', _auto_start_database_update, automation_engine.register_action_handler('start_database_update', _auto_start_database_update,
@ -5789,6 +5862,30 @@ def _collect_known_signals():
pass pass
return sorted(signals) 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']) @app.route('/api/automations/blocks', methods=['GET'])
def get_automation_blocks(): def get_automation_blocks():
"""Return available block types for the automation builder sidebar.""" """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}, "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", {"type": "deep_scan_library", "label": "Deep Scan Library", "icon": "search",
"description": "Full library comparison without losing enrichment data", "available": True}, "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": [ "notifications": [
{"type": "discord_webhook", "label": "Discord Webhook", "icon": "message", "description": "Send a Discord notification", "available": True, {"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": [ "config_fields": [
{"key": "signal_name", "type": "signal_input", "label": "Signal Name"} {"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(), "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", "• Replace lower quality files on import — opt-in toggle in Settings > Library",
"• HiFi API instance health check in Settings > Downloads", "• HiFi API instance health check in Settings > Downloads",
"• Debug test activity feed message removed from startup", "• 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 # 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.""" """The actual sync function that runs in the background thread."""
global sync_states, sync_service 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") 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 # Record sync history completion with per-track data
try: try:
matched = getattr(result, 'matched_tracks', 0) matched = getattr(result, 'matched_tracks', 0)
@ -36572,10 +36692,11 @@ def start_playlist_sync():
playlist_id = data.get('playlist_id') playlist_id = data.get('playlist_id')
playlist_name = data.get('playlist_name') playlist_name = data.get('playlist_name')
tracks_json = data.get('tracks') # Pass the full track list 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]): if not all([playlist_id, playlist_name, tracks_json]):
return jsonify({"success": False, "error": "Missing playlist_id, name, or tracks."}), 400 return jsonify({"success": False, "error": "Missing playlist_id, name, or tracks."}), 400
# Add activity for sync start # Add activity for sync start
add_activity_item("🔄", "Spotify Sync Started", f"'{playlist_name}' - {len(tracks_json)} tracks", "Now") 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) # Submit the task to the thread pool (capture profile_id while still in request context)
_sync_profile_id = get_current_profile_id() _sync_profile_id = get_current_profile_id()
thread_submit_time = time.time() 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 active_sync_workers[playlist_id] = future
thread_submit_duration = (time.time() - thread_submit_time) * 1000 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)") print(f"⏱️ [TIMING] Thread submitted at {time.strftime('%H:%M:%S')} (took {thread_submit_duration:.1f}ms)")

View file

@ -259,7 +259,7 @@
<!-- Track info row --> <!-- Track info row -->
<div class="media-header"> <div class="media-header">
<img class="sidebar-album-art" id="sidebar-album-art" src="" alt=""> <img class="sidebar-album-art" id="sidebar-album-art" src="/static/trans2.png" alt="" onerror="this.src='/static/trans2.png'">
<div class="media-info"> <div class="media-info">
<div class="track-title" id="track-title">No track</div> <div class="track-title" id="track-title">No track</div>
<div class="artist-name" id="artist-name">Unknown Artist</div> <div class="artist-name" id="artist-name">Unknown Artist</div>

View file

@ -1463,6 +1463,7 @@ const DOCS_SECTIONS = [
<li><strong>Music Library Paths</strong> &mdash; In Settings &gt; 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.</li> <li><strong>Music Library Paths</strong> &mdash; In Settings &gt; 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.</li>
<li><strong>Replace Lower Quality on Import</strong> &mdash; Opt-in toggle in Settings &gt; 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.</li> <li><strong>Replace Lower Quality on Import</strong> &mdash; Opt-in toggle in Settings &gt; 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.</li>
<li><strong>HiFi Instance Health</strong> &mdash; In Settings &gt; Downloads &gt; HiFi, click "Check All Instances" to see which community API instances are online, searchable, or able to download.</li> <li><strong>HiFi Instance Health</strong> &mdash; In Settings &gt; Downloads &gt; HiFi, click "Check All Instances" to see which community API instances are online, searchable, or able to download.</li>
<li><strong>Dead File Fix Options</strong> &mdash; 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.</li>
</ul> </ul>
</div> </div>
<div class="docs-subsection" id="set-db-maintenance"> <div class="docs-subsection" id="set-db-maintenance">

View file

@ -4284,9 +4284,9 @@ function updateNpTrackInfo() {
if (artUrl) { if (artUrl) {
sidebarArt.src = artUrl; sidebarArt.src = artUrl;
sidebarArt.style.display = ''; sidebarArt.style.display = '';
sidebarArt.onerror = () => { sidebarArt.src = ''; }; sidebarArt.onerror = () => { sidebarArt.src = '/static/trans2.png'; };
} else { } else {
sidebarArt.src = ''; sidebarArt.src = '/static/trans2.png';
} }
} }
@ -4332,7 +4332,7 @@ function updateNpTrackInfo() {
artistEl.textContent = 'Unknown Artist'; artistEl.textContent = 'Unknown Artist';
albumEl.textContent = 'Unknown Album'; albumEl.textContent = 'Unknown Album';
if (artImg) artImg.classList.add('hidden'); if (artImg) artImg.classList.add('hidden');
if (sidebarArt) sidebarArt.src = ''; if (sidebarArt) sidebarArt.src = '/static/trans2.png';
if (badgesEl) badgesEl.innerHTML = ''; if (badgesEl) badgesEl.innerHTML = '';
if (actionBtns) actionBtns.classList.add('hidden'); if (actionBtns) actionBtns.classList.add('hidden');
npResetAmbientGlow(); npResetAmbientGlow();
@ -10195,7 +10195,29 @@ async function rehydrateModal(processInfo, userRequested = false) {
return; 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); let playlistData = spotifyPlaylists.find(p => p.id === playlist_id);
if (!playlistData) { if (!playlistData) {
console.warn(`Cannot rehydrate modal: Playlist data for ${playlist_id} not loaded.`); console.warn(`Cannot rehydrate modal: Playlist data for ${playlist_id} not loaded.`);
@ -11707,7 +11729,10 @@ async function openDownloadMissingModal(playlistId) {
let tracks = playlistTrackCache[playlistId]; let tracks = playlistTrackCache[playlistId];
if (!tracks) { if (!tracks) {
try { 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(); const fullPlaylist = await response.json();
if (fullPlaylist.error) throw new Error(fullPlaylist.error); if (fullPlaylist.error) throw new Error(fullPlaylist.error);
tracks = fullPlaylist.tracks; tracks = fullPlaylist.tracks;
@ -15958,7 +15983,8 @@ async function startPlaylistSync(playlistId) {
body: JSON.stringify({ body: JSON.stringify({
playlist_id: playlist.id, playlist_id: playlist.id,
playlist_name: playlist.name, 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(); renderDeezerArlPlaylists();
deezerArlPlaylistsLoaded = true; 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) { } catch (error) {
container.innerHTML = `<div class="playlist-placeholder">❌ Error: ${error.message}</div>`; container.innerHTML = `<div class="playlist-placeholder">❌ Error: ${error.message}</div>`;
showToast(`Error loading Deezer playlists: ${error.message}`, 'error'); showToast(`Error loading Deezer playlists: ${error.message}`, 'error');
@ -62710,23 +62757,27 @@ function _renderFindingDetail(f) {
case 'duplicate_tracks': case 'duplicate_tracks':
if (!d.tracks || !d.tracks.length) return _gridRows([['Count', d.count || '?']]); 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 bestDup = d.tracks.reduce((best, t) => {
const bBr = best.bitrate || 0, tBr = t.bitrate || 0; const bBr = best.bitrate || 0, tBr = t.bitrate || 0;
const bDur = best.duration || 0, tDur = t.duration || 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]); }, d.tracks[0]);
const findingId = f.id;
return media + `<div class="repair-detail-sublist">${d.tracks.map((t, i) => { return media + `<div class="repair-detail-sublist">${d.tracks.map((t, i) => {
const isBest = t.id === bestDup.id; const tid = t.track_id || t.id;
return `<div class="repair-detail-subitem ${isBest ? 'best' : 'removable'}"> const isBest = (t.id === bestDup.id);
return `<div class="repair-detail-subitem ${isBest ? 'best' : 'removable'}" style="cursor:pointer;" onclick="selectDuplicateToKeep(${findingId}, '${tid}')" title="Click to keep this version">
<strong> <strong>
${isBest ? '<span class="repair-keep-badge">KEEP</span>' : '<span class="repair-remove-badge">REMOVE</span>'} ${isBest ? '<span class="repair-keep-badge">KEEP</span>' : '<span class="repair-remove-badge">REMOVE</span>'}
${_escFinding(t.title)} by ${_escFinding(t.artist)} ${_escFinding(t.title)} by ${_escFinding(t.artist)}
</strong> </strong>
<span>Album: ${_escFinding(t.album || 'Unknown')}${t.bitrate ? ` &middot; ${t.bitrate} kbps` : ''}${t.duration ? ` &middot; ${Math.round(t.duration)}s` : ''}</span> <span>Album: ${_escFinding(t.album || 'Unknown')}${t.bitrate ? ` &middot; ${t.bitrate} kbps` : ''}${t.duration ? ` &middot; ${Math.round(t.duration)}s` : ''}${t.track_number ? ` &middot; Track #${t.track_number}` : ''}</span>
${t.file_path ? `<span class="mono">${_escFinding(t.file_path)}</span>` : ''} ${t.file_path ? `<span class="mono">${_escFinding(t.file_path)}</span>` : ''}
</div>`; </div>`;
}).join('')}</div>`; }).join('')}</div>
<div style="color:rgba(255,255,255,0.3);font-size:11px;padding:4px 0;">Click on a version to keep it, or use "Keep Best" for auto-selection</div>`;
case 'incomplete_album': case 'incomplete_album':
if (d.artist) rows.push(['Artist', d.artist]); if (d.artist) rows.push(['Artist', d.artist]);
@ -62894,9 +62945,12 @@ async function fixAllMatchingFindings() {
const jobId = jobFilter ? jobFilter.value : ''; const jobId = jobFilter ? jobFilter.value : '';
const severity = severityFilter ? severityFilter.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; 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(); fixAction = await _promptOrphanAction();
if (!fixAction) return; if (!fixAction) return;
// Confirm before proceeding // Confirm before proceeding
@ -62998,6 +63052,29 @@ function renderRepairFindingsPagination(total, currentPage) {
container.innerHTML = html; 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) { async function fixRepairFinding(id, findingType) {
// Orphan files require user to choose an action // Orphan files require user to choose an action
let fixAction = null; let fixAction = null;
@ -66348,7 +66425,7 @@ const _autoIcons = {
scan_library: '\uD83D\uDD04', refresh_mirrored: '\uD83D\uDCC2', sync_playlist: '\uD83D\uDD01', scan_library: '\uD83D\uDD04', refresh_mirrored: '\uD83D\uDCC2', sync_playlist: '\uD83D\uDD01',
discover_playlist: '\uD83D\uDD0D', discovery_completed: '\uD83D\uDD0D', 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', 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 // Phase 3
wishlist_processing_completed: '\u2705', watchlist_scan_completed: '\u2705', wishlist_processing_completed: '\u2705', watchlist_scan_completed: '\u2705',
database_update_completed: '\uD83D\uDDC4\uFE0F', download_failed: '\u274C', database_update_completed: '\uD83D\uDDC4\uFE0F', download_failed: '\u274C',
@ -67612,6 +67689,7 @@ function _autoFormatNotify(type) {
if (type === 'pushbullet') return 'Pushbullet'; if (type === 'pushbullet') return 'Pushbullet';
if (type === 'telegram') return 'Telegram'; if (type === 'telegram') return 'Telegram';
if (type === 'fire_signal') return '\u26A1 Signal'; if (type === 'fire_signal') return '\u26A1 Signal';
if (type === 'run_script') return '\uD83D\uDCBB Script';
return type || ''; return type || '';
} }
function _autoParseUTC(ts) { function _autoParseUTC(ts) {
@ -68292,6 +68370,34 @@ function _renderBlockConfigFields(slotKey, blockType, config) {
</div> </div>
<div class="config-row" style="color:rgba(255,255,255,0.35);font-size:11px;">Other automations with "Signal Received" trigger will wake up</div>`; <div class="config-row" style="color:rgba(255,255,255,0.35);font-size:11px;">Other automations with "Signal Received" trigger will wake up</div>`;
} }
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 = '<option value="">Select a script...</option>' +
data.scripts.map(s => `<option value="${_escAttr(s.name)}"${s.name === scriptName ? ' selected' : ''}>${escapeHtml(s.name)} (${s.extension})</option>`).join('');
}
} catch (e) { console.warn('Failed to load scripts:', e); }
}, 100);
return `<div class="config-row">
<label>Script</label>
<select id="${selectId}">
<option value="${scriptName}">${scriptName || 'Loading...'}</option>
</select>
</div>
<div class="config-row">
<label>Timeout</label>
<input type="number" id="cfg-${slotKey}-timeout" value="${timeout}" min="5" max="300" style="width:80px;"> seconds
</div>
<div class="config-row" style="color:rgba(255,255,255,0.35);font-size:11px;">Place scripts in the <code>scripts/</code> folder. Supported: .sh, .py, .bat, .ps1</div>`;
}
if (blockType === 'scan_watchlist' || blockType === 'scan_library' || blockType === 'notify_only') { if (blockType === 'scan_watchlist' || blockType === 'scan_library' || blockType === 'notify_only') {
return '<div class="config-row" style="color:rgba(255,255,255,0.4);font-size:12px;">No configuration needed</div>'; return '<div class="config-row" style="color:rgba(255,255,255,0.4);font-size:12px;">No configuration needed</div>';
} }
@ -68651,6 +68757,12 @@ function _readPlacedConfig(slotKey) {
if (type === 'signal_received' || type === 'fire_signal') { if (type === 'signal_received' || type === 'fire_signal') {
return { signal_name: document.getElementById('cfg-' + slotKey + '-signal_name')?.value?.trim() || '' }; 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') { if (type === 'discord_webhook') {
return { return {
webhook_url: document.getElementById('cfg-' + slotKey + '-webhook_url')?.value?.trim() || '', webhook_url: document.getElementById('cfg-' + slotKey + '-webhook_url')?.value?.trim() || '',