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
# 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

View file

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

View file

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

View file

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

View file

@ -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():

View file

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

View file

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

View file

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

View file

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

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,
}
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)")

View file

@ -259,7 +259,7 @@
<!-- Track info row -->
<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="track-title" id="track-title">No track</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>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>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>
</div>
<div class="docs-subsection" id="set-db-maintenance">

View file

@ -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 = `<div class="playlist-placeholder">❌ Error: ${error.message}</div>`;
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 + `<div class="repair-detail-sublist">${d.tracks.map((t, i) => {
const isBest = t.id === bestDup.id;
return `<div class="repair-detail-subitem ${isBest ? 'best' : 'removable'}">
const tid = t.track_id || t.id;
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>
${isBest ? '<span class="repair-keep-badge">KEEP</span>' : '<span class="repair-remove-badge">REMOVE</span>'}
${_escFinding(t.title)} by ${_escFinding(t.artist)}
</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>` : ''}
</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':
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) {
</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') {
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') {
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() || '',