Merge branch 'Nezreka:main' into main
This commit is contained in:
commit
985afb7c2a
16 changed files with 395 additions and 46 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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}")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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():
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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'}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
6
scripts/hello_world.sh
Normal 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
12
scripts/notify_ntfy.sh
Normal 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
17
scripts/system_info.py
Normal 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")
|
||||||
129
web_server.py
129
web_server.py
|
|
@ -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)")
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -1463,6 +1463,7 @@ const DOCS_SECTIONS = [
|
||||||
<li><strong>Music Library Paths</strong> — 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.</li>
|
<li><strong>Music Library Paths</strong> — 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.</li>
|
||||||
<li><strong>Replace Lower Quality on Import</strong> — 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.</li>
|
<li><strong>Replace Lower Quality on Import</strong> — 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.</li>
|
||||||
<li><strong>HiFi Instance Health</strong> — In Settings > Downloads > 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> — In Settings > Downloads > 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> — 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">
|
||||||
|
|
|
||||||
|
|
@ -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 ? ` · ${t.bitrate} kbps` : ''}${t.duration ? ` · ${Math.round(t.duration)}s` : ''}</span>
|
<span>Album: ${_escFinding(t.album || 'Unknown')}${t.bitrate ? ` · ${t.bitrate} kbps` : ''}${t.duration ? ` · ${Math.round(t.duration)}s` : ''}${t.track_number ? ` · 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() || '',
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue