_DIRECT_RUN = (__name__ == '__main__') import os import json import asyncio import requests import socket import subprocess import platform import threading import time import uuid import re import sqlite3 import types import collections import functools from contextlib import contextmanager from pathlib import Path from urllib.parse import urljoin, urlparse from concurrent.futures import ThreadPoolExecutor, as_completed from flask import Flask, abort, render_template, request, jsonify, redirect, send_file, send_from_directory, Response, session, g from flask_socketio import SocketIO, emit, join_room, leave_room from utils.logging_config import get_logger, setup_logging from utils.async_helpers import run_async from mutagen.flac import FLAC from mutagen.mp4 import MP4 from mutagen.oggvorbis import OggVorbis # --- Core Application Imports --- # Import the same core clients and config manager used by the GUI app from config.settings import config_manager # Setup logging early to avoid any import-time logs from being swallowed _log_level = config_manager.get('logging.level', 'INFO') _log_path = config_manager.get('logging.path', 'logs/app.log') _log_dir = Path(_log_path).parent logger = setup_logging(_log_level, _log_path) # App version — single source of truth for backup metadata, system-info, update check, etc. # Semver: MAJOR.MINOR.PATCH. Bump at each dev→main release. _SOULSYNC_BASE_VERSION = "2.7.2" def _build_version_string(): """Append short commit hash to version when available (e.g. 2.35+abc1234).""" sha = os.environ.get('SOULSYNC_COMMIT_SHA', '').strip() if not sha: try: import subprocess result = subprocess.run(['git', 'rev-parse', 'HEAD'], capture_output=True, text=True, cwd=os.path.dirname(__file__) or '.') if result.returncode == 0: sha = result.stdout.strip() except Exception as e: logger.debug("git rev-parse failed: %s", e) if sha: return f"{_SOULSYNC_BASE_VERSION}+{sha[:7]}" return _SOULSYNC_BASE_VERSION SOULSYNC_VERSION = _build_version_string() # Dedicated source reuse logger — writes alongside app.log in the configured log directory import logging as _logging import logging.handlers as _logging_handlers source_reuse_logger = _logging.getLogger("source_reuse") source_reuse_logger.setLevel(_logging.DEBUG) if not source_reuse_logger.handlers: _sr_handler = _logging_handlers.RotatingFileHandler( _log_dir / "source_reuse.log", encoding="utf-8", maxBytes=5*1024*1024, backupCount=2 ) _sr_handler.setFormatter(_logging.Formatter("%(asctime)s - %(message)s", datefmt="%Y-%m-%d %H:%M:%S")) source_reuse_logger.addHandler(_sr_handler) source_reuse_logger.propagate = False # Dedicated post-processing logger (failures only) — writes alongside app.log in the configured log directory pp_logger = _logging.getLogger("post_processing") pp_logger.setLevel(_logging.DEBUG) if not pp_logger.handlers: _pp_handler = _logging_handlers.RotatingFileHandler( _log_dir / "post_processing.log", encoding="utf-8", maxBytes=5*1024*1024, backupCount=2 ) _pp_handler.setFormatter(_logging.Formatter("%(asctime)s - %(message)s", datefmt="%Y-%m-%d %H:%M:%S")) pp_logger.addHandler(_pp_handler) pp_logger.propagate = False from core.spotify_client import SpotifyClient, Playlist as SpotifyPlaylist, Track as SpotifyTrack, _is_globally_rate_limited as _spotify_rate_limited from core.plex_client import PlexClient from plexapi.myplex import MyPlexAccount, MyPlexPinLogin from core.jellyfin_client import JellyfinClient from core.navidrome_client import NavidromeClient from core.soulseek_client import SoulseekClient from core.download_orchestrator import DownloadOrchestrator, set_download_orchestrator from core.tidal_client import TidalClient # Added import for Tidal from core.matching_engine import MusicMatchingEngine from core.database_update_worker import DatabaseUpdateWorker from core.web_scan_manager import WebScanManager from core.metadata.cache import get_metadata_cache from core.metadata import registry as metadata_registry from core.metadata import is_internal_image_host from core.metadata import normalize_image_url as fix_artist_image_url from core.webui import build_webui_vite_assets, should_serve_webui_spa from core.metadata.registry import ( clear_cached_metadata_client, get_metadata_source_label, get_spotify_client, get_spotify_disconnect_source, register_runtime_clients, ) from core.metadata.status import ( get_status_snapshot as get_metadata_status_snapshot, get_spotify_status, publish_spotify_status, invalidate_metadata_status_caches, ) from core.imports.context import ( get_import_clean_album, get_import_clean_title, get_import_context_album, get_import_original_search, ) from core.wishlist.payloads import ( build_cancelled_task_wishlist_payload as _build_cancelled_task_wishlist_payload, build_failed_track_wishlist_context as _build_failed_track_wishlist_context, ensure_wishlist_track_format as _ensure_wishlist_track_format, get_track_artist_name as _get_track_artist_name, ) from core.wishlist.routes import ( WishlistRouteRuntime as _WishlistRouteRuntime, add_album_track_to_wishlist as _wishlist_add_album_track_to_wishlist, clear_wishlist as _wishlist_clear_wishlist, get_wishlist_count as _wishlist_get_wishlist_count, get_wishlist_cycle as _wishlist_get_wishlist_cycle, get_wishlist_stats as _wishlist_get_wishlist_stats, get_wishlist_tracks as _wishlist_get_wishlist_tracks, process_wishlist_api as _wishlist_process_api, remove_album_from_wishlist as _wishlist_remove_album_from_wishlist, remove_batch_from_wishlist as _wishlist_remove_batch_from_wishlist, remove_track_from_wishlist as _wishlist_remove_track_from_wishlist, set_wishlist_cycle as _wishlist_set_wishlist_cycle, ) from core.wishlist.processing import ( add_cancelled_tracks_to_failed_tracks as _add_cancelled_tracks_to_failed_tracks, automatic_wishlist_cleanup_after_db_update as _cleanup_wishlist_after_db_update, cleanup_wishlist_against_library as _cleanup_wishlist_against_library, build_wishlist_source_context as _build_wishlist_source_context, finalize_auto_wishlist_completion as _finalize_auto_wishlist_completion, start_manual_wishlist_download_batch as _start_manual_wishlist_download_batch, process_wishlist_automatically as _process_wishlist_automatically_impl, recover_uncaptured_failed_tracks as _recover_uncaptured_failed_tracks, remove_completed_tracks_from_wishlist as _remove_completed_tracks_from_wishlist, WishlistAutoProcessingRuntime as _WishlistAutoProcessingRuntime, WishlistManualDownloadRuntime as _WishlistManualDownloadRuntime, ) from core.wishlist.resolution import ( check_and_remove_from_wishlist as _check_and_remove_from_wishlist, check_and_remove_track_from_wishlist_by_metadata as _check_and_remove_track_from_wishlist_by_metadata, ) from core.wishlist.state import ( is_wishlist_actually_processing as _is_wishlist_actually_processing, reset_flag_if_stuck as _reset_wishlist_flag_if_stuck, ) from core.imports.album_naming import resolve_album_group as _resolve_album_group from core.imports.album import build_album_import_match_payload from core.imports.filename import extract_track_number_from_filename, parse_filename_metadata from core.imports.staging import ( get_staging_path, read_staging_file_metadata, start_import_suggestions_cache, ) from core.imports.routes import ImportRouteRuntime as _ImportRouteRuntime from core.imports.routes import album_match as _import_album_match from core.imports.routes import album_process as _import_album_process from core.imports.routes import process_single_import_file as _import_process_single_import_file from core.imports.routes import search_albums as _import_search_albums from core.imports.routes import search_tracks as _import_search_tracks from core.imports.routes import singles_process as _import_singles_process from core.imports.routes import staging_files as _import_staging_files from core.imports.routes import staging_groups as _import_staging_groups from core.imports.routes import staging_hints as _import_staging_hints from core.imports.routes import staging_suggestions as _import_staging_suggestions from core.imports.paths import build_final_path_for_track as _build_final_path_for_track from core.imports.pipeline import build_import_pipeline_runtime as _build_import_pipeline_runtime from core.metadata.common import get_file_lock from core.metadata.enrichment import build_metadata_enrichment_runtime as _build_metadata_enrichment_runtime from core.metadata.source import ( mb_release_cache, mb_release_cache_lock, mb_release_detail_cache, mb_release_detail_cache_lock, normalize_album_cache_key, ) from core.runtime_state import ( activity_feed, activity_feed_lock, add_activity_item, download_batches, download_tasks, matched_context_lock, matched_downloads_context, mark_task_completed, processed_download_ids, set_activity_toast_emitter, tasks_lock, ) from core.metadata import enrichment as metadata_enrichment from database.music_database import get_database, MusicDatabase from services.sync_service import PlaylistSyncService # --- Docker Volume Mount Guard --- # Pre-v1.3 docker-compose files mounted soulsync_database:/app/database, which overlays # the Python package with stale volume contents. Detect this after import. if not hasattr(MusicDatabase, 'get_system_automation_by_action'): logger.error( "Stale database module detected!\n" "MusicDatabase is missing required methods. This usually means your docker-compose.yml has an outdated volume mount.\n" "Fix:\n" " OLD: soulsync_database:/app/database\n" " NEW: soulsync_database:/app/data\n" "Then run: docker compose down && docker compose up -d" ) from datetime import datetime, timezone import yt_dlp from beatport_unified_scraper import BeatportUnifiedScraper from core.musicbrainz_worker import MusicBrainzWorker from core.audiodb_worker import AudioDBWorker from core.discogs_worker import DiscogsWorker from core.deezer_worker import DeezerWorker from core.spotify_worker import SpotifyWorker from core.itunes_worker import iTunesWorker from core.lastfm_worker import LastFMWorker from core.genius_worker import GeniusWorker from core.tidal_worker import TidalWorker from core.qobuz_worker import QobuzWorker from core.hydrabase_worker import HydrabaseWorker from core.amazon_worker import AmazonWorker from core.hydrabase_client import HydrabaseClient from core.automation_engine import AutomationEngine # --- Flask App Setup --- base_dir = os.path.abspath(os.path.dirname(__file__)) project_root = os.path.dirname(base_dir) # Go up one level to the project root DEV_STATIC_NO_CACHE = os.environ.get('SOULSYNC_WEB_DEV_NO_CACHE', '0').lower() in ('1', 'true', 'yes', 'on') # Check for environment variable first (Docker support), then fallback to calculated path env_config_path = os.environ.get('SOULSYNC_CONFIG_PATH') if env_config_path: config_path = env_config_path logger.info(f"Using config path from environment: {config_path}") else: config_path = os.path.join(project_root, 'config', 'config.json') if os.path.exists(config_path): # Check if we need to reload or if settings.py already handled it current_loaded_path = getattr(config_manager, 'config_path', None) target_path = Path(config_path).resolve() # Resolve current loaded path if it's a Path object if isinstance(current_loaded_path, Path): current_loaded_path = current_loaded_path.resolve() if current_loaded_path == target_path and config_manager.config_data: logger.info(f"Web server configuration already loaded from: {config_path}") else: logger.info(f"Found config file at: {config_path}") # Load configuration into the existing singleton instance if hasattr(config_manager, 'load_config'): config_manager.load_config(config_path) else: # Fallback for older settings.py in Docker volumes logger.warning("Legacy configuration detected: using fallback loading method") config_manager.config_path = Path(config_path) config_manager._load_config() logger.info("Web server configuration loaded successfully.") else: logger.warning(f"config.json not found at {config_path}. Using default settings.") # Correctly point to the 'webui' directory for templates and static files app = Flask( __name__, template_folder=os.path.join(base_dir, 'webui'), static_folder=os.path.join(base_dir, 'webui', 'static') ) app.config['TEMPLATES_AUTO_RELOAD'] = DEV_STATIC_NO_CACHE app.jinja_env.auto_reload = DEV_STATIC_NO_CACHE # Static assets (library.js / style.css / etc.) get aggressive browser # caching (1 year). Safe because every static URL is bust-tagged with # `?v=static_v` (computed once per process start — see below) so each # server restart effectively invalidates every cached asset for every # user. Within a single deploy, repeat page loads hit zero round-trips # on static files — was a 304 round-trip per asset under the old # max-age=0 setting. # # In dev, DEV_STATIC_NO_CACHE flips this back to 0 so iterating on JS # / CSS doesn't require a server restart between edits. app.config['SEND_FILE_MAX_AGE_DEFAULT'] = 0 if DEV_STATIC_NO_CACHE else 31536000 # Cache-bust query string for static assets. Computed once per process # start so each server restart invalidates the browser's cached copy of # every JS/CSS file. This is the surefire fix for "user has stale JS # even after Ctrl+Shift+R" — the URL itself changes, so the browser # cannot reuse a previously-cached response no matter what its # Cache-Control header said. import time as _cache_bust_time _STATIC_CACHE_BUST = str(int(_cache_bust_time.time())) @app.context_processor def _inject_static_cache_bust(): static_v = _STATIC_CACHE_BUST if DEV_STATIC_NO_CACHE: try: static_dir = Path(app.static_folder) mtimes = [ p.stat().st_mtime_ns for p in static_dir.rglob('*') if p.is_file() and p.suffix.lower() in {'.css', '.js'} ] if mtimes: static_v = str(max(mtimes)) except Exception: static_v = _STATIC_CACHE_BUST return {'static_v': static_v} @app.context_processor def _inject_soulsync_version(): """Expose the version string to every Jinja template so the sidebar version button + version-modal subtitle don't have to be manually edited at every release. The base version is the source of truth at `_SOULSYNC_BASE_VERSION`; bumping that single constant updates the UI everywhere it's rendered.""" return {'soulsync_version': SOULSYNC_VERSION, 'soulsync_base_version': _SOULSYNC_BASE_VERSION} # --- Flask Session Setup (for multi-profile support) --- import secrets as _secrets def _init_flask_secret_key(): """Load or generate a persistent secret key for Flask sessions""" try: db = get_database() key = db.get_metadata('flask_secret_key') if not key: key = _secrets.token_hex(32) db.set_metadata('flask_secret_key', key) return key except Exception: return _secrets.token_hex(32) app.secret_key = _init_flask_secret_key() # --- Reverse-proxy mode (opt-in, default OFF) --- # OFF by default → a strict no-op, so direct/LAN installs are unchanged. Only when # the operator sets security.trust_reverse_proxy=true (behind nginx/Caddy/Traefik # with TLS) does this trust X-Forwarded-* + mark the session cookie Secure. from core.security.reverse_proxy import apply_reverse_proxy_mode as _apply_reverse_proxy_mode if _apply_reverse_proxy_mode(app, config_manager.get): logger.info("[Security] Reverse-proxy mode ON: trusting X-Forwarded-* and Secure session cookie") # --- WebSocket (Socket.IO) Setup --- from core.socketio_cors import ( resolve_cors_origins as _resolve_socketio_cors_origins, RejectionLogger as _SocketIORejectionLogger, log_startup_status as _log_socketio_startup_status, ) _socketio_cors_origins = _resolve_socketio_cors_origins(config_manager) socketio = SocketIO(app, async_mode='threading', cors_allowed_origins=_socketio_cors_origins) _log_socketio_startup_status(_socketio_cors_origins, logger) _socketio_rejection_logger = _SocketIORejectionLogger(logger) set_activity_toast_emitter(socketio.emit) # Plex PIN auth requests stored in memory for polling _plex_pin_requests = {} _plex_pin_requests_lock = threading.Lock() @app.before_request def _log_rejected_socketio_origin(): """Hook the WS upgrade path so users see a clear log line when their Origin is about to be rejected (engineio otherwise just silently 403s the upgrade). Dedup + threading lives in `core/socketio_cors`. Note: Flask's ``before_request`` runs on every HTTP request to every endpoint — there's no path-scoped equivalent for arbitrary URL prefixes. We early-return on non-/socket.io/ paths to keep the overhead to one string compare per request. """ if not request.path.startswith('/socket.io/'): return _socketio_rejection_logger.maybe_log( _socketio_cors_origins, request.headers.get('Origin'), request.headers.get('Host', ''), request.scheme, request.headers.get('X-Forwarded-Host', ''), request.headers.get('X-Forwarded-Proto', ''), ) @app.context_processor def inject_webui_assets(): return { 'vite_assets': build_webui_vite_assets, } # Brute-force limiter for the launch-PIN unlock (lenient; only a flood of wrong # PINs from one IP trips it — correct entry clears it instantly). from core.security.rate_limit import AttemptLimiter as _AttemptLimiter _launch_pin_limiter = _AttemptLimiter(max_attempts=10, window_seconds=300) _login_limiter = _AttemptLimiter(max_attempts=10, window_seconds=300) def _require_login_enabled(): try: return bool(config_manager.get('security.require_login', False)) if config_manager else False except Exception: return False # --- Login gate (opt-in username/password mode; replaces the launch PIN) --- @app.before_request def _enforce_login(): """Server-side enforcement of username/password login. No-op unless security.require_login is on. When on, an unauthenticated session can only reach the page shell + the login flow + the key-authed public API.""" if not _require_login_enabled(): return from core.security.login_gate import login_request_is_blocked from core.security.launch_lock import is_html_navigation if login_request_is_blocked( request.path, request.method, require_login=True, authenticated=bool(session.get('login_authenticated', False)), ): if is_html_navigation(request.method, request.headers.get('Accept', ''), request.headers.get('Sec-Fetch-Mode', '')): return redirect('/') return jsonify({"error": "login_required", "login_required": True}), 401 # --- Launch PIN gate (before_request hook) --- @app.before_request def _enforce_launch_pin(): """Server-side enforcement of the launch PIN (#832). The PIN was previously a client-only overlay — removing the div (Safari "Hide Distracting Items", devtools, curl) gave full API access. This rejects every request from an unverified session with 401 while the launch lock is on, except the page shell + the unlock flow + the key-authed public API. No-ops entirely when ``security.require_pin_on_launch`` is off (the default). """ # Login mode replaces the launch PIN entirely — when it's on, _enforce_login # owns the gate and this no-ops. if _require_login_enabled(): return try: require_pin = bool(config_manager.get('security.require_pin_on_launch', False)) if config_manager else False except Exception: require_pin = False if not require_pin: return from core.security.launch_lock import request_is_locked, is_html_navigation # An auth proxy (Authelia/Authentik/oauth2-proxy) that already authenticated the # user counts as verified — opt-in via security.auth_proxy_header, OFF (empty) # by default so a direct install is unaffected. from core.security.auth_proxy import trusted_proxy_user try: _proxy_header = config_manager.get('security.auth_proxy_header', '') or '' except Exception: _proxy_header = '' _verified = bool(session.get('launch_pin_verified', False)) or bool( trusted_proxy_user(request.headers.get, _proxy_header) ) if request_is_locked( request.path, request.method, require_pin=require_pin, pin_verified=_verified, ): # A browser navigating to a sub-page (deep link / refresh) should land # on the lock screen, not raw JSON — bounce it to the root, which serves # the lock UI. Programmatic fetch/XHR get the JSON so the frontend reacts. if is_html_navigation( request.method, request.headers.get('Accept', ''), request.headers.get('Sec-Fetch-Mode', ''), ): return redirect('/') return jsonify({"error": "locked", "launch_pin_required": True}), 401 # --- Profile Context (before_request hook) --- @app.before_request def _set_profile_context(): """Set g.profile_id from session for every request""" g.request_start_monotonic = time.perf_counter() # Skip for profile management, static, and root routes path = request.path if (path.startswith('/api/profiles') or path.startswith('/static/') or path == '/' or path.startswith('/api/v1/')): g.profile_id = session.get('profile_id', 1) return pid = session.get('profile_id', 1) # Validate session profile still exists (handles deleted profiles) if pid != 1 and 'profile_id' in session: try: database = get_database() profile = database.get_profile(pid) if not profile: session.pop('profile_id', None) from flask import jsonify as _jsonify return _jsonify({"error": "profile_required", "message": "Profile no longer exists"}), 401 except Exception as e: logger.debug("profile session validate: %s", e) g.profile_id = pid @app.after_request def _log_slow_request(response): """Log slow HTTP requests so we can identify UI stall sources.""" try: path = request.path if path.startswith('/socket.io/'): return response start = getattr(g, 'request_start_monotonic', None) if start is None: return response elapsed_ms = (time.perf_counter() - start) * 1000 slow_threshold_ms = 1000.0 if elapsed_ms >= slow_threshold_ms: logger.warning( "Slow request: %s %s -> %s in %.1fms", request.method, request.full_path.rstrip('?'), response.status_code, elapsed_ms, ) except Exception as e: logger.debug("slow request log failed: %s", e) return response @app.after_request def _add_discover_cache_headers(response): """Browser-cache discover GETs for 5 minutes. The discover surface (hero, similar artists, recent releases, release radar, deep cuts, etc.) returns semi-stable data that's expensive to compute and not user-action-driven within a session. A short browser cache eliminates redundant fetches when the user toggles between Discover sections or navigates back. Scope: only `/api/discover/` and `/api/discovery/` paths, only GET, only successful 2xx responses. Any endpoint that explicitly sets its own Cache-Control wins (we don't override). Uses `private` not `public` because discover data is user-specific (hero artists from your watchlist, similar artists from your taste, etc.). `private` keeps it browser-only — intermediate proxies (corporate caching proxies, Cloudflare with cache rules, Nginx proxy_cache) won't store one user's response and serve it to another. """ try: if request.method != 'GET': return response if not (request.path.startswith('/api/discover/') or request.path.startswith('/api/discovery/')): return response if not (200 <= response.status_code < 300): return response if response.headers.get('Cache-Control'): return response response.headers['Cache-Control'] = 'private, max-age=300' except Exception as exc: # Don't let a header-tagging bug turn a successful response into # a 500 — log and ship the response without the cache header. logger.warning(f"[discover-cache-headers] failed for {request.path}: {exc}") return response def get_current_profile_id() -> int: """Get the current profile ID from Flask g context or default to 1. Background callers (automation engine, sync threads, watchlist scanner) have no request context, so `g.profile_id` raises `RuntimeError("Working outside of application context")` rather than `AttributeError`. Catch both so non-request callers degrade to the admin profile instead of crashing the handler. A real web request always wins. Only when there's NO request do we honour a background-profile override (set by the automation engine to the automation's owner) — so a non-admin's scheduled job acts as them, while admin/system jobs (profile 1) and anything with no override resolve to admin exactly as before.""" try: return g.profile_id except (AttributeError, RuntimeError): pass from core.profile_context import get_background_profile pid = get_background_profile() return pid if pid is not None else 1 def admin_only(view_fn): """Restrict a Flask view to the admin profile (profile_id == 1). Settings-class endpoints expose / mutate service tokens, OAuth secrets, and API keys. Non-admin profiles must not see them. NOTE on the underlying auth model: `get_current_profile_id()` defaults to 1 (admin) when no session is present, which means single-admin / no-multi-profile installs have no actual gate here — any request from the local network is treated as admin. This decorator's job is to gate non-admin profiles in MULTI-profile setups, not to authenticate the network. The "trust local network" posture is the project's existing model; tightening it (real auth on every request) is out of scope for this decorator. """ @functools.wraps(view_fn) def wrapper(*args, **kwargs): if get_current_profile_id() != 1: return jsonify({ "success": False, "error": "Admin access required", }), 403 return view_fn(*args, **kwargs) return wrapper def get_spotify_client_for_profile(profile_id=None): """Get the Spotify client for the current profile. If the profile has custom Spotify credentials, returns a dedicated SpotifyClient instance (cached per profile). Otherwise returns the global spotify_client. """ if profile_id is None: profile_id = get_current_profile_id() return metadata_registry.get_spotify_client_for_profile(profile_id) _profile_tidal_clients = {} _profile_tidal_lock = threading.Lock() def get_tidal_client_for_profile(profile_id=None): """Get the Tidal client for a profile's OWN playlists, or the global one. A profile that has connected its own Tidal account gets a dedicated client seeded with its tokens (refreshed via the shared/global app creds). Crucially its token refresh is redirected to the profile row, so a per-profile refresh never overwrites the global tidal_tokens the app runs on. Admin (profile 1) and unconnected profiles use the global client unchanged.""" if profile_id is None: profile_id = get_current_profile_id() if not profile_id or profile_id == 1: return tidal_client try: toks = get_database().get_profile_tidal(profile_id) or {} except Exception: return tidal_client if not toks.get('access_token') and not toks.get('refresh_token'): return tidal_client with _profile_tidal_lock: cached = _profile_tidal_clients.get(profile_id) if cached is not None: return cached try: c = TidalClient() c.access_token = toks.get('access_token') or None c.refresh_token = toks.get('refresh_token') or None c.token_expires_at = 0 # force a refresh check on first use if c.access_token: c.session.headers['Authorization'] = f'Bearer {c.access_token}' # Redirect token persistence to the PROFILE, never the global slot. _pid = profile_id def _save_to_profile(_c=c, _p=_pid): try: get_database().set_profile_tidal_tokens(_p, _c.access_token, _c.refresh_token) except Exception as e: logger.debug("per-profile Tidal token save failed: %s", e) c._save_tokens = _save_to_profile _profile_tidal_clients[profile_id] = c return c except Exception as e: logger.error("per-profile Tidal client build failed for %s: %s", profile_id, e) return tidal_client def clear_profile_tidal_client(profile_id): """Evict a profile's cached Tidal client (after (dis)connect).""" with _profile_tidal_lock: _profile_tidal_clients.pop(profile_id, None) # Valid page IDs for profile permission validation VALID_PAGE_IDS = { 'dashboard', 'sync', 'search', 'discover', 'playlist-explorer', 'watchlist', 'wishlist', 'automations', 'active-downloads', 'library', 'tools', 'artist-detail', 'stats', 'import', 'settings', 'help', 'hydrabase', 'issues', } def check_download_permission(): """Check if current profile has download permission. Returns error response or None if allowed.""" pid = get_current_profile_id() if pid == 1: return None # Root admin always allowed try: profile = get_database().get_profile(pid) if profile and not profile.get('can_download', True): return jsonify({'success': False, 'error': 'Downloads are disabled for this profile.'}), 403 except Exception as e: logger.debug("download permission check: %s", e) return None # --- Docker Helper Functions --- def docker_resolve_path(path_str): """ Resolve absolute paths for Docker container access In Docker, Windows drive paths (E:/) need to be mapped to WSL mount points (/mnt/e/) """ if os.path.exists('/.dockerenv') and len(path_str) >= 3 and path_str[1] == ':' and path_str[0].isalpha(): # Convert Windows path (E:/path) to WSL mount path (/mnt/e/path) drive_letter = path_str[0].lower() rest_of_path = path_str[2:].replace('\\', '/') # Remove E: and convert backslashes return f"/host/mnt/{drive_letter}{rest_of_path}" return path_str def extract_filename(full_path): """ Extract filename by working backwards from the end until we hit a separator. This is cross-platform compatible and handles both Windows and Unix path separators. Special handling for YouTube/Tidal: If the filename contains '||' (encoded format), treat it as a filename, not a path, to avoid splitting on '/' in titles. """ if not full_path: return "" # YouTube filenames are encoded as "video_id||title" and may contain '/' in the title # Don't split these on path separators if '||' in full_path: return full_path last_slash = max(full_path.rfind('/'), full_path.rfind('\\')) if last_slash != -1: return full_path[last_slash + 1:] else: return full_path def _make_context_key(username, filename): """Build a unique context key from username and full Soulseek path. Uses the full remote path (not just filename) to prevent collisions when different tracks from the same user share a filename (e.g., two albums both containing '01 - Intro.flac'). """ normalized = filename.replace('\\', '/').lstrip('/') if filename else '' return f"{username}::{normalized}" IS_SHUTTING_DOWN = False # --- Initialize Core Application Components --- # Each client is initialized independently so one failure doesn't take down everything. # Previously, a single exception set ALL clients to None, breaking the entire app. logger.info("Initializing SoulSync services for Web UI...") spotify_client = download_orchestrator = tidal_client = matching_engine = sync_service = web_scan_manager = media_server_engine = None try: spotify_client = get_spotify_client() logger.info(" Spotify client initialized via metadata registry") except Exception as e: logger.error(f" Spotify client failed to initialize: {e}") def _safe_init_media_client(factory, name): """Build a media-server client, capturing per-server init failures so one broken server doesn't take the engine down with it. Logs the exception class so the boot log distinguishes config errors (ConnectionError, AuthenticationError) from genuine import / dependency failures — the broad ``Exception`` catch is intentional (any failure on this code path means the user can't use that server, and we want the other three to keep working) but the log makes the cause diagnosable.""" try: instance = factory() logger.info(f" {name} client initialized") return instance except Exception as exc: logger.error( f" {name} client failed to initialize: {type(exc).__name__}: {exc}", exc_info=True, ) return None # Build the MediaServerEngine. The engine OWNS the per-server client # instances — no separate web_server.py globals (Cin's standard from # the download refactor: drop redundant access paths). All callers go # through media_server_engine.client(''). try: from core.media_server.engine import MediaServerEngine, set_media_server_engine from core.soulsync_client import SoulSyncClient media_server_engine = MediaServerEngine(clients={ 'plex': _safe_init_media_client(PlexClient, "Plex"), 'jellyfin': _safe_init_media_client(JellyfinClient, "Jellyfin"), 'navidrome': _safe_init_media_client(NavidromeClient, "Navidrome"), 'soulsync': _safe_init_media_client(SoulSyncClient, "SoulSync library"), }) # Install as process-wide singleton so callers reaching via # get_media_server_engine() see the same instance web_server.py # constructs at boot. Matches the metadata + download engine # patterns. set_media_server_engine(media_server_engine) logger.info(" Media server engine initialized") except Exception as e: logger.error(f" Media server engine failed to initialize: {e}", exc_info=True) # Fallback: empty engine so downstream `engine.client('plex')` # returns None instead of AttributeError'ing on a None engine # global. Pre-refactor each per-server client global had its own # try/except so engine failure didn't take down dispatch sites; # this preserves that resilience. If the fallback ALSO fails # (e.g. the import itself broke), media_server_engine stays as # the None initialized at the top of the module. try: from core.media_server.engine import MediaServerEngine, set_media_server_engine media_server_engine = MediaServerEngine(clients={}) set_media_server_engine(media_server_engine) except Exception as fallback_exc: logger.error(f" Empty-engine fallback also failed: {fallback_exc}", exc_info=True) try: download_orchestrator = DownloadOrchestrator() # Install as the process-wide singleton so callers reaching for # get_download_orchestrator() see the same instance web_server.py # constructs at boot. Matches Cin's metadata engine pattern. set_download_orchestrator(download_orchestrator) logger.info(" Download orchestrator initialized") except Exception as e: logger.error(f" Download orchestrator failed to initialize: {e}") try: tidal_client = TidalClient() logger.info(" Tidal client initialized") except Exception as e: logger.error(f" Tidal client failed to initialize: {e}") try: matching_engine = MusicMatchingEngine() logger.info(" Matching engine initialized") except Exception as e: logger.error(f" Matching engine failed to initialize: {e}") try: sync_service = PlaylistSyncService(spotify_client, download_orchestrator, media_server_engine=media_server_engine) logger.info(" Playlist sync service initialized") except Exception as e: logger.error(f" Playlist sync service failed to initialize: {e}") # Inject shutdown check callback into every download source that # accepts one. Generic dispatch via the registry — no per-source # attribute reaches needed. if download_orchestrator and hasattr(download_orchestrator, 'registry'): for _src_name, _src_client in download_orchestrator.registry.all_plugins(): if _src_client is not None and hasattr(_src_client, 'set_shutdown_check'): try: _src_client.set_shutdown_check(lambda: IS_SHUTTING_DOWN) logger.info(" Configured %s client shutdown callback", _src_name) except Exception as _exc: logger.warning(" %s set_shutdown_check failed: %s", _src_name, _exc) # Initialize web scan manager for automatic post-download scanning try: web_scan_manager = WebScanManager(media_server_engine, delay_seconds=60) logger.info(" Web scan manager initialized") except Exception as e: logger.error(f" Web scan manager failed to initialize: {e}") logger.info("Core service initialization complete.") # --- Shared Runtime State --- # These globals are used by routes, background workers, and shutdown helpers. # A prior refactor accidentally dropped this initializer block, so several # modules and handlers were still referencing names that never got created. # Global Streaming State Management # Stream playback state. Lifted into core.streaming.state.StreamStateStore — # a per-session registry that's unit-tested and is the foundation for # multi-listener playback (player-revamp Phase 3). Today we use only the # DEFAULT session, so behavior is identical to the old single global: the # whole server still shares one "currently playing". The store just makes the # eventual per-listener split a wiring change instead of a rewrite. # # ``stream_state`` is the default session — dict-compatible (s["k"], s.get, # s.update) so the ~20 existing call sites work unchanged. ``stream_lock`` is # that session's own lock, so ``with stream_lock:`` guards exactly what it did. from core.streaming.state import StreamStateStore as _StreamStateStore from core.streaming.state import DEFAULT_SESSION as _DEFAULT_STREAM_SESSION stream_state_store = _StreamStateStore() stream_state = stream_state_store.get() # DEFAULT_SESSION (back-compat alias) stream_lock = stream_state.lock # Phase 3b — per-listener playback: each browser/device gets its own stream # session so two listeners no longer collide on one global. Background tasks are # tracked per session id. max_workers bumped so concurrent listeners don't queue # behind each other. Single-user behavior is unchanged (one cookie → one session). stream_background_task = None # legacy alias (default session) stream_tasks = {} # session_id -> Future stream_executor = ThreadPoolExecutor(max_workers=4, thread_name_prefix="StreamPrep") def _stream_session_id(): """Stable per-browser stream session id, from the Flask session cookie. Falls back to the DEFAULT session when there's no request context or no cookie yet (e.g. the 1s socket broadcast thread) — so single-user behavior is identical to before. Each distinct browser/device gets its own id and therefore its own independent playback + Stream/ staging dir. """ try: sid = session.get('stream_sid') if not sid: sid = uuid.uuid4().hex[:16] session['stream_sid'] = sid return sid except Exception: return _DEFAULT_STREAM_SESSION def _current_stream_state(): """The StreamSession for the calling browser (dict-compatible).""" return stream_state_store.get(_stream_session_id()) # Global OAuth State Management # Store PKCE values for Tidal OAuth flow tidal_oauth_state = { "code_verifier": None, "code_challenge": None, } tidal_oauth_lock = threading.Lock() # Sync Page Globals sync_executor = ThreadPoolExecutor(max_workers=3, thread_name_prefix="SyncWorker") active_sync_workers = {} # Key: playlist_id, Value: Future object sync_states = {} # Key: playlist_id, Value: dict with progress info sync_lock = threading.Lock() # Database Update / Tool Progress State db_update_executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="DBUpdate") db_update_worker = None db_update_state = { "status": "idle", # idle, running, finished, error "phase": "Idle", "progress": 0, "current_item": "", "processed": 0, "total": 0, "error_message": "", "removed_artists": 0, "removed_albums": 0, "removed_tracks": 0, # Heartbeat epoch (seconds): bumped at start and on every progress/phase # callback. The stall watchdog (core.database_update_health) flips a job to # 'error' when this goes stale while status is still 'running' (#859). "last_progress_at": 0, } _db_update_automation_id = None # Set when automation triggers DB update, used by callbacks db_update_lock = threading.Lock() def _set_db_update_automation_id(value): """Setter exposed to extracted automation handlers — keeps the legacy `_db_update_automation_id` global in sync so the live DB-update progress callbacks below (which still read the global directly) emit against the right automation card.""" global _db_update_automation_id _db_update_automation_id = value # Quality scanning is now the 'quality_upgrade' library-maintenance repair job # (core/repair_jobs/quality_upgrade.py) — no standalone state/executor here. # Duplicate Cleaner state duplicate_cleaner_state = { "status": "idle", # idle, running, finished, error "phase": "Ready to scan", "progress": 0, "files_scanned": 0, "total_files": 0, "duplicates_found": 0, "deleted": 0, "space_freed": 0, # in bytes "error_message": "", } duplicate_cleaner_lock = threading.Lock() duplicate_cleaner_executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="DuplicateCleaner") # Download Missing Tracks Modal State Management # Thread-safe state tracking for modal download functionality. # Shared task/batch state now lives in core.runtime_state. missing_download_executor = ThreadPoolExecutor(max_workers=3, thread_name_prefix="MissingTrackWorker") # Dedicated pool for per-album bundle downloads (#740). An album-bundle batch # blocks its worker thread for the entire search+download; if these run on the # shared missing_download_executor, a burst of album batches (e.g. a large # Album-Completeness "Fix all" → wishlist that splits into ~one batch per album) # saturates all 3 workers and starves the per-track flow AND the user's manual # "Download Wishlist" analysis, which then never starts. Keeping them on their # own bounded pool decouples that: hung/slow album downloads can only delay # other album downloads, never the user-facing path. album_bundle_executor = ThreadPoolExecutor(max_workers=3, thread_name_prefix="AlbumBundleWorker") # Parallelizes the per-file metadata-lookup + post-processing in # /api/import/singles/process. Single-file work is dominated by # Spotify/iTunes/Deezer search round-trips so 3 workers give a near- # linear speedup on a typical user's network without saturating any # one provider's rate limit. Each file is independent (unique # context_key, separate disk path), and the downstream pipeline # already serializes DB access through its own SQLite locks. import_singles_executor = ThreadPoolExecutor(max_workers=3, thread_name_prefix="ImportSingleWorker") # Automatic Wishlist / Watchlist Processing Flags # Processing state flags (guards/recovery - timers are now managed by AutomationEngine) wishlist_auto_processing = False wishlist_auto_processing_timestamp = 0 wishlist_timer_lock = threading.Lock() watchlist_auto_scanning = False watchlist_auto_scanning_timestamp = 0 watchlist_timer_lock = threading.Lock() # Beatport Data Cache # Cache Beatport scraping data to reduce load times and avoid hammering Beatport.com beatport_data_cache = { 'homepage': { 'hero_tracks': {'data': None, 'timestamp': 0, 'ttl': 86400}, # 24 hours 'top_10_lists': {'data': None, 'timestamp': 0, 'ttl': 86400}, # 24 hours 'top_10_releases': {'data': None, 'timestamp': 0, 'ttl': 86400}, # 24 hours 'new_releases': {'data': None, 'timestamp': 0, 'ttl': 86400}, # 24 hours 'hype_picks': {'data': None, 'timestamp': 0, 'ttl': 86400}, # 24 hours 'featured_charts': {'data': None, 'timestamp': 0, 'ttl': 86400}, # 24 hours 'dj_charts': {'data': None, 'timestamp': 0, 'ttl': 86400}, # 24 hours }, 'genre': { # Future expansion for genre-specific caching # 'house': {'top_10': {...}, 'releases': {...}}, # 'techno': {'top_10': {...}, 'releases': {...}} }, 'cache_lock': threading.Lock(), } # Shared Soulseek transfer cache # Keeps download status polling from hammering the API when multiple modals are open. transfer_data_cache = { 'data': {}, 'last_update': 0, 'update_lock': threading.Lock(), 'cache_duration': 0.75, } # --- Restored Web UI Helper State --- session_completed_downloads = 0 session_stats_lock = threading.Lock() # `batch_locks` lives in core/runtime_state.py (re-exported here so the existing # call sites resolve without modification). All download globals share that home. from core.runtime_state import batch_locks _orphaned_download_keys = set() _enrichment_activity_log = {} _idle_since = {} _IDLE_GRACE_SECONDS = 5 _status_cache = { 'media_server': {'connected': False, 'response_time': 0, 'type': None}, 'soulseek': {'connected': False, 'response_time': 0}, } _status_cache_timestamps: dict[str, float] = { 'media_server': 0, 'soulseek': 0, } STATUS_CACHE_TTL = 120 dev_mode_enabled = False _hydrabase_ws = None _hydrabase_lock = threading.Lock() # --- Automation Engine --- try: automation_engine = AutomationEngine(get_database()) logger.info("Automation engine initialized.") except Exception as e: logger.error(f"Automation engine failed to initialize: {e}") automation_engine = None # --- Automation Progress Tracking --- _scan_library_automation_id = None _automation_deps = None # Playlist-native manual pipeline runs share the automation dependency # bundle, but keep their own small progress state for the playlist UI. playlist_pipeline_progress_states = {} playlist_pipeline_progress_lock = threading.Lock() def _register_automation_handlers(): """Register real SoulSync action handlers with the automation engine. Per-handler bodies live in ``core.automation.handlers``. This function wires the dependency-injection surface (clients, callables, mutable state) into a single ``AutomationDeps`` object and hands it to ``register_all``. NOTE: extraction is in progress. The first batch of handlers (``process_wishlist`` / ``scan_watchlist`` / ``scan_library``) has been moved to ``core/automation/handlers/``; the remaining closures still live below until subsequent commits in the same branch finish the lift. """ global _automation_deps if not automation_engine: return from core.automation.deps import AutomationDeps, AutomationState from core.automation.handlers import register_all as _register_extracted_handlers from core.playlists.sources.bootstrap import build_playlist_source_registry # Mutable shared state previously lived as module-level globals # (`_scan_library_automation_id`, `_pipeline_running`, etc). # Keeping the legacy globals AS WELL as the new state object during # the transitional period so the not-yet-extracted closures still # work; they'll be removed once the rest of the lift is done. _automation_state = AutomationState() from core.watchlist_scanner import get_watchlist_scanner as _get_watchlist_scanner_fn # ListenBrainz / Last.fm are profile-scoped, so the manager getter # resolves the current profile's manager on each call. iTunes-link # parsing lives as a module helper rather than a class — wrap it in # a callable that matches the adapter contract. def _lb_manager_for_registry(): try: manager, _username, _source = _get_profile_lb_manager() return manager except Exception: return None def _itunes_link_parser_for_registry(url): # ``parse_itunes_link_endpoint`` is a Flask route handler; the # actual parsing work needs the underlying helpers. For now # we punt — Phase 2 will lift the parsing into a pure helper # the adapter can call directly. Refresh of itunes_link # mirrors is not yet wired (current handler skips them). return None # Discovery callable for the LB / Last.fm adapters' ``discover_tracks``. # Wraps the pure ``match_mb_tracks`` helper with the live matching # engine + Spotify / iTunes clients. Adapter calls it per refresh # when any track has ``needs_discovery=True``. from core.discovery.matching import MBMatchDeps, match_mb_tracks _mb_match_deps = MBMatchDeps( matching_engine=matching_engine, score_candidates=_discovery_score_candidates, spotify_client_getter=lambda: spotify_client, itunes_client_getter=_get_itunes_client, prefer_spotify_getter=lambda: (_get_active_discovery_source() == 'spotify'), ) def _discover_callable_for_registry(tracks): return match_mb_tracks(tracks, _mb_match_deps) _playlist_source_registry = build_playlist_source_registry( # Per-profile source reads: the adapter calls these fresh on every read, # so they resolve the CURRENT profile's account (their session # interactively, their automation's owner in the background — via # core.profile_context). Admin/profile 1 → the global client, so the # admin's existing auto-sync pipelines are unchanged. (Deezer/Qobuz stay # global for now — their playlist login is tangled with downloads.) spotify_client_getter=get_spotify_client_for_profile, tidal_client_getter=get_tidal_client_for_profile, qobuz_client_getter=_get_qobuz_client_for_sync, deezer_client_getter=_get_deezer_client, youtube_parser=parse_youtube_playlist, itunes_link_parser=_itunes_link_parser_for_registry, listenbrainz_manager_getter=_lb_manager_for_registry, lastfm_manager_getter=_lb_manager_for_registry, personalized_manager_getter=_build_personalized_manager, profile_id_getter=get_current_profile_id, discover_callable=_discover_callable_for_registry, ) _automation_deps = AutomationDeps( engine=automation_engine, state=_automation_state, config_manager=config_manager, update_progress=_update_automation_progress, logger=logger, get_database=get_database, spotify_client=spotify_client, tidal_client=tidal_client, web_scan_manager=web_scan_manager, process_wishlist_automatically=_process_wishlist_automatically, process_watchlist_scan_automatically=_process_watchlist_scan_automatically, is_wishlist_actually_processing=is_wishlist_actually_processing, is_watchlist_actually_scanning=is_watchlist_actually_scanning, get_watchlist_scan_state=lambda: watchlist_scan_state, run_playlist_discovery_worker=_run_playlist_discovery_worker, run_sync_task=_run_sync_task, run_playlist_organize_download=_run_playlist_organize_download, missing_download_executor=missing_download_executor, load_sync_status_file=_load_sync_status_file, get_deezer_client=_get_deezer_client, parse_youtube_playlist=parse_youtube_playlist, get_sync_states=lambda: sync_states, playlist_source_registry=_playlist_source_registry, set_db_update_automation_id=_set_db_update_automation_id, get_db_update_state=lambda: db_update_state, db_update_lock=db_update_lock, db_update_executor=db_update_executor, run_db_update_task=_run_db_update_task, run_deep_scan_task=_run_deep_scan_task, get_duplicate_cleaner_state=lambda: duplicate_cleaner_state, duplicate_cleaner_lock=duplicate_cleaner_lock, duplicate_cleaner_executor=duplicate_cleaner_executor, run_duplicate_cleaner=_run_duplicate_cleaner, run_repair_job_now=lambda job_id: repair_worker.run_job_now(job_id) if repair_worker else None, download_orchestrator=download_orchestrator, run_async=run_async, tasks_lock=tasks_lock, get_download_batches=lambda: download_batches, get_download_tasks=lambda: download_tasks, sweep_empty_download_directories=_sweep_empty_download_directories, get_staging_path=get_staging_path, docker_resolve_path=docker_resolve_path, get_current_profile_id=get_current_profile_id, get_watchlist_scanner=_get_watchlist_scanner_fn, get_app=lambda: app, get_beatport_data_cache=lambda: beatport_data_cache, init_automation_progress=_init_automation_progress, record_progress_history=_auto_progress.record_history, build_personalized_manager=_build_personalized_manager, ) _register_extracted_handlers(_automation_deps) # Bridge the isolated video download monitor's batch-complete signal into the # automation engine (core/video can't import the engine). Mirrors how the music # web_scan_manager forwards library_scan_completed. Fires the 'Auto-Scan Video # After Downloads' automation when a batch of video downloads finishes. if automation_engine is not None: try: from core.video.download_events import register_batch_complete_callback register_batch_complete_callback( lambda data: automation_engine.emit('video_batch_complete', data or {})) except Exception: logger.exception("Could not wire video batch-complete -> automation engine") logger.info("Automation action handlers registered") # --- Register Public REST API Blueprint (v1) --- try: from api import create_api_blueprint, limiter limiter.init_app(app) api_bp = create_api_blueprint() app.register_blueprint(api_bp, url_prefix='/api/v1') app.soulsync = { 'spotify_client': spotify_client, 'download_orchestrator': download_orchestrator, 'tidal_client': tidal_client, 'matching_engine': matching_engine, 'config_manager': config_manager, 'automation_engine': automation_engine, 'hydrabase_client': None, # updated after Hydrabase init 'hydrabase_worker': None, # updated after Hydrabase init } logger.info("Public REST API v1 registered at /api/v1") except Exception as e: logger.error(f"Public REST API v1 failed to register: {e}") # --- Automation Progress Tracking --- # State + helpers live in core/automation/progress.py. Re-exported here # so the existing call sites (registered as engine progress callbacks # and used inside _record_automation_history) keep resolving. from core.automation import progress as _auto_progress automation_progress_states = _auto_progress.progress_states automation_progress_lock = _auto_progress.progress_lock def _init_automation_progress(automation_id, automation_name, action_type): """Initialize progress state when an automation starts running.""" _auto_progress.init_progress(automation_id, automation_name, action_type) def _update_automation_progress(automation_id, **kwargs): """Update progress state from handler threads. Thread-safe.""" _auto_progress.update_progress(automation_id, socketio_emit=socketio.emit, **kwargs) # --- Global Matched Downloads Context Management --- # Shared with core.runtime_state so the refactored pipeline and web # server operate on the same context registry. # --- Download Missing Tracks Modal State Management --- # Thread-safe state tracking for modal download functionality with batch management def _get_max_concurrent(): """Get configured max concurrent downloads. Default 3.""" return config_manager.get('download_source.max_concurrent', 3) def _get_batch_max_concurrent(is_album=False, source=None): """Get max concurrent workers for a batch. Soulseek album downloads always use 1 (source reuse per user). Everything else uses the configured setting.""" if is_album and source in ('soulseek', None): # Check if active source is soulseek if source == 'soulseek': return 1 mode = config_manager.get('download_source.mode', 'soulseek') if mode == 'soulseek': return 1 if mode == 'hybrid': hybrid_order = config_manager.get('download_source.hybrid_order', []) or [] if isinstance(hybrid_order, str): hybrid_order = [hybrid_order] first_source = next((str(s).strip().lower() for s in hybrid_order if str(s).strip()), '') if first_source == 'soulseek': return 1 return _get_max_concurrent() # --- Session Download Statistics --- # Track individual download completions (matches dashboard.py behavior) def _mark_task_completed(task_id, track_info=None): """ Mark a download task as completed and increment session counter. Centralizes completion logic to ensure consistent behavior. Assumes task_id exists in download_tasks (should be called within tasks_lock). """ global session_completed_downloads mark_task_completed(task_id, track_info) # Increment session counter (matches dashboard.py behavior) with session_stats_lock: session_completed_downloads += 1 # --- Automatic Wishlist Processing Infrastructure --- # Server-side timer system for automatic wishlist processing (replaces client-side JavaScript timers) # --- Automatic Wishlist/Watchlist Processing Flags --- # Processing state flags (guards/recovery — timers are now managed by AutomationEngine) # --- Shared Transfer Data Cache --- # Cache transfer data to avoid hammering the Soulseek API with multiple concurrent modals def get_cached_transfer_data(): """ Get transfer data with caching to reduce API calls when multiple modals are active. Returns a lookup dictionary for efficient transfer matching. """ current_time = time.time() with transfer_data_cache['update_lock']: # Check if cache is still valid if (current_time - transfer_data_cache['last_update']) < transfer_data_cache['cache_duration']: return transfer_data_cache['data'] # Cache expired or empty, fetch new data live_transfers_lookup = {} try: # Skip Soulseek API call if not active or known disconnected _dl_mode = config_manager.get('download_source.mode', 'hybrid') _hybrid_order = config_manager.get('download_source.hybrid_order', ['hifi', 'youtube', 'soulseek']) _slsk_active = (_dl_mode == 'soulseek' or (_dl_mode == 'hybrid' and 'soulseek' in _hybrid_order)) soulseek_known_down = not _slsk_active or not _status_cache.get('soulseek', {}).get('connected', True) # First, get Soulseek downloads from API transfers_data = None _slsk = download_orchestrator.client("soulseek") if download_orchestrator else None if not soulseek_known_down and _slsk and _slsk.base_url: transfers_data = run_async(download_orchestrator._make_request('GET', 'transfers/downloads')) if transfers_data: all_transfers = [] for user_data in transfers_data: username = user_data.get('username', 'Unknown') if 'directories' in user_data: for directory in user_data['directories']: if 'files' in directory: for file_info in directory['files']: file_info['username'] = username all_transfers.append(file_info) for transfer in all_transfers: key = _make_context_key(transfer.get('username'), transfer.get('filename', '')) live_transfers_lookup[key] = transfer # Also add non-Soulseek downloads. Soulseek is excluded # because slskd's transfers endpoint was already pulled # above — without the exclude both fetch paths run. # Every streaming source must appear here — task progress # for in-flight downloads comes from this lookup. Missing a # source = task.progress stays at 0 even when the # underlying client knows the real percent. try: all_downloads = [] if download_orchestrator and hasattr(download_orchestrator, 'engine'): try: all_downloads = run_async( download_orchestrator.engine.get_all_downloads(exclude=('soulseek',)) ) except Exception as e: logger.debug("get_all_downloads failed: %s", e) for download in all_downloads: key = _make_context_key(download.username, download.filename) # Convert DownloadStatus to transfer dict format live_transfers_lookup[key] = { 'id': download.id, 'filename': download.filename, 'username': download.username, 'state': download.state, 'percentComplete': download.progress, 'size': download.size, 'bytesTransferred': download.transferred, 'averageSpeed': download.speed, } except Exception as e: logger.error(f"Could not fetch streaming source downloads: {e}") # Update cache transfer_data_cache['data'] = live_transfers_lookup transfer_data_cache['last_update'] = current_time except Exception as e: logger.error(f"Could not fetch live transfers (cached): {e}") # Return empty dict on error, but don't update cache timestamp # This way we'll retry on the next request return {} return live_transfers_lookup # --- Beatport Data Cache --- # Cache Beatport scraping data to reduce load times and avoid hammering Beatport.com def get_cached_beatport_data(section_type, data_key, genre_slug=None): """ Get Beatport data from cache if valid, otherwise return None. Args: section_type: 'homepage' or 'genre' data_key: specific data type (e.g., 'hero_tracks', 'top_10_lists') genre_slug: only used for genre section_type Returns: Cached data if valid, None if cache miss or expired """ current_time = time.time() with beatport_data_cache['cache_lock']: try: if section_type == 'homepage': cache_entry = beatport_data_cache['homepage'].get(data_key) elif section_type == 'genre' and genre_slug: cache_entry = beatport_data_cache['genre'].get(genre_slug, {}).get(data_key) else: return None if not cache_entry: return None # Check if cache is still valid age = current_time - cache_entry['timestamp'] if age < cache_entry['ttl'] and cache_entry['data'] is not None: logger.debug(f"Cache HIT for {section_type}/{data_key} (age: {age:.1f}s)") return cache_entry['data'] else: logger.debug(f"⏰ Cache MISS for {section_type}/{data_key} (age: {age:.1f}s, ttl: {cache_entry['ttl']}s)") return None except Exception as e: logger.error(f"Cache lookup error for {section_type}/{data_key}: {e}") return None def set_cached_beatport_data(section_type, data_key, data, genre_slug=None): """ Store Beatport data in cache with current timestamp. Args: section_type: 'homepage' or 'genre' data_key: specific data type (e.g., 'hero_tracks', 'top_10_lists') data: the data to cache genre_slug: only used for genre section_type """ current_time = time.time() with beatport_data_cache['cache_lock']: try: if section_type == 'homepage': if data_key in beatport_data_cache['homepage']: beatport_data_cache['homepage'][data_key]['data'] = data beatport_data_cache['homepage'][data_key]['timestamp'] = current_time logger.info(f"Cached {section_type}/{data_key} (ttl: {beatport_data_cache['homepage'][data_key]['ttl']}s)") elif section_type == 'genre' and genre_slug: # Initialize genre cache if not exists if genre_slug not in beatport_data_cache['genre']: beatport_data_cache['genre'][genre_slug] = {} # For genre caching, we need to define TTL structure (for future use) if data_key not in beatport_data_cache['genre'][genre_slug]: beatport_data_cache['genre'][genre_slug][data_key] = { 'data': None, 'timestamp': 0, 'ttl': 600 # Default 10 minutes } beatport_data_cache['genre'][genre_slug][data_key]['data'] = data beatport_data_cache['genre'][genre_slug][data_key]['timestamp'] = current_time logger.info(f"Cached {section_type}/{genre_slug}/{data_key}") except Exception as e: logger.error(f"Cache storage error for {section_type}/{data_key}: {e}") def add_cache_headers(response, cache_duration=300): """ Add HTTP cache control headers to responses for browser-side caching. Args: response: Flask response object cache_duration: Cache duration in seconds (default: 5 minutes) """ response.headers['Cache-Control'] = f'public, max-age={cache_duration}' response.headers['Pragma'] = 'cache' return response # Enhanced-search cache + helpers live in core/search/cache.py. # Re-exported here so the existing call sites in this module still resolve. from core.search.cache import ( get_cache_key as _get_enhanced_search_cache_key_impl, get_cached_response as _get_cached_enhanced_search_response, set_cached_response as _set_cached_enhanced_search_response, ) from core.search.orchestrator import VALID_SOURCES as ENHANCED_SEARCH_VALID_SOURCES def _get_enhanced_search_cache_key(query, requested_source=None): """Thin wrapper that wires live config providers into the cache-key builder.""" return _get_enhanced_search_cache_key_impl( query, requested_source, active_server_provider=config_manager.get_active_media_server, fallback_source_provider=_get_metadata_fallback_source, hydrabase_active_provider=_is_hydrabase_active, ) # --- Background Download Monitoring (GUI Parity) --- from core.downloads.monitor import ( WebUIDownloadMonitor, init as _init_download_monitor, ) import core.downloads.monitor as _download_monitor_module # Global download monitor instance download_monitor = WebUIDownloadMonitor() def validate_and_heal_batch_states(): """ Periodic validation and healing of batch states to prevent permanent inconsistencies. This is the server-side equivalent of the frontend's worker count validation. """ try: if globals().get('IS_SHUTTING_DOWN', False): return import time current_time = time.time() # Collect work to do outside the lock batches_needing_workers = [] # batch_ids that need _start_next_batch_of_downloads batches_needing_completion_check = [] # batch_ids that need _check_batch_completion_v2 with tasks_lock: healed_batches = [] batches_to_cleanup = [] for batch_id, batch_data in list(download_batches.items()): active_count = batch_data.get('active_count', 0) queue = batch_data.get('queue', []) phase = batch_data.get('phase', 'unknown') # AUTO-CLEANUP: Remove terminal batches after 5 minutes to prevent stale state. # 'failed' (e.g. an album-bundle hard failure) was missing here, so a failed # batch lingered in the UI forever ("No tracks loaded") and never cleared. if phase in ['complete', 'error', 'cancelled', 'failed']: # Check if batch has a completion timestamp completion_time = batch_data.get('completion_time') if not completion_time: # Set completion time if not set batch_data['completion_time'] = current_time else: # Check if batch has been complete for >5 minutes time_since_completion = current_time - completion_time if time_since_completion > 300: # 5 minutes logger.warning(f"[Auto-Cleanup] Removing stale completed batch {batch_id} (completed {time_since_completion:.0f}s ago)") batches_to_cleanup.append(batch_id) continue # Skip other healing logic for this batch # Count actually active tasks actually_active = 0 orphaned_tasks = [] # Respect _on_download_completed dedup set — don't re-inflate active_count completed_task_ids = batch_data.get('_completed_task_ids', set()) for task_id in queue: if task_id in download_tasks: task_status = download_tasks[task_id]['status'] if task_status in ['searching', 'downloading', 'queued', 'post_processing']: if task_id not in completed_task_ids: actually_active += 1 elif task_status in ['failed', 'completed', 'cancelled', 'not_found']: orphaned_tasks.append(task_id) else: # Task in queue but not in download_tasks dict orphaned_tasks.append(task_id) # Check for inconsistencies if active_count != actually_active: logger.info(f"[Batch Healing] {batch_id}: fixing active count {active_count} → {actually_active}") batch_data['active_count'] = actually_active healed_batches.append(batch_id) # If we freed up slots, defer starting workers to outside the lock if actually_active < batch_data.get('max_concurrent', 3): queue_index = batch_data.get('queue_index', 0) if queue_index < len(queue): batches_needing_workers.append(batch_id) # Clean up orphaned tasks that are blocking progress if orphaned_tasks and phase == 'downloading': logger.warning(f"[Batch Healing] Found {len(orphaned_tasks)} orphaned tasks in active batch {batch_id}") batches_needing_completion_check.append(batch_id) # Cleanup stale batches inside the lock (safe - just dict mutations) for batch_id in batches_to_cleanup: task_ids_to_remove = download_batches[batch_id].get('queue', []) del download_batches[batch_id] # Clean up associated tasks for task_id in task_ids_to_remove: if task_id in download_tasks: del download_tasks[task_id] if batches_to_cleanup: logger.warning(f"[Auto-Cleanup] Removed {len(batches_to_cleanup)} stale completed batches") if healed_batches: logger.info(f"[Batch Healing] Healed {len(healed_batches)} batches: {healed_batches}") # ---- All work below runs WITHOUT tasks_lock held ---- # Start replacement workers for healed batches for batch_id in batches_needing_workers: try: logger.info(f"[Batch Healing] Starting replacement workers for {batch_id}") _start_next_batch_of_downloads(batch_id) except Exception as e: logger.error(f"[Batch Healing] Error starting workers for {batch_id}: {e}") # Trigger completion checks for batches with orphaned tasks for batch_id in batches_needing_completion_check: try: logger.warning("[Batch Healing] Triggering completion check for batch with orphaned tasks") _check_batch_completion_v2(batch_id) except Exception as e: logger.error(f"[Batch Healing] Error checking completion for {batch_id}: {e}") except Exception as healing_error: logger.error(f"[Batch Healing] Error during validation: {healing_error}") # Start periodic batch healing (every 30 seconds) import threading _batch_healing_timer = None _batch_healing_timer_lock = threading.Lock() def _schedule_batch_healing_timer(delay_seconds=30.0): """Schedule the next batch healing cycle.""" global _batch_healing_timer if globals().get('IS_SHUTTING_DOWN', False): return timer = threading.Timer(delay_seconds, start_batch_healing_timer) timer.daemon = True with _batch_healing_timer_lock: _batch_healing_timer = timer timer.start() def _cancel_batch_healing_timer(): """Cancel the current batch healing timer if one exists.""" global _batch_healing_timer with _batch_healing_timer_lock: timer = _batch_healing_timer _batch_healing_timer = None if timer: timer.cancel() def start_batch_healing_timer(): """Start periodic batch state validation and healing""" try: if globals().get('IS_SHUTTING_DOWN', False): return validate_and_heal_batch_states() except Exception as e: logger.error(f"[Batch Healing Timer] {e}") finally: # Schedule next healing cycle _schedule_batch_healing_timer(30.0) # Start the healing timer when the server starts start_batch_healing_timer() # Cleanup handler for Flask shutdown/reload import atexit import signal import sys def cleanup_monitor(): """Clean up background monitor on shutdown""" if download_monitor.monitoring: logger.info("Flask shutdown detected, stopping download monitor...") download_monitor.shutdown() # Give the thread a moment to exit cleanly time.sleep(0.5) # Clean up batch locks to prevent memory leaks try: acquired = tasks_lock.acquire(timeout=1.0) if acquired: try: batch_locks.clear() logger.info("Cleaned up batch locks") finally: tasks_lock.release() else: logger.warning("Skipped batch lock cleanup - tasks_lock busy") except Exception as e: logger.error(f"Error cleaning up batch locks: {e}") # Global shutdown flag def _shutdown_executor(executor, name): """Shut down a ThreadPoolExecutor without waiting for long-running tasks.""" if executor is None: return try: logger.info(f"Shutting down {name}...") executor.shutdown(wait=False, cancel_futures=True) except Exception as e: logger.error(f"Error shutting down {name}: {e}") def _stop_component(component, name, method_names=("stop", "shutdown")): """Call a best-effort stop method on a component if it has one.""" if component is None: return for method_name in method_names: method = getattr(component, method_name, None) if callable(method): try: logger.info(f"Stopping {name}...") method() except Exception as e: logger.error(f"Error stopping {name}: {e}") return def _stop_components_parallel(components): """Stop multiple components concurrently and wait for all stop calls to finish.""" stop_threads = [] for component, name in components: if component is None: continue thread = threading.Thread( target=_stop_component, args=(component, name), name=f"shutdown-{name.replace(' ', '-')}", ) thread.start() stop_threads.append((name, thread)) for _name, thread in stop_threads: thread.join() def _shutdown_runtime_components(): """Best-effort shutdown for timers, monitors, workers, and executors.""" global IS_SHUTTING_DOWN if IS_SHUTTING_DOWN: return IS_SHUTTING_DOWN = True _download_monitor_module.IS_SHUTTING_DOWN = True _cancel_batch_healing_timer() cleanup_monitor() _stop_component(web_scan_manager, "web scan manager") _stop_component(automation_engine, "automation engine") # Persist API call history before shutting down worker pools. try: from core.api_call_tracker import api_call_tracker api_call_tracker.save() logger.info("API call history saved") except Exception as e: logger.error(f"Error saving API call history: {e}") # Stop the active DB update worker before tearing down the executor it runs on. # This lets an in-flight update observe should_stop and exit cleanly. _stop_component(db_update_worker, "db update worker") _stop_component(metadata_update_runtime_worker, "metadata update worker") # Stop long-lived worker components in parallel so shutdown waits for the # slowest worker instead of serially burning the timeout for each one. _stop_components_parallel([ (mb_worker, "musicbrainz worker"), (audiodb_worker, "audiodb worker"), (discogs_worker, "discogs worker"), (deezer_worker, "deezer worker"), (spotify_enrichment_worker, "spotify enrichment worker"), (itunes_enrichment_worker, "itunes enrichment worker"), (lastfm_worker, "lastfm worker"), (genius_worker, "genius worker"), (tidal_enrichment_worker, "tidal enrichment worker"), (qobuz_enrichment_worker, "qobuz enrichment worker"), (hydrabase_worker, "hydrabase worker"), (soulid_worker, "soulid worker"), (listening_stats_worker, "listening stats worker"), (repair_worker, "repair worker"), ]) # Shut down executor pools so their worker threads stop keeping the process alive. for executor, name in [ (stream_executor, "stream executor"), (db_update_executor, "db update executor"), (duplicate_cleaner_executor, "duplicate cleaner executor"), (sync_executor, "sync executor"), (missing_download_executor, "missing download executor"), (album_bundle_executor, "album bundle executor"), (import_singles_executor, "import singles executor"), (tidal_discovery_executor, "tidal discovery executor"), (deezer_discovery_executor, "deezer discovery executor"), (qobuz_discovery_executor, "qobuz discovery executor"), (spotify_public_discovery_executor, "spotify public discovery executor"), (youtube_discovery_executor, "youtube discovery executor"), (beatport_discovery_executor, "beatport discovery executor"), (listenbrainz_discovery_executor, "listenbrainz discovery executor"), (similar_artists_executor, "similar artists executor"), (metadata_update_executor, "metadata update executor"), ]: _shutdown_executor(executor, name) # Give daemon cleanup threads a moment to observe the shutdown flag. time.sleep(0.2) def signal_handler(signum, frame): """Handle SIGINT (Ctrl+C) and SIGTERM""" logger.info(f"Signal {signum} received, cleaning up...") _shutdown_runtime_components() sys.exit(0) # Register cleanup handlers def _atexit_save_history(): try: from core.api_call_tracker import api_call_tracker api_call_tracker.save() except Exception: # noqa: S110 — atexit handler, log handles may be closed pass def _atexit_shutdown(): try: _shutdown_runtime_components() except Exception: # noqa: S110 — atexit handler, log handles may be closed pass def _atexit_silence_shutdown_logger_errors(): # Pytest tears down log file handles before atexit fires, so every # "Shutting down ..." line a worker emits while stopping crashes # Python's logger with "I/O operation on closed file" and floods # CI stderr. The messages themselves are best-effort debug # breadcrumbs, not data we need to preserve at process exit. # Registered last so atexit's LIFO order makes this run FIRST, # ahead of cleanup_monitor / _atexit_shutdown / _atexit_save_history. import logging as _logging _logging.raiseExceptions = False atexit.register(_atexit_save_history) atexit.register(_atexit_shutdown) atexit.register(cleanup_monitor) # atexit runs in LIFO order — register the silencer LAST so it runs # FIRST, before any other shutdown handler emits its "Shutting down" # log line into a stream pytest already closed. atexit.register(_atexit_silence_shutdown_logger_errors) signal.signal(signal.SIGINT, signal_handler) signal.signal(signal.SIGTERM, signal_handler) def _update_task_status(task_id, new_status): """Helper to update task status and timestamp for timeout tracking""" with tasks_lock: if task_id in download_tasks: download_tasks[task_id]['status'] = new_status download_tasks[task_id]['status_change_time'] = time.time() # --- Album Grouping State Management (Ported from GUI) --- # Thread-safe album grouping for consistent naming across tracks album_cache_lock = threading.Lock() album_groups = {} # album_key -> final_album_name album_artists = {} # album_key -> artist_name album_editions = {} # album_key -> "standard" or "deluxe" album_name_cache = {} # album_key -> cached_final_name # Regexes to strip edition suffixes for cache key normalization. # Prevents Navidrome splitting albums when tracks get different MusicBrainz release IDs. import re as _re # Strip parenthetical/bracketed editions: "Album (Deluxe Edition)" → "Album" _EDITION_PAREN_RE = _re.compile( r'\s*[\(\[]' r'[^)\]]*' r'(?:deluxe|expanded|remaster(?:ed)?|anniversary|special|collector|' r'limited|bonus|platinum|gold|super\s*deluxe|standard|edition)' r'[^)\]]*' r'[\)\]]', _re.IGNORECASE ) # Strip trailing bare editions (no parens): "Album Deluxe Edition" → "Album" _EDITION_BARE_RE = _re.compile( r'\s+(?:-\s+)?(?:deluxe|expanded|remaster(?:ed)?|anniversary|special|collector|' r'limited|bonus|platinum|gold|super\s*deluxe|standard)' r'(?:\s+(?:edition|version))?\s*$', _re.IGNORECASE ) # Stream-prep worker logic lives in core/streaming/prepare.py. from core.streaming import prepare as _streaming_prepare def _build_prepare_stream_deps(sess, sid): """Build the PrepareStreamDeps bundle for a specific stream session. ``sess`` is the StreamSession (dict-compatible) for this listener; ``sid`` is its id, used to give each session its own ``Stream/`` staging subdir so concurrent listeners don't clear each other's files. """ def _get_stream_state(): return sess def _set_stream_state(value): # prepare.py only mutates in place (.update / [k]=) so this is # effectively dead — but if anything reassigns, route it through the # session's replace() so the store keeps the live object. if value is sess: return sess.replace(dict(value)) base_root = os.path.dirname(os.path.abspath(__file__)) # prepare.py stages into /Stream. Default session keeps the # historical flat Stream/; a named session stages under Stream//Stream so # concurrent listeners never clear each other's files. (The served file_path # is absolute, so staging location only affects isolation/cleanup.) project_root = base_root if sid == _DEFAULT_STREAM_SESSION else os.path.join(base_root, 'Stream', sid) return _streaming_prepare.PrepareStreamDeps( config_manager=config_manager, download_orchestrator=download_orchestrator, stream_lock=sess.lock, project_root=project_root, docker_resolve_path=docker_resolve_path, find_streaming_download_in_all_downloads=_find_streaming_download_in_all_downloads, find_downloaded_file=_find_downloaded_file, extract_filename=extract_filename, cleanup_empty_directories=_cleanup_empty_directories, _get_stream_state=_get_stream_state, _set_stream_state=_set_stream_state, ) def _prepare_stream_task(track_data, sess, sid): return _streaming_prepare.prepare_stream_task(track_data, _build_prepare_stream_deps(sess, sid)) def _find_streaming_download_in_all_downloads(all_downloads, track_data): """ Find streaming download in DownloadStatus list (works for Soulseek, YouTube, and Tidal). Replaces the old _find_streaming_download_in_transfers function. """ try: if not all_downloads: return None # Look for our specific file by filename and username target_filename = extract_filename(track_data.get('filename', '')) target_username = track_data.get('username', '') for download in all_downloads: download_filename = extract_filename(download.filename) download_username = download.username if (download_filename == target_filename and download_username == target_username): # Convert DownloadStatus to dict format expected by caller return { 'percentComplete': download.progress, 'state': download.state, 'size': download.size, 'bytesTransferred': download.transferred, 'averageSpeed': download.speed, } return None except Exception as e: logger.error(f"Error finding streaming download: {e}") return None def _find_downloaded_file(download_path, track_data): """Find the downloaded audio file in the downloads directory tree (works for Soulseek, YouTube, and Tidal)""" # Ensure path is accessible in Docker (handles E:/ -> /host/mnt/e/) download_path = docker_resolve_path(download_path) audio_extensions = {'.mp3', '.flac', '.ogg', '.aac', '.wma', '.wav', '.m4a'} target_filename = extract_filename(track_data.get('filename', '')) # YOUTUBE/TIDAL/QOBUZ/HIFI/AMAZON SUPPORT: Handle encoded filename format "id||title" # The file on disk will be "title.ext", not "id||title" is_youtube = track_data.get('username') == 'youtube' is_tidal = track_data.get('username') == 'tidal' is_qobuz = track_data.get('username') == 'qobuz' is_hifi = track_data.get('username') == 'hifi' is_amazon = track_data.get('username') == 'amazon' is_streaming_source = is_youtube or is_tidal or is_qobuz or is_hifi or is_amazon target_filename_youtube = None if is_streaming_source and '||' in target_filename: _, title = target_filename.split('||', 1) if is_tidal or is_qobuz or is_hifi or is_amazon: # Tidal/Qobuz/HiFi/Amazon files can be flac, opus, eac3, or m4a — match any audio extension safe_title = re.sub(r'[<>:"/\\|?*]', '_', title) target_filename_youtube = safe_title # Extension-less for flexible matching source_name = 'HiFi' if is_hifi else ('Qobuz' if is_qobuz else ('Amazon' if is_amazon else 'Tidal')) logger.debug(f"[{source_name} Stream] Looking for file starting with: {target_filename_youtube}") else: # yt-dlp will create "Title.mp3" from "Title" target_filename_youtube = f"{title}.mp3" logger.debug(f"[YouTube Stream] Looking for file: {target_filename_youtube}") elif is_streaming_source: # Fallback: if streaming source but no encoded format, use as-is target_filename_youtube = target_filename logger.debug(f"[Stream] Using direct filename: {target_filename_youtube}") try: # Walk through the downloads directory to find the file best_match = None best_similarity = 0.0 for root, _dirs, files in os.walk(download_path): for file in files: # Skip non-audio files if os.path.splitext(file)[1].lower() not in audio_extensions: continue file_path = os.path.join(root, file) # Skip empty files try: if os.path.getsize(file_path) < 1024: # At least 1KB continue except: continue # Check if this is our target file if is_streaming_source and target_filename_youtube: # For YouTube/Tidal, use fuzzy matching (case-insensitive, flexible) from difflib import SequenceMatcher # For Tidal, compare without extension (file could be .flac or .m4a) compare_target = target_filename_youtube.lower() compare_file = file.lower() if is_tidal or is_qobuz or is_hifi or is_amazon: compare_file = os.path.splitext(compare_file)[0] similarity = SequenceMatcher(None, compare_file, compare_target).ratio() source_label = 'HiFi' if is_hifi else ('Qobuz' if is_qobuz else ('Amazon' if is_amazon else ('Tidal' if is_tidal else 'YouTube'))) logger.debug(f"[{source_label} Stream] Comparing: '{file}' vs '{target_filename_youtube}' = {similarity:.2f}") # Keep track of best match if similarity > best_similarity: best_similarity = similarity best_match = file_path # If we have a very good match (95%+), use it immediately if similarity >= 0.95: logger.debug(f"Found excellent match for streaming file: {file_path}") return file_path else: # For Soulseek, exact match if file == target_filename: logger.debug(f"Found streaming file: {file_path}") return file_path # For YouTube/Tidal, if we found a good enough match (80%+), use it if is_streaming_source and best_match and best_similarity >= 0.80: source_label = 'Qobuz' if is_qobuz else ('Amazon' if is_amazon else ('Tidal' if is_tidal else 'YouTube')) logger.debug(f"Found good match ({best_similarity:.2f}) for {source_label} streaming file: {best_match}") return best_match logger.error(f"Could not find downloaded file: {target_filename}") if is_streaming_source: logger.debug(f" Looking for: {target_filename_youtube}") logger.debug(f" Best similarity: {best_similarity:.2f}") return None except Exception as e: logger.error(f"Error searching for downloaded file: {e}") return None # --- Refactored Logic from GUI Threads --- # This logic is extracted from the database update worker to be used directly by Flask. # ── Settings Connection Status Registry ── # Maps each service shown in Settings → Connections to its config requirements. # Used by _is_service_configured() to drive the green/yellow header gradient. # # Registry entry shapes: # {'required': [keys]} — green if all keys populated in config_manager.get(service) # {'always': True} — always green (no credentials required, e.g. default-storefront iTunes) # {'custom': callable} — callable(service_name) -> bool, for services with non-field checks (e.g. token file) # {'any_of': [[keys_a], [keys_b]]} — green if any one group's keys are all populated (e.g. Qobuz: email+password OR token) SERVICE_CONFIG_REGISTRY = { 'spotify': {'required': ['client_id', 'client_secret']}, 'itunes': {'always': True}, # default storefront works anon 'deezer': {'always': True}, # anon search works, premium ARL is optional 'musicbrainz': {'always': True}, # public API, no credentials required 'amazon': {'always': True}, # T2Tunes proxy, no credentials required 'discogs': {'required': ['token']}, 'tidal': {'custom': lambda _svc: _tidal_has_auth_token()}, 'qobuz': {'any_of': [['email', 'password'], ['token'], ['user_auth_token']]}, 'lastfm': {'required': ['api_key']}, 'genius': {'required': ['access_token']}, 'acoustid': {'required': ['api_key']}, 'listenbrainz': {'required': ['token']}, 'hydrabase': {'required': ['url', 'api_key']}, # Soulseek (slskd) needs a base URL. Used by the search source picker # to dim Soulseek and redirect to Settings when the user has no slskd # configured — clicking it would otherwise fire searches that always # fail. URL field lives on Settings → Downloads, gated behind the # download-source-mode dropdown. 'soulseek': {'required': ['slskd_url']}, } def _tidal_has_auth_token() -> bool: """Check if Tidal has a cached OAuth token. Tidal uses a token file, not config fields.""" try: return bool(tidal_client and tidal_client.is_authenticated()) except Exception: return False def _is_service_configured(service: str) -> bool: """Return True if the user has provided the required credentials for this service. Drives the green/yellow header gradient on the Connections tab. """ entry = SERVICE_CONFIG_REGISTRY.get(service) if not entry: return False if entry.get('always'): return True if 'custom' in entry: try: return bool(entry['custom'](service)) except Exception: return False service_config = config_manager.get(service, {}) or {} if 'required' in entry: return all(bool(service_config.get(key)) for key in entry['required']) if 'any_of' in entry: for key_group in entry['any_of']: if all(bool(service_config.get(key)) for key in key_group): return True return False return False from core.connection_test import ( run_service_test, init as _init_connection_test, ) from core.connection_detect import run_detection # --- Web UI Routes --- @app.route('/') def index(): return render_template('index.html') @app.route('/sw.js') def service_worker(): """Serve sw.js from root scope so the service worker can intercept fetches site-wide. A service worker only controls URLs at or below its own served path — `/static/sw.js` would scope to `/static/*` only. Serving from `/sw.js` (with the file living under static/) grants full-site scope without needing the Service-Worker-Allowed header dance. Cache-Control: no-cache so deploys that change the SW propagate on the next page load instead of being pinned by the 1-year static cache the rest of /static/ uses. """ response = send_from_directory(app.static_folder, 'sw.js', mimetype='application/javascript') response.headers['Cache-Control'] = 'no-cache' return response @app.route('/') def spa_catch_all(page): # Serve index.html for client-side routes; let Flask handle real routes first. if not should_serve_webui_spa(f'/{page}'): abort(404) return index() # --- API Endpoints --- # Tracks cumulative item-processed totals over time for windowed counting. # Each entry: (timestamp, cumulative_total). Polled every ~5s, 24h = ~17280 entries. def _get_windowed_calls(key, current_total): """Record current cumulative total and return (calls_1h, calls_24h). Deque stores (timestamp, cumulative_total) in chronological order. To get calls in a window: current_total minus the oldest total within that window.""" now = time.time() history = _enrichment_activity_log.setdefault(key, collections.deque(maxlen=17300)) history.append((now, current_total)) cutoff_1h = now - 3600 cutoff_24h = now - 86400 # Forward scan: first entry with ts >= cutoff is the oldest in that window oldest_1h_total = current_total oldest_24h_total = current_total found_24h = False for ts, total in history: if not found_24h and ts >= cutoff_24h: oldest_24h_total = total found_24h = True if ts >= cutoff_1h: oldest_1h_total = total break return max(0, current_total - oldest_1h_total), max(0, current_total - oldest_24h_total) def _get_enrichment_status(): """Get lightweight status for all enrichment services (no DB queries). Reads worker properties directly to avoid expensive get_stats() calls.""" services = {} # Worker-based enrichment services: (key, display_name, worker_var) workers_info = [ ('musicbrainz', 'MusicBrainz', lambda: mb_worker), ('spotify_enrichment', 'Spotify', lambda: spotify_enrichment_worker), ('itunes_enrichment', 'iTunes', lambda: itunes_enrichment_worker), ('deezer_enrichment', 'Deezer', lambda: deezer_worker), ('tidal_enrichment', 'Tidal', lambda: tidal_enrichment_worker), ('qobuz_enrichment', 'Qobuz', lambda: qobuz_enrichment_worker), ('lastfm', 'Last.fm', lambda: lastfm_worker), ('genius', 'Genius', lambda: genius_worker), ('audiodb', 'AudioDB', lambda: audiodb_worker), ('discogs', 'Discogs', lambda: discogs_worker), ('amazon_enrichment', 'Amazon Music', lambda: amazon_worker), ] # Config-based "configured" checks for services that need API keys/credentials configured_checks = { 'spotify_enrichment': lambda: bool(config_manager.get('spotify.client_id') and config_manager.get('spotify.client_secret')), 'tidal_enrichment': lambda: bool(tidal_client and getattr(tidal_client, 'access_token', None)), 'qobuz_enrichment': lambda: bool(qobuz_enrichment_worker and qobuz_enrichment_worker.client and qobuz_enrichment_worker.client.user_auth_token), 'lastfm': lambda: bool(config_manager.get('lastfm.api_key', '')), 'genius': lambda: bool(config_manager.get('genius.access_token', '')), } for key, name, get_worker in workers_info: worker = get_worker() if worker is not None: is_alive = worker.thread is not None and worker.thread.is_alive() try: configured = configured_checks.get(key, lambda: True)() except Exception: configured = False # Compute windowed API call counts from cumulative stats stats = worker.stats total_processed = stats.get('matched', 0) + stats.get('not_found', 0) + stats.get('errors', 0) calls_1h, calls_24h = _get_windowed_calls(key, total_processed) # Debounced idle detection — only report idle after 5s of no current_item has_item = getattr(worker, 'current_item', None) is not None if has_item: _idle_since.pop(key, None) is_idle = False else: if key not in _idle_since: _idle_since[key] = time.time() is_idle = (time.time() - _idle_since[key]) >= _IDLE_GRACE_SECONDS svc_data = { 'name': name, 'configured': configured, 'running': worker.running and is_alive and not worker.paused, 'paused': worker.paused, 'idle': is_alive and not worker.paused and is_idle, 'calls_1h': calls_1h, 'calls_24h': calls_24h, } # Spotify-specific: include daily budget info if key == 'spotify_enrichment': try: svc_data['daily_budget'] = worker._get_daily_budget_info() except Exception as e: logger.debug("spotify daily budget read failed: %s", e) services[key] = svc_data else: services[key] = { 'name': name, 'configured': False, 'running': False, 'paused': False, 'idle': False, 'calls_1h': 0, 'calls_24h': 0, } # Non-worker services (configured status only) services['acoustid'] = { 'name': 'AcoustID', 'configured': bool(config_manager.get('acoustid.api_key', '')), } services['listenbrainz'] = { 'name': 'ListenBrainz', 'configured': bool(config_manager.get('listenbrainz.token', '')), } return services # Status check caching to reduce unnecessary API calls @app.route('/status') def get_status(): if not config_manager: return jsonify({"error": "Server configuration manager is not initialized."}), 500 try: import time current_time = time.time() active_server = config_manager.get_active_media_server() metadata_status = get_metadata_status_snapshot(spotify_client=spotify_client) # Test media server — engine reads active_server config + dispatches # to the right client (with internal connection caching). Engine # returns False safely if the active client is None / not registered. if current_time - _status_cache_timestamps['media_server'] > STATUS_CACHE_TTL: media_server_start = time.time() media_server_status = ( media_server_engine.is_connected() if media_server_engine else False ) media_server_response_time = (time.time() - media_server_start) * 1000 _status_cache['media_server'] = { 'connected': media_server_status, 'response_time': round(media_server_response_time, 1), 'type': active_server } _status_cache_timestamps['media_server'] = current_time # else: use cached value download_mode = config_manager.get('download_source.mode', 'hybrid') hybrid_order = config_manager.get('download_source.hybrid_order', ['hifi', 'youtube', 'soulseek']) if isinstance(hybrid_order, str): hybrid_order = [hybrid_order] source_cache_key = f"{download_mode}:{','.join(str(s) for s in (hybrid_order or []))}" # Test Soulseek/download source only when the cached source selection is still current. if (current_time - _status_cache_timestamps['soulseek'] > STATUS_CACHE_TTL or _status_cache['soulseek'].get('source_cache_key') != source_cache_key): soulseek_relevant = (download_mode == 'soulseek' or (download_mode == 'hybrid' and 'soulseek' in hybrid_order)) # Serverless sources (YouTube, HiFi, Qobuz, Tidal, Deezer, Lidarr, SoundCloud) # don't depend on slskd being reachable — when one of these is the # active source, surface "connected" without probing slskd so the # dashboard / sidebar indicator stays green. serverless_sources = ('youtube', 'hifi', 'qobuz', 'tidal', 'deezer_dl', 'lidarr', 'soundcloud', 'amazon') is_serverless = (download_mode in serverless_sources or (download_mode == 'hybrid' and hybrid_order and any(s in serverless_sources for s in hybrid_order))) external_client_sources = ('torrent', 'usenet') external_client_relevant = ( download_mode in external_client_sources or (download_mode == 'hybrid' and hybrid_order and any(s in external_client_sources for s in hybrid_order)) ) # Serverless check first — avoids slow slskd timeout when YouTube/HiFi are in hybrid order if is_serverless: soulseek_status = True soulseek_response_time = 0 elif external_client_relevant and download_orchestrator: soulseek_start = time.time() try: soulseek_status = run_async(download_orchestrator.check_connection()) except Exception: soulseek_status = False soulseek_response_time = (time.time() - soulseek_start) * 1000 elif soulseek_relevant and download_orchestrator: soulseek_start = time.time() try: soulseek_status = run_async(download_orchestrator.check_connection()) except Exception: soulseek_status = False soulseek_response_time = (time.time() - soulseek_start) * 1000 else: soulseek_status = False soulseek_response_time = 0 _status_cache['soulseek'] = { 'connected': soulseek_status, 'response_time': round(soulseek_response_time, 1), 'source_cache_key': source_cache_key, } _status_cache_timestamps['soulseek'] = current_time # Include download source mode so frontend can update labels _status_cache['soulseek']['source'] = download_mode soulseek_data = { key: value for key, value in _status_cache['soulseek'].items() if key != 'source_cache_key' } # Count active downloads for nav badge active_dl_count = 0 with tasks_lock: for t in download_tasks.values(): if t.get('status') in ('downloading', 'searching', 'post_processing', 'queued', 'pending'): active_dl_count += 1 status_data = { 'metadata_source': metadata_status['metadata_source'], 'spotify': _spotify_status_with_availability(metadata_status['spotify']), 'media_server': _status_cache['media_server'], 'soulseek': soulseek_data, 'active_media_server': active_server, 'enrichment': _get_enrichment_status(), 'active_downloads': active_dl_count, } return jsonify(status_data) except Exception as e: return jsonify({'error': str(e)}), 500 @app.route('/api/fix-navidrome-urls', methods=['POST']) def fix_navidrome_urls(): """Fix Navidrome artist image URLs to use correct Subsonic format""" try: db = get_database() with db._get_connection() as conn: cursor = conn.cursor() # Get all Navidrome artists with old URL format cursor.execute('SELECT id, name, thumb_url FROM artists WHERE server_source = "navidrome" AND thumb_url LIKE "/api/artist/%"') artists = cursor.fetchall() if not artists: return jsonify({"status": "success", "message": "No URLs needed fixing", "updated": 0}) # Update URLs to new Subsonic format import re updated = 0 examples = [] for artist_id, name, old_url in artists: # Extract artist ID from old URL: /api/artist/ARTIST_ID/image match = re.search(r'/api/artist/([^/]+)/image', old_url) if match: artist_spotify_id = match.group(1) new_url = f'/rest/getCoverArt?id={artist_spotify_id}' cursor.execute('UPDATE artists SET thumb_url = ? WHERE id = ? AND server_source = "navidrome"', (new_url, artist_id)) updated += 1 if len(examples) < 3: # Show first 3 as examples examples.append(f'{name}: {old_url} -> {new_url}') conn.commit() return jsonify({ "status": "success", "message": f"Updated {updated} Navidrome artist URLs to Subsonic format", "updated": updated, "examples": examples }) except Exception as e: return jsonify({"status": "error", "message": str(e)}), 500 def _regenerate_batch_m3u(batch, tracks): """Regenerate M3U file for a completed batch using real library DB paths. Called from batch completion handler after all post-processing is done.""" try: from difflib import SequenceMatcher try: from unidecode import unidecode as _unidecode except ImportError: _unidecode = lambda x: x def _norm(text): return _unidecode(text).lower().strip() if text else '' playlist_name = batch.get('playlist_name', 'Playlist') db = get_database() active_server = config_manager.get_active_media_server() raw_base = config_manager.get('m3u_export.entry_base_path', '') or '' entry_base_path = raw_base.rstrip('/\\') # Resolve file paths from library DB from collections import defaultdict artist_groups = defaultdict(list) for idx, t in enumerate(tracks): artist_groups[t.get('artist', '') or ''].append((idx, t)) file_path_map = {} for artist, group in artist_groups.items(): if not artist: for idx, _ in group: file_path_map[idx] = None continue db_tracks = db.search_tracks(artist=artist, limit=500, server_source=active_server) if not db_tracks: for idx, _ in group: file_path_map[idx] = None continue db_entries = [(_norm(t.title), t) for t in db_tracks] for idx, track in group: name = track.get('name', '') if not name: file_path_map[idx] = None continue s_norm = _norm(name) matched = None for db_n, db_t in db_entries: if s_norm == db_n or SequenceMatcher(None, s_norm, db_n).ratio() >= 0.7: matched = db_t break file_path_map[idx] = matched.file_path if matched else None # Build M3U content import datetime as _dt lines = ['#EXTM3U', f'#PLAYLIST:{playlist_name}', f'#GENERATED:{_dt.datetime.utcnow().isoformat()}Z', ''] found = 0 for idx, track in enumerate(tracks): dur_s = int((track.get('duration_ms', 0) or 0) / 1000) artist = track.get('artist', 'Unknown') name = track.get('name', 'Unknown') lines.append(f'#EXTINF:{dur_s},{artist} - {name}') fp = file_path_map.get(idx) if fp: path = f'{entry_base_path}/{fp}' if entry_base_path else fp lines.append(path) found += 1 else: lines.append(f'# MISSING: {artist} - {name}') lines.append('') if found == 0: return # Don't overwrite with an all-missing M3U m3u_content = '\n'.join(lines) transfer_dir = docker_resolve_path(config_manager.get('soulseek.transfer_path', './Transfer')) m3u_folder = _compute_m3u_folder(transfer_dir, 'playlist', playlist_name, '', '', '') os.makedirs(m3u_folder, exist_ok=True) safe_fn = _sanitize_filename(playlist_name) m3u_path = os.path.join(m3u_folder, f'{safe_fn}.m3u') with open(m3u_path, 'w', encoding='utf-8') as f: f.write(m3u_content) logger.info(f"[M3U] Regenerated M3U on batch complete: {m3u_path} ({found}/{len(tracks)} resolved)") except Exception as e: logger.error(f"[M3U] Error in _regenerate_batch_m3u: {e}") @app.route('/api/save-playlist-m3u', methods=['POST']) def save_playlist_m3u(): """Save M3U playlist file to the relevant download folder""" try: data = request.get_json() if not data: return jsonify({"status": "error", "message": "No data provided"}), 400 playlist_name = data.get('playlist_name', 'Playlist') m3u_content = data.get('m3u_content', '') context_type = data.get('context_type', 'playlist') artist_name = data.get('artist_name', '') album_name = data.get('album_name', '') year = data.get('year', '') force = data.get('force', False) # Check if M3U export is enabled (unless force=True from manual Export button) if not force and not config_manager.get('m3u_export.enabled', False): return jsonify({"status": "success", "message": "M3U export disabled in settings", "skipped": True}) if not m3u_content: return jsonify({"status": "error", "message": "No M3U content provided"}), 400 # Compute target folder using the template system transfer_dir = docker_resolve_path(config_manager.get('soulseek.transfer_path', './Transfer')) m3u_folder = _compute_m3u_folder(transfer_dir, context_type, playlist_name, artist_name, album_name, year) os.makedirs(m3u_folder, exist_ok=True) # Build M3U filename from playlist or album name if context_type == 'album' and artist_name and album_name: safe_filename = _sanitize_filename(f"{artist_name} - {album_name}") else: safe_filename = _sanitize_filename(playlist_name) m3u_filename = f"{safe_filename}.m3u" m3u_path = os.path.join(m3u_folder, m3u_filename) # Write M3U file (overwrite if exists) with open(m3u_path, 'w', encoding='utf-8') as f: f.write(m3u_content) logger.info(f"Saved M3U file: {m3u_path}") return jsonify({ "status": "success", "message": f"M3U file saved: {m3u_filename}", "path": m3u_path }) except Exception as e: logger.error(f"Error saving M3U file: {e}") return jsonify({"status": "error", "message": str(e)}), 500 @app.route('/api/generate-playlist-m3u', methods=['POST']) def generate_playlist_m3u(): """Generate M3U content with real file paths resolved from the library DB. Each track entry uses its actual stored file_path rather than a synthesised Artist - Title.mp3 string, so media servers can locate the files. An optional entry_base_path prefix (from settings) is prepended to every path. """ try: data = request.get_json() if not data: return jsonify({"success": False, "error": "No data"}), 400 playlist_name = data.get('playlist_name', 'Playlist') tracks = data.get('tracks', []) # [{name, artist, duration_ms}, ...] context_type = data.get('context_type', 'playlist') artist_name_ctx = data.get('artist_name', '') album_name = data.get('album_name', '') year = data.get('year', '') save_to_disk = data.get('save_to_disk', False) force = data.get('force', False) # The download modal auto-saves an M3U (save_to_disk, no force) on every # render. When M3U export is disabled it would do nothing anyway — but only # AFTER ~30s of per-track DB search + fuzzy matching below, which it then # throws away (and which, fired repeatedly, jams the analysis). Bail out # immediately for that case. The manual "Export as M3U" sends force=True and # is unaffected; a content-only request (save_to_disk False) also proceeds. if save_to_disk and not force and not config_manager.get('m3u_export.enabled', False): return jsonify({"success": True, "skipped": True, "reason": "m3u_export disabled"}) raw_base = config_manager.get('m3u_export.entry_base_path', '') or '' entry_base_path = raw_base.rstrip('/\\') db = get_database() active_server = config_manager.get_active_media_server() # --- fuzzy matching helpers (same logic as library_check_tracks) --- import re as _re from difflib import SequenceMatcher try: from unidecode import unidecode as _unidecode except ImportError: _unidecode = lambda x: x def _norm(text): return _unidecode(text).lower().strip() if text else '' def _clean(text): s = _norm(text) s = _re.sub(r'\s*[\[\(].*?[\]\)]', '', s) s = _re.sub(r'\s*-\s*', ' ', s) s = _re.sub(r'\s*feat\..*', '', s) s = _re.sub(r'\s*featuring.*', '', s) s = _re.sub(r'\s*ft\..*', '', s) s = _re.sub(r'\s*\d{4}\s*remaster.*', '', s) s = _re.sub(r'\s*remaster(ed)?.*', '', s) return _re.sub(r'\s+', ' ', s).strip() # Resolve each track's library file path. We bulk-load the library ONCE and # match in memory, keyed by cleaned artist, instead of issuing a # search_tracks() query per distinct artist. The per-artist loop could # block for a long time behind the enrichment/scan writers (SQLite lock # contention) — which is exactly why "Export M3U" hung with nothing in the # logs. One WAL-concurrent read can't be starved that way. from collections import defaultdict lib_by_artist = defaultdict(list) for row in db.get_tracks_for_m3u_resolution(server_source=active_server): lib_by_artist[_clean(row['artist'])].append( (_norm(row['title']), _clean(row['title']), row['file_path']) ) file_path_map = {} for idx, track in enumerate(tracks): name = track.get('name', '') or '' artist = track.get('artist', '') or '' if not name or not artist: file_path_map[idx] = None continue candidates = lib_by_artist.get(_clean(artist)) if not candidates: file_path_map[idx] = None continue s_norm, s_clean = _norm(name), _clean(name) matched_path = None for db_n, db_c, fp in candidates: if s_norm == db_n or s_clean == db_c: matched_path = fp break if max(SequenceMatcher(None, s_norm, db_n).ratio(), SequenceMatcher(None, s_clean, db_c).ratio()) >= 0.7: matched_path = fp break file_path_map[idx] = matched_path # --- build M3U content --- import datetime as _dt found_count = 0 missing_count = 0 lines = [ '#EXTM3U', f'#PLAYLIST:{playlist_name}', f'#GENERATED:{_dt.datetime.utcnow().isoformat()}Z', '', ] for idx, track in enumerate(tracks): name = track.get('name', '') or 'Unknown' artist = track.get('artist', '') or 'Unknown Artist' dur_s = int((track.get('duration_ms') or 0) / 1000) or -1 file_path = file_path_map.get(idx) lines.append(f'#EXTINF:{dur_s},{artist} - {name}') if file_path: found_count += 1 lines.append('#STATUS:FOUND_IN_LIBRARY') entry = f'{entry_base_path}/{file_path}' if entry_base_path else file_path lines.append(entry.replace('\\', '/')) else: missing_count += 1 lines.append('#STATUS:MISSING') safe = _re.sub(r'[/\\?%*:|"<>]', '-', f'{artist} - {name}') lines.append(f'# NOT AVAILABLE: {safe}') lines.append('') lines += [ '#SUMMARY', f'#TOTAL_TRACKS:{len(tracks)}', f'#FOUND_IN_LIBRARY:{found_count}', '#DOWNLOADED:0', f'#MISSING:{missing_count}', ] m3u_content = '\n'.join(lines) # --- optionally save to disk --- saved_path = None if save_to_disk and (force or config_manager.get('m3u_export.enabled', False)): transfer_dir = docker_resolve_path(config_manager.get('soulseek.transfer_path', './Transfer')) m3u_folder = _compute_m3u_folder(transfer_dir, context_type, playlist_name, artist_name_ctx, album_name, year) os.makedirs(m3u_folder, exist_ok=True) if context_type == 'album' and artist_name_ctx and album_name: safe_fn = _sanitize_filename(f'{artist_name_ctx} - {album_name}') else: safe_fn = _sanitize_filename(playlist_name) m3u_path = os.path.join(m3u_folder, f'{safe_fn}.m3u') with open(m3u_path, 'w', encoding='utf-8') as f: f.write(m3u_content) saved_path = m3u_path logger.info(f"Saved M3U file: {m3u_path}") return jsonify({ "success": True, "m3u_content": m3u_content, "stats": {"found": found_count, "downloaded": 0, "missing": missing_count}, "path": saved_path }) except Exception as e: logger.error(f"Error generating M3U: {e}") import traceback traceback.print_exc() return jsonify({"success": False, "error": str(e)}), 500 def _build_system_stats(): """Build system statistics dict — shared by HTTP handler and WebSocket emitter.""" import psutil import time from datetime import timedelta # Calculate uptime start_time = getattr(app, 'start_time', time.time()) uptime_seconds = time.time() - start_time uptime = str(timedelta(seconds=int(uptime_seconds))) # Get memory usage memory = psutil.virtual_memory() memory_usage = f"{memory.percent}%" # Count active downloads from download_batches (batches that are currently downloading) active_downloads = len([batch_id for batch_id, batch_data in download_batches.items() if batch_data.get('phase') == 'downloading']) # Count finished downloads from persistent history so the dashboard # survives Docker/container restarts and streaming downloads that leave # the in-memory task tracker after post-processing. try: persistent_finished = int(get_database().get_library_history_stats().get('downloads', 0) or 0) with session_stats_lock: finished_downloads = max(persistent_finished, session_completed_downloads) except Exception: with session_stats_lock: finished_downloads = session_completed_downloads # Calculate total download speed from active soulseek transfers # Skip the slskd API call entirely when Soulseek is not the active download # source — avoids connection timeout spam every 10 seconds for users who have # a slskd URL configured but are using YouTube/Tidal/etc. total_download_speed = 0.0 soulseek_known_down = not _status_cache.get('soulseek', {}).get('connected', True) download_mode = config_manager.get('download_source.mode', 'hybrid') hybrid_order = config_manager.get('download_source.hybrid_order', ['hifi', 'youtube', 'soulseek']) soulseek_active = (download_mode == 'soulseek' or (download_mode == 'hybrid' and 'soulseek' in hybrid_order)) if soulseek_active and not soulseek_known_down: try: transfers_data = run_async(download_orchestrator._make_request('GET', 'transfers/downloads')) if transfers_data: for user_data in transfers_data: if 'directories' in user_data: for directory in user_data['directories']: if 'files' in directory: for file_info in directory['files']: state = file_info.get('state', '').lower() # Only count actively downloading files if 'inprogress' in state or 'downloading' in state or 'transferring' in state: speed = file_info.get('averageSpeed', 0) if isinstance(speed, (int, float)) and speed > 0: total_download_speed += float(speed) except Exception as e: logger.error(f"Could not fetch download speeds: {e}") # Convert bytes/sec to KB/s and format if total_download_speed > 0: speed_kb_s = total_download_speed / 1024 if speed_kb_s >= 1024: speed_mb_s = speed_kb_s / 1024 download_speed_str = f"{speed_mb_s:.1f} MB/s" else: download_speed_str = f"{speed_kb_s:.1f} KB/s" else: download_speed_str = "0 KB/s" # Count active syncs (playlists currently syncing) active_syncs = 0 # Count Spotify playlist syncs for _playlist_id, sync_state in sync_states.items(): if sync_state.get('status') == 'syncing': active_syncs += 1 # Count YouTube playlist syncs for _url_hash, state in youtube_playlist_states.items(): if state.get('phase') == 'syncing': active_syncs += 1 # Count Tidal playlist syncs for _playlist_id, state in tidal_discovery_states.items(): if state.get('phase') == 'syncing': active_syncs += 1 return { 'active_downloads': active_downloads, 'finished_downloads': finished_downloads, 'download_speed': download_speed_str, 'active_syncs': active_syncs, 'uptime': uptime, 'memory_usage': memory_usage } @app.route('/api/system/stats') def get_system_stats(): """Get system statistics for dashboard""" try: return jsonify(_build_system_stats()) except Exception as e: return jsonify({'error': str(e)}), 500 from core.debug_info import ( _safe_check, get_debug_info as _debug_info_get, init as _init_debug_info, ) @app.route('/api/debug-info') def get_debug_info(): return _debug_info_get() # ── Memory-growth diagnostic (#802) ── # Opt-in tracemalloc capture, drivable entirely from a browser: # /api/debug/memory/start -> begin tracing (baseline snapshot) # ...reproduce the growth for a few minutes... # /api/debug/memory/report -> top allocation sites by GROWTH since baseline # /api/debug/memory/stop -> end tracing, free the trace bookkeeping # GET on purpose so a user can paste URLs; tracing costs CPU+memory while # active, which is why it never runs by default. @app.route('/api/debug/memory/start') def debug_memory_start(): try: from core.diagnostics.memory_tracker import start_tracking return jsonify(start_tracking()) except Exception as e: return jsonify({'error': str(e)}), 500 @app.route('/api/debug/memory/report') def debug_memory_report(): try: from core.diagnostics.memory_tracker import report top = request.args.get('top', 25, type=int) return jsonify(report(top=max(1, min(top, 100)))) except Exception as e: return jsonify({'error': str(e)}), 500 @app.route('/api/debug/memory/stop') def debug_memory_stop(): try: from core.diagnostics.memory_tracker import stop_tracking return jsonify(stop_tracking()) except Exception as e: return jsonify({'error': str(e)}), 500 @app.route('/api/activity/feed') def get_activity_feed(): """Get recent activity feed for dashboard""" try: with activity_feed_lock: # Return last 10 activities in reverse chronological order return jsonify({'activities': activity_feed[-10:][::-1]}) except Exception as e: return jsonify({'error': str(e)}), 500 @app.route('/api/activity/toasts') def get_recent_toasts(): """Get recent activities that should show toasts""" try: import time current_time = time.time() with activity_feed_lock: # Return activities from last 10 seconds that should show toasts recent_toasts = [ activity for activity in activity_feed if activity.get('show_toast', True) and (current_time - activity.get('timestamp', 0)) <= 10 ] return jsonify({'toasts': recent_toasts}) except Exception as e: return jsonify({'error': str(e)}), 500 @app.route('/api/logs') def get_activity_logs(): """Get formatted activity feed for display in sync page log area""" try: with activity_feed_lock: # Get the last 50 activities (more than the dashboard shows) recent_activities = activity_feed[-50:] if len(activity_feed) > 50 else activity_feed[:] # Reverse order so newest appears at top recent_activities = recent_activities[::-1] # Format activities as readable log entries formatted_logs = [] if not recent_activities: formatted_logs = [ "No recent activity.", "Sync and download operations will appear here in real-time." ] else: for activity in recent_activities: # Format: [TIME] ICON TITLE - SUBTITLE timestamp = activity.get('time', 'Unknown') icon = activity.get('icon', '•') title = activity.get('title', 'Activity') subtitle = activity.get('subtitle', '') # Create a clean, readable log entry if subtitle: log_entry = f"[{timestamp}] {icon} {title} - {subtitle}" else: log_entry = f"[{timestamp}] {icon} {title}" formatted_logs.append(log_entry) return jsonify({'logs': formatted_logs}) except Exception as e: return jsonify({'logs': [f'Error reading activity feed: {str(e)}']}) # --- Internal API Key Management (browser-only, no auth) --- @app.route('/api/v1/api-keys-internal', methods=['GET']) @admin_only def list_api_keys_internal(): """List API keys for the settings page (no auth required — same as all UI routes).""" keys = config_manager.get('api_keys', []) safe_keys = [ { "id": k.get("id"), "label": k.get("label", ""), "key_prefix": k.get("key_prefix", ""), "created_at": k.get("created_at"), "last_used_at": k.get("last_used_at"), } for k in keys ] return jsonify({"success": True, "data": {"keys": safe_keys}}) @app.route('/api/v1/api-keys-internal/generate', methods=['POST']) @admin_only def generate_api_key_internal(): """Generate API key from settings page (no auth required).""" from api.auth import generate_api_key body = request.get_json(silent=True) or {} label = body.get("label", "") raw_key, record = generate_api_key(label) keys = config_manager.get('api_keys', []) keys.append(record) config_manager.set('api_keys', keys) return jsonify({"success": True, "data": { "key": raw_key, "id": record["id"], "label": record["label"], "key_prefix": record["key_prefix"], "created_at": record["created_at"], }}), 201 @app.route('/api/v1/api-keys-internal/revoke/', methods=['DELETE']) @admin_only def revoke_api_key_internal(key_id): """Revoke API key from settings page (no auth required).""" keys = config_manager.get('api_keys', []) original_len = len(keys) keys = [k for k in keys if k.get("id") != key_id] if len(keys) == original_len: return jsonify({"success": False, "error": {"message": "Key not found"}}), 404 config_manager.set('api_keys', keys) return jsonify({"success": True, "data": {"message": "API key revoked"}}) @app.route('/api/settings', methods=['GET', 'POST']) @admin_only def handle_settings(): global tidal_client # Declare that we might modify the global instance if not config_manager: return jsonify({"error": "Server configuration manager is not initialized."}), 500 if request.method == 'POST': try: new_settings = request.get_json() if not new_settings: return jsonify({"success": False, "error": "No data received."}), 400 # Anti-lockout: refuse to turn ON login mode until the admin account # has a password — otherwise enabling it would lock everyone out. _sec_in = new_settings.get('security') or {} if _sec_in.get('require_login') and not config_manager.get('security.require_login', False): if not get_database().profile_has_password(1): return jsonify({"success": False, "error": "Set an admin password before enabling login mode."}), 400 # No-gaps: every member must have a password too, or they'd be # locked out the moment login turns on. from core.security.login_provisioning import members_without_password _stranded = members_without_password(get_database().get_all_profiles()) if _stranded: _names = ', '.join(str(m.get('name') or '?') for m in _stranded) return jsonify({"success": False, "error": f"These members have no login password and " f"couldn't sign in: {_names}. Set their passwords " f"in Manage Profiles first.", "members_without_password": _stranded}), 400 if 'active_media_server' in new_settings: config_manager.set_active_media_server(new_settings['active_media_server']) for service in ['spotify', 'plex', 'jellyfin', 'navidrome', 'soulseek', 'download_source', 'settings', 'database', 'metadata_enhancement', 'file_organization', 'playlist_sync', 'tidal', 'tidal_download', 'qobuz', 'hifi_download', 'deezer_download', 'amazon_download', 'lidarr_download', 'prowlarr', 'torrent_client', 'usenet_client', 'listenbrainz', 'acoustid', 'lastfm', 'genius', 'import', 'lossy_copy', 'listening_stats', 'ui_appearance', 'youtube', 'content_filter', 'itunes', 'm3u_export', 'musicbrainz', 'deezer', 'audiodb', 'metadata', 'hydrabase', 'security', 'discogs', 'library', 'discover', 'wishlist', 'genre_whitelist', 'post_processing', 'playlists']: if service in new_settings: for key, value in new_settings[service].items(): config_manager.set(f'{service}.{key}', value) logger.info("Settings saved successfully via Web UI.") # Add activity for settings save changed_services = list(new_settings.keys()) services_text = ", ".join(changed_services) add_activity_item("", "Settings Updated", f"{services_text} configuration saved", "Now") add_activity_item("", "Settings Updated", f"{services_text} configuration saved", "Now") # Reload service clients with new settings (guard against None from partial init) if spotify_client: spotify_client.reload_config() if media_server_engine.client('plex'): media_server_engine.client('plex').server = None if media_server_engine.client('jellyfin'): media_server_engine.client('jellyfin').reload_config() if media_server_engine.client('navidrome'): media_server_engine.client('navidrome').reload_config() # Reload orchestrator settings (download source mode, hybrid_primary, etc.) if download_orchestrator: download_orchestrator.reload_settings() # Reload YouTube client settings (rate limiting, cookies) _yt = download_orchestrator.client("youtube") if _yt: _yt.reload_settings() # FIX: Re-instantiate the global tidal_client to pick up new settings try: tidal_client = TidalClient() except Exception as e: logger.debug("tidal client re-init: %s", e) # Reload enrichment worker clients for key-based services if lastfm_worker: lastfm_worker._init_client() if genius_worker: genius_worker._init_client() if tidal_enrichment_worker: tidal_enrichment_worker.client = tidal_client if 'spotify' in new_settings: publish_spotify_status( connected=False, authenticated=False, rate_limited=False, rate_limit=None, post_ban_cooldown=None, ) # Invalidate status cache so next poll reflects new settings (e.g. fallback source change) invalidate_metadata_status_caches() logger.info("Service clients re-initialized with new settings.") return jsonify({"success": True, "message": "Settings saved successfully."}) except Exception as e: return jsonify({"success": False, "error": str(e)}), 500 else: # GET request try: # Masks every configured secret (API keys, tokens, passwords) as a # sentinel so decrypted credentials never reach the browser/devtools/ # HAR captures (#832 follow-up). Deep copy — safe to mutate below. data = config_manager.redacted_config() # Also drop the Spotify OAuth token payload — not a settings field # and not in the sensitive-paths list. if isinstance(data.get('spotify'), dict) and 'token_info' in data['spotify']: data['spotify'] = {k: v for k, v in data['spotify'].items() if k != 'token_info'} # Include which download sources are configured so the UI can auto-disable unconfigured ones try: data['_source_status'] = download_orchestrator.get_source_status() except Exception as e: logger.debug("download source status read failed: %s", e) return jsonify(data) except Exception as e: return jsonify({"error": str(e)}), 500 @app.route('/api/metadata/art-sources', methods=['GET']) def get_art_sources(): """Cover-art sources the current user can actually use, in default order. Free sources (CAA/Deezer/iTunes/AudioDB) are always available; account sources (Spotify) only when connected. The settings UI uses this to offer only sources the user is set up with ("not everybody has every source"). """ try: from core.metadata.art_lookup import available_art_sources labels = { 'caa': 'Cover Art Archive', 'deezer': 'Deezer', 'itunes': 'iTunes', 'spotify': 'Spotify', 'audiodb': 'TheAudioDB', } available = [{'id': s, 'name': labels.get(s, s.title())} for s in available_art_sources()] return jsonify({'available': available}) except Exception as e: logger.error(f"Error listing art sources: {e}") return jsonify({'available': [], 'error': str(e)}), 500 @app.route('/api/dev-mode', methods=['GET', 'POST']) def handle_dev_mode(): global dev_mode_enabled if request.method == 'POST': data = request.get_json() if data.get('password') == 'hydratest': dev_mode_enabled = True logger.info("Dev mode activated") return jsonify({"success": True, "enabled": True}) return jsonify({"success": False, "error": "Invalid password"}), 401 return jsonify({"enabled": dev_mode_enabled}) # ── Hydrabase WebSocket Connection ── # ── Hydrabase Comparison Store ── import collections as _collections _hydrabase_comparisons = _collections.OrderedDict() _COMPARISON_MAX_ENTRIES = 50 _comparison_lock = threading.Lock() def _is_hydrabase_active(): """Check if Hydrabase is connected and enabled for metadata use.""" try: from core.metadata.registry import is_hydrabase_enabled return is_hydrabase_enabled() except Exception: return False def _run_background_comparison(query, hydrabase_counts=None): """Run Spotify + fallback source searches in background and store for comparison. Args: query: Search query string. hydrabase_counts: Optional pre-computed dict {'tracks': N, 'artists': N, 'albums': N} from the primary search to avoid redundant Hydrabase round-trips. """ def _worker(): try: result = {'timestamp': time.time(), 'query': query} # Use pre-computed counts if available, otherwise fetch from Hydrabase if hydrabase_counts is not None: hydra_data = hydrabase_counts else: hydra_data = {'tracks': 0, 'artists': 0, 'albums': 0} if _is_hydrabase_active(): raw_t = hydrabase_client.search_raw(query, 'track') raw_ar = hydrabase_client.search_raw(query, 'artists') raw_al = hydrabase_client.search_raw(query, 'album') hydra_data = { 'tracks': len(raw_t) if raw_t else 0, 'artists': len(raw_ar) if raw_ar else 0, 'albums': len(raw_al) if raw_al else 0 } result['hydrabase'] = hydra_data # Spotify results spotify_data = {'tracks': 0, 'artists': 0, 'albums': 0} if spotify_client and spotify_client.is_authenticated(): try: s_tracks = spotify_client.search_tracks(query, limit=10) s_artists = spotify_client.search_artists(query, limit=10) s_albums = spotify_client.search_albums(query, limit=10) spotify_data = { 'tracks': len(s_tracks), 'artists': len(s_artists), 'albums': len(s_albums) } except Exception as e: logger.debug(f"Comparison Spotify search failed: {e}") result['spotify'] = spotify_data # Fallback metadata source results (iTunes or Deezer) fallback_source = _get_metadata_fallback_source() fallback_data = {'tracks': 0, 'artists': 0, 'albums': 0} try: fallback_client = _get_metadata_fallback_client() f_tracks = fallback_client.search_tracks(query, limit=10) f_artists = fallback_client.search_artists(query, limit=10) f_albums = fallback_client.search_albums(query, limit=10) fallback_data = { 'tracks': len(f_tracks), 'artists': len(f_artists), 'albums': len(f_albums) } except Exception as e: logger.debug(f"Comparison {fallback_source} search failed: {e}") result['fallback'] = fallback_data result['fallback_source'] = fallback_source with _comparison_lock: _hydrabase_comparisons[query] = result while len(_hydrabase_comparisons) > _COMPARISON_MAX_ENTRIES: _hydrabase_comparisons.popitem(last=False) logger.info(f"Background comparison stored for '{query}': H={hydra_data}, S={spotify_data}, {fallback_source.capitalize()}={fallback_data}") except Exception as e: logger.error(f"Background comparison failed for '{query}': {e}") threading.Thread(target=_worker, daemon=True).start() @app.route('/api/hydrabase/connect', methods=['POST']) def hydrabase_connect(): """Connect to a Hydrabase instance via WebSocket.""" global _hydrabase_ws data = request.get_json() url = data.get('url', '').strip() api_key = data.get('api_key', '').strip() if not url or not api_key: return jsonify({"success": False, "error": "URL and API key required"}), 400 try: import websocket with _hydrabase_lock: # Close existing connection if any if _hydrabase_ws: try: _hydrabase_ws.close() except Exception as _e: logger.debug("hydrabase connect-existing close: %s", _e) ws = websocket.create_connection( url, header={"x-api-key": api_key}, timeout=10 ) _hydrabase_ws = ws # Save credentials for auto-reconnect config_manager.set('hydrabase.url', url) config_manager.set('hydrabase.api_key', api_key) config_manager.set('hydrabase.auto_connect', True) logger.info(f"[Hydrabase] Connected to {url}") return jsonify({"success": True, "message": "Connected"}) except Exception as e: logger.error(f"[Hydrabase] Connection failed: {e}") return jsonify({"success": False, "error": str(e)}), 500 @app.route('/api/hydrabase/disconnect', methods=['POST']) def hydrabase_disconnect(): """Disconnect from Hydrabase and disable dev mode.""" global _hydrabase_ws, dev_mode_enabled with _hydrabase_lock: if _hydrabase_ws: try: _hydrabase_ws.close() except Exception as e: logger.debug("hydrabase disconnect close: %s", e) _hydrabase_ws = None config_manager.set('hydrabase.auto_connect', False) # Only disable dev mode if not using Hydrabase as a regular fallback source if _get_metadata_fallback_source() != 'hydrabase': dev_mode_enabled = False logger.info("[Hydrabase] Disconnected") return jsonify({"success": True}) @app.route('/api/hydrabase/status') def hydrabase_status(): """Check if connected to Hydrabase.""" try: connected = _hydrabase_ws is not None and _hydrabase_ws.connected except Exception: connected = False try: hydra_config = config_manager.get_hydrabase_config() except AttributeError: hydra_config = {} peer_count = None try: if hydrabase_client and hydrabase_client.last_peer_count is not None: peer_count = hydrabase_client.last_peer_count except NameError: pass return jsonify({ "connected": connected, "saved_url": hydra_config.get('url', ''), "saved_api_key": hydra_config.get('api_key', ''), "auto_connect": hydra_config.get('auto_connect', False), "peer_count": peer_count }) @app.route('/api/hydrabase/comparisons') def hydrabase_comparisons(): """Get recent comparison results (Hydrabase vs Spotify vs fallback source).""" if not dev_mode_enabled: return jsonify({"success": False, "error": "Dev mode not active"}), 403 with _comparison_lock: items = list(reversed(_hydrabase_comparisons.values())) return jsonify({"success": True, "comparisons": items}) @app.route('/api/hydrabase/send', methods=['POST']) def hydrabase_send(): """Send a raw JSON payload to Hydrabase and return the response.""" global _hydrabase_ws if not dev_mode_enabled: return jsonify({"success": False, "error": "Dev mode not active"}), 403 if not _hydrabase_ws or not _hydrabase_ws.connected: return jsonify({"success": False, "error": "Not connected to Hydrabase"}), 400 data = request.get_json() payload = data.get('payload') if not payload: return jsonify({"success": False, "error": "No payload provided"}), 400 try: message = json.dumps(payload) if isinstance(payload, dict) else str(payload) with _hydrabase_lock: _hydrabase_ws.send(message) response = _hydrabase_ws.recv() try: result = json.loads(response) except json.JSONDecodeError: result = response logger.info("[Hydrabase] Sent payload — got response") return jsonify({"success": True, "data": result}) except Exception as e: logger.error(f"[Hydrabase] Send failed: {e}") with _hydrabase_lock: try: _hydrabase_ws.close() except Exception as _e: logger.debug("hydrabase send close: %s", _e) _hydrabase_ws = None return jsonify({"success": False, "error": str(e)}), 500 @app.route('/api/prowlarr/indexers', methods=['GET']) def prowlarr_indexers_endpoint(): """List indexers Prowlarr currently exposes — name, protocol, enabled state. Drives the Indexers panel on Settings → Indexers & Downloaders so the user can see what they're searching against without leaving SoulSync. Returns ``[]`` if Prowlarr isn't configured / reachable. """ try: from core.prowlarr_client import ProwlarrClient client = ProwlarrClient() if not client.is_configured(): return jsonify({"success": False, "error": "Prowlarr not configured", "indexers": []}), 200 indexers = run_async(client.get_indexers()) items = [ { 'id': idx.id, 'name': idx.name, 'protocol': idx.protocol, 'enable': idx.enable, 'privacy': idx.privacy, } for idx in indexers ] return jsonify({"success": True, "indexers": items}) except Exception as e: logger.error(f"prowlarr indexers fetch error: {e}") return jsonify({"success": False, "error": str(e), "indexers": []}), 500 @app.route('/api/settings/log-level', methods=['GET', 'POST']) @admin_only def handle_log_level(): """Get or set the application log level""" from utils.logging_config import set_log_level, get_current_log_level from database.music_database import MusicDatabase if request.method == 'POST': try: data = request.get_json() level = data.get('level') if not level or level.upper() not in ['DEBUG', 'INFO', 'WARNING', 'ERROR']: return jsonify({"success": False, "error": "Invalid log level. Must be DEBUG, INFO, WARNING, or ERROR"}), 400 # Change the log level dynamically success = set_log_level(level) if success: # Save to database preferences db = MusicDatabase() db.set_preference('log_level', level.upper()) logger.info(f"Log level changed to {level.upper()} via Web UI") add_activity_item("", "Log Level Changed", f"Set to {level.upper()}", "Now") return jsonify({"success": True, "level": level.upper()}) else: return jsonify({"success": False, "error": "Failed to set log level"}), 500 except Exception as e: logger.error(f"Error setting log level: {e}") return jsonify({"success": False, "error": str(e)}), 500 else: # GET request try: current_level = get_current_log_level() return jsonify({"success": True, "level": current_level}) except Exception as e: logger.error(f"Error getting log level: {e}") return jsonify({"success": False, "error": str(e)}), 500 # =========================== # LIVE LOG VIEWER API # =========================== # In-memory ring buffer for live log streaming via WebSocket _live_log_buffer = [] _live_log_buffer_lock = threading.Lock() _LIVE_LOG_BUFFER_MAX = 500 @app.route('/api/logs/tail', methods=['GET']) def get_log_tail(): """Return the last N lines from a log file, optionally filtered by level.""" log_source = request.args.get('source', 'app') lines = request.args.get('lines', 200, type=int) lines = max(10, min(lines, 1000)) level_filter = request.args.get('level', '').upper() # DEBUG, INFO, WARNING, ERROR or empty log_map = { 'app': Path(_log_path), 'acoustid': _log_dir / 'acoustid.log', 'post_processing': _log_dir / 'post_processing.log', 'source_reuse': _log_dir / 'source_reuse.log', } log_path = log_map.get(log_source, log_map['app']) search = request.args.get('search', '').lower() def _classify_log_level(line): """Classify a log line's level. Returns DEBUG/INFO/WARNING/ERROR or empty for unclassified.""" if ' - DEBUG - ' in line: return 'DEBUG' if ' - INFO - ' in line: return 'INFO' if ' - WARNING - ' in line: return 'WARNING' if ' - ERROR - ' in line or ' - CRITICAL - ' in line: return 'ERROR' # Heuristic for plain-text output and non-logger lines ll = line.lower() if 'error' in ll or 'traceback' in ll or 'exception' in ll or 'failed' in ll: return 'ERROR' if 'warning' in ll or 'warn' in ll: return 'WARNING' if 'debug' in ll: return 'DEBUG' return 'INFO' # Default unclassified lines to INFO result_lines = [] if os.path.exists(log_path): try: with open(log_path, 'r', encoding='utf-8', errors='replace') as f: all_lines = f.readlines() # Read more lines than requested so filtering has enough to work with pool_size = lines * 5 if (level_filter or search) else lines tail = all_lines[-pool_size:] for line in tail: stripped = line.rstrip() if not stripped: continue if level_filter and level_filter in ('DEBUG', 'INFO', 'WARNING', 'ERROR'): if _classify_log_level(stripped) != level_filter: continue if search and search not in stripped.lower(): continue result_lines.append(stripped) # Trim to requested count after filtering result_lines = result_lines[-lines:] except Exception as e: result_lines = [f'Error reading log file: {e}'] # Available log files available = [] logs_dir = 'logs' if os.path.isdir(logs_dir): for fname in sorted(os.listdir(logs_dir)): if fname.endswith('.log'): fpath = os.path.join(logs_dir, fname) size_kb = os.path.getsize(fpath) / 1024 available.append({ 'key': fname.replace('.log', ''), 'file': fname, 'size': f"{size_kb:.0f} KB" if size_kb < 1024 else f"{size_kb/1024:.1f} MB", }) return jsonify({ 'lines': result_lines, 'source': log_source, 'total': len(result_lines), 'available_logs': available, }) # =========================== # AUTOMATIONS API # =========================== @app.route('/api/genre-whitelist/defaults', methods=['GET']) def get_genre_whitelist_defaults(): """Return the default genre whitelist.""" from core.genre_filter import DEFAULT_GENRES return jsonify({'genres': sorted(DEFAULT_GENRES, key=str.lower)}) # Automation route bodies live in core/automation/api.py — these routes are thin handlers. from core.automation import api as _auto_api from core.automation import blocks as _auto_blocks from core.automation import signals as _auto_signals @app.route('/api/automations', methods=['GET']) def list_automations(): """List all automations for the current profile.""" try: profile_id = session.get('profile_id', 1) return jsonify(_auto_api.list_automations(get_database(), profile_id)) except Exception as e: logger.error(f"Error listing automations: {e}") return jsonify({"error": str(e)}), 500 @app.route('/api/automations', methods=['POST']) def create_automation(): """Create a new automation.""" try: profile_id = session.get('profile_id', 1) body, status = _auto_api.create_automation(get_database(), automation_engine, profile_id, request.get_json()) return jsonify(body), status except Exception as e: logger.error(f"Error creating automation: {e}") return jsonify({"error": str(e)}), 500 @app.route('/api/automations/', methods=['GET']) def get_automation(automation_id): """Get a single automation.""" try: auto = _auto_api.get_automation(get_database(), automation_id) if auto is None: return jsonify({"error": "Automation not found"}), 404 return jsonify(auto) except Exception as e: logger.error(f"Error getting automation: {e}") return jsonify({"error": str(e)}), 500 @app.route('/api/automations/', methods=['PUT']) def update_automation_endpoint(automation_id): """Update an automation.""" try: body, status = _auto_api.update_automation(get_database(), automation_engine, automation_id, request.get_json()) return jsonify(body), status except Exception as e: logger.error(f"Error updating automation: {e}") return jsonify({"error": str(e)}), 500 @app.route('/api/automations/group', methods=['PUT']) def batch_update_automation_group(): """Batch update group_name for multiple automations.""" try: data = request.get_json() body, status = _auto_api.batch_update_group(get_database(), data.get('automation_ids', []), data.get('group_name')) return jsonify(body), status except Exception as e: logger.error(f"Error batch updating automation group: {e}") return jsonify({"error": str(e)}), 500 @app.route('/api/automations/bulk-toggle', methods=['POST']) def bulk_toggle_automations(): """Bulk enable/disable multiple automations.""" try: data = request.get_json() body, status = _auto_api.bulk_toggle(get_database(), automation_engine, data.get('automation_ids', []), data.get('enabled', True)) return jsonify(body), status except Exception as e: logger.error(f"Error bulk toggling automations: {e}") return jsonify({"error": str(e)}), 500 @app.route('/api/automations/', methods=['DELETE']) def delete_automation_endpoint(automation_id): """Delete an automation. System automations cannot be deleted.""" try: body, status = _auto_api.delete_automation(get_database(), automation_engine, automation_id) return jsonify(body), status except Exception as e: logger.error(f"Error deleting automation: {e}") return jsonify({"error": str(e)}), 500 @app.route('/api/automations//duplicate', methods=['POST']) def duplicate_automation_endpoint(automation_id): """Duplicate an automation. System automations cannot be duplicated.""" try: profile_id = session.get('profile_id', 1) body, status = _auto_api.duplicate_automation(get_database(), automation_engine, profile_id, automation_id) return jsonify(body), status except Exception as e: logger.error(f"Error duplicating automation: {e}") return jsonify({"error": str(e)}), 500 @app.route('/api/automations//toggle', methods=['POST']) def toggle_automation_endpoint(automation_id): """Toggle an automation's enabled state.""" try: body, status = _auto_api.toggle_automation(get_database(), automation_engine, automation_id) return jsonify(body), status except Exception as e: logger.error(f"Error toggling automation: {e}") return jsonify({"error": str(e)}), 500 @app.route('/api/automations//run', methods=['POST']) def run_automation_endpoint(automation_id): """Manually trigger an automation.""" try: body, status = _auto_api.run_automation(automation_engine, automation_id, get_current_profile_id()) return jsonify(body), status except Exception as e: logger.error(f"Error running automation: {e}") return jsonify({"error": str(e)}), 500 @app.route('/api/automations/progress', methods=['GET']) def get_automation_progress(): """Get current progress state for all running/recently finished automations.""" try: return jsonify(_auto_progress.get_running_progress()) except Exception as e: return jsonify({"error": str(e)}), 500 @app.route('/api/automations//history', methods=['GET']) def get_automation_history(automation_id): """Get run history for a specific automation.""" try: limit = request.args.get('limit', 50, type=int) offset = request.args.get('offset', 0, type=int) return jsonify(_auto_api.get_history(get_database(), automation_id, limit=limit, offset=offset)) except Exception as e: logger.error(f"Error getting automation history: {e}") return jsonify({"error": str(e)}), 500 def _collect_known_signals(): """Collect all signal names used across automations (for autocomplete).""" return _auto_signals.collect_known_signals(get_database()) @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. Music builder only — video-only blocks (scope='video') are filtered out so the music builder never offers a video action. The video side fetches its own scope via /api/video/automations/blocks.""" scoped = _auto_blocks.blocks_for_scope('music') scoped['known_signals'] = _collect_known_signals() return jsonify(scoped) @app.route('/api/mirrored-playlists/list', methods=['GET']) def get_mirrored_playlists_list(): """Return simple list of mirrored playlists for automation config dropdowns.""" try: database = get_database() profile_id = get_current_profile_id() playlists = database.get_mirrored_playlists(profile_id=profile_id) spotify_authed = bool(spotify_client and spotify_client.is_spotify_authenticated()) return jsonify({ "playlists": [{"id": p['id'], "name": p['name'], "source": p.get('source', '')} for p in playlists], "spotify_authenticated": spotify_authed }) except Exception as e: return jsonify({"playlists": [], "spotify_authenticated": False}), 200 @app.route('/api/playlists/materialize/rebuild', methods=['POST']) def rebuild_playlist_materialization_endpoint(): """(Re)build every "organize by playlist" folder from current library ownership (the manual Settings button). Safe to call any time — it's a derived view and only links tracks the user actually owns.""" try: from core.playlists.materialize_service import rebuild_organized_playlists_from_db database = get_database() profile_id = get_current_profile_id() results = rebuild_organized_playlists_from_db(database, config_manager, profile_id=profile_id) return jsonify({ 'success': True, 'count': len(results), 'results': [{ 'playlist': name, 'folder': s.playlist_dir, 'linked': s.linked, 'copied': s.copied, 'unchanged': s.unchanged, 'removed_stale': s.removed_stale, 'missing_source': s.missing_source, 'failed': s.failed, 'fellback': s.fellback, } for name, s in results], }) except Exception as e: logger.error(f"[Playlist Materialize] Rebuild endpoint failed: {e}") return jsonify({'success': False, 'error': str(e)}), 500 @app.route('/api/setup/status', methods=['GET']) def setup_status_endpoint(): """Check if first-run setup has been completed.""" # The setup wizard sets this flag when completed. download_source.mode is only # set by user action (wizard or settings page), never by config.json defaults. setup_done = config_manager.get('setup.completed', False) download_mode = config_manager.get('download_source.mode', '') # Either the explicit flag or a user-configured download source means setup is done has_user_config = bool(setup_done) or bool(download_mode) return jsonify({ "setup_complete": has_user_config, }) @app.route('/api/setup/complete', methods=['POST']) def setup_complete_endpoint(): """Mark first-run setup as completed.""" config_manager.set('setup.completed', True) return jsonify({"success": True}) @app.route('/api/test-connection', methods=['POST']) def test_connection_endpoint(): data = request.get_json() service = data.get('service') if not service: return jsonify({"success": False, "error": "No service specified."}), 400 logger.info(f"Received test connection request for: {service}") # Get the current settings from the main config manager to test with test_config = config_manager.get(service, {}) # For media servers, the service name might be 'server' if service == 'server': active_server = config_manager.get_active_media_server() test_config = config_manager.get(active_server, {}) service = active_server # use the actual server name for the test success, message = run_service_test(service, test_config) # Update status cache immediately when test succeeds to reflect current state import time if success: current_time = time.time() if service == 'spotify': invalidate_metadata_status_caches() logger.info("Updated Spotify status cache after successful test") elif service in ['plex', 'jellyfin', 'navidrome', 'soulsync']: _status_cache['media_server']['connected'] = True _status_cache['media_server']['type'] = service _status_cache_timestamps['media_server'] = current_time logger.info(f"Updated {service} status cache after successful test") elif service == 'soulseek': _status_cache['soulseek']['connected'] = True _status_cache_timestamps['soulseek'] = current_time logger.info("Updated Soulseek status cache after successful test") elif service == 'listenbrainz': logger.info("ListenBrainz test successful") # Add activity for connection test if success: add_activity_item("", "Connection Test", message, "Now") else: add_activity_item("", "Connection Test", f"{service.title()} connection failed", "Now") return jsonify({"success": success, "error": "" if success else message, "message": message if success else ""}) @app.route('/api/settings/config-status', methods=['GET']) @admin_only def settings_config_status_endpoint(): """Return per-service config state for the Settings → Connections page. Drives the green/yellow header gradient. No API calls — just config reads. """ try: result = { service: {'configured': _is_service_configured(service)} for service in SERVICE_CONFIG_REGISTRY } # Spotify Free: Spotify metadata can be available without credentials # (opt-in no-creds source). Surface that separately so the search source # picker offers Spotify, while `configured` (the Connections indicator) # keeps meaning "has client credentials". if 'spotify' in result: try: meta_avail = bool(spotify_client and spotify_client.is_spotify_metadata_available()) except Exception: meta_avail = False result['spotify']['metadata_available'] = ( result['spotify']['configured'] or meta_avail ) return jsonify(result) except Exception as e: logger.error(f"config-status error: {e}") return jsonify({"error": str(e)}), 500 # ── Per-service verify cache ── # Stores the last verify result per service for 5 minutes to prevent # hammering external APIs when the user rapidly expands/collapses cards. _settings_verify_cache = {} # service -> {'success': bool, 'message': str, 'error': str, 'ts': float} _settings_verify_cache_lock = threading.Lock() _SETTINGS_VERIFY_TTL_SECONDS = 300 def _get_cached_verify_result(service: str): with _settings_verify_cache_lock: entry = _settings_verify_cache.get(service) if entry and (time.time() - entry['ts']) < _SETTINGS_VERIFY_TTL_SECONDS: return entry return None def _store_verify_result(service: str, success: bool, message: str): with _settings_verify_cache_lock: _settings_verify_cache[service] = { 'success': bool(success), 'message': message or '', 'error': '' if success else (message or 'Unknown error'), 'ts': time.time(), } def _run_single_verify(service: str): """Run verify for one service, reading its current saved config. Returns cached result if recent, else executes run_service_test and caches the outcome. """ if service not in SERVICE_CONFIG_REGISTRY: return {'success': False, 'error': f'Unknown service: {service}', 'cached': False} cached = _get_cached_verify_result(service) if cached: return { 'success': cached['success'], 'error': cached.get('error', ''), 'message': cached.get('message', ''), 'cached': True, } try: saved_config = config_manager.get(service, {}) or {} success, message = run_service_test(service, saved_config) _store_verify_result(service, success, message) return { 'success': bool(success), 'error': '' if success else (message or 'Verification failed'), 'message': message or '', 'cached': False, } except Exception as e: logger.error(f"verify error for {service}: {e}") _store_verify_result(service, False, str(e)) return {'success': False, 'error': str(e), 'cached': False} @app.route('/api/settings/verify', methods=['POST']) @admin_only def settings_verify_endpoint(): """Run connection verification for one or more services. Body: {"services": ["spotify", "deezer"]} — which services to check Query: ?force=true — bust cache and re-run Returns {service: {success, error, message, cached}} per requested service. Concurrency capped at 3 to avoid rate-limiting ourselves on Expand All. """ try: data = request.get_json(silent=True) or {} services = data.get('services') or [] if isinstance(services, str): services = [services] if not services: return jsonify({'error': 'No services specified'}), 400 force = (request.args.get('force') or '').strip().lower() in ('1', 'true', 'yes') if force: with _settings_verify_cache_lock: for svc in services: _settings_verify_cache.pop(svc, None) from concurrent.futures import ThreadPoolExecutor, as_completed results = {} with ThreadPoolExecutor(max_workers=3) as pool: futures = {pool.submit(_run_single_verify, svc): svc for svc in services} for fut in as_completed(futures): svc = futures[fut] try: results[svc] = fut.result() except Exception as e: results[svc] = {'success': False, 'error': str(e), 'cached': False} return jsonify(results) except Exception as e: logger.error(f"settings/verify error: {e}") return jsonify({'error': str(e)}), 500 @app.route('/api/test-dashboard-connection', methods=['POST']) def test_dashboard_connection_endpoint(): """Test connection from dashboard - creates specific dashboard activity items""" data = request.get_json() service = data.get('service') if not service: return jsonify({"success": False, "error": "No service specified."}), 400 logger.info(f"Received dashboard test connection request for: {service}") # Get the current settings from the main config manager to test with test_config = config_manager.get(service, {}) # For media servers, the service name might be 'server' if service == 'server': active_server = config_manager.get_active_media_server() test_config = config_manager.get(active_server, {}) service = active_server # use the actual server name for the test success, message = run_service_test(service, test_config) # Update status cache immediately when test succeeds to reflect current state import time if success: current_time = time.time() if service == 'spotify': invalidate_metadata_status_caches() logger.info("Updated Spotify status cache after successful dashboard test") elif service in ['plex', 'jellyfin', 'navidrome', 'soulsync']: _status_cache['media_server']['connected'] = True _status_cache['media_server']['type'] = service _status_cache_timestamps['media_server'] = current_time logger.info(f"Updated {service} status cache after successful dashboard test") elif service == 'soulseek': _status_cache['soulseek']['connected'] = True _status_cache_timestamps['soulseek'] = current_time logger.info("Updated Soulseek status cache after successful dashboard test") # Add activity for dashboard connection test (different from settings test) if success: add_activity_item("", "Dashboard Test", message, "Now") else: add_activity_item("", "Dashboard Test", f"{service.title()} service check failed", "Now") return jsonify({"success": success, "error": "" if success else message, "message": message if success else ""}) @app.route('/api/detect-media-server', methods=['POST']) def detect_media_server_endpoint(): data = request.get_json() server_type = data.get('server_type') logger.info(f"Received auto-detect request for: {server_type}") # Add activity for auto-detect start add_activity_item("", "Auto-Detect Started", f"Searching for {server_type} server", "Now") found_url = run_detection(server_type) if found_url: add_activity_item("", "Auto-Detect Complete", f"{server_type} found at {found_url}", "Now") return jsonify({"success": True, "found_url": found_url}) else: add_activity_item("", "Auto-Detect Failed", f"No {server_type} server found", "Now") return jsonify({"success": False, "error": f"No {server_type} server found on common local addresses."}) @app.route('/api/plex/pin/start', methods=['POST']) def start_plex_pin_auth(): try: pinlogin = MyPlexPinLogin(oauth=False) except Exception as e: logger.error(f'Failed to start Plex PIN auth: {e}') return jsonify({"success": False, "error": str(e)}), 500 pin_code = getattr(pinlogin, 'pin', None) if not pin_code: return jsonify({"success": False, "error": 'Failed to generate Plex PIN code.'}), 500 request_id = str(uuid.uuid4()) with _plex_pin_requests_lock: _plex_pin_requests[request_id] = { 'pinlogin': pinlogin, 'created_at': time.time(), 'expires_at': getattr(pinlogin, 'expires_at', None) } expires_in = None expires_at = getattr(pinlogin, 'expires_at', None) if expires_at: try: expires_in = int((expires_at - datetime.now(timezone.utc)).total_seconds()) except Exception: expires_in = None return jsonify({ "success": True, "request_id": request_id, "code": str(pin_code), "auth_url": "https://plex.tv/link", "expires_in": expires_in }) @app.route('/api/plex/pin/status', methods=['GET']) def get_plex_pin_status(): request_id = request.args.get('request_id') if not request_id: return jsonify({"success": False, "error": 'request_id is required'}), 400 with _plex_pin_requests_lock: entry = _plex_pin_requests.get(request_id) if not entry: return jsonify({"success": False, "error": 'Invalid or expired PIN request id.'}), 400 pinlogin = entry.get('pinlogin') if not pinlogin: return jsonify({"success": False, "error": 'Invalid PIN login state.'}), 500 try: if getattr(pinlogin, 'expired', False): with _plex_pin_requests_lock: _plex_pin_requests.pop(request_id, None) return jsonify({"success": False, "expired": True, "error": 'PIN code expired.'}) if pinlogin.checkLogin(): token = getattr(pinlogin, 'token', None) if not token: raise ValueError('Plex token was not returned after authorization.') try: account = MyPlexAccount(token=token) resources = account.resources() except Exception as e: logger.error(f'Failed to fetch Plex account resources: {e}') return jsonify({"success": False, "error": f'Plex authorization succeeded but failed to resolve server resources: {e}'}), 500 server_resources = [r for r in resources if 'server' in (getattr(r, 'provides', '') or '').lower()] if not server_resources: return jsonify({"success": False, "error": 'No Plex server resources found for this account.'}), 500 local_conn = None relay_conn = None for resource in server_resources: for conn in getattr(resource, 'connections', []) or []: if getattr(conn, 'local', False): local_conn = conn break if getattr(conn, 'relay', False) and relay_conn is None: relay_conn = conn if local_conn: break chosen_conn = local_conn or relay_conn if not chosen_conn: chosen_conn = getattr(server_resources[0], 'connections', [None])[0] found_url = getattr(chosen_conn, 'uri', None) if chosen_conn else None with _plex_pin_requests_lock: _plex_pin_requests.pop(request_id, None) if not found_url: return jsonify({"success": False, "error": 'Plex authorized, but no usable server connection URI was found.'}), 500 return jsonify({ "success": True, "found_url": found_url, "token": token, "status": 'Plex authorization complete.' }) return jsonify({"success": False, "status": 'Waiting for Plex authorization. Enter the PIN on plex.tv/link.'}) except Exception as e: logger.error(f'Error checking Plex PIN status: {e}') return jsonify({"success": False, "error": str(e)}), 500 @app.route('/api/plex/clear-library', methods=['POST']) @admin_only def clear_plex_library_preference(): try: from database.music_database import MusicDatabase db = MusicDatabase() db.set_preference('plex_music_library', '') plex = media_server_engine.client('plex') if plex: plex.music_library = None # Also clear all-libraries mode so a fresh "select library" # flow doesn't inherit stale state. plex._all_libraries_mode = False return jsonify({"success": True, "message": "Plex library preference cleared."}) except Exception as e: logger.error(f"Error clearing Plex library preference: {e}") return jsonify({"success": False, "error": str(e)}), 500 @app.route('/api/plex/music-libraries', methods=['GET']) def get_plex_music_libraries(): """Get list of all available music libraries from Plex""" try: libraries = media_server_engine.client('plex').get_available_music_libraries() # Get currently selected library from database.music_database import MusicDatabase db = MusicDatabase() selected_library = db.get_preference('plex_music_library') # Get the currently active library name. In all-libraries mode # ``music_library`` is None — surface a friendly label so the # settings UI displays the active selection correctly. current_library = None plex = media_server_engine.client('plex') if plex.music_library: current_library = plex.music_library.title elif plex.is_all_libraries_mode(): current_library = 'All Libraries (combined)' return jsonify({ "success": True, "libraries": libraries, "selected": selected_library, "current": current_library }) except Exception as e: logger.error(f"Error getting Plex music libraries: {e}") return jsonify({"success": False, "error": str(e)}), 500 @app.route('/api/plex/select-music-library', methods=['POST']) def select_plex_music_library(): """Set the active Plex music library""" try: data = request.get_json() library_name = data.get('library_name') if not library_name: return jsonify({"success": False, "error": "No library name provided"}), 400 success = media_server_engine.client('plex').set_music_library_by_name(library_name) if success: add_activity_item("", "Library Selected", f"Plex music library set to: {library_name}", "Now") return jsonify({"success": True, "message": f"Music library set to: {library_name}"}) else: return jsonify({"success": False, "error": f"Library '{library_name}' not found"}), 404 except Exception as e: logger.error(f"Error setting Plex music library: {e}") return jsonify({"success": False, "error": str(e)}), 500 @app.route('/api/jellyfin/users', methods=['GET']) def get_jellyfin_users(): """Get list of Jellyfin users that have music libraries""" try: users = media_server_engine.client('jellyfin').get_available_users() # Get currently selected user from database.music_database import MusicDatabase db = MusicDatabase() selected_user = db.get_preference('jellyfin_user') # Determine the current user name from user_id current_user = None if media_server_engine.client('jellyfin').user_id: for u in users: if u['id'] == media_server_engine.client('jellyfin').user_id: current_user = u['name'] break return jsonify({ "success": True, "users": users, "selected": selected_user, "current": current_user }) except Exception as e: logger.error(f"Error getting Jellyfin users: {e}") return jsonify({"success": False, "error": str(e)}), 500 @app.route('/api/jellyfin/select-user', methods=['POST']) def select_jellyfin_user(): """Set the active Jellyfin user""" try: data = request.get_json() username = data.get('username') if not username: return jsonify({"success": False, "error": "No username provided"}), 400 success = media_server_engine.client('jellyfin').set_user_by_name(username) if success: add_activity_item("", "User Selected", f"Jellyfin user set to: {username}", "Now") return jsonify({"success": True, "message": f"User set to: {username}"}) else: return jsonify({"success": False, "error": f"User '{username}' not found or has no music library"}), 404 except Exception as e: logger.error(f"Error setting Jellyfin user: {e}") return jsonify({"success": False, "error": str(e)}), 500 @app.route('/api/jellyfin/music-libraries', methods=['GET']) def get_jellyfin_music_libraries(): """Get list of all available music libraries from Jellyfin""" try: libraries = media_server_engine.client('jellyfin').get_available_music_libraries() # Get currently selected library from database.music_database import MusicDatabase db = MusicDatabase() selected_library = db.get_preference('jellyfin_music_library') # Get the currently active library name (match Plex behavior) current_library = None if media_server_engine.client('jellyfin').music_library_id: # Look up library name from ID for lib in libraries: if lib['key'] == media_server_engine.client('jellyfin').music_library_id: current_library = lib['title'] break return jsonify({ "success": True, "libraries": libraries, "selected": selected_library, "current": current_library }) except Exception as e: logger.error(f"Error getting Jellyfin music libraries: {e}") return jsonify({"success": False, "error": str(e)}), 500 @app.route('/api/jellyfin/select-music-library', methods=['POST']) def select_jellyfin_music_library(): """Set the active Jellyfin music library""" try: data = request.get_json() library_name = data.get('library_name') if not library_name: return jsonify({"success": False, "error": "No library name provided"}), 400 success = media_server_engine.client('jellyfin').set_music_library_by_name(library_name) if success: add_activity_item("", "Library Selected", f"Jellyfin music library set to: {library_name}", "Now") return jsonify({"success": True, "message": f"Music library set to: {library_name}"}) else: return jsonify({"success": False, "error": f"Library '{library_name}' not found"}), 404 except Exception as e: logger.error(f"Error setting Jellyfin music library: {e}") return jsonify({"success": False, "error": str(e)}), 500 @app.route('/api/navidrome/cover/', methods=['GET']) def navidrome_cover(cover_id): """Proxy a Navidrome (Subsonic) cover-art image to the browser. The sync editor and other modals reference /api/navidrome/cover/, but no route served it — so every Navidrome cover came back blank (#766). We build the authenticated getCoverArt URL server-side (keeping Subsonic credentials off the client) and stream it through the shared image cache. """ try: client = media_server_engine.client('navidrome') if not client: return '', 404 url = client.build_cover_art_url(cover_id) if not url: return '', 404 from core.image_cache import get_image_cache cached = get_image_cache().get_url(url) response = send_file(cached.path, mimetype=cached.mime_type, conditional=True) max_age = int(config_manager.get("image_cache.ttl_seconds", 2592000)) response.headers['Cache-Control'] = f'private, max-age={max_age}' return response except Exception as exc: logger.debug("navidrome cover proxy failed for %s: %s", cover_id, exc) return '', 502 @app.route('/api/navidrome/music-folders', methods=['GET']) def get_navidrome_music_folders(): """Get list of available music folders from Navidrome""" try: if not media_server_engine.client('navidrome'): return jsonify({"success": False, "error": "Navidrome client not configured"}), 400 folders = media_server_engine.client('navidrome').get_music_folders() from database.music_database import MusicDatabase db = MusicDatabase() selected_folder = db.get_preference('navidrome_music_folder') current_folder = None if media_server_engine.client('navidrome').music_folder_id: for f in folders: if f['key'] == media_server_engine.client('navidrome').music_folder_id: current_folder = f['title'] break return jsonify({ "success": True, "folders": folders, "selected": selected_folder, "current": current_folder }) except Exception as e: logger.error(f"Error getting Navidrome music folders: {e}") return jsonify({"success": False, "error": str(e)}), 500 @app.route('/api/navidrome/select-music-folder', methods=['POST']) def select_navidrome_music_folder(): """Set the active Navidrome music folder""" try: data = request.get_json() folder_name = data.get('folder_name', '') success = media_server_engine.client('navidrome').set_music_folder_by_name(folder_name) if success: if folder_name: add_activity_item("", "Library Selected", f"Navidrome music folder set to: {folder_name}", "Now") return jsonify({"success": True, "message": f"Music folder set to: {folder_name}"}) else: add_activity_item("", "Library Selection Cleared", "Navidrome will use all libraries", "Now") return jsonify({"success": True, "message": "Music folder selection cleared — using all libraries"}) else: return jsonify({"success": False, "error": f"Folder '{folder_name}' not found"}), 404 except Exception as e: logger.error(f"Error setting Navidrome music folder: {e}") return jsonify({"success": False, "error": str(e)}), 500 # =============================== # == QUALITY PROFILE API == # =============================== @app.route('/api/quality-profile', methods=['GET']) def get_quality_profile(): """Get current quality profile configuration""" try: from database.music_database import MusicDatabase db = MusicDatabase() profile = db.get_quality_profile() return jsonify({ "success": True, "profile": profile }) except Exception as e: logger.error(f"Error getting quality profile: {e}") return jsonify({"success": False, "error": str(e)}), 500 @app.route('/api/quality-profile', methods=['POST']) def save_quality_profile(): """Save quality profile configuration""" try: from database.music_database import MusicDatabase db = MusicDatabase() data = request.get_json() if not data: return jsonify({"success": False, "error": "No profile data provided"}), 400 success = db.set_quality_profile(data) if success: add_activity_item("", "Quality Profile Updated", f"Preset: {data.get('preset', 'custom')}", "Now") return jsonify({"success": True, "message": "Quality profile saved successfully"}) else: return jsonify({"success": False, "error": "Failed to save quality profile"}), 500 except Exception as e: logger.error(f"Error saving quality profile: {e}") return jsonify({"success": False, "error": str(e)}), 500 @app.route('/api/quality-profile/presets', methods=['GET']) def get_quality_presets(): """Get all available quality presets""" try: from database.music_database import MusicDatabase db = MusicDatabase() presets = { "audiophile": db.get_quality_preset("audiophile"), "balanced": db.get_quality_preset("balanced"), "space_saver": db.get_quality_preset("space_saver") } return jsonify({ "success": True, "presets": presets }) except Exception as e: logger.error(f"Error getting quality presets: {e}") return jsonify({"success": False, "error": str(e)}), 500 @app.route('/api/quality-profile/preset/', methods=['POST']) def apply_quality_preset(preset_name): """Apply a predefined quality preset""" try: from database.music_database import MusicDatabase db = MusicDatabase() preset = db.get_quality_preset(preset_name) success = db.set_quality_profile(preset) if success: add_activity_item("", "Quality Preset Applied", f"Applied '{preset_name}' preset", "Now") return jsonify({ "success": True, "message": f"Applied '{preset_name}' preset", "profile": preset }) else: return jsonify({"success": False, "error": "Failed to apply preset"}), 500 except Exception as e: logger.error(f"Error applying quality preset: {e}") return jsonify({"success": False, "error": str(e)}), 500 # =============================== # == END QUALITY PROFILE API == # =============================== @app.route('/api/detect-soulseek', methods=['POST']) def detect_soulseek_endpoint(): logger.info("Received auto-detect request for slskd") # Add activity for soulseek auto-detect start add_activity_item("", "Auto-Detect Started", "Searching for slskd server", "Now") found_url = run_detection('slskd') if found_url: add_activity_item("", "Auto-Detect Complete", f"slskd found at {found_url}", "Now") return jsonify({"success": True, "found_url": found_url}) else: add_activity_item("", "Auto-Detect Failed", "No slskd server found", "Now") return jsonify({"success": False, "error": "No slskd server found on common local addresses."}) # --- Authentication Routes --- def _profile_spotify_oauth(profile_id_int): """Build a SpotifyOAuth for a profile's connect/callback. Shared-app model (#profiles): a profile authenticates its OWN account through the GLOBAL app credentials and gets its own token cache. A profile that set its own app creds (legacy) still works. show_dialog forces Spotify's account chooser so a user can't silently inherit whatever Spotify session is active in their browser (e.g. the admin's). Returns None if no app creds exist.""" from spotipy.oauth2 import SpotifyOAuth creds = (get_database().get_profile_spotify(profile_id_int) or {}) cfg = config_manager.get_spotify_config() client_id = creds.get('client_id') or cfg.get('client_id') client_secret = creds.get('client_secret') or cfg.get('client_secret') redirect_uri = creds.get('redirect_uri') or cfg.get('redirect_uri', 'http://127.0.0.1:8888/callback') if not client_id or not client_secret: return None return SpotifyOAuth( client_id=client_id, client_secret=client_secret, redirect_uri=redirect_uri, scope="user-library-read user-read-private playlist-read-private playlist-read-collaborative user-read-email user-follow-read", cache_path=f'config/.spotify_cache_profile_{profile_id_int}', state=f'profile_{profile_id_int}', show_dialog=True, ) @app.route('/auth/spotify') def auth_spotify(): """ Initiates Spotify OAuth authentication flow. Supports per-profile auth via ?profile_id= query param. """ try: profile_id = request.args.get('profile_id', '') # Per-profile auth: the profile's own account via the shared (global) app. if profile_id and profile_id != '1': try: profile_id_int = int(profile_id) auth_manager = _profile_spotify_oauth(profile_id_int) if auth_manager: auth_url = auth_manager.get_authorize_url() logger.info(f"Per-profile Spotify auth initiated for profile {profile_id_int}") return redirect(auth_url) except (ValueError, Exception) as e: logger.error(f"Per-profile Spotify auth failed, falling back to global: {e}") # Global auth (admin or fallback) temp_spotify_client = get_spotify_client() if temp_spotify_client.sp and temp_spotify_client.sp.auth_manager: # Get the authorization URL auth_url = temp_spotify_client.sp.auth_manager.get_authorize_url() configured_uri = config_manager.get_spotify_config().get('redirect_uri', 'http://127.0.0.1:8888/callback') logger.info(f"Spotify auth initiated — redirect_uri: {configured_uri}") add_activity_item("", "Spotify Auth Started", "Please complete OAuth in browser", "Now") # Detect if accessing remotely host = request.host.split(':')[0] is_remote = host not in ['127.0.0.1', 'localhost'] is_docker = os.path.exists('/.dockerenv') # If in Docker and accessing via 127.0.0.1, recommend localhost if is_docker and host == '127.0.0.1': host = 'localhost' # Check if the redirect_uri uses port 8008 (main app) vs the standalone callback server. # A localhost URI (127.0.0.1/localhost) on a non-8008 port → standalone callback server. # A custom domain or port 8008 → main Flask app (or reverse proxy to it). uses_main_port = ':8008' in configured_uri or ( '127.0.0.1' not in configured_uri and 'localhost' not in configured_uri ) if not (is_remote or is_docker): return redirect(auth_url) if uses_main_port: # The OAuth callback returns to the app itself, so there is no # need to keep an intermediate page open. return redirect(auth_url) # redirect_uri points to the standalone callback server — show manual steps AND suggest switching import re as _re _port_match = _re.search(r':(\d+)/', configured_uri) callback_server_port = _port_match.group(1) if _port_match else str(os.environ.get('SOULSYNC_SPOTIFY_CALLBACK_PORT', '8888')) return f'''

Spotify Authentication (Remote/Docker)

Using a reverse proxy? Your redirect URI is set to {configured_uri} which uses port {callback_server_port}. If you're behind a reverse proxy (Caddy, Nginx, Traefik), change the redirect URI in SoulSync settings to use your proxy URL on the main port instead, e.g.:
https://{host}/callback
Then update the same URI in your Spotify Dashboard. This avoids the need for manual URL editing below.

Step 1: Click the link below to authenticate with Spotify

{auth_url}


Step 2: After authorizing, you'll see a blank page. The URL will look like:

http://127.0.0.1:{callback_server_port}/callback?code=...

Step 3: Change 127.0.0.1 to {host} and press Enter:

http://{host}:{callback_server_port}/callback?code=...

Authentication will then complete!

''' else: return "

Spotify Authentication Failed

Could not initialize Spotify client. Check your credentials.

", 400 except Exception as e: logger.error(f"Error starting Spotify auth: {e}") return f"

Spotify Authentication Error

{str(e)}

", 500 @app.route('/auth/tidal') def auth_tidal(): """ Initiates Tidal OAuth authentication flow """ logger.info("TIDAL AUTH ROUTE CALLED ") try: # Create a fresh tidal client to get OAuth URL from core.tidal_client import TidalClient temp_tidal_client = TidalClient() if not temp_tidal_client.client_id: return "

Tidal Authentication Failed

Tidal client ID not configured. Check your credentials.

", 400 # Generate PKCE challenge and store globally temp_tidal_client._generate_pkce_challenge() # Store PKCE values globally for callback use global tidal_oauth_state with tidal_oauth_lock: tidal_oauth_state["code_verifier"] = temp_tidal_client.code_verifier tidal_oauth_state["code_challenge"] = temp_tidal_client.code_challenge # Use the user's configured redirect_uri from settings, falling back # to the constructor default (``http://127.0.0.1:/tidal/callback``). # The settings UI displays the default as the placeholder, and SoulSync's # docs tell users to register THAT URI with their Tidal Developer App # — Tidal validates the redirect_uri sent in the authorize request # against the one in the portal, so sending anything else (e.g. a # network-IP variant built from request.host) returns Tidal error 1002 # "Invalid redirect URI" and the user can't authenticate. # # Docker/remote-access workflow is preserved by the post-auth swap step # in the instructions page below: SoulSync sends ``127.0.0.1:``, # Tidal redirects the user's browser to that URI (which fails locally), # the instructions tell the user to swap ``127.0.0.1`` for the host # they're accessing SoulSync from, and the swapped URL hits the # container's exposed callback port. Building the URI from request.host # at authorize time used to skip the swap entirely but broke users # who registered the documented default. configured_redirect = config_manager.get('tidal.redirect_uri', '') if configured_redirect: temp_tidal_client.redirect_uri = configured_redirect logger.info(f"Using configured Tidal redirect_uri: {configured_redirect}") else: logger.info( f"Using default Tidal redirect_uri (no config override): " f"{temp_tidal_client.redirect_uri}" ) # Store PKCE + redirect_uri for callback to use the same values with tidal_oauth_lock: tidal_oauth_state["redirect_uri"] = temp_tidal_client.redirect_uri logger.info(f"Stored PKCE - verifier: {temp_tidal_client.code_verifier[:20]}... challenge: {temp_tidal_client.code_challenge[:20]}...") # Store profile_id for per-profile auth profile_id = request.args.get('profile_id', '') with tidal_oauth_lock: tidal_oauth_state["profile_id"] = profile_id if profile_id and profile_id != '1' else None # Create OAuth URL. # `collection.read` is required for the `userCollectionTracks` # endpoint that powers the virtual "Favorite Tracks" playlist # (issue #502). `prompt=consent` forces Tidal to display the # consent screen even when the app is already authorized — without # it, re-authenticating after a scope expansion can silently # return a token carrying only the ORIGINAL scope set because # Tidal treats the existing authorization as still valid. import urllib.parse params = { 'response_type': 'code', 'client_id': temp_tidal_client.client_id, 'redirect_uri': temp_tidal_client.redirect_uri, 'scope': 'user.read playlists.read collection.read', 'code_challenge': temp_tidal_client.code_challenge, 'code_challenge_method': 'S256', 'prompt': 'consent', } auth_url = f"{temp_tidal_client.auth_url}?" + urllib.parse.urlencode(params) logger.info(f"Generated Tidal OAuth URL: {auth_url}") logger.info(f"Redirect URI in URL: {params['redirect_uri']}") add_activity_item("", "Tidal Auth Started", "Please complete OAuth in browser", "Now") # Detect if accessing remotely (copied from Spotify auth logic) host = request.host.split(':')[0] is_remote = host not in ['127.0.0.1', 'localhost'] is_docker = os.path.exists('/.dockerenv') # If in Docker and accessing via 127.0.0.1, recommend localhost if is_docker and host == '127.0.0.1': host = 'localhost' if is_remote or is_docker: # Show instructions for remote/docker access page_title = "Tidal Authentication (Remote/Docker)" step_1_text = "Click the link below to authenticate with Tidal" # Pull the actual Tidal callback port from the same place the # OAuth URL was built. Using a hardcoded 8888 here used to # mislead users into saving Spotify's port into their Tidal # redirect URI, which then gave Tidal error 1002 (invalid # redirect URI) on every auth attempt. try: from urllib.parse import urlparse as _urlparse _parsed = _urlparse(temp_tidal_client.redirect_uri) tidal_port = _parsed.port or int( os.environ.get('SOULSYNC_TIDAL_CALLBACK_PORT', 8889) ) except Exception: tidal_port = int(os.environ.get('SOULSYNC_TIDAL_CALLBACK_PORT', 8889)) return f'''

{page_title}

Step 1: {step_1_text}

{auth_url}


Step 2: After authorizing, you'll see a blank page or an error. The URL will look like:

http://127.0.0.1:{tidal_port}/tidal/callback?code=...

Step 3: Change 127.0.0.1 to {host} and press Enter:

http://{host}:{tidal_port}/tidal/callback?code=...

Authentication will then complete!

''' else: return f'

Tidal Authentication

Please visit this URL to authenticate:

{auth_url}

After authentication, return to the app.

' except Exception as e: logger.error(f"Error starting Tidal auth: {e}") import traceback logger.error(f"Full traceback: {traceback.format_exc()}") return f"

Tidal Authentication Error

{str(e)}

", 500 def _spotify_auth_result_page(detail_text: str, authenticated: bool = True) -> str: """Return the post-auth page and notify the opener.""" title = "Spotify Authentication Successful" if authenticated else "Spotify Authentication Completed" heading = title close_script = """ setTimeout(() => window.close(), 300); """ if authenticated else """ const closeBtn = document.getElementById('close-window-btn'); if (closeBtn) { closeBtn.addEventListener('click', () => window.close()); } """ return f""" {title}

{heading}

{detail_text}

{'' if not authenticated else ''} """ @app.route('/callback') def spotify_callback(): """ Handles Spotify OAuth callback via the main Flask app (port 8008). This is the recommended callback for reverse proxy / Docker setups. The dedicated HTTPServer on port 8888 continues to work for direct/local access. """ global spotify_client auth_code = request.args.get('code') if not auth_code: error = request.args.get('error') if error: logger.error(f"Spotify OAuth error on port 8008: Spotify returned error: {error}") add_activity_item("", "Spotify Auth Failed", f"Spotify returned error: {error}", "Now") return f"

Spotify Authentication Failed

Spotify returned error: {error}

", 400 # No code AND no error — check if query params were stripped if request.args: logger.info(f"Spotify callback on port 8008 received unexpected params: {dict(request.args)}") else: # Completely empty — likely a healthcheck or spurious request pass return '', 204 logger.info("Spotify callback received on port 8008 with authorization code") # Check for per-profile state parameter state = request.args.get('state', '') profile_id_from_state = None if state and state.startswith('profile_'): try: profile_id_from_state = int(state.replace('profile_', '')) logger.info(f"Per-profile callback detected for profile {profile_id_from_state}") except ValueError: pass try: from core.spotify_client import SpotifyClient from spotipy.oauth2 import SpotifyOAuth from config.settings import config_manager # Per-profile callback: the profile's own account via the shared app. if profile_id_from_state and profile_id_from_state != 1: auth_manager = _profile_spotify_oauth(profile_id_from_state) if auth_manager: token_info = auth_manager.get_access_token(auth_code) if token_info: metadata_registry.clear_cached_profile_spotify_client(profile_id_from_state) profile_client = metadata_registry.get_spotify_client_for_profile(profile_id_from_state) profile_authenticated = bool(profile_client and profile_client.is_spotify_authenticated()) if profile_authenticated: if profile_client: profile_client._invalidate_auth_cache() add_activity_item("", "Spotify Auth Complete", f"Profile {profile_id_from_state} authenticated with Spotify", "Now") return _spotify_auth_result_page("Your personal Spotify account is now connected. You can close this window.", authenticated=True) if profile_client: profile_client._invalidate_auth_cache() invalidate_metadata_status_caches() add_activity_item("", "Spotify Auth Warning", f"Profile {profile_id_from_state} completed OAuth but Spotify did not confirm an authenticated session", "Now") return _spotify_auth_result_page( "Spotify authorization completed, but SoulSync could not confirm an authenticated Spotify session for this profile. You can close this window and try Authenticate again.", authenticated=False, ) else: raise Exception("Failed to exchange authorization code for access token") # Global callback (admin) config = config_manager.get_spotify_config() configured_uri = config.get('redirect_uri', "http://127.0.0.1:8888/callback") logger.info(f"Using redirect_uri for token exchange: {configured_uri}") auth_manager = SpotifyOAuth( client_id=config['client_id'], client_secret=config['client_secret'], redirect_uri=configured_uri, scope="user-library-read user-read-private playlist-read-private playlist-read-collaborative user-read-email user-follow-read", cache_path='config/.spotify_cache' ) token_info = auth_manager.get_access_token(auth_code) if token_info: # CRITICAL: update the GLOBAL spotify_client, not a local variable global spotify_client clear_cached_metadata_client("spotify") spotify_client = get_spotify_client() if spotify_client.is_spotify_authenticated(): # Clear any active rate limit ban and post-ban cooldown # so Spotify is immediately usable after re-auth from core.spotify_client import _clear_rate_limit _clear_rate_limit() spotify_client._invalidate_auth_cache() # Invalidate status cache so next poll picks up the new connection invalidate_metadata_status_caches() # Refresh enrichment worker's client so it picks up new auth if spotify_enrichment_worker and hasattr(spotify_enrichment_worker, 'client'): spotify_enrichment_worker.client.reload_config() spotify_enrichment_worker.client._invalidate_auth_cache() add_activity_item("", "Spotify Auth Complete", "Successfully authenticated with Spotify", "Now") return _spotify_auth_result_page("You can close this window.", authenticated=True) else: logger.warning("Spotify OAuth token exchange succeeded but authentication validation failed") spotify_client._invalidate_auth_cache() invalidate_metadata_status_caches() add_activity_item("", "Spotify Auth Warning", "OAuth completed, but Spotify did not confirm an authenticated session", "Now") return _spotify_auth_result_page( "Spotify authorization completed, but SoulSync could not confirm an authenticated Spotify session. You can close this window and try Authenticate again.", authenticated=False, ) else: raise Exception("Failed to exchange authorization code for access token") except Exception as e: logger.error(f"Spotify OAuth callback error on port 8008: {e}") add_activity_item("", "Spotify Auth Failed", f"Token processing failed: {str(e)}", "Now") return f"

Spotify Authentication Failed

{str(e)}

", 400 @app.route('/api/spotify/disconnect', methods=['POST']) def spotify_disconnect(): """Disconnect Spotify and keep using the active primary metadata source.""" global spotify_client try: configured_source = config_manager.get('metadata.fallback_source', 'deezer') or 'deezer' # Pause enrichment worker before disconnecting to prevent it from hammering API if spotify_enrichment_worker: spotify_enrichment_worker.pause() spotify_client.disconnect() # Immediately update status cache so UI reflects the change active_source = get_spotify_disconnect_source(configured_source) source_label = get_metadata_source_label(active_source) if configured_source == 'spotify': config_manager.set('metadata.fallback_source', active_source) invalidate_metadata_status_caches() add_activity_item("", "Spotify Disconnected", f"Using {source_label} for metadata", "Now") return jsonify({ 'success': True, 'message': f'Spotify disconnected. Using {source_label} for metadata.', 'source': active_source, 'authenticated': False, 'primary_source_changed': configured_source == 'spotify' }) except Exception as e: logger.error(f"Error disconnecting Spotify: {e}") return jsonify({'success': False, 'error': str(e)}), 500 @app.route('/api/spotify/rate-limit-status', methods=['GET']) def spotify_rate_limit_status(): """Get Spotify rate limit ban details""" try: info = get_spotify_status(spotify_client=spotify_client) rate_limit_info = info.get('rate_limit') if info: payload = { 'rate_limited': bool(info.get('rate_limited')), } if rate_limit_info: payload.update({ 'remaining_seconds': rate_limit_info.get('remaining_seconds', 0), 'retry_after': rate_limit_info.get('retry_after'), 'endpoint': rate_limit_info.get('endpoint'), 'expires_at': rate_limit_info.get('expires_at'), }) return jsonify(payload) return jsonify({'rate_limited': False}) except Exception as e: logger.error(f"Error getting Spotify rate limit status: {e}") return jsonify({'error': str(e)}), 500 @app.route('/tidal/callback') def tidal_callback(): """ Handles the callback from Tidal after the user authorizes the application. It receives an authorization code, exchanges it for an access token, and saves the token. """ global tidal_client # We will re-initialize the global client auth_code = request.args.get('code') if not auth_code: error = request.args.get('error', 'Unknown error') error_description = request.args.get('error_description', 'No description provided.') return f"

Tidal Authentication Failed

Error: {error}

{error_description}

Please close this window and try again.

", 400 try: # Create a temporary client for the token exchange temp_tidal_client = TidalClient() # Restore PKCE values, redirect_uri, and profile_id from the auth request profile_id_for_tidal = None with tidal_oauth_lock: temp_tidal_client.code_verifier = tidal_oauth_state["code_verifier"] temp_tidal_client.code_challenge = tidal_oauth_state["code_challenge"] if "redirect_uri" in tidal_oauth_state: temp_tidal_client.redirect_uri = tidal_oauth_state["redirect_uri"] profile_id_for_tidal = tidal_oauth_state.get("profile_id") success = temp_tidal_client.fetch_token_from_code(auth_code) if success: # Per-profile: store tokens on profile, don't touch global client if profile_id_for_tidal: try: profile_id_int = int(profile_id_for_tidal) get_database().set_profile_tidal_tokens( profile_id_int, temp_tidal_client.access_token, temp_tidal_client.refresh_token) clear_profile_tidal_client(profile_id_int) # rebuild with fresh tokens add_activity_item("", "Tidal Auth Complete", f"Profile {profile_id_int} authenticated with Tidal", "Now") return "

Tidal Authentication Successful!

Your personal Tidal account is now connected. You can close this window.

" except Exception as profile_err: logger.error(f"Per-profile Tidal auth failed, falling back to global: {profile_err}") # Global: Re-initialize the main global tidal_client instance with the new token tidal_client = TidalClient() if tidal_enrichment_worker: tidal_enrichment_worker.client = tidal_client return "

Tidal Authentication Successful!

You can now close this window and return to the SoulSync application.

" else: return "

Tidal Authentication Failed

Could not exchange authorization code for a token. Please try again.

", 400 except Exception as e: logger.error(f"Error during Tidal token exchange: {e}") return f"

An Error Occurred

An unexpected error occurred during the authentication process: {e}

", 500 # --- Deezer OAuth --- @app.route('/auth/deezer') def auth_deezer(): """Initialize Deezer OAuth flow. Redirects user to Deezer authorization page.""" try: app_id = config_manager.get('deezer.app_id', '') redirect_uri = config_manager.get('deezer.redirect_uri', 'http://127.0.0.1:8008/deezer/callback') if not app_id: return "

Deezer App ID not configured

Go to Settings → Connections and enter your Deezer App ID first.

", 400 perms = 'basic_access,email,offline_access,manage_library,listening_history' import urllib.parse auth_url = f"https://connect.deezer.com/oauth/auth.php?app_id={app_id}&redirect_uri={urllib.parse.quote(redirect_uri)}&perms={perms}" host = request.host.split(':')[0] return f"""

Deezer Authorization

Click the link below to authorize SoulSync with your Deezer account:

Authorize on Deezer →


If running remotely, replace 127.0.0.1 in the redirect URI with {host}

""" except Exception as e: return f"

Error

{e}

", 500 @app.route('/deezer/callback') def deezer_callback(): """Handle Deezer OAuth callback — exchange code for access token.""" auth_code = request.args.get('code') error_reason = request.args.get('error_reason', '') if not auth_code: return f"

Deezer Authentication Failed

{error_reason or 'No authorization code received.'}

", 400 try: app_id = config_manager.get('deezer.app_id', '') app_secret = config_manager.get('deezer.app_secret', '') if not app_id or not app_secret: return "

Missing Credentials

Deezer App ID or Secret not configured.

", 400 # Exchange code for token — simple GET request (Deezer's unique approach) token_url = f"https://connect.deezer.com/oauth/access_token.php?app_id={app_id}&secret={app_secret}&code={auth_code}" resp = requests.get(token_url, timeout=15) if resp.status_code != 200: return f"

Token Exchange Failed

Deezer returned status {resp.status_code}

", 400 # Deezer returns: access_token=TOKEN&expires=SECONDS (URL-encoded, not JSON) import urllib.parse token_data = dict(urllib.parse.parse_qsl(resp.text)) access_token = token_data.get('access_token') if not access_token: # Try JSON format (some Deezer API versions) try: json_data = resp.json() access_token = json_data.get('access_token') except Exception as e: logger.debug("deezer token json parse failed: %s", e) if not access_token: return f"

No Access Token

Deezer response: {resp.text[:200]}

", 400 # Save token to config (encrypted at rest) config_manager.set('deezer.access_token', access_token) # Reload the global deezer client to pick up the token deezer_client = _get_deezer_client() deezer_client.reload_config() add_activity_item("", "Deezer Auth Complete", "Deezer account connected via OAuth", "Now") logger.info("Deezer OAuth authentication successful") return """

Deezer Authentication Successful!

Your Deezer account is now connected. You can close this window.

""" except Exception as e: logger.error(f"Deezer OAuth callback error: {e}") return f"

Error

{e}

", 500 # --- Beatport Data API --- @app.route('/api/beatport/hero-tracks') def get_beatport_hero_tracks(): """Get fresh tracks from Beatport hero slideshow for the rebuild slider""" try: logger.info("Fetching Beatport hero tracks...") # Check cache first cached_data = get_cached_beatport_data('homepage', 'hero_tracks') if cached_data: logger.info("Returning cached hero tracks data") response = jsonify(cached_data) return add_cache_headers(response, 3600) # 1 hour # Cache miss - scrape fresh data logger.info("Cache miss - scraping fresh hero tracks data...") # Initialize scraper scraper = BeatportUnifiedScraper() # Get tracks from hero slideshow (increased limit to capture all slides) tracks = scraper.scrape_new_on_beatport_hero(limit=15) # SMART FILTERING - Remove duplicates and invalid tracks valid_tracks = [] seen_urls = set() filtered_reasons = collections.Counter() for _i, track in enumerate(tracks): # Extract and clean basic data title = track.get('title', '').strip() artist = track.get('artist', '').strip() url = track.get('url', '').strip() image_url = track.get('image_url', '').strip() # Apply text cleaning for proper spacing if title: title = clean_beatport_text(title) if artist: artist = clean_beatport_text(artist) # Validation filters is_valid = True skip_reasons = [] # Filter 1: Must have title (artist can be fallback) if not title or title in ['No title', 'MISSING', 'Unknown Title']: is_valid = False skip_reasons.append("Missing/invalid title") # If no artist, use fallback based on URL or default if not artist or artist in ['No artist', 'MISSING', 'Unknown Artist', 'NO_ARTIST']: if url and '/release/' in url: artist = 'Various Artists' # Release pages often have multiple artists else: artist = 'Unknown Artist' # Filter 2: Must have valid URL and image if not url or not image_url: is_valid = False skip_reasons.append("Missing URL or image") # Filter 3: URL must be a track/release page (not promotional pages) if url and not any(pattern in url for pattern in ['/release/', '/track/']): is_valid = False skip_reasons.append("URL is not a track/release page") # Filter 4: Deduplication by URL (most reliable method) if url in seen_urls: is_valid = False skip_reasons.append("Duplicate URL") if not is_valid: filtered_reasons.update(skip_reasons) continue # Mark URL as seen for deduplication seen_urls.add(url) # Clean up title title = title.replace(" t ", "'t ").replace("(Extended)DJ", "(Extended)") # Clean up artist names if 'SyrossianHappy' in artist: artist = 'Darius Syrossian' if 'Carroll,' in artist: artist = 'Ron Carroll' if artist.endswith('DJ') and ' ' not in artist[-4:]: artist = artist[:-2].strip() # Create clean track data track_data = { 'title': title, 'artist': artist, 'url': url, 'image_url': image_url, 'genre': 'Electronic', # Default genre 'year': datetime.now().year } # Determine genre based on artist genre_mapping = { 'thakzin': 'Afro House', 'yaya': 'Tech House', 'darius syrossian': 'Techno', 'ron carroll': 'House', 'dj minx': 'House', 'durante': 'Progressive House' } for artist_key, mapped_genre in genre_mapping.items(): if artist_key in artist.lower(): track_data['genre'] = mapped_genre break valid_tracks.append(track_data) sample_titles = [f"{t['title']} - {t['artist']}" for t in valid_tracks[:3]] logger.debug( "Beatport smart filter summary: raw=%s valid=%s filtered=%s reasons=%s sample=%s", len(tracks), len(valid_tracks), len(tracks) - len(valid_tracks), dict(filtered_reasons), sample_titles, ) logger.info(f"Retrieved {len(valid_tracks)} valid unique Beatport tracks (SMART FILTERING)") # Prepare response data response_data = { 'success': True, 'tracks': valid_tracks, 'count': len(valid_tracks), 'timestamp': datetime.now().isoformat() } # Cache the successful response set_cached_beatport_data('homepage', 'hero_tracks', response_data) response = jsonify(response_data) return add_cache_headers(response, 3600) # 1 hour except Exception as e: logger.error(f"Error fetching Beatport tracks: {str(e)}") return jsonify({ 'success': False, 'error': str(e), 'tracks': [] }), 500 @app.route('/api/beatport/new-releases') def get_beatport_new_releases(): """Get new releases from Beatport for the rebuild slider grid""" try: logger.info("🆕 Fetching Beatport new releases...") # Check cache first cached_data = get_cached_beatport_data('homepage', 'new_releases') if cached_data: logger.info("🆕 Returning cached new releases data") response = jsonify(cached_data) return add_cache_headers(response, 3600) # 1 hour # Cache miss - scrape fresh data logger.info("Cache miss - scraping fresh new releases data...") # Initialize scraper scraper = BeatportUnifiedScraper() # Get page and extract releases soup = scraper.get_page(scraper.base_url) if not soup: raise Exception("Could not fetch Beatport homepage") # Find New Releases GridSlider container by section heading # Use partial class match to avoid brittle hashed class names gridsliders = soup.select('[class*="GridSlider-style__Wrapper"]') releases_container = None for container in gridsliders: h2 = container.select_one('h2') if h2: title = h2.get_text(strip=True).lower() if 'new release' in title: releases_container = container logger.info(f"🆕 FOUND NEW RELEASES: '{h2.get_text(strip=True)}'") break # Fallback: try ReleaseCard partial class match on whole page if releases_container: release_cards = releases_container.select('[class*="ReleaseCard-style__Wrapper"]') else: logger.warning("No New Releases GridSlider found, trying page-wide ReleaseCard search") release_cards = soup.select('[class*="ReleaseCard-style__Wrapper"]') releases = [] logger.info(f"Found {len(release_cards)} release cards") for _i, card in enumerate(release_cards[:100]): # Limit to 100 for 10 slides release_data = {} # Extract title from Meta section title_elem = card.select_one('[class*="ReleaseCard-style__Meta"] a[href*="/release/"]') if not title_elem: title_elem = card.select_one('[class*="title"], [class*="Title"], h3, h4, h5, h6') if title_elem: title_text = title_elem.get_text(strip=True) if title_text and len(title_text) > 2 and title_text not in ['New Releases', 'Buy', 'Play']: release_data['title'] = title_text # Extract artist artist_elem = card.select_one('a[href*="/artist/"]') if artist_elem: artist_text = artist_elem.get_text(strip=True) if artist_text and len(artist_text) > 1: release_data['artist'] = artist_text # Extract label label_elem = card.select_one('a[href*="/label/"]') if label_elem: label_text = label_elem.get_text(strip=True) if label_text and len(label_text) > 1: release_data['label'] = label_text # Extract URL url_link = card.select_one('a[href*="/release/"]') if url_link: href = url_link.get('href') if href: release_data['url'] = urljoin(scraper.base_url, href) # Extract image img = card.select_one('img') if img: src = img.get('src') or img.get('data-src') or img.get('data-lazy-src') if src: release_data['image_url'] = src # URL fallback for title if not release_data.get('title') and release_data.get('url'): url_parts = release_data['url'].split('/release/') if len(url_parts) > 1: slug = url_parts[1].split('/')[0] release_data['title'] = slug.replace('-', ' ').title() # Only add if we have essential data if release_data.get('title') and release_data.get('url'): # Add fallbacks for missing data if not release_data.get('artist'): release_data['artist'] = 'Various Artists' if not release_data.get('label'): release_data['label'] = 'Unknown Label' releases.append(release_data) logger.info(f"Successfully extracted {len(releases)} new releases") # Prepare response data response_data = { 'success': True, 'releases': releases, 'count': len(releases), 'slides': (len(releases) + 9) // 10, # Calculate number of slides needed 'timestamp': datetime.now().isoformat() } # Cache the successful response set_cached_beatport_data('homepage', 'new_releases', response_data) response = jsonify(response_data) return add_cache_headers(response, 3600) # 1 hour except Exception as e: logger.error(f"Error fetching new releases: {str(e)}") return jsonify({ 'success': False, 'error': str(e), 'releases': [] }), 500 @app.route('/api/beatport/featured-charts') def get_beatport_featured_charts(): """Get featured charts from Beatport for the charts slider grid using GridSlider approach""" try: logger.info("Fetching Beatport featured charts...") # Check cache first cached_data = get_cached_beatport_data('homepage', 'featured_charts') if cached_data: logger.info("Returning cached featured charts data") response = jsonify(cached_data) return add_cache_headers(response, 3600) # 1 hour # Cache miss - scrape fresh data logger.info("Cache miss - scraping fresh featured charts data...") # Initialize scraper scraper = BeatportUnifiedScraper() # Get page and extract charts soup = scraper.get_page(scraper.base_url) if not soup: raise Exception("Could not fetch Beatport homepage") # Find Featured Charts GridSlider container (like New Releases) gridsliders = soup.select('[class*="GridSlider-style__Wrapper"]') featured_container = None logger.debug(f"Checking {len(gridsliders)} GridSlider containers for featured charts...") for container in gridsliders: h2 = container.select_one('h2') if h2: title = h2.get_text(strip=True).lower() logger.debug(f"Found section: '{h2.get_text(strip=True)}'") if 'featured' in title and 'chart' in title: featured_container = container logger.debug(f"FOUND FEATURED CHARTS: '{h2.get_text(strip=True)}'") break if not featured_container: logger.warning("No Featured Charts GridSlider container found") return jsonify({ 'success': False, 'error': 'Featured Charts section not found', 'charts': [] }) # Extract charts from the container using chart links charts = [] chart_links = featured_container.select('a[href*="/chart/"]') logger.debug(f"Found {len(chart_links)} chart links in Featured Charts section") for _i, link in enumerate(chart_links[:100]): # Limit to 100 for 10 slides chart_data = {} # Extract chart name from link text or nearby elements name_elem = link.select_one('h3, h4, h5, h6, [class*="title"], [class*="Title"], [class*="name"], [class*="Name"]') if name_elem: name_text = name_elem.get_text(strip=True) else: name_text = link.get_text(strip=True) if name_text and len(name_text) > 2 and name_text.lower() not in ['featured charts', 'buy', 'play']: chart_data['name'] = name_text # Extract creator using the specific CSS class pattern from chart cards creator = 'Beatport' # Default # Look for the ChartCard Name class that contains the creator creator_elem = link.select_one('[class*="ChartCard-style__Name"]') if creator_elem: creator_text = creator_elem.get_text(strip=True) if creator_text and len(creator_text) > 1 and creator_text.lower() not in ['by', 'chart', 'featured', 'beatport']: creator = creator_text elif creator_text.lower() == 'beatport': creator = 'Beatport' else: # Fallback: look for other creator indicators parent = link.parent if parent: fallback_selectors = [ '[class*="artist"]', '[class*="Artist"]', '[class*="creator"]', '[class*="Creator"]', '[class*="author"]', '[class*="Author"]' ] for selector in fallback_selectors: fallback_elem = parent.select_one(selector) if fallback_elem: fallback_text = fallback_elem.get_text(strip=True) if fallback_text and len(fallback_text) > 1 and fallback_text.lower() not in ['by', 'chart', 'featured']: creator = fallback_text break chart_data['creator'] = creator # Extract URL href = link.get('href', '') if href: if href.startswith('/'): chart_data['url'] = f"https://www.beatport.com{href}" else: chart_data['url'] = href # Extract image img_elem = link.select_one('img') or (link.parent.select_one('img') if link.parent else None) if img_elem: src = img_elem.get('src', '') or img_elem.get('data-src', '') if src: if src.startswith('//'): src = f"https:{src}" elif src.startswith('/'): src = f"https://www.beatport.com{src}" chart_data['image'] = src # Only add if we have meaningful data if 'name' in chart_data and 'url' in chart_data: charts.append(chart_data) logger.debug(f"Chart {len(charts)}: {chart_data['name']} by {chart_data['creator']}") logger.info(f"Successfully extracted {len(charts)} featured charts") # Prepare response data response_data = { 'success': True, 'charts': charts, 'count': len(charts), 'slides': (len(charts) + 9) // 10, # Calculate number of slides needed 'timestamp': datetime.now().isoformat() } # Cache the successful response set_cached_beatport_data('homepage', 'featured_charts', response_data) response = jsonify(response_data) return add_cache_headers(response, 3600) # 1 hour except Exception as e: logger.error(f"Error fetching featured charts: {str(e)}") return jsonify({ 'success': False, 'error': str(e), 'charts': [] }), 500 @app.route('/api/beatport/dj-charts') def get_beatport_dj_charts(): """Get DJ charts from Beatport for the DJ charts slider using Carousel approach""" try: logger.info("Fetching Beatport DJ charts...") # Check cache first cached_data = get_cached_beatport_data('homepage', 'dj_charts') if cached_data: logger.info("Returning cached DJ charts data") response = jsonify(cached_data) return add_cache_headers(response, 3600) # 1 hour # Cache miss - scrape fresh data logger.info("Cache miss - scraping fresh DJ charts data...") # Initialize scraper scraper = BeatportUnifiedScraper() # Get page and extract charts soup = scraper.get_page(scraper.base_url) if not soup: raise Exception("Could not fetch Beatport homepage") # Find all Carousel containers carousels = soup.select('[class*="Carousel-style__Wrapper"]') dj_container = None logger.debug(f"Checking {len(carousels)} Carousel containers for DJ charts...") # Based on test results, DJ charts are in the second carousel (index 1) with ~9 chart links for i, container in enumerate(carousels): chart_links = container.select('a[href*="/chart/"]') logger.debug(f"Carousel {i+1}: {len(chart_links)} chart links") # DJ charts container typically has 8-12 chart links (not 99+ like featured charts) if 5 <= len(chart_links) <= 15: dj_container = container logger.info(f"FOUND DJ CHARTS: Carousel {i+1} with {len(chart_links)} charts") break if not dj_container: logger.warning("No DJ Charts Carousel container found") return jsonify({ 'success': False, 'error': 'DJ Charts section not found', 'charts': [] }) # Extract charts from the container using chart links charts = [] chart_links = dj_container.select('a[href*="/chart/"]') logger.info(f"Found {len(chart_links)} DJ chart links") for _i, link in enumerate(chart_links): chart_data = {} # Extract chart name from link text or nearby elements name_elem = link.select_one('h3, h4, h5, h6, [class*="title"], [class*="Title"], [class*="name"], [class*="Name"]') if name_elem: name_text = name_elem.get_text(strip=True) else: name_text = link.get_text(strip=True) if name_text and len(name_text) > 2: chart_data['name'] = name_text # Extract creator - for DJ charts, the chart name might be the artist name creator = name_text # Use chart name as creator for DJ charts # Look for additional creator info in parent elements parent = link.parent if parent: creator_selectors = [ '[class*="artist"]', '[class*="Artist"]', '[class*="creator"]', '[class*="Creator"]', '[class*="author"]', '[class*="Author"]' ] for selector in creator_selectors: creator_elem = parent.select_one(selector) if creator_elem: creator_text = creator_elem.get_text(strip=True) if creator_text and len(creator_text) > 1 and creator_text != name_text: creator = creator_text break chart_data['creator'] = creator # Extract URL href = link.get('href', '') if href: if href.startswith('/'): chart_data['url'] = f"https://www.beatport.com{href}" else: chart_data['url'] = href # Extract image img_elem = link.select_one('img') or (link.parent.select_one('img') if link.parent else None) if img_elem: src = img_elem.get('src', '') or img_elem.get('data-src', '') if src: if src.startswith('//'): src = f"https:{src}" elif src.startswith('/'): src = f"https://www.beatport.com{src}" chart_data['image'] = src # Only add if we have meaningful data if 'name' in chart_data and 'url' in chart_data: charts.append(chart_data) logger.info(f"DJ Chart {len(charts)}: {chart_data['name']} by {chart_data['creator']}") logger.info(f"Successfully extracted {len(charts)} DJ charts") # Prepare response data response_data = { 'success': True, 'charts': charts, 'count': len(charts), 'slides': max(1, (len(charts) + 2) // 3), # 3 cards per slide 'timestamp': datetime.now().isoformat() } # Cache the successful response set_cached_beatport_data('homepage', 'dj_charts', response_data) response = jsonify(response_data) return add_cache_headers(response, 3600) # 1 hour except Exception as e: logger.error(f"Error fetching DJ charts: {str(e)}") return jsonify({ 'success': False, 'error': str(e), 'charts': [] }), 500 # --- Placeholder API Endpoints for Other Pages --- @app.route('/api/activity') def get_activity(): # Placeholder: returns mock activity data mock_activity = [ {"time": "1 min ago", "text": "Service status checked."}, {"time": "5 min ago", "text": "Application server started."} ] return jsonify({"activities": mock_activity}) @app.route('/api/playlists') def get_playlists(): # Placeholder: returns mock playlist data if spotify_client and spotify_client.is_authenticated(): # In a real implementation, you would call spotify_client.get_user_playlists() mock_playlists = [ {"id": "1", "name": "Chill Vibes"}, {"id": "2", "name": "Workout Mix"}, {"id": "3", "name": "Liked Songs"} ] return jsonify({"playlists": mock_playlists}) return jsonify({"playlists": [], "error": "Spotify not authenticated."}) @app.route('/api/sync', methods=['POST']) def start_sync(): # Placeholder: simulates starting a sync return jsonify({"success": True, "message": "Sync process started."}) # Search route bodies live in core/search/* — these routes are thin handlers. from core.search import basic as _search_basic from core.search import by_id as _search_by_id from core.search import library_check as _search_library_check from core.search import orchestrator as _search_orchestrator from core.search import stream as _search_stream def _build_search_deps(): """Build the SearchDeps bundle from this module's globals on each request. Constructed per-request so config / client state is always live. """ return _search_orchestrator.SearchDeps( database=get_database(), config_manager=config_manager, spotify_client=spotify_client, hydrabase_client=hydrabase_client, hydrabase_worker=hydrabase_worker, download_orchestrator=download_orchestrator, fix_artist_image_url=fix_artist_image_url, is_hydrabase_active=_is_hydrabase_active, get_metadata_fallback_source=_get_metadata_fallback_source, get_metadata_fallback_client=_get_metadata_fallback_client, get_itunes_client=_get_itunes_client, get_deezer_client=_get_deezer_client, get_discogs_client=_get_discogs_client, run_background_comparison=_run_background_comparison, run_async=run_async, dev_mode_enabled_provider=lambda: dev_mode_enabled, ) @app.route('/api/search/sources', methods=['GET']) def search_sources(): """Return the list of active download sources available for basic search. In single-source mode returns that one source. In hybrid mode returns every source in the configured chain so the frontend can render a source-picker chip row and let the user search specific sources. Response shape: ``{"mode": "hybrid"|, "sources": [{"name": str, "display_name": str}]}`` """ if not download_orchestrator: return jsonify({"mode": "soulseek", "sources": [{"name": "soulseek", "display_name": "Soulseek"}]}) mode = download_orchestrator.mode if mode == 'hybrid': chain = download_orchestrator._resolve_source_chain() sources = [ {"name": s, "display_name": download_orchestrator.registry.display_name(s)} for s in chain ] else: sources = [{"name": mode, "display_name": download_orchestrator.registry.display_name(mode)}] return jsonify({"mode": mode, "sources": sources}) @app.route('/api/search', methods=['POST']) def search_music(): """Basic download-source file search. Accepts an optional ``source`` body param to target a specific source in hybrid mode (e.g. ``"soulseek"``, ``"tidal"``). When omitted, uses the active source (single-source mode) or the first hybrid source. """ data = request.get_json() query = data.get('query') if not query: return jsonify({"error": "No search query provided."}), 400 requested_source = (data.get('source') or '').strip().lower() or None logger.info(f"Web UI Search initiated for: '{query}'" + (f" (source={requested_source})" if requested_source else "")) add_activity_item("", "Search Started", f"'{query}'", "Now") try: results = _search_basic.run_basic_search(query, download_orchestrator, run_async, source=requested_source) add_activity_item("", "Search Complete", f"'{query}' - {len(results)} results", "Now") return jsonify({"results": results}) except Exception as e: logger.error(f"Search error: {e}") return jsonify({"error": str(e)}), 500 @app.route('/api/enhanced-search', methods=['POST']) def enhanced_search(): """Unified metadata search across configured sources + local DB artists. Optional `source` body param ("spotify"|"itunes"|"deezer"|"discogs"| "hydrabase"|"musicbrainz"|"auto"|"") forces a single-source search and bypasses the fan-out. Otherwise picks a primary source per the user's configuration and lists alternates for the frontend to fetch async. """ data = request.get_json() query = data.get('query', '').strip() requested_source = (data.get('source') or '').strip().lower() if requested_source == 'auto': requested_source = '' if requested_source and requested_source not in ENHANCED_SEARCH_VALID_SOURCES: return jsonify({"error": f"Unknown source: {requested_source}"}), 400 if not query: return jsonify(_search_orchestrator.empty_response()) cache_key = _get_enhanced_search_cache_key(query, requested_source) cached = _get_cached_enhanced_search_response(cache_key) if cached is not None: logger.info(f"Enhanced search cache hit for: '{query}'") return jsonify(cached) logger.info(f"Enhanced search initiated for: '{query}' (source={requested_source or 'auto'})") try: deps = _build_search_deps() response_data = _search_orchestrator.run_enhanced_search(query, requested_source, deps) _set_cached_enhanced_search_response(cache_key, response_data) return jsonify(response_data) except Exception as e: logger.error(f"Enhanced search error: {e}") return jsonify({"error": str(e)}), 500 @app.route('/api/enhanced-search/by-id', methods=['POST']) def enhanced_search_by_id(): """Resolve a pasted metadata link to a single album or track (#775). A provider URL (Spotify/Apple/MusicBrainz/Deezer) is looked up directly on the owning source via its get-by-id — no fuzzy search, no scoring. The domain pins the source and the path pins album-vs-track, so it's unambiguous. Returns the same dropdown shape the normal enhanced search renders, plus the resolving ``source`` so the frontend can route downloads/imports through the existing flow. Body: ``{"query": ""}``. Links only — a bare ID is rejected with a hint, since it carries no source or type. """ data = request.get_json() or {} raw = (data.get('query') or '').strip() if not raw: return jsonify(_search_by_id._empty_result('')) try: deps = _build_search_deps() result = _search_by_id.resolve_identifier(raw, deps) return jsonify(result) except Exception as e: logger.error(f"Link/ID resolve error: {e}") return jsonify({"error": str(e)}), 500 @app.route('/api/enhanced-search/source/', methods=['POST']) def enhanced_search_source(source_name): """Streaming NDJSON search for one alternate metadata source. One line per search-kind (artists, albums, tracks) as it completes, plus a final `{"type":"done"}` marker. `youtube_videos` yields a single `videos` chunk via yt-dlp instead. When the requested source's client isn't available (Spotify unauthed, Discogs missing token, Hydrabase disconnected, MusicBrainz import failure, download_orchestrator.client("youtube") missing), returns plain JSON `{"artists":[],"albums":[],"tracks":[],"available":false}` to match the original endpoint contract. """ if source_name not in _search_orchestrator.VALID_STREAM_SOURCES: return jsonify({"error": f"Unknown source: {source_name}"}), 400 data = request.get_json() query = (data.get('query', '') if data else '').strip() if not query: return jsonify({"artists": [], "albums": [], "tracks": [], "available": False}) deps = _build_search_deps() if source_name == 'youtube_videos': youtube_client = _search_orchestrator.resolve_youtube_videos_client(deps) if youtube_client is None: return jsonify({"videos": [], "available": False}) try: return app.response_class( _search_orchestrator.stream_youtube_videos(query, youtube_client, run_async), mimetype='application/x-ndjson', ) except Exception as e: return jsonify({"error": str(e)}), 500 try: client, _available = _search_orchestrator.resolve_client(source_name, deps) if client is None: return jsonify({"artists": [], "albums": [], "tracks": [], "available": False}) return app.response_class( _search_orchestrator.stream_metadata_source(source_name, query, client), mimetype='application/x-ndjson', ) except Exception as e: logger.error(f"Enhanced search source ({source_name}) error: {e}") return jsonify({"artists": [], "albums": [], "tracks": [], "available": False}) @app.route('/api/enhanced-search/library-check', methods=['POST']) def enhanced_search_library_check(): """Batch-check which albums/tracks from search results are already in the library. Called async after search renders so badges fade in without blocking results. """ try: data = request.get_json() or {} result = _search_library_check.check_library_presence( database=get_database(), plex_client=media_server_engine.client('plex') if media_server_engine else None, config_manager=config_manager, profile_id=get_current_profile_id(), albums=data.get('albums', []), tracks=data.get('tracks', []), ) return jsonify(result) except Exception as e: logger.debug(f"Library check error: {e}") return jsonify({'albums': [], 'tracks': []}) @app.route('/api/enhanced-search/stream-track', methods=['POST']) def stream_enhanced_search_track(): """Single-track preview search — finds the best Soulseek/stream-source match. Uses a multi-query retry strategy to work around Soulseek keyword filtering. """ data = request.get_json() track_name = data.get('track_name', '').strip() artist_name = data.get('artist_name', '').strip() album_name = data.get('album_name', '').strip() duration_ms = data.get('duration_ms', 0) if not track_name or not artist_name: return jsonify({"error": "Track name and artist name are required"}), 400 logger.info(f"Enhanced search stream request: '{track_name}' by '{artist_name}'") try: result = _search_stream.stream_search_track( track_name=track_name, artist_name=artist_name, album_name=album_name, duration_ms=duration_ms, config_manager=config_manager, download_orchestrator=download_orchestrator, matching_engine=matching_engine, run_async=run_async, ) if result is None: return jsonify({ "success": False, "error": "No suitable track found after trying multiple search strategies", }), 404 return jsonify({"success": True, "result": result}) except Exception as e: logger.error(f"Error streaming enhanced search track: {e}", exc_info=True) return jsonify({"error": str(e)}), 500 # ============================================================================= # MUSIC VIDEO DOWNLOADS # ============================================================================= _music_video_downloads = {} # {video_id: {status, progress, path, error}} @app.route('/api/music-video/download', methods=['POST']) def download_music_video(): """Download a YouTube video as a music video file to the configured music videos folder.""" data = request.get_json() if not data: return jsonify({"error": "No data"}), 400 video_id = data.get('video_id', '') video_url = data.get('url', '') raw_title = data.get('title', '') raw_channel = data.get('channel', '') if not video_id or not video_url: return jsonify({"error": "Missing video_id or url"}), 400 # Check if already downloading if video_id in _music_video_downloads and _music_video_downloads[video_id].get('status') == 'downloading': return jsonify({"error": "Already downloading"}), 409 # Get and validate music videos path music_videos_path = config_manager.get('library.music_videos_path', '') or '' if not music_videos_path.strip(): return jsonify({"error": "Music Videos directory not configured. Set it in Settings > Downloads."}), 400 music_videos_path = docker_resolve_path(music_videos_path) try: os.makedirs(music_videos_path, exist_ok=True) # Quick write test test_file = os.path.join(music_videos_path, '.soulsync_write_test') with open(test_file, 'w') as f: f.write('test') os.remove(test_file) except (OSError, PermissionError) as e: return jsonify({"error": f"Music Videos directory is not writable: {e}"}), 400 # Initialize download state _music_video_downloads[video_id] = {'status': 'searching', 'progress': 0, 'path': None, 'error': None} def _do_download(): try: # Step 1: Try to match against primary metadata source for clean artist/title _music_video_downloads[video_id]['status'] = 'matching' artist_name = raw_channel track_title = raw_title year = '' # Strip common YouTube suffixes for cleaner search import re as _re clean_search = _re.sub(r'\s*[\(\[](official\s*(music\s*)?video|official\s*lyric\s*video|official\s*audio|official\s*hd|hd|4k|remastered|lyric\s*video|visualizer|audio)[\)\]]', '', raw_title, flags=_re.IGNORECASE).strip() clean_search = _re.sub(r'\s*-\s*$', '', clean_search).strip() try: fallback_client = _get_metadata_fallback_client() results = fallback_client.search_tracks(clean_search, limit=5) if results: from difflib import SequenceMatcher best = None best_score = 0 for r in results: name_sim = SequenceMatcher(None, clean_search.lower(), r.name.lower()).ratio() if r.artists: artist_sim = SequenceMatcher(None, raw_channel.lower(), r.artists[0].lower()).ratio() name_sim = (name_sim * 0.6) + (artist_sim * 0.4) if name_sim > best_score: best_score = name_sim best = r if best and best_score >= 0.5: artist_name = best.artists[0] if best.artists else raw_channel track_title = best.name if hasattr(best, 'release_date') and best.release_date: year = str(best.release_date)[:4] logger.info(f"[Music Video] Matched to: {artist_name} - {track_title} (confidence: {best_score:.2f})") else: # Parse artist from video title: "Artist - Title" pattern if ' - ' in raw_title: parts = raw_title.split(' - ', 1) artist_name = parts[0].strip() track_title = _re.sub(r'\s*[\(\[].*?[\)\]]', '', parts[1]).strip() logger.warning(f"[Music Video] No metadata match, using parsed: {artist_name} - {track_title}") except Exception as e: logger.error(f"[Music Video] Metadata lookup failed: {e}") if ' - ' in raw_title: parts = raw_title.split(' - ', 1) artist_name = parts[0].strip() track_title = _re.sub(r'\s*[\(\[].*?[\)\]]', '', parts[1]).strip() # Sanitize for filesystem def _sanitize(s): return _re.sub(r'[<>:"/\\|?*]', '_', s).strip().rstrip('.') # Apply video path template video_template = config_manager.get('file_organization.templates', {}).get('video_path', '$artist/$title-video') if not video_template or not video_template.strip(): video_template = '$artist/$title-video' safe_artist = _sanitize(artist_name) video_path = video_template video_path = video_path.replace('$artistletter', safe_artist[0].upper() if safe_artist else 'A') video_path = video_path.replace('$artist', safe_artist) video_path = video_path.replace('$title', _sanitize(track_title)) video_path = video_path.replace('$year', str(year) if year else '') # Clean up empty segments from missing variables video_path = _re.sub(r'//+', '/', video_path).strip('/') # Split into folder and filename path_parts = video_path.rsplit('/', 1) if len(path_parts) == 2: folder_part, file_part = path_parts else: folder_part, file_part = '', path_parts[0] output_dir = os.path.join(music_videos_path, folder_part) if folder_part else music_videos_path os.makedirs(output_dir, exist_ok=True) output_path = os.path.join(output_dir, file_part) # Step 2: Download _music_video_downloads[video_id]['status'] = 'downloading' _music_video_downloads[video_id]['artist'] = artist_name _music_video_downloads[video_id]['title'] = track_title def _progress(pct): _music_video_downloads[video_id]['progress'] = round(pct, 1) final_path = download_orchestrator.client("youtube").download_music_video(video_url, output_path, progress_callback=_progress) if final_path and os.path.exists(final_path): _music_video_downloads[video_id]['status'] = 'completed' _music_video_downloads[video_id]['progress'] = 100 _music_video_downloads[video_id]['path'] = final_path logger.info(f"[Music Video] Downloaded: {artist_name} - {track_title} → {final_path}") add_activity_item("", "Music Video Downloaded", f"{artist_name} - {track_title}", "Now") else: _music_video_downloads[video_id]['status'] = 'error' _music_video_downloads[video_id]['error'] = 'Download failed — file not found' logger.error(f"[Music Video] Download failed for: {artist_name} - {track_title}") except Exception as e: _music_video_downloads[video_id]['status'] = 'error' _music_video_downloads[video_id]['error'] = str(e) logger.error(f"[Music Video] {e}") # Run in background thread import threading threading.Thread(target=_do_download, daemon=True, name=f'music-video-{video_id}').start() return jsonify({"success": True, "video_id": video_id}) @app.route('/api/music-video/status/', methods=['GET']) def get_music_video_status(video_id): """Get download status for a music video.""" status = _music_video_downloads.get(video_id) if not status: return jsonify({"status": "unknown"}) return jsonify(status) @app.route('/api/download', methods=['POST']) def start_download(): """Simple download route""" dl_err = check_download_permission() if dl_err: return dl_err data = request.get_json() if not data: return jsonify({"error": "No download data provided."}), 400 try: result_type = data.get('result_type', 'track') if result_type == 'album': tracks = data.get('tracks', []) if not tracks: return jsonify({"error": "No tracks found in album."}), 400 started_downloads = 0 for track_data in tracks: try: username = track_data.get('username') filename = track_data.get('filename') file_size = track_data.get('size', 0) download_id = run_async(download_orchestrator.download( username, filename, file_size )) if download_id: # Register download for post-processing (simple transfer to /Transfer) context_key = _make_context_key(username, filename) with matched_context_lock: matched_downloads_context[context_key] = { 'search_result': { 'username': username, 'filename': filename, 'size': file_size, 'title': track_data.get('title', 'Unknown'), 'artist': track_data.get('artist', 'Unknown'), 'quality': track_data.get('quality', 'Unknown'), 'is_simple_download': True # Flag for simple processing }, 'spotify_artist': None, # No Spotify metadata 'spotify_album': None, 'track_info': None } started_downloads += 1 except Exception as e: logger.error(f"Failed to start track download: {e}") continue # Add activity for album download start album_name = data.get('album_name', 'Unknown Album') logger.info(f"Starting simple album download: '{album_name}' with {started_downloads}/{len(tracks)} tracks") add_activity_item("", "Album Download Started", f"'{album_name}' - {started_downloads} tracks", "Now") return jsonify({ "success": True, "message": f"Started {started_downloads} downloads from album" }) else: # Single track download username = data.get('username') filename = data.get('filename') file_size = data.get('size', 0) logger.info(f"Download request - Username: {username}, Filename: {filename[:50]}...") if not username or not filename: return jsonify({"error": "Missing username or filename."}), 400 # Blocklist guard (Phase 2b): a manual download is source-file-centric # (no metadata IDs), so this matches the blocked ARTIST by name. The # frontend shows "blocked — download anyway?" and re-POSTs with # ignore_blocklist=true on confirm. if not data.get('ignore_blocklist'): try: _dl_artist = data.get('artist') if _dl_artist and _dl_artist != 'Unknown': _reason = get_database().blocklist_reason_for_track( get_current_profile_id(), {'name': data.get('title'), 'artists': [{'name': _dl_artist}]}) if _reason: return jsonify({ "success": False, "blocked": True, "blocked_entity_type": _reason[0], "blocked_name": _reason[1], }), 409 except Exception as _bl_err: logger.debug("manual download blocklist check skipped: %s", _bl_err) download_id = run_async(download_orchestrator.download(username, filename, file_size)) logger.info(f"Download ID returned: {download_id}") if download_id: # Register download for post-processing (simple transfer to /Transfer) context_key = _make_context_key(username, filename) is_streaming_source = username in ('youtube', 'tidal', 'qobuz', 'hifi', 'deezer_dl', 'lidarr', 'soundcloud', 'amazon') with matched_context_lock: matched_downloads_context[context_key] = { 'search_result': { 'username': username, 'filename': filename, 'size': file_size, 'title': data.get('title', 'Unknown'), 'artist': data.get('artist', 'Unknown'), 'quality': data.get('quality', 'Unknown'), 'is_simple_download': True # Flag for simple processing }, 'spotify_artist': None, # No Spotify metadata 'spotify_album': None, 'track_info': None } source_label = username.title() if is_streaming_source else 'Soulseek' logger.info(f"[{source_label}] Registered simple download for post-processing: {context_key}") # Extract track name from filename for activity track_name = filename.split('/')[-1] if '/' in filename else filename.split('\\')[-1] if '\\' in filename else filename logger.info(f"Starting simple track download: '{track_name}'") add_activity_item("", "Track Download Started", f"'{track_name}'", "Now") return jsonify({"success": True, "message": "Download started"}) else: logger.error(f"Failed to start download for: {filename}") return jsonify({"error": "Failed to start download"}), 500 except Exception as e: logger.error(f"Download error: {e}") return jsonify({"error": str(e)}), 500 def _find_completed_file_robust(download_dir, api_filename, transfer_dir=None): """Thin wrapper around the shared file finder. Behaviour preserved verbatim — the implementation lives in ``core/downloads/file_finder.py`` so the Soulseek album-bundle poll (which previously had its own 3-candidate probe and silently timed out on slskd configs that nested downloads under a username subdir, see issue #715) and the per-track download poll go through the same recursive-walk + path-confirm logic. """ from core.downloads.file_finder import find_completed_audio_file return find_completed_audio_file(download_dir, api_filename, transfer_dir) def _find_completed_file_robust_legacy(download_dir, api_filename, transfer_dir=None): """Legacy inline implementation, kept for reference. Unused after the lift to ``core/downloads/file_finder.py``. Will be removed after the next release ships and the new finder proves itself in the field.""" import re import os from difflib import SequenceMatcher from unidecode import unidecode audio_extensions = { '.mp3', '.flac', '.m4a', '.aac', '.ogg', '.opus', '.wav', '.wma', '.alac', '.aiff', '.aif', '.dsf', '.dff', '.ape', } def _is_audio_candidate(path): return os.path.splitext(str(path or ''))[1].lower() in audio_extensions # YOUTUBE/TIDAL SUPPORT: Handle encoded filename format "id||title" # Extract just the title part for file matching if '||' in api_filename: _, title = api_filename.split('||', 1) api_filename = title # Use just the title for file searching def normalize_for_finding(text: str) -> str: """A powerful normalization function adapted from matching_engine.py.""" if not text: return "" text = unidecode(text).lower() # Replace common separators with spaces to preserve word boundaries text = re.sub(r'[._/]', ' ', text) # Keep alphanumeric, spaces, and hyphens. Remove brackets/parentheses content. text = re.sub(r'[\[\(].*?[\]\)]', '', text) text = re.sub(r'[^a-z0-9\s-]', '', text) # Consolidate multiple spaces return ' '.join(text.split()).strip() def _path_matches_api_dirs(file_path): """Check if ALL api directory components appear in the file's path.""" path_parts = set(p.lower() for p in file_path.replace('\\', '/').split('/')) return all(d in path_parts for d in api_dir_parts) def search_in_directory(search_dir, location_name): """Search for the file in a specific directory.""" best_fuzzy_path = None highest_fuzzy_similarity = 0.0 exact_matches = [] # Walk through the entire directory for root, dirs, files in os.walk(search_dir): # Skip quarantine folder — contains known-wrong files from AcoustID verification dirs[:] = [d for d in dirs if d != 'ss_quarantine'] for file in files: file_path = os.path.join(root, file) if not _is_audio_candidate(file_path): continue # Direct basename match if os.path.basename(file) == target_basename: # Fast path: if path aligns with expected directory structure, return now if api_dir_parts and _path_matches_api_dirs(file_path): logger.info(f"Found path-confirmed match in {location_name}: {file_path}") return file_path, 1.0 if not api_dir_parts: # No directory info to disambiguate — return first match (original behavior) logger.info(f"Found exact match in {location_name}: {file_path}") return file_path, 1.0 exact_matches.append(file_path) continue # Check for slskd dedup suffix (e.g. "Song_639067852665564677.flac") # slskd appends _ when a file with the same name already exists file_stem, file_ext_part = os.path.splitext(file) stripped_stem = re.sub(r'_\d{10,}$', '', file_stem) if stripped_stem != file_stem and stripped_stem + file_ext_part == target_basename: if api_dir_parts and _path_matches_api_dirs(file_path): logger.info(f"Found path-confirmed dedup match in {location_name}: {file_path}") return file_path, 1.0 if not api_dir_parts: logger.info(f"Found dedup-suffix match in {location_name}: {file_path}") return file_path, 1.0 exact_matches.append(file_path) continue # Fuzzy matching for variations normalized_file = normalize_for_finding(file) similarity = SequenceMatcher(None, normalized_target, normalized_file).ratio() if similarity > highest_fuzzy_similarity: highest_fuzzy_similarity = similarity best_fuzzy_path = file_path # Return best exact match (disambiguated by path), or fall back to fuzzy if exact_matches: if len(exact_matches) == 1: logger.info(f"Found exact match in {location_name}: {exact_matches[0]}") return exact_matches[0], 1.0 # Multiple files share the basename — pick the one whose path best # matches the expected directory structure from the Soulseek remote path best = exact_matches[0] best_score = -1 for m in exact_matches: m_parts = set(p.lower() for p in m.replace('\\', '/').split('/')) score = sum(1 for d in api_dir_parts if d in m_parts) if score > best_score: best_score = score best = m logger.info(f"Found {len(exact_matches)} files named '{target_basename}' in {location_name}, picked best path match: {best}") return best, 1.0 return best_fuzzy_path, highest_fuzzy_similarity # Extract filename using the helper function target_basename = extract_filename(api_filename) normalized_target = normalize_for_finding(target_basename) # Extract directory components from the API path for disambiguation. # When multiple downloads produce the same basename (e.g., "01 - Silent Night.flac" # from different albums/users), these let us pick the correct file on disk. api_path_normalized = api_filename.replace('\\', '/') if api_filename else '' api_dir_parts = [p.lower() for p in api_path_normalized.split('/')[:-1] if p] # First search in downloads directory best_downloads_path, downloads_similarity = search_in_directory(download_dir, 'downloads') # Use a high confidence threshold for fuzzy matches to prevent false positives if downloads_similarity > 0.85: location = 'downloads' if downloads_similarity < 1.0: logger.info(f"Found fuzzy match in downloads ({downloads_similarity:.2f}): {best_downloads_path}") return (best_downloads_path, location) # If not found in downloads and transfer_dir is provided, search there transfer_similarity = 0.0 # Initialize transfer_similarity if transfer_dir and os.path.exists(transfer_dir): best_transfer_path, transfer_similarity = search_in_directory(transfer_dir, 'transfer') if transfer_similarity > 0.85: location = 'transfer' if transfer_similarity < 1.0: logger.info(f"Found fuzzy match in transfer ({transfer_similarity:.2f}): {best_transfer_path}") return (best_transfer_path, location) # Don't spam logs - file not found is common for completed/processed downloads return (None, None) @app.route('/api/downloads/status') def get_download_status(): """ A robust status checker that correctly finds completed files by searching the entire download directory with fuzzy matching, mirroring the logic from downloads.py. """ if not download_orchestrator: return jsonify({"transfers": []}) try: global processed_download_ids # Skip slskd API call if Soulseek is already known to be disconnected soulseek_known_down = not _status_cache.get('soulseek', {}).get('connected', True) transfers_data = None if not soulseek_known_down: transfers_data = run_async(download_orchestrator._make_request('GET', 'transfers/downloads')) # Don't return early if no Soulseek transfers - YouTube/Tidal downloads need to be checked too! all_transfers = [] completed_matched_downloads = [] # Track files already claimed this poll cycle to prevent two contexts from # grabbing the same physical file when downloads share a basename (e.g., # "07 - Aurora.flac" from two different albums/artists). _files_claimed_this_cycle = set() if not transfers_data: # No Soulseek transfers, but continue to check YouTube/Tidal downloads below pass else: # This logic now correctly processes the nested structure from the slskd API for user_data in transfers_data: username = user_data.get('username', 'Unknown') if 'directories' in user_data: for directory in user_data['directories']: if 'files' in directory: for file_info in directory['files']: file_info['username'] = username all_transfers.append(file_info) state = file_info.get('state', '').lower() # Check for completion state if ('succeeded' in state or 'completed' in state) and 'errored' not in state and 'rejected' not in state: # Verify bytes actually transferred before trusting state _fi_size = file_info.get('size', 0) _fi_transferred = file_info.get('bytesTransferred', 0) if _fi_size > 0 and _fi_transferred < _fi_size: continue # Not truly complete yet filename_from_api = file_info.get('filename') if not filename_from_api: continue # Check if this completed download has a matched context context_key = _make_context_key(username, filename_from_api) # Check if this is an orphaned download that completed after retry if context_key in _orphaned_download_keys: # Safety check: if a new context exists for this key, the retry # re-claimed the same source — treat it as active, not orphaned with matched_context_lock: has_active_context = context_key in matched_downloads_context if has_active_context: logger.warning(f"Orphaned key {context_key} has active context — retry re-used same source, treating as active") _orphaned_download_keys.discard(context_key) # Fall through to normal processing below else: download_dir = docker_resolve_path(config_manager.get('soulseek.download_path', './downloads')) found_result = _find_completed_file_robust(download_dir, filename_from_api) found_path = found_result[0] if found_result and found_result[0] else None orphan_cleaned = False if found_path: try: os.remove(found_path) logger.warning(f"Deleted orphaned download: {os.path.basename(found_path)}") orphan_cleaned = True except Exception as e: logger.error(f"Failed to delete orphaned file (will retry next poll): {e}") else: # File not on disk (already gone or never written) — nothing to clean orphan_cleaned = True if orphan_cleaned: # Remove transfer from slskd transfer_id = file_info.get('id') if transfer_id: try: logger.info( f"[CancelTrigger:web.orphan_cleanup] " f"download_id={transfer_id} username={username}" ) run_async(download_orchestrator.cancel_download(str(transfer_id), username, remove=True)) except Exception as e: logger.debug("orphan transfer cancel failed: %s", e) _orphaned_download_keys.discard(context_key) continue # Skip normal post-processing either way # Skip downloads we've already processed (prevents log spam) if context_key in processed_download_ids: continue with matched_context_lock: context = matched_downloads_context.get(context_key) available_keys = list(matched_downloads_context.keys())[:5] if not context else None if context: logger.info(f"[Context Lookup] Found context for key: {context_key}") elif context_key not in _stale_transfer_keys: # Only log once per stale key to avoid spamming every poll cycle logger.warning(f"[Context Lookup] No context found for key: {context_key}") logger.info(f" Available keys: {available_keys}...") _stale_transfer_keys.add(context_key) if context: download_dir = docker_resolve_path(config_manager.get('soulseek.download_path', './downloads')) # Use the new robust file finder (only search downloads for post-processing candidates) found_result = _find_completed_file_robust(download_dir, filename_from_api) found_path = found_result[0] if found_result and found_result[0] else None if found_path: # Prevent two contexts from claiming the same physical file _norm_path = os.path.normpath(found_path) if _norm_path in _files_claimed_this_cycle: logger.info(f"File already claimed by another context this cycle: {os.path.basename(found_path)} — deferring to next poll") else: _files_claimed_this_cycle.add(_norm_path) logger.info(f"Found completed matched file on disk: {found_path}") completed_matched_downloads.append((context_key, context, found_path)) # Don't add to processed_download_ids yet - wait until thread starts successfully # Clean up retry tracking if file was found after retries with _download_retry_lock: if context_key in _download_retry_attempts: retry_count = _download_retry_attempts[context_key]['count'] elapsed = time.time() - _download_retry_attempts[context_key]['first_attempt'] logger.warning(f"File found after {retry_count} retry attempt(s) ({elapsed:.1f}s): {os.path.basename(filename_from_api)}") del _download_retry_attempts[context_key] else: # File not found yet - implement retry logic instead of immediate give-up # This fixes race condition where slskd reports completion before file is written to disk with _download_retry_lock: if context_key not in _download_retry_attempts: # First retry attempt _download_retry_attempts[context_key] = { 'count': 1, 'first_attempt': time.time() } logger.warning(f"File not found yet: '{os.path.basename(filename_from_api)}' - Will retry (attempt 1/{_download_retry_max})") else: # Increment retry count _download_retry_attempts[context_key]['count'] += 1 retry_count = _download_retry_attempts[context_key]['count'] elapsed = time.time() - _download_retry_attempts[context_key]['first_attempt'] if retry_count >= _download_retry_max: # Max retries reached, give up logger.error(f"CRITICAL: Could not find '{os.path.basename(filename_from_api)}' after {retry_count} attempts over {elapsed:.1f}s. Giving up.") processed_download_ids.add(context_key) # Clean up retry tracking del _download_retry_attempts[context_key] else: logger.warning(f"File not found yet: '{os.path.basename(filename_from_api)}' - Will retry (attempt {retry_count}/{_download_retry_max}, elapsed: {elapsed:.1f}s)") # If we found completed matched downloads, start processing them in background threads if completed_matched_downloads: def process_completed_downloads(): for context_key, context, found_path in completed_matched_downloads: try: logger.info(f"Starting post-processing thread for: {context_key}") # Use verification wrapper if context has task tracking IDs, # otherwise call directly (race guard flag still gets set on context) _pp_task_id = context.get('task_id') _pp_batch_id = context.get('batch_id') if _pp_task_id and _pp_batch_id: _pp_target = _post_process_matched_download_with_verification _pp_args = (context_key, context, found_path, _pp_task_id, _pp_batch_id) else: _pp_target = _post_process_matched_download _pp_args = (context_key, context, found_path) thread = threading.Thread(target=_pp_target, args=_pp_args) thread.daemon = True thread.start() # Only mark as processed AFTER thread starts successfully processed_download_ids.add(context_key) logger.info(f"Marked as processed: {context_key}") # DON'T remove context immediately - verification worker needs it # Context will be cleaned up by verification worker after both processors complete logger.info(f"Keeping context for verification worker: {context_key}") except Exception as e: logger.error(f"Error starting post-processing thread for {context_key}: {e}") # Don't add to processed set if thread failed to start logger.warning(f"Will retry {context_key} on next check") # Start a single thread to manage the launching of all processing threads processing_thread = threading.Thread(target=process_completed_downloads) processing_thread.daemon = True processing_thread.start() # Also include YouTube/Tidal downloads in the response try: all_streaming_downloads = run_async(download_orchestrator.get_all_downloads()) for download in all_streaming_downloads: if download.username in ('youtube', 'tidal', 'qobuz', 'hifi', 'deezer_dl', 'lidarr', 'soundcloud', 'amazon'): source_label = download.username.title() # Convert DownloadStatus to transfer format that frontend expects streaming_transfer = { 'id': download.id, 'filename': download.filename, 'username': download.username, 'state': download.state, 'percentComplete': download.progress, 'size': download.size, 'bytesTransferred': download.transferred, 'averageSpeed': download.speed, 'direction': 'Download', # Required by frontend } all_transfers.append(streaming_transfer) # Check if download is completed and needs post-processing # Verify bytes match before trusting state string _st_bytes_ok = download.size <= 0 or download.transferred >= download.size if _st_bytes_ok and download.state and ('succeeded' in download.state.lower() or 'completed' in download.state.lower()): context_key = _make_context_key(download.username, download.filename) with matched_context_lock: context = matched_downloads_context.get(context_key) if context and context_key not in processed_download_ids: download_dir = docker_resolve_path(config_manager.get('soulseek.download_path', './downloads')) found_result = _find_completed_file_robust(download_dir, download.filename) found_path = found_result[0] if found_result and found_result[0] else None if found_path: # Prevent two contexts from claiming the same physical file _st_norm = os.path.normpath(found_path) if _st_norm in _files_claimed_this_cycle: logger.info(f"[{source_label}] File already claimed this cycle: {os.path.basename(found_path)} — deferring") continue _files_claimed_this_cycle.add(_st_norm) logger.info(f"[{source_label}] Found completed matched file on disk: {found_path}") # Start post-processing thread def process_streaming_download(_ctx_key=context_key, _ctx=context, _path=found_path, _label=source_label): try: logger.info(f"[{_label}] Starting post-processing thread for: {_ctx_key}") # Use verification wrapper if context has task tracking IDs _st_task_id = _ctx.get('task_id') _st_batch_id = _ctx.get('batch_id') if _st_task_id and _st_batch_id: _st_target = _post_process_matched_download_with_verification _st_args = (_ctx_key, _ctx, _path, _st_task_id, _st_batch_id) else: _st_target = _post_process_matched_download _st_args = (_ctx_key, _ctx, _path) thread = threading.Thread(target=_st_target, args=_st_args) thread.daemon = True thread.start() processed_download_ids.add(_ctx_key) logger.info(f"[{_label}] Marked as processed: {_ctx_key}") except Exception as e: logger.error(f"[{_label}] Error starting post-processing thread for {_ctx_key}: {e}") processing_thread = threading.Thread(target=process_streaming_download) processing_thread.daemon = True processing_thread.start() else: # File not found - likely already processed and moved to library # Mark as processed to prevent infinite checking processed_download_ids.add(context_key) except Exception as streaming_error: import traceback logger.error(f"Could not fetch YouTube/Tidal downloads for status: {streaming_error}") traceback.print_exc() # Enrich transfers with metadata from download context (artist, album, artwork) with matched_context_lock: for transfer in all_transfers: ctx_key = _make_context_key(transfer.get('username', ''), transfer.get('filename', '')) ctx = matched_downloads_context.get(ctx_key) if ctx: _sp_artist = ctx.get('spotify_artist') or {} _sp_album = ctx.get('spotify_album') or {} _sp_track = ctx.get('track_info') or {} # Cover art can live in a few places depending on the # origin path: album.images[0].url (Spotify-shaped), # album.image_url (context-built / wishlist / fixes), or # track_info.image_url (some discovery flows). Try them # all so fixed-discovery tracks render their artwork. _sp_images = _sp_album.get('images') or [] _art_url = '' if _sp_images and isinstance(_sp_images[0], dict): _art_url = _sp_images[0].get('url', '') or '' if not _art_url: _art_url = _sp_album.get('image_url') or '' if not _art_url: _art_url = _sp_track.get('image_url') or '' transfer['_meta'] = { 'artist': _sp_artist.get('name', ''), 'album': _sp_album.get('name', ''), 'artwork_url': _art_url, 'track_number': _sp_track.get('track_number'), 'quality': ctx.get('_audio_quality', ''), } return jsonify({"transfers": all_transfers}) except Exception as e: logger.error(f"Error fetching download status: {e}") return jsonify({"error": str(e)}), 500 # Cancel + clear logic lives in core/downloads/cancel.py — these routes are thin handlers. from core.downloads import cancel as _downloads_cancel @app.route('/api/downloads/cancel', methods=['POST']) def cancel_download(): """Cancel a specific download transfer, matching GUI functionality.""" data = request.get_json() if not data: return jsonify({"success": False, "error": "No data provided."}), 400 download_id = data.get('download_id') username = data.get('username') if not all([download_id, username]): return jsonify({"success": False, "error": "Missing download_id or username."}), 400 try: success = _downloads_cancel.cancel_single_download(download_orchestrator, run_async, download_id, username) if success: return jsonify({"success": True, "message": "Download cancelled."}) return jsonify({"success": False, "error": "Failed to cancel download via slskd."}), 500 except Exception as e: logger.error(f"Error cancelling download: {e}") return jsonify({"success": False, "error": str(e)}), 500 @app.route('/api/downloads/cancel-all', methods=['POST']) def cancel_all_downloads(): """Cancel all active downloads from slskd, then clear completed ones.""" try: success, msg = _downloads_cancel.cancel_all_active( download_orchestrator, run_async, _sweep_empty_download_directories, ) if success: return jsonify({"success": True, "message": msg}) return jsonify({"success": False, "error": msg}), 500 except Exception as e: logger.error(f"Error cancelling all downloads: {e}") return jsonify({"success": False, "error": str(e)}), 500 @app.route('/api/downloads/clear-finished', methods=['POST']) def clear_finished_downloads(): """Clear all terminal (completed, cancelled, failed) downloads from slskd.""" try: success = _downloads_cancel.clear_finished_active( download_orchestrator, run_async, _sweep_empty_download_directories, ) if success: return jsonify({"success": True, "message": "Finished downloads cleared."}) return jsonify({"success": False, "error": "Backend failed to clear downloads."}), 500 except Exception as e: logger.error(f"Error clearing finished downloads: {e}") return jsonify({"success": False, "error": str(e)}), 500 # Streaming sources where the candidate's `username` field IS the source name # (Soulseek uses a real peer username; everything else stamps the source string). _STREAMING_SOURCE_NAMES = frozenset(( 'youtube', 'tidal', 'qobuz', 'hifi', 'deezer_dl', 'lidarr', 'soundcloud' )) def _infer_candidate_source(username: str) -> str: """Infer which download source a candidate came from based on its `username` field. Streaming sources stamp their canonical name there; everything else is Soulseek.""" if not username: return 'soulseek' return username if username in _STREAMING_SOURCE_NAMES else 'soulseek' def _serialize_candidate(c, source_override: str = None) -> dict: """Convert a TrackResult (or dict) into the JSON shape the candidates modal expects. ``source_override`` lets manual-search callers stamp the source explicitly when the dispatcher knows it; otherwise we infer from the username.""" if hasattr(c, '__dict__'): username = getattr(c, 'username', '') return { 'username': username, 'filename': getattr(c, 'filename', ''), 'size': getattr(c, 'size', 0), 'bitrate': getattr(c, 'bitrate', None), 'duration': getattr(c, 'duration', None), 'quality': getattr(c, 'quality', ''), 'free_upload_slots': getattr(c, 'free_upload_slots', 0), 'upload_speed': getattr(c, 'upload_speed', 0), 'queue_length': getattr(c, 'queue_length', 0), 'artist': getattr(c, 'artist', None), 'title': getattr(c, 'title', None), 'album': getattr(c, 'album', None), 'source': source_override or _infer_candidate_source(username), } if isinstance(c, dict): out = dict(c) out.setdefault('source', source_override or _infer_candidate_source(out.get('username', ''))) return out return {} def _list_available_download_sources() -> tuple: """Return ``(download_mode, available_sources)`` for the current download configuration. ``download_mode`` is the value of ``download_source.mode`` (one of 'soulseek'/'youtube'/.../'hybrid'). ``available_sources`` is a list of ``{id, label}`` dicts — the sources the manual-search dropdown should offer. In single-source mode: returns just that one source if it's initialized + configured (the user picked it, so we expose it even if is_configured() doesn't fully approve — they may still want to retry). In hybrid mode: filters ``hybrid_order`` down to sources that are BOTH initialized and ``is_configured()`` — same gate hybrid-mode fallback already uses. """ if not download_orchestrator: return 'soulseek', [] download_mode = config_manager.get('download_source.mode', 'soulseek') sources = [] def _make_entry(name: str) -> dict: spec = download_orchestrator.registry.get_spec(name) if hasattr(download_orchestrator, 'registry') else None return { 'id': name, 'label': spec.display_name if spec else name.title(), } if download_mode == 'hybrid': hybrid_order = config_manager.get('download_source.hybrid_order', ['hifi', 'youtube', 'soulseek']) or [] seen = set() for raw_name in hybrid_order: spec = download_orchestrator.registry.get_spec(raw_name) if hasattr(download_orchestrator, 'registry') else None canonical = spec.name if spec else raw_name if canonical in seen: continue client = download_orchestrator.client(canonical) if not client: continue try: if not client.is_configured(): continue except Exception: continue seen.add(canonical) sources.append(_make_entry(canonical)) else: # Single-source mode — just expose the configured mode (the user # picked it, so they expect manual search to hit that source). client = download_orchestrator.client(download_mode) if client: sources.append(_make_entry(download_mode)) return download_mode, sources def _norm_track_key(s: str) -> str: """Loose normalization for matching a task to its library_history row.""" import re return re.sub(r'[^a-z0-9]+', '', (s or '').lower()) @app.route('/api/downloads/task//detail', methods=['GET']) def get_task_detail(task_id): """Full per-track detail for the track-detail modal: live task state merged with the durable library_history provenance (location, quality, AcoustID verdict, source, expected-vs-downloaded). Thin glue over build_track_detail.""" try: from core.downloads.track_detail import build_track_detail with tasks_lock: t = download_tasks.get(task_id) task = dict(t) if isinstance(t, dict) else None if task is None: return jsonify({"success": False, "error": "Task not found"}), 404 task['task_id'] = task_id # Enrich from the most recent download-history row matching this track. history = None try: ti = task.get('track_info') if isinstance(task.get('track_info'), dict) else {} want_title = _norm_track_key(ti.get('name', '')) if want_title: db = get_database() entries, _ = db.get_library_history(event_type='download', page=1, limit=100) for e in entries: if _norm_track_key(e.get('title', '')) == want_title: history = e break except Exception as hist_err: logger.debug(f"track-detail history lookup failed: {hist_err}") detail = build_track_detail(task, history) return jsonify({"success": True, "detail": detail}) except Exception as e: logger.error(f"get_task_detail error: {e}") return jsonify({"success": False, "error": str(e)}), 500 @app.route('/api/downloads/task//candidates', methods=['GET']) def get_task_candidates(task_id): """Returns the cached search candidates for a download task so the UI can show what was found.""" try: with tasks_lock: task = download_tasks.get(task_id) if not task: return jsonify({"error": "Task not found"}), 404 candidates = task.get('cached_candidates', []) track_info = task.get('track_info', {}) error_message = task.get('error_message', '') serialized = [_serialize_candidate(c) for c in candidates if c is not None] serialized = [s for s in serialized if s] download_mode, available_sources = _list_available_download_sources() return jsonify({ "task_id": task_id, "track_info": { "name": track_info.get('name', 'Unknown') if isinstance(track_info, dict) else 'Unknown', "artist": _get_track_artist_name(track_info) if isinstance(track_info, dict) else 'Unknown', }, "error_message": error_message, "candidates": serialized, "candidate_count": len(serialized), "download_mode": download_mode, "available_sources": available_sources, }) except Exception as e: logger.error(f"[Candidates] Error fetching candidates for task {task_id}: {e}") return jsonify({"error": str(e)}), 500 @app.route('/api/downloads/task//download-candidate', methods=['POST']) def download_selected_candidate(task_id): """Restart a not_found/failed task by downloading a user-selected candidate.""" dl_err = check_download_permission() if dl_err: return dl_err try: data = request.get_json() if not data or not data.get('username') or not data.get('filename'): return jsonify({"error": "Missing username or filename"}), 400 username = data['username'] filename = data['filename'] size = data.get('size', 0) with tasks_lock: task = download_tasks.get(task_id) if not task: return jsonify({"error": "Task not found"}), 404 if task['status'] not in ('not_found', 'failed'): return jsonify({"error": f"Task is {task['status']}, not eligible for retry"}), 400 batch_id = task.get('batch_id') track_info = task.get('track_info', {}) # Reset task state task['status'] = 'downloading' task['error_message'] = None task['status_change_time'] = time.time() task.pop('download_id', None) task.pop('username', None) task.pop('filename', None) # Mark this as a user-initiated manual pick. The auto-retry # monitor (`_should_retry_task`) and the engine-state status # fallback both check this flag and skip the "fall back to # another candidate via fresh search" behavior. When the user # explicitly chose THIS file, the mental model is "try this # one and tell me if it failed", not "try this, then auto- # pick something else if it fails". Stays set until the task # reaches a terminal state. task['_user_manual_pick'] = True # Reset retry counters so previous auto-attempts don't # immediately exhaust the manual pick. task.pop('stuck_retry_count', None) task.pop('error_retry_count', None) task.pop('last_retry_time', None) task.pop('last_error_retry_time', None) # Clear the selected candidate from used_sources so it won't be skipped used_sources = task.get('used_sources', set()) source_key = f"{username}_{filename}" used_sources.discard(source_key) # Reset batch tracking for this task if batch_id and batch_id in download_batches: batch = download_batches[batch_id] # Remove from completed set so _on_download_completed can fire again completed_set = batch.get('_completed_task_ids', set()) completed_set.discard(task_id) # Remove from permanently_failed_tracks track_index = task.get('track_index') batch['permanently_failed_tracks'] = [ t for t in batch.get('permanently_failed_tracks', []) if t.get('table_index') != track_index and t.get('download_index') != track_index ] # Restore worker slot batch['active_count'] = batch.get('active_count', 0) + 1 # Build a TrackResult-like candidate object from core.download_plugins.types import TrackResult candidate = TrackResult( username=username, filename=filename, size=size, bitrate=data.get('bitrate'), duration=data.get('duration'), quality=data.get('quality', 'unknown'), free_upload_slots=data.get('free_upload_slots', 0), upload_speed=data.get('upload_speed', 0), queue_length=data.get('queue_length', 0), artist=data.get('artist'), title=data.get('title'), album=data.get('album'), ) candidate.confidence = 1.0 # Required by _attempt_download_with_candidates sort # Reconstruct Track object from task's track_info from core.itunes_client import Track artists = track_info.get('artists', []) artist_names = [] for a in (artists if isinstance(artists, list) else []): if isinstance(a, dict): artist_names.append(a.get('name', 'Unknown')) elif isinstance(a, str): artist_names.append(a) if not artist_names: artist_names = [track_info.get('artist', 'Unknown')] track = Track( id=track_info.get('id', ''), name=track_info.get('name', 'Unknown'), artists=artist_names, album=track_info.get('album', {}).get('name', '') if isinstance(track_info.get('album'), dict) else track_info.get('album', ''), duration_ms=track_info.get('duration_ms', 0), popularity=0, ) track_name = track_info.get('name', 'Unknown') # Run on a dedicated thread instead of `missing_download_executor` # — that pool is shared with the batch's other in-flight tracks # (3 workers total) and a saturated pool would queue the manual # pick indefinitely, leaving the user stuck at "downloading 0%". # Manual picks are user-initiated and infrequent; a fresh thread # per pick is cheaper than starving them behind background work. def _run_manual_download(): logger.info(f"[Manual Download] worker started for task {task_id} ({username} / {track_name})") try: success = _attempt_download_with_candidates(task_id, [candidate], track, batch_id) logger.info(f"[Manual Download] worker finished for task {task_id} success={success}") if not success: with tasks_lock: if task_id in download_tasks: download_tasks[task_id]['status'] = 'failed' download_tasks[task_id]['error_message'] = 'Manual download failed to start — source may be unavailable' if batch_id: _on_download_completed(batch_id, task_id, success=False) except Exception as exc: logger.exception(f"[Manual Download] worker crashed for task {task_id}: {exc}") with tasks_lock: if task_id in download_tasks: download_tasks[task_id]['status'] = 'failed' download_tasks[task_id]['error_message'] = f'Manual download crashed: {exc}' if batch_id: try: _on_download_completed(batch_id, task_id, success=False) except Exception: logger.exception("[Manual Download] _on_download_completed cleanup also failed") threading.Thread( target=_run_manual_download, name=f"manual-download-{task_id[:8]}", daemon=True, ).start() logger.info(f"[Manual Download] User selected candidate for '{track_name}' from {username}") return jsonify({"success": True, "message": f"Download initiated for '{track_name}'"}) except Exception as e: logger.error(f"[Manual Download] {e}") import traceback traceback.print_exc() return jsonify({"error": str(e)}), 500 def _resolve_link_track_query(source: str, track_id: str): """Resolve a pasted (source, track_id) to a clean "artist title" search query via the source client's get_track (#813). Returns (query, None) or (None, error). Used so a pasted Tidal/Qobuz link runs the source's normal search (proven-downloadable candidates) instead of a hand-built one.""" from core.downloads.track_link import query_from_track_payload client = download_orchestrator.client(source) if download_orchestrator else None if not client or not hasattr(client, 'get_track'): return None, f"{source.title()} is not connected" try: raw = client.get_track(track_id) except Exception as e: return None, f"Could not resolve {source.title()} track: {e}" if not raw: return None, f"{source.title()} track {track_id} not found" query = query_from_track_payload(source, raw) if not query: return None, f"Could not read the track title from {source.title()}" return query, None @app.route('/api/downloads/task//manual-search', methods=['POST']) def manual_search_for_task(task_id): """Run a user-driven search against one (or all) configured download sources and stream candidate results as NDJSON — one JSON object per line, terminated by ``\\n``. Streaming lets the modal render results as each source completes instead of blocking on the slowest source. The candidates modal lets the user pick a result; that retry still goes through ``/download-candidate``, so all AcoustID + post-download safety nets stay in the loop. Stream shape (one JSON object per line): - ``{"type": "header", ...}`` — emitted first; carries ``track_info``, ``download_mode``, ``available_sources``, ``query``, ``sources_queried``. - ``{"type": "source_results", "source": "", "candidates": [...]}`` — one per source, emitted as that source's search completes. - ``{"type": "source_error", "source": "", "error": ""}`` — when a source's search raised. - ``{"type": "done", "total": }`` — terminator. """ try: data = request.get_json(silent=True) or {} raw_query = data.get('query', '') query = raw_query.strip() if isinstance(raw_query, str) else '' source = data.get('source', 'all') if len(query) < 2: return jsonify({"error": "Query must be at least 2 characters"}), 400 with tasks_lock: task = download_tasks.get(task_id) if not task: return jsonify({"error": "Task not found"}), 404 track_info = dict(task.get('track_info', {})) download_mode, available_sources = _list_available_download_sources() valid_source_ids = {s['id'] for s in available_sources} # Pasted streaming-source track link (#813): resolve it to a clean # "artist title" query and search ONLY that source, then bubble the # exact track to the top. Falls back to a normal text search if the # source isn't connected or the link can't be resolved — so the user is # never worse off than typing the query themselves. from core.downloads.track_link import parse_download_track_link from core.soundcloud_client import is_soundcloud_url link = parse_download_track_link(query) link_source = None link_track_id = None # A pasted SoundCloud link can't be turned into an "artist title" query # and searched — unlisted/private tracks aren't searchable. Instead force # the SoundCloud source and keep the URL as the query; the SoundCloud # client resolves the link directly (#865). if is_soundcloud_url(query): if 'soundcloud' not in valid_source_ids: return jsonify({ "error": "SoundCloud isn't connected — enable it in Settings to " "resolve a SoundCloud link." }), 400 source = 'soundcloud' elif link: _src, _tid = link # A parsed link is unambiguously a Tidal/Qobuz track URL, never a # name a user would type — so if we can't use it, say why clearly # instead of running a useless search of the raw URL text. if _src not in valid_source_ids: return jsonify({ "error": f"{_src.title()} isn't connected — can't resolve a " f"{_src.title()} link. Connect it in Settings, or search by name." }), 400 clean_q, link_err = _resolve_link_track_query(_src, _tid) if not clean_q: return jsonify({ "error": link_err or f"Couldn't resolve that {_src.title()} link." }), 400 query = clean_q source = _src link_source, link_track_id = _src, _tid if source != 'all': if source not in valid_source_ids: return jsonify({ "error": f"Source '{source}' is not configured or available" }), 400 sources_to_query = [source] else: sources_to_query = list(valid_source_ids) track_payload = { "name": track_info.get('name', 'Unknown'), "artist": _get_track_artist_name(track_info) if isinstance(track_info, dict) else 'Unknown', } from concurrent.futures import ThreadPoolExecutor, as_completed def _search_one(src_name: str): client = download_orchestrator.client(src_name) if download_orchestrator else None if not client: return src_name, [], None try: result = run_async(client.search(query)) if isinstance(result, tuple): tracks = result[0] if result else [] else: tracks = result or [] return src_name, tracks, None except Exception as exc: logger.warning(f"[Manual Search] {src_name} search failed for query '{query}': {exc}") return src_name, [], str(exc) def _generate(): yield json.dumps({ "type": "header", "task_id": task_id, "track_info": track_payload, "download_mode": download_mode, "available_sources": available_sources, "query": query, "sources_queried": sources_to_query, }) + "\n" if not sources_to_query: yield json.dumps({"type": "done", "total": 0}) + "\n" return total = 0 max_workers = min(8, max(1, len(sources_to_query))) with ThreadPoolExecutor(max_workers=max_workers, thread_name_prefix='manual-search') as executor: futures = [executor.submit(_search_one, name) for name in sources_to_query] for future in as_completed(futures): src_name, tracks, error = future.result() if error is not None: yield json.dumps({ "type": "source_error", "source": src_name, "error": error, }) + "\n" continue # Pasted-link exact match: bubble the track whose id matches # the link to the top so the user sees the exact version # first (graceful no-op if ids don't line up). if src_name == link_source and link_track_id and tracks: tracks = sorted( tracks, key=lambda t: str(getattr(t, 'id', '')) != str(link_track_id)) serialized = [] for t in tracks: s = _serialize_candidate(t, source_override=src_name) if s: serialized.append(s) total += len(serialized) yield json.dumps({ "type": "source_results", "source": src_name, "candidates": serialized, }) + "\n" logger.info( f"[Manual Search] task={task_id} query='{query}' source={source} " f"sources_queried={sources_to_query} results={total}" ) yield json.dumps({"type": "done", "total": total}) + "\n" return Response( _generate(), mimetype='application/x-ndjson', headers={'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no'}, ) except Exception as e: logger.error(f"[Manual Search] {e}") import traceback traceback.print_exc() return jsonify({"error": str(e)}), 500 @app.route('/api/quarantine/clear', methods=['POST']) def clear_quarantine(): """Delete all files and folders inside the ss_quarantine directory.""" import shutil try: download_path = docker_resolve_path(config_manager.get('soulseek.download_path', './downloads')) quarantine_path = os.path.join(download_path, 'ss_quarantine') if not os.path.isdir(quarantine_path): return jsonify({"success": True, "message": "Quarantine folder is already empty."}) removed_files = 0 for entry in os.listdir(quarantine_path): entry_path = os.path.join(quarantine_path, entry) try: if os.path.isfile(entry_path): os.remove(entry_path) removed_files += 1 elif os.path.isdir(entry_path): shutil.rmtree(entry_path) removed_files += 1 except Exception as e: logger.error(f"[Quarantine] Failed to remove {entry}: {e}") logger.info(f"[Quarantine] Cleared {removed_files} item(s) from quarantine folder") return jsonify({"success": True, "message": f"Quarantine cleared ({removed_files} item{'s' if removed_files != 1 else ''} removed)."}) except Exception as e: logger.error(f"[Quarantine] Error clearing quarantine: {e}") return jsonify({"success": False, "error": str(e)}), 500 def _get_quarantine_dir(): return os.path.join( docker_resolve_path(config_manager.get('soulseek.download_path', './downloads')), 'ss_quarantine', ) @app.route('/api/quarantine/list', methods=['GET']) def list_quarantine(): """Return all quarantined files with sidecar metadata.""" try: from core.imports.quarantine import list_quarantine_entries entries = list_quarantine_entries(_get_quarantine_dir()) return jsonify({"success": True, "entries": entries}) except Exception as e: logger.error(f"[Quarantine] Error listing entries: {e}") return jsonify({"success": False, "error": str(e)}), 500 @app.route('/api/quarantine/', methods=['DELETE']) def delete_quarantine_item(entry_id): """Delete a single quarantined file + sidecar.""" try: from core.imports.quarantine import delete_quarantine_entry ok = delete_quarantine_entry(_get_quarantine_dir(), entry_id) if not ok: return jsonify({"success": False, "error": "Entry not found"}), 404 return jsonify({"success": True}) except Exception as e: logger.error(f"[Quarantine] Error deleting {entry_id}: {e}") return jsonify({"success": False, "error": str(e)}), 500 @app.route('/api/quarantine//approve', methods=['POST']) def approve_quarantine_item(entry_id): """One-click approve: restore the file and re-run post-process with the quarantine gates skipped for this explicit user-approved pass.""" try: from core.imports.quarantine import approve_quarantine_entry # Restore inside the soulseek download dir so existing path-resolution # logic finds it. Unique subdir keeps it from re-mingling with active # transfers. restore_dir = os.path.join( docker_resolve_path(config_manager.get('soulseek.download_path', './downloads')), 'Transfer', ) result = approve_quarantine_entry(_get_quarantine_dir(), entry_id, restore_dir) if result is None: return jsonify({ "success": False, "error": "Cannot one-click approve — entry has thin sidecar (no embedded context). Use 'Recover to Staging' instead.", }), 400 restored_path, context, trigger = result # User approval means "import this file"; skip all quarantine gates # for this one restored pass so multi-reason failures do not loop. context['_skip_quarantine_check'] = 'all' context['_approved_quarantine_trigger'] = trigger # If the caller (download-modal chooser) passed the originating task, run # the re-import through the verification WRAPPER with that task_id so the # task is marked completed on success — otherwise the modal row stays # stuck on "Quarantined" even though the file imported. The sidecar # context lost task_id/batch_id (the wrapper pops them before quarantine), # so we re-supply them here. Manager-tab approvals (no task_id) keep the # original inner-pipeline path. _req = request.get_json(silent=True) or {} _task_id = (_req.get('task_id') or '').strip() or None _batch_id = None if _task_id: with tasks_lock: _t = download_tasks.get(_task_id) if isinstance(_t, dict): _batch_id = _t.get('batch_id') context['task_id'] = _task_id if _batch_id: context['batch_id'] = _batch_id context_key = f"approve_{entry_id}_{int(time.time())}" if _task_id: _reprocess = lambda: _post_process_matched_download_with_verification( context_key, context, restored_path, _task_id, _batch_id, ) else: _reprocess = lambda: _post_process_matched_download(context_key, context, restored_path) threading.Thread(target=_reprocess, daemon=True).start() logger.info(f"[Quarantine] Approved {entry_id} (original_trigger={trigger}, bypass=all, task={_task_id}) → re-running pipeline") return jsonify({"success": True, "trigger_bypassed": "all", "original_trigger": trigger}) except Exception as e: logger.error(f"[Quarantine] Error approving {entry_id}: {e}") return jsonify({"success": False, "error": str(e)}), 500 @app.route('/api/quarantine//stream', methods=['GET']) def stream_quarantine_item(entry_id): """Stream a quarantined audio file in-app (range-supported) so the user can listen before deciding to approve, search again, or delete it. The file lives in the quarantine dir with a `.quarantined` suffix, so the real audio extension (and thus Content-Type) is recovered from the sidecar.""" try: from core.imports.quarantine import get_quarantine_entry_stream_info info = get_quarantine_entry_stream_info(_get_quarantine_dir(), entry_id) if info is None: return jsonify({"error": "Quarantined file not found"}), 404 file_path, extension = info mimetype = _AUDIO_MIME_TYPES.get(extension, 'audio/mpeg') return _serve_audio_file_with_range(file_path, mimetype_override=mimetype) except Exception as e: logger.error(f"[Quarantine] Error streaming {entry_id}: {e}") return jsonify({"error": str(e)}), 500 def _get_library_history_row(history_id): """Fetch one full library_history row as a dict (or None).""" conn = get_database()._get_connection() cursor = conn.cursor() cursor.execute("SELECT * FROM library_history WHERE id = ?", (history_id,)) row = cursor.fetchone() return dict(row) if row else None def _resolve_history_audio_path(row): """Resolve a library_history row to a playable on-disk file. The recorded path can go stale: Docker↔host prefix differences, or the media server / organizer renaming files with exotic titles (e.g. 凸】♀】♂】←Titan) after import. Fallback chain: 1. the recorded path as-is, 2. `_resolve_library_file_path` (transfer/download/library prefix swap), 3. the tracks table — the media-server mirror knows the CURRENT path for this title+artist even after a rename — resolved the same way. """ def _lookup_titled_paths(title): # tracks-table mirror: paths for this title (knows the CURRENT path # after a media-server rename). [] on any DB error → resolver returns None. try: conn = get_database()._get_connection() cursor = conn.cursor() cursor.execute( "SELECT file_path FROM tracks WHERE file_path IS NOT NULL AND LOWER(title) = LOWER(?)", (title,)) return [r[0] for r in cursor.fetchall() if r[0]] except Exception as e: logger.debug(f"[Verification] tracks-table path fallback failed: {e}") return [] from core.matching.history_paths import resolve_history_audio_path return resolve_history_audio_path( row, exists=os.path.exists, resolve_library_path=_resolve_library_file_path, lookup_titled_paths=_lookup_titled_paths, ) @app.route('/api/verification//stream', methods=['GET']) def stream_verification_item(history_id): """Stream a completed download for the verification review queue (listen before approving). Path comes ONLY from the history row — no client paths.""" try: row = _get_library_history_row(history_id) if not row: return jsonify({"error": "History entry not found"}), 404 file_path = _resolve_history_audio_path(row) if not file_path: return jsonify({"error": "File not found on disk"}), 404 # _AUDIO_MIME_TYPES keys keep the dot ('.flac') — don't strip it, or # everything falls back to audio/mpeg and FLAC playback breaks. ext = os.path.splitext(file_path)[1].lower() mimetype = _AUDIO_MIME_TYPES.get(ext, 'audio/mpeg') return _serve_audio_file_with_range(file_path, mimetype_override=mimetype) except Exception as e: logger.error(f"[Verification] Error streaming history {history_id}: {e}") return jsonify({"error": str(e)}), 500 @app.route('/api/verification//entry', methods=['GET']) def get_verification_entry(history_id): """Full library_history row for one review-queue item — feeds the Audit Trail modal when opened from the Downloads page (where the history-page entry cache is not populated).""" try: row = _get_library_history_row(history_id) if not row: return jsonify({"success": False, "error": "History entry not found"}), 404 return jsonify({"success": True, "entry": row}) except Exception as e: logger.error(f"[Verification] Entry fetch failed for {history_id}: {e}") return jsonify({"success": False, "error": str(e)}), 500 @app.route('/api/verification/config', methods=['GET']) def get_verification_config(): """Whether AcoustID/download-verification is enabled — if not, the review queue collapses to quarantine-only in the UI.""" try: enabled = bool(config_manager.get('acoustid.enabled', False)) return jsonify({"success": True, "acoustid_enabled": enabled}) except Exception as e: return jsonify({"success": True, "acoustid_enabled": True, "error": str(e)}) def _audio_file_duration_ms(path): """Best-effort duration of an on-disk audio file (0 when unreadable). mutagen detects the format from content, so this also works for quarantined files whose extension was swapped to `.quarantined`.""" try: import mutagen mf = mutagen.File(path) if mf and mf.info and getattr(mf.info, 'length', 0): return int(mf.info.length * 1000) except Exception: # noqa: S110 — duration probe is best-effort; fall through to 0 pass return 0 def _set_review_play_session(file_path, title, artist, album, mimetype=None): """Point THIS listener's media-player session at a local file — same mechanism as /api/library/play, so the bottom player UI drives playback (seek/stop/volume) instead of an invisible Audio element.""" sess = _current_stream_state() with sess.lock: sess.update({ "status": "ready", "progress": 100, "track_info": { "title": title or os.path.basename(file_path), "artist": artist or 'Unknown Artist', "album": album or '', }, "file_path": file_path, "stream_url": None, "error_message": None, "is_library": True, # Content-Type hint for /stream/audio — needed for quarantined # files whose on-disk extension is `.quarantined`. Keyed to the # exact path so a stale hint can never leak onto another file. "mimetype_override": mimetype, "mimetype_override_path": file_path if mimetype else None, }) @app.route('/api/verification//play', methods=['POST']) def play_verification_item(history_id): """Load the downloaded file into the media player (review queue ▶).""" try: row = _get_library_history_row(history_id) if not row: return jsonify({"success": False, "error": "History entry not found"}), 404 file_path = _resolve_history_audio_path(row) if not file_path: return jsonify({"success": False, "error": "File not found on disk"}), 404 _set_review_play_session( file_path, row.get('title'), row.get('artist_name'), row.get('album_name')) return jsonify({"success": True, "track_info": { "title": row.get('title') or os.path.basename(file_path), "artist": row.get('artist_name') or '', "album": row.get('album_name') or '', "image_url": row.get('thumb_url') or None, }}) except Exception as e: logger.error(f"[Verification] Play failed for {history_id}: {e}") return jsonify({"success": False, "error": str(e)}), 500 @app.route('/api/verification//compare-stream', methods=['POST']) def compare_stream_verification_item(history_id): """Find the expected track on Soulseek/streaming sources for an A/B comparison — the SAME pipeline as the /search page play button, but fed server-side so the local file's duration guides candidate ranking (a missing duration lets e.g. 10-hour YouTube loops win and time out).""" try: row = _get_library_history_row(history_id) if not row: return jsonify({"success": False, "error": "History entry not found"}), 404 local = _resolve_history_audio_path(row) duration_ms = _audio_file_duration_ms(local) if local else 0 result = _search_stream.stream_search_track( track_name=row.get('title') or '', artist_name=row.get('artist_name') or '', album_name=row.get('album_name') or '', duration_ms=duration_ms, config_manager=config_manager, download_orchestrator=download_orchestrator, matching_engine=matching_engine, run_async=run_async, ) if result is None: return jsonify({"success": False, "error": "No suitable stream candidate found"}), 404 result['title'] = row.get('title') or '' result['artist'] = row.get('artist_name') or '' result['album'] = row.get('album_name') or '' result['image_url'] = row.get('thumb_url') or None return jsonify({"success": True, "result": result}) except Exception as e: logger.error(f"[Verification] Compare-stream failed for {history_id}: {e}") return jsonify({"success": False, "error": str(e)}), 500 def _get_quarantine_entry(entry_id): from core.imports.quarantine import list_quarantine_entries for entry in list_quarantine_entries(_get_quarantine_dir()): if entry.get('id') == entry_id: return entry return None @app.route('/api/quarantine//play', methods=['POST']) def play_quarantine_item(entry_id): """Load a quarantined file into the media player (review queue ▶).""" try: from core.imports.quarantine import get_quarantine_entry_stream_info info = get_quarantine_entry_stream_info(_get_quarantine_dir(), entry_id) if info is None: return jsonify({"success": False, "error": "Quarantined file not found"}), 404 file_path, extension = info entry = _get_quarantine_entry(entry_id) or {} title = entry.get('expected_track') or entry.get('original_filename') or os.path.basename(file_path) _set_review_play_session( file_path, f"{title} (quarantined)", entry.get('expected_artist'), '', mimetype=_AUDIO_MIME_TYPES.get(extension, 'audio/mpeg')) return jsonify({"success": True, "track_info": { "title": f"{title} (quarantined)", "artist": entry.get('expected_artist') or '', "album": '', }}) except Exception as e: logger.error(f"[Quarantine] Play failed for {entry_id}: {e}") return jsonify({"success": False, "error": str(e)}), 500 @app.route('/api/quarantine//compare-stream', methods=['POST']) def compare_stream_quarantine_item(entry_id): """Stream-search the EXPECTED track for a quarantined file (A/B compare), using the quarantined file's duration to guide candidate ranking.""" try: from core.imports.quarantine import get_quarantine_entry_stream_info entry = _get_quarantine_entry(entry_id) if not entry: return jsonify({"success": False, "error": "Quarantine entry not found"}), 404 track_name = entry.get('expected_track') or '' artist_name = entry.get('expected_artist') or '' if not track_name: return jsonify({"success": False, "error": "Entry has no expected-track metadata"}), 400 info = get_quarantine_entry_stream_info(_get_quarantine_dir(), entry_id) duration_ms = _audio_file_duration_ms(info[0]) if info else 0 result = _search_stream.stream_search_track( track_name=track_name, artist_name=artist_name, album_name='', duration_ms=duration_ms, config_manager=config_manager, download_orchestrator=download_orchestrator, matching_engine=matching_engine, run_async=run_async, ) if result is None: return jsonify({"success": False, "error": "No suitable stream candidate found"}), 404 result['title'] = track_name result['artist'] = artist_name return jsonify({"success": True, "result": result}) except Exception as e: logger.error(f"[Quarantine] Compare-stream failed for {entry_id}: {e}") return jsonify({"success": False, "error": str(e)}), 500 @app.route('/api/quarantine//entry', methods=['GET']) def get_quarantine_audit_entry(entry_id): """Synthesize a library_history-shaped entry from a quarantine sidecar so the review queue opens the SAME Audit Trail modal for quarantined files (they were never imported, so no history row exists). ``id`` is None on purpose — the modal fetches tags/lyrics through ``_file_tags_url``.""" try: from core.imports.quarantine import ( get_quarantine_entry_context, get_quarantine_entry_stream_info) entry = _get_quarantine_entry(entry_id) if not entry: return jsonify({"success": False, "error": "Quarantine entry not found"}), 404 ctx = get_quarantine_entry_context(_get_quarantine_dir(), entry_id) info = get_quarantine_entry_stream_info(_get_quarantine_dir(), entry_id) osr = ctx.get('original_search_result') if isinstance(ctx.get('original_search_result'), dict) else {} username = (osr.get('username') or '') if isinstance(osr, dict) else '' streaming = ('tidal', 'youtube', 'qobuz', 'hifi', 'deezer_dl', 'lidarr', 'soundcloud', 'amazon') ti = ctx.get('track_info') if isinstance(ctx.get('track_info'), dict) else {} album_raw = ti.get('album', '') album_name = album_raw.get('name', '') if isinstance(album_raw, dict) else str(album_raw or '') synthetic = { 'id': None, 'event_type': 'download', 'title': entry.get('expected_track') or entry.get('original_filename') or '', 'artist_name': entry.get('expected_artist') or '', 'album_name': album_name, 'created_at': entry.get('timestamp') or '', 'thumb_url': entry.get('thumb_url') or '', 'file_path': info[0] if info else '', 'quality': ctx.get('_audio_quality') or '', 'download_source': username if username in streaming else ('soulseek' if username else ''), 'source_filename': entry.get('source_filename') or '', 'source_artist': (osr.get('artist') or '') if isinstance(osr, dict) else '', 'source_track_title': (osr.get('title') or osr.get('name') or '') if isinstance(osr, dict) else '', 'acoustid_result': 'fail' if entry.get('trigger') == 'acoustid' else None, 'verification_status': None, '_quarantined': True, '_quarantine_reason': entry.get('reason') or '', '_file_tags_url': f"/api/quarantine/{entry_id}/file-tags", } return jsonify({"success": True, "entry": synthetic}) except Exception as e: logger.error(f"[Quarantine] Audit entry failed for {entry_id}: {e}") return jsonify({"success": False, "error": str(e)}), 500 @app.route('/api/quarantine//file-tags', methods=['GET']) def get_quarantine_file_tags(entry_id): """Embedded tags of a quarantined file — feeds the Audit modal's Tags / Lyrics tabs. mutagen detects the format from content, so the swapped `.quarantined` extension is no obstacle.""" try: from core.imports.quarantine import get_quarantine_entry_stream_info from core.library.file_tags import read_embedded_tags info = get_quarantine_entry_stream_info(_get_quarantine_dir(), entry_id) if info is None: return jsonify({'success': False, 'error': 'Quarantined file not found'}), 404 result = read_embedded_tags(info[0]) return jsonify({'success': True, **result}) except Exception as e: logger.error(f"[Quarantine] File-tags failed for {entry_id}: {e}") return jsonify({'success': False, 'error': str(e)}), 500 @app.route('/api/verification//approve', methods=['POST']) @admin_only def approve_verification_item(history_id): """User confirmed the file IS the right track: set human_verified on the history row, the file tag, and (best-effort) the tracks row. The AcoustID scanner skips human-verified files entirely. Admin-only: mutates shared library/verification state.""" try: from core.matching.verification_status import HUMAN_VERIFIED from core.tag_writer import write_verification_status db = get_database() row = _get_library_history_row(history_id) if not row: return jsonify({"success": False, "error": "History entry not found"}), 404 file_path = row.get('file_path') or '' on_disk = _resolve_history_audio_path(row) with db._get_connection() as conn: conn.execute( "UPDATE library_history SET verification_status = ? WHERE id = ?", (HUMAN_VERIFIED, history_id)) # The tracks row may carry either the recorded or the resolved path. for p in {p for p in (file_path, on_disk) if p}: conn.execute( "UPDATE tracks SET verification_status = ? WHERE file_path = ?", (HUMAN_VERIFIED, p)) conn.commit() tag_written = bool(on_disk) and write_verification_status(on_disk, HUMAN_VERIFIED) return jsonify({"success": True, "tag_written": tag_written}) except Exception as e: logger.error(f"[Verification] Approve failed for {history_id}: {e}") return jsonify({"success": False, "error": str(e)}), 500 @app.route('/api/verification//delete', methods=['POST']) @admin_only def delete_verification_item(history_id): """User decided the file is wrong: delete it from disk and drop the history row (the media-server mirror cleans the tracks row on next scan). Admin-only: it removes a file from disk + the library.""" try: db = get_database() row = _get_library_history_row(history_id) if not row: return jsonify({"success": False, "error": "History entry not found"}), 404 on_disk = _resolve_history_audio_path(row) file_deleted = False if on_disk and os.path.exists(on_disk): os.remove(on_disk) file_deleted = True logger.info(f"[Verification] Deleted rejected file: {on_disk}") db.delete_library_history_rows([history_id]) return jsonify({"success": True, "file_deleted": file_deleted}) except Exception as e: logger.error(f"[Verification] Delete failed for {history_id}: {e}") return jsonify({"success": False, "error": str(e)}), 500 @app.route('/api/quarantine//recover', methods=['POST']) def recover_quarantine_item(entry_id): """Fallback for legacy thin sidecars: move file into Staging so the user can manually finish via the existing Import flow.""" try: from core.imports.quarantine import recover_to_staging from core.imports.staging import get_staging_path target = recover_to_staging(_get_quarantine_dir(), get_staging_path(), entry_id) if not target: return jsonify({"success": False, "error": "Entry not found"}), 404 return jsonify({"success": True, "staged_path": target}) except Exception as e: logger.error(f"[Quarantine] Error recovering {entry_id}: {e}") return jsonify({"success": False, "error": str(e)}), 500 @app.route('/api/scan/request', methods=['POST']) def request_media_scan(): """ Request a media library scan. Database update is handled automatically by the 'Auto-Update Database After Scan' system automation. """ try: if not web_scan_manager: return jsonify({"success": False, "error": "Scan manager not initialized"}), 500 data = request.get_json() or {} reason = data.get('reason', 'Web UI download completed') result = web_scan_manager.request_scan(reason=reason) add_activity_item("", "Media Scan", f"Scan requested: {reason}", "Now") return jsonify({ "success": True, "scan_info": result, }) except Exception as e: logger.error(f"Error requesting media scan: {e}") return jsonify({"success": False, "error": str(e)}), 500 @app.route('/api/scan/status', methods=['GET']) def get_scan_status(): """ Get current media scan status. """ try: if not web_scan_manager: return jsonify({"success": False, "error": "Scan manager not initialized"}), 500 status = web_scan_manager.get_scan_status() return jsonify({"success": True, "status": status}) except Exception as e: logger.error(f"Error getting scan status: {e}") return jsonify({"success": False, "error": str(e)}), 500 @app.route('/api/database/incremental-update', methods=['POST']) def request_incremental_database_update(): """ Request an incremental database update with prerequisites checking. """ try: data = request.get_json() or {} reason = data.get('reason', 'Web UI manual request') # Check prerequisites (similar to GUI logic) db = get_database() # Check if database has enough content for incremental updates track_count = db.execute("SELECT COUNT(*) FROM tracks").fetchone()[0] if track_count < 100: return jsonify({ "success": False, "error": f"Database has only {track_count} tracks - insufficient for incremental updates (minimum 100)", "track_count": track_count }), 400 # Check if there's been a previous full refresh last_refresh = db.execute( "SELECT value FROM system_info WHERE key = 'last_full_refresh'" ).fetchone() if not last_refresh: return jsonify({ "success": False, "error": "No previous full refresh found - incremental updates require established database", "suggestion": "Run a full refresh first" }), 400 # Start incremental update if db_update_state.get('status') == 'running': return jsonify({"success": False, "error": "Database update already running"}), 409 active_server = config_manager.get_active_media_server() with db_update_lock: db_update_state.update({ "status": "running", "phase": "Initializing...", "progress": 0, "current_item": "", "processed": 0, "total": 0, "error_message": "", "last_progress_at": time.time(), # seed heartbeat for the stall watchdog }) db_update_executor.submit(_run_db_update_task, False, active_server) add_activity_item("", "Database Update", f"Incremental update started: {reason}", "Now") return jsonify({ "success": True, "message": "Incremental database update started", "track_count": track_count, "last_refresh": last_refresh[0] if last_refresh else None, "reason": reason }) except Exception as e: logger.error(f"Error requesting incremental database update: {e}") return jsonify({"success": False, "error": str(e)}), 500 @app.route('/api/test/automation', methods=['POST']) def test_automation_workflow(): """ Test endpoint to verify the automatic workflow functionality. """ try: data = request.get_json() or {} test_type = data.get('test_type', 'full') results = {} # Test 1: Scan manager status if web_scan_manager: scan_status = web_scan_manager.get_scan_status() results['scan_manager'] = {'status': 'available', 'current_status': scan_status} else: results['scan_manager'] = {'status': 'unavailable'} # Test 2: Database prerequisites try: db = get_database() track_count = db.execute("SELECT COUNT(*) FROM tracks").fetchone()[0] last_refresh = db.execute( "SELECT value FROM system_info WHERE key = 'last_full_refresh'" ).fetchone() results['database'] = { 'track_count': track_count, 'meets_minimum': track_count >= 100, 'has_previous_refresh': last_refresh is not None, 'last_refresh': last_refresh[0] if last_refresh else None } except Exception as e: results['database'] = {'error': str(e)} # Test 3: Media client connections active_server = config_manager.get_active_media_server() results['media_clients'] = {'active_server': active_server} for client_name, client in [ ('plex', media_server_engine.client('plex')), ('jellyfin', media_server_engine.client('jellyfin')), ('navidrome', media_server_engine.client('navidrome')) ]: try: is_connected = client.is_connected() if client else False results['media_clients'][client_name] = { 'available': client is not None, 'connected': is_connected } except Exception as e: results['media_clients'][client_name] = { 'available': client is not None, 'connected': False, 'error': str(e) } # Test 4: If requested, actually test the scan request if test_type == 'full' and web_scan_manager: try: scan_result = web_scan_manager.request_scan( reason="Automation test", callback=None ) results['scan_test'] = {'success': True, 'result': scan_result} except Exception as e: results['scan_test'] = {'success': False, 'error': str(e)} return jsonify({ "success": True, "test_results": results, "automation_ready": ( results.get('scan_manager', {}).get('status') == 'available' and results.get('database', {}).get('meets_minimum', False) and results.get('database', {}).get('has_previous_refresh', False) ) }) except Exception as e: logger.error(f"Error in automation test: {e}") return jsonify({"success": False, "error": str(e)}), 500 @app.route('/api/searches/clear-all', methods=['POST']) def clear_all_searches(): """ Clear all searches from slskd search history. """ try: success = run_async(download_orchestrator.clear_all_searches()) if success: add_activity_item("", "Search Cleanup", "All search history cleared manually", "Now") return jsonify({"success": True, "message": "All searches cleared."}) else: return jsonify({"success": False, "error": "Backend failed to clear searches."}), 500 except Exception as e: logger.error(f"Error clearing searches: {e}") return jsonify({"success": False, "error": str(e)}), 500 @app.route('/api/searches/maintain', methods=['POST']) def maintain_search_history(): """ Maintain search history by keeping only recent searches. """ try: data = request.get_json() or {} keep_searches = data.get('keep_searches', 50) trigger_threshold = data.get('trigger_threshold', 200) success = run_async(download_orchestrator.maintain_search_history_with_buffer( keep_searches=keep_searches, trigger_threshold=trigger_threshold )) if success: add_activity_item("", "Search Maintenance", f"Search history maintained (keeping {keep_searches} searches)", "Now") return jsonify({"success": True, "message": f"Search history maintained (keeping {keep_searches} searches)."}) else: return jsonify({"success": False, "error": "Backend failed to maintain search history."}), 500 except Exception as e: logger.error(f"Error maintaining search history: {e}") return jsonify({"success": False, "error": str(e)}), 500 # ── Download-origin history (origin modal: watchlist page / sync page) ── # Lists downloads by what TRIGGERED them ('watchlist' / 'playlist'), recorded # at the import chokepoint via core.downloads.origin. Delete removes the file # on disk (resolved through the same container/host path resolver everything # else uses), the matching library track row, and the history entries. @app.route('/api/download-origins') def get_download_origins(): try: origin = request.args.get('origin', 'watchlist') if origin not in ('watchlist', 'playlist'): return jsonify({'success': False, 'error': 'origin must be watchlist or playlist'}), 400 limit = min(500, max(1, int(request.args.get('limit', 200)))) offset = max(0, int(request.args.get('offset', 0))) entries, total = get_database().get_download_origin_entries(origin, limit=limit, offset=offset) return jsonify({'success': True, 'origin': origin, 'entries': entries, 'total': total}) except Exception as e: logger.error(f"Error listing download origins: {e}") return jsonify({'success': False, 'error': str(e)}), 500 @app.route('/api/download-origins/delete', methods=['POST']) def delete_download_origins(): """Delete origin-history entries; optionally (default) also delete the files on disk and their library track rows.""" try: data = request.get_json(silent=True) or {} ids = [int(i) for i in (data.get('ids') or []) if str(i).strip()] if not ids: return jsonify({'success': False, 'error': 'No ids given'}), 400 delete_files = bool(data.get('delete_files', True)) from core.library.path_resolver import resolve_library_file_path db = get_database() rows = db.get_library_history_rows_by_ids(ids) files_deleted, files_missing, file_errors = 0, 0, [] failed_ids = set() for row in rows: raw_path = row.get('file_path') or '' if not delete_files or not raw_path: continue resolved = resolve_library_file_path(raw_path, config_manager=config_manager) if resolved and os.path.isfile(resolved): try: os.remove(resolved) files_deleted += 1 except OSError as e: file_errors.append(f"{row.get('title') or raw_path}: {e}") failed_ids.add(row['id']) # keep the row when the file refuses to go continue else: files_missing += 1 # already gone — still clean up the rows db.delete_track_by_file_path(raw_path) removed = db.delete_library_history_rows( [r['id'] for r in rows if r['id'] not in failed_ids]) return jsonify({ 'success': True, 'removed': removed, 'files_deleted': files_deleted, 'files_missing': files_missing, 'errors': file_errors, }) except Exception as e: logger.error(f"Error deleting download origins: {e}") return jsonify({'success': False, 'error': str(e)}), 500 @app.route('/api/library/history') def get_library_history(): """Get persistent library history (downloads and server imports).""" try: event_type = request.args.get('type', None) if event_type and event_type not in ('download', 'import'): event_type = None page = max(1, int(request.args.get('page', 1))) limit = min(200, max(1, int(request.args.get('limit', 50)))) db = get_database() entries, total = db.get_library_history(event_type=event_type, page=page, limit=limit) stats = db.get_library_history_stats() return jsonify({ 'success': True, 'entries': entries, 'total': total, 'page': page, 'limit': limit, 'stats': stats }) except Exception as e: logger.error(f"Error fetching library history: {e}") return jsonify({'success': False, 'error': str(e)}), 500 @app.route('/api/library/history//file-tags') def get_library_history_file_tags(history_id: int): """Read embedded tags from the actual audio file for one library history row. Backs the Audit Trail modal's "Embedded Tags" section. The file is the single source of truth — persisted snapshot columns drift the moment a background worker writes more tags. `read_embedded_tags` returns a uniform dict; we pass through. """ try: db = get_database() entries, _total = db.get_library_history(event_type=None, page=1, limit=200) entry = next((e for e in entries if e.get('id') == history_id), None) if entry is None: # Wider lookup — pagination above may not have caught older rows. conn = db._get_connection() cursor = conn.cursor() cursor.execute("SELECT * FROM library_history WHERE id = ?", (history_id,)) row = cursor.fetchone() entry = dict(row) if row else None if entry is None: return jsonify({'success': False, 'error': 'history row not found'}), 404 raw_path = entry.get('file_path') or '' resolved = _resolve_library_file_path(raw_path) if raw_path else None target_path = resolved or raw_path from core.library.file_tags import read_embedded_tags result = read_embedded_tags(target_path) return jsonify({'success': True, **result}) except Exception as e: logger.error(f"Error reading file tags for history {history_id}: {e}") return jsonify({'success': False, 'error': str(e)}), 500 @app.route('/api/library/artists') def get_library_artists(): """Get artists for the library page with search, filtering, and pagination""" try: # Get query parameters search_query = request.args.get('search', '') letter = request.args.get('letter', 'all') page = int(request.args.get('page', 1)) limit = int(request.args.get('limit', 75)) watchlist_filter = request.args.get('watchlist', 'all') source_filter = request.args.get('source_filter', '') # Get database instance database = get_database() # Get artists from database result = database.get_library_artists( search_query=search_query, letter=letter, page=page, limit=limit, watchlist_filter=watchlist_filter, profile_id=get_current_profile_id(), source_filter=source_filter ) # Fix image URLs for all artists for artist in result['artists']: if artist.get('image_url'): artist['image_url'] = fix_artist_image_url(artist['image_url']) return jsonify({ "success": True, **result }) except Exception as e: logger.error(f"Error fetching library artists: {e}") import traceback traceback.print_exc() return jsonify({ "success": False, "error": str(e), "artists": [], "pagination": { "page": 1, "limit": 75, "total_count": 0, "total_pages": 0, "has_prev": False, "has_next": False } }), 500 @app.route('/api/test-artist/') def test_artist_endpoint(artist_id): """Simple test endpoint""" return jsonify({ "success": True, "message": f"Test endpoint working for artist ID: {artist_id}" }) from core.artist_source_lookup import ( SOURCE_ID_FIELD as _SOURCE_ID_FIELD, SOURCE_ONLY_ARTIST_SOURCES as _SOURCE_ONLY_ARTIST_SOURCES, find_library_artist_for_source as _core_find_library_artist_for_source, ) def _find_library_artist_for_source(database, source, source_artist_id, artist_name=None): """Thin wrapper that injects the active-server context for the core lookup.""" try: active_server = config_manager.get_active_media_server() except Exception: active_server = None return _core_find_library_artist_for_source( database, source, source_artist_id, artist_name, active_server=active_server ) def _resolve_source_artist_name(source, artist_id): """Resolve a source artist's display name by id, or '' on any failure. Reuses the #775 link-resolver's per-source artist fetch so we have one place that knows each source's get-by-id quirks. Used by the artist-detail library upgrade to disambiguate a duplicated/corrupt source id by name when the URL-driven navigation didn't carry a name. """ try: deps = _build_search_deps() client, _available = _search_orchestrator.resolve_client(source, deps) if client is None: return '' data = _search_by_id._fetch_artist(client, source, artist_id) return (data or {}).get('name') or '' except Exception as e: logger.debug(f"Source artist name resolution failed for {source}:{artist_id}: {e}") return '' def _build_source_only_artist_detail(artist_id, artist_name, source): """Thin wrapper around ``core.artist_source_detail.build_source_only_artist_detail``. Builds the per-source client bag from web_server's module globals (each source's module-level client + Last.fm api key), forwards to the pure implementation in ``core/``, and wraps the (dict, status) return in ``jsonify``. """ from core.artist_source_detail import build_source_only_artist_detail # Resolve the per-source clients defensively — the original inline code # wrapped the whole source-side lookup in try/except so a failing # client helper (e.g. Spotify auth probe during a rate-limit ban, # Discogs client init error) would degrade gracefully to empty # enrichment instead of 500-ing the request. Preserve that. sp = None dz = None it = None dc = None try: if spotify_client and spotify_client.is_spotify_authenticated(): sp = spotify_client except Exception as e: logger.debug(f"Spotify client resolution failed: {e}") try: dz = _get_deezer_client() except Exception as e: logger.debug(f"Deezer client resolution failed: {e}") try: it = _get_itunes_client() except Exception as e: logger.debug(f"iTunes client resolution failed: {e}") try: discogs_token = config_manager.get('discogs.token', '') or '' if discogs_token: dc = _get_discogs_client(discogs_token) except Exception as e: logger.debug(f"Discogs client resolution failed: {e}") az = None try: from core.metadata.registry import get_amazon_client az = get_amazon_client() except Exception as e: logger.debug(f"Amazon client resolution failed: {e}") try: lastfm_api_key = config_manager.get('lastfm.api_key', '') or None except Exception: lastfm_api_key = None payload, status = build_source_only_artist_detail( artist_id, artist_name, source, spotify_client=sp, deezer_client=dz, itunes_client=it, discogs_client=dc, amazon_client=az, lastfm_api_key=lastfm_api_key, ) return jsonify(payload), status @app.route('/api/artist-detail/') def get_artist_detail(artist_id): """Get artist detail data. For library artists, `artist_id` is the local DB primary key and the full library-aware path runs (owned releases + merged source discography + per- service enrichment coverage). For source artists (Spotify/Deezer/iTunes/etc. that aren't in the library yet), pass `?source=&name=` and the endpoint synthesizes a response directly from the metadata source — no owned releases, just name + image + discography so the artist-detail page can still render. """ try: source_param = (request.args.get('source', '') or '').strip().lower() artist_name_arg = (request.args.get('name', '') or '').strip() logger.info( f"Getting artist detail for ID: {artist_id} " f"(source={source_param or 'library'})" ) # Get database instance database = get_database() # Get artist discography from database db_result = database.get_artist_discography(artist_id) # Library upgrade: if direct ID lookup missed AND we have a source hint, # check whether the user already owns this artist in the library under # a different ID (e.g. clicking a Deezer search result for an artist # they have indexed in Plex). Prefer the library record so they get # all their owned releases + enrichment instead of a bare source view. if not db_result.get('success') and source_param in _SOURCE_ONLY_ARTIST_SOURCES: library_pk = _find_library_artist_for_source( database, source_param, artist_id, artist_name_arg ) # URL-driven navigation carries no name, so a duplicated/corrupt # source id (one Deezer id on several artists) can't be matched by # the id alone — it's ambiguous and the lookup bails. Resolve the # artist's name from the source and retry so an owned artist still # gets the rich library view instead of the bare source one. if not library_pk and not artist_name_arg: resolved_name = _resolve_source_artist_name(source_param, artist_id) if resolved_name: artist_name_arg = resolved_name library_pk = _find_library_artist_for_source( database, source_param, artist_id, artist_name_arg ) if library_pk: logger.info( f"Source-id {source_param}:{artist_id} matched library artist " f"PK={library_pk} — upgrading to library response" ) db_result = database.get_artist_discography(library_pk) if not db_result.get('success'): # Library lookup still failed. If a metadata source was specified, # fall back to a source-only response so the page can render a # non-library artist. if source_param in _SOURCE_ONLY_ARTIST_SOURCES: return _build_source_only_artist_detail( artist_id, artist_name_arg, source_param ) logger.error(f"Database returned error: {db_result}") return jsonify({ "success": False, "error": db_result.get('error', 'Artist not found') }), 404 artist_info = db_result['artist'] owned_releases = db_result['owned_releases'] logger.info(f"Found artist: {artist_info['name']} with {len(owned_releases['albums'])} albums") # Fix artist image URL. # NOTE: don't log image_url or the full artist_info dict here. # The fixed URL embeds the media-server token (and the proxy # variant URL-encodes it), so logging at INFO writes the token # straight into app.log. Issue: tokens leaked to disk on every # artist-page render until this was scrubbed. if artist_info.get('image_url'): artist_info['image_url'] = fix_artist_image_url(artist_info['image_url']) else: logger.warning(f"No artist image URL found for {artist_info['name']}") # Fix image URLs for all albums for album in owned_releases['albums']: if album.get('image_url'): album['image_url'] = fix_artist_image_url(album['image_url']) # Fix image URLs for EPs and singles (currently empty but for future use) for ep in owned_releases['eps']: if ep.get('image_url'): ep['image_url'] = fix_artist_image_url(ep['image_url']) for single in owned_releases['singles']: if single.get('image_url'): single['image_url'] = fix_artist_image_url(single['image_url']) # Get source-priority discography for proper categorization and missing releases artist_detail_discography = None try: from core.metadata.lookup import MetadataLookupOptions from core.metadata_service import get_artist_detail_discography as _get_artist_detail_discography from core.source_ids import source_id_map # Per-source artist IDs, read via the canonical source-ID registry # (same columns as before: spotify_artist_id / deezer_id / # itunes_artist_id / discogs_id / soul_id / amazon_id). artist_source_ids = source_id_map( artist_info, 'artist', providers=('spotify', 'deezer', 'itunes', 'discogs', 'hydrabase', 'amazon'), ) artist_detail_discography = _get_artist_detail_discography( artist_id, artist_name=artist_info['name'], options=MetadataLookupOptions( allow_fallback=True, skip_cache=False, max_pages=0, # Match the Download Discography endpoint cap (200) # so the artist detail view sees the same release # set the modal lists. Spotify already paginates # all; Deezer/iTunes/Discogs/Hydrabase respect the # outer limit. 200 matches iTunes/Discogs internal # caps and covers prolific catalogues. limit=200, artist_source_ids=artist_source_ids, ), ) if artist_detail_discography['success']: logger.debug( "Source-priority discography found - " f"Albums: {len(artist_detail_discography['albums'])}, " f"EPs: {len(artist_detail_discography['eps'])}, " f"Singles: {len(artist_detail_discography['singles'])}" ) merged_discography = artist_detail_discography else: logger.debug(f"Source-priority discography not found: {artist_detail_discography.get('error', 'Unknown error')}") merged_discography = owned_releases except Exception as detail_error: logger.error(f"Error fetching source-priority discography: {detail_error}") merged_discography = owned_releases spotify_artist_data = None if artist_info.get('spotify_artist_id'): spotify_artist_data = { 'spotify_artist_id': artist_info.get('spotify_artist_id'), 'spotify_artist_name': artist_info.get('name'), 'artist_image': artist_info.get('image_url') } # Compute per-artist track enrichment coverage enrichment_coverage = {} try: with database._get_connection() as conn: cursor = conn.cursor() artist_name = artist_info['name'] server_source = artist_info.get('server_source', '') cursor.execute(""" SELECT COUNT(*) FROM tracks t JOIN albums al ON al.id = t.album_id JOIN artists ar ON ar.id = al.artist_id WHERE ar.name = ? AND ar.server_source = ? """, (artist_name, server_source)) total = (cursor.fetchone() or [0])[0] if total > 0: for svc, col in [('spotify', 'spotify_track_id'), ('musicbrainz', 'musicbrainz_recording_id'), ('deezer', 'deezer_id'), ('lastfm', 'lastfm_url'), ('itunes', 'itunes_track_id'), ('audiodb', 'audiodb_id'), ('genius', 'genius_id'), ('tidal', 'tidal_id'), ('qobuz', 'qobuz_id')]: try: cursor.execute(f""" SELECT COUNT(*) FROM tracks t JOIN albums al ON al.id = t.album_id JOIN artists ar ON ar.id = al.artist_id WHERE ar.name = ? AND ar.server_source = ? AND t.{col} IS NOT NULL AND t.{col} != '' """, (artist_name, server_source)) matched = (cursor.fetchone() or [0])[0] enrichment_coverage[svc] = round(matched / total * 100, 1) except Exception: enrichment_coverage[svc] = 0 enrichment_coverage['total_tracks'] = total except Exception as e: logger.debug("enrichment coverage build failed: %s", e) response_data = { "success": True, "artist": artist_info, "discography": merged_discography, "enrichment_coverage": enrichment_coverage } # Add Spotify artist data if available if spotify_artist_data: response_data["spotify_artist"] = spotify_artist_data return jsonify(response_data) except Exception as e: logger.error(f"Error in get_artist_detail: {e}") import traceback traceback.print_exc() return jsonify({ "success": False, "error": str(e) }), 500 @app.route('/api/library/debug-photos') def debug_library_photos(): """Debug endpoint to check artist photo URLs""" try: database = get_database() with database._get_connection() as conn: cursor = conn.cursor() # Get first 10 artists with their photo URLs cursor.execute(""" SELECT name, thumb_url, server_source FROM artists WHERE thumb_url IS NOT NULL AND thumb_url != '' LIMIT 10 """) artists_with_photos = cursor.fetchall() # Get first 10 artists without photos cursor.execute(""" SELECT name, thumb_url, server_source FROM artists WHERE thumb_url IS NULL OR thumb_url = '' LIMIT 10 """) artists_without_photos = cursor.fetchall() return jsonify({ "artists_with_photos": [dict(row) for row in artists_with_photos], "artists_without_photos": [dict(row) for row in artists_without_photos], "total_with_photos": len(artists_with_photos), "total_without_photos": len(artists_without_photos) }) except Exception as e: return jsonify({"error": str(e)}), 500 @app.route('/api/artist/similar//stream') def get_similar_artists_stream(artist_name): """Stream MusicMap similar artists using source-priority metadata matching.""" from core.metadata_service import iter_musicmap_similar_artist_events def generate(): logger.info(f"Streaming similar artists for: {artist_name}") for event in iter_musicmap_similar_artist_events(artist_name, limit=20): yield f"data: {json.dumps(event)}\n\n" if event.get('artist'): time.sleep(0.1) return Response(generate(), mimetype='text/event-stream') @app.route('/api/artist/similar/') def get_similar_artists(artist_name): """Get MusicMap similar artists using source-priority metadata matching.""" from core.metadata_service import get_musicmap_similar_artists try: logger.info(f"Getting similar artists for: {artist_name}") result = get_musicmap_similar_artists(artist_name, limit=20) if not result.get('success'): error = result.get('error', 'Failed to fetch similar artists') status_code = int(result.get('status_code') or 500) return jsonify({ "success": False, "error": error }), status_code return jsonify({ "success": True, "artist": artist_name, "similar_artists": result.get('similar_artists', []), "total_found": result.get('total_found', 0), "total_matched": result.get('total_matched', 0), "source_priority": result.get('source_priority', []), }) except Exception as e: logger.error(f"Error getting similar artists: {e}") import traceback traceback.print_exc() return jsonify({ "success": False, "error": str(e) }), 500 @app.route('/api/artist//write-image-to-disk', methods=['POST']) def write_artist_image_to_disk(artist_id): """Write `artist.jpg` to the artist's folder on disk. Issue #572 (rhwc): Navidrome has no API for setting an artist image — it reads `artist.jpg` from the artist's folder during library scans. SoulSync's `update_artist_poster` for Navidrome is a NO-OP today. This endpoint closes the gap by: 1. Resolving the artist's folder on disk via any of their albums' tracks (`_resolve_library_file_path` handles Docker mount translation + the same library-path probes #558 settled on) 2. Fetching an artist photo URL from the configured metadata source priority chain (Spotify → Deezer → ... already wired through `core.metadata_service.get_artist_image_url`) 3. Downloading the image bytes and writing `/artist.jpg` atomically via the pure helpers in `core/library/artist_image.py` 4. Triggering a Navidrome library scan so the file gets picked up immediately Request body (JSON, all optional): - ``image_url`` — explicit URL to use, bypassing metadata source resolution (useful for "use this exact photo" UX) - ``overwrite`` — when True, replace existing `artist.jpg` (default False respects user-supplied files) - ``source_override`` — pin the metadata source for URL resolution (e.g. ``"deezer"``) """ try: from core.library.artist_image import ( derive_artist_folder, download_image_bytes, write_artist_jpg, ) from core.metadata_service import get_artist_image_url as _get_artist_image_url data = request.get_json(silent=True) or {} explicit_url = (data.get('image_url') or '').strip() or None overwrite = bool(data.get('overwrite', False)) source_override = (data.get('source_override') or '').strip().lower() or None db = get_database() try: artist_id_int = int(artist_id) except (TypeError, ValueError): return jsonify({"success": False, "error": "Invalid artist id"}), 400 artist_row = db.get_artist(artist_id_int) if artist_row is None: return jsonify({"success": False, "error": "Artist not found"}), 404 # Find a track file on disk so we can derive the artist folder. # Walk albums in DB order; first one with a resolvable track wins. albums = db.get_albums_by_artist(artist_id_int) if not albums: return jsonify({"success": False, "error": "No albums for this artist; cannot derive folder."}), 400 resolved_track_path = None for album in albums: tracks = db.get_tracks_by_album(album.id) for tr in tracks: if not getattr(tr, 'file_path', None): continue candidate = _resolve_library_file_path(tr.file_path) or tr.file_path if candidate and os.path.exists(candidate): resolved_track_path = candidate break if resolved_track_path: break if not resolved_track_path: return jsonify({"success": False, "error": "Could not locate any track file on disk to derive the artist folder. " "Configure Settings → Library → Music Paths to point at the library mount."}), 400 album_folder = os.path.dirname(resolved_track_path) artist_folder = derive_artist_folder(album_folder) if not artist_folder or not os.path.isdir(artist_folder): return jsonify({"success": False, "error": f"Resolved artist folder is invalid: {artist_folder!r}"}), 400 # Pick the image URL. Explicit override (from request body) # wins so users can paste a specific photo URL. Otherwise # resolve from the active metadata source. if explicit_url: image_url = explicit_url else: try: image_url = _get_artist_image_url( artist_id_int, source_override=source_override, artist_name=getattr(artist_row, 'name', None), ) except Exception as exc: logger.error(f"artist image lookup failed: {exc}") image_url = None if not image_url: return jsonify({"success": False, "error": "No artist image URL found from metadata sources."}), 404 image_bytes = download_image_bytes(image_url) if not image_bytes: return jsonify({"success": False, "error": f"Failed to download image from {image_url}"}), 502 success, detail = write_artist_jpg(artist_folder, image_bytes, overwrite=overwrite) if not success: return jsonify({"success": False, "error": detail}), 400 # If the active media server is Navidrome, trigger a scan so # the new file gets indexed without waiting for the next # automatic scan cycle. scan_triggered = False try: active_server = config_manager.get_active_media_server() if active_server == 'navidrome': nav = media_server_engine.client('navidrome') if nav is not None: nav.trigger_library_scan() scan_triggered = True except Exception as exc: logger.debug(f"Navidrome scan trigger after artist image write failed: {exc}") return jsonify({ "success": True, "written_to": detail, "image_url": image_url, "scan_triggered": scan_triggered, }) except Exception as e: logger.error(f"Error writing artist image to disk: {e}") return jsonify({"success": False, "error": str(e)}), 500 @app.route('/api/artist//image', methods=['GET']) def get_artist_image(artist_id): """Get an artist image URL using source-aware metadata resolution.""" try: from core.metadata_service import get_artist_image_url as _get_artist_image_url source_override = request.args.get('source', '').strip().lower() or None plugin = request.args.get('plugin', '').strip().lower() or None # `name` is optional but required for sources that don't store # artist images directly (MusicBrainz) — the resolver falls back # to searching iTunes/Deezer by name. artist_name = request.args.get('name', '').strip() or None image_url = _get_artist_image_url( artist_id, source_override=source_override, plugin=plugin, artist_name=artist_name, ) return jsonify({"success": True, "image_url": image_url}) except Exception as e: logger.error(f"Error fetching artist image: {e}") return jsonify({"success": False, "image_url": None, "error": str(e)}) @app.route('/api/artist//top-tracks', methods=['GET']) def get_artist_top_tracks_endpoint(artist_id): """Return an artist's top-N tracks via the primary metadata source. Issue #513: users want a "top X popular songs" path that doesn't pull the entire discography. Spotify's `artist_top_tracks` endpoint and Deezer's `/artist/{id}/top` both expose this; iTunes / Discogs / MusicBrainz don't have popularity ranking, so this endpoint returns `success=False` for those primary sources and the frontend falls back to the existing Last.fm display-only sidebar. Resolves per-source artist IDs from the DB row (matching what /discography already does) so a Spotify ID in the URL still works when Deezer is primary, and vice versa. """ try: primary_source = _get_metadata_fallback_source() if primary_source not in ('spotify', 'deezer'): return jsonify({ 'success': False, 'reason': 'unsupported_source', 'source': primary_source, 'tracks': [], }) try: limit = max(1, min(int(request.args.get('limit', 10)), 50)) except (TypeError, ValueError): limit = 10 # Per-source ID resolution from the DB — same pattern as # /discography. Without this, the frontend's chosen ID type # (Spotify, Deezer, iTunes, library DB id) decides which source # can answer; we want the URL ID to be neutral. resolved_id = artist_id try: _db = get_database() _conn = _db._get_connection() try: _cur = _conn.cursor() _cur.execute(""" SELECT spotify_artist_id, deezer_id FROM artists WHERE id = ? OR spotify_artist_id = ? OR itunes_artist_id = ? OR deezer_id = ? OR musicbrainz_id = ? LIMIT 1 """, (artist_id, artist_id, artist_id, artist_id, artist_id)) _row = _cur.fetchone() if _row: if primary_source == 'spotify' and _row['spotify_artist_id']: resolved_id = str(_row['spotify_artist_id']) elif primary_source == 'deezer' and _row['deezer_id']: resolved_id = str(_row['deezer_id']) finally: _conn.close() except Exception as e: logger.debug("top-tracks per-source ID resolution failed: %s", e) tracks = [] if primary_source == 'spotify': if not spotify_client or not spotify_client.is_spotify_authenticated(): return jsonify({ 'success': False, 'reason': 'spotify_not_authenticated', 'source': 'spotify', 'tracks': [], }) market = config_manager.get('spotify.market', 'US') or 'US' tracks = spotify_client.get_artist_top_tracks(resolved_id, country=market, limit=limit) else: # deezer deezer_client = _get_deezer_client() if not deezer_client: return jsonify({ 'success': False, 'reason': 'deezer_unavailable', 'source': 'deezer', 'tracks': [], }) tracks = deezer_client.get_artist_top_tracks(resolved_id, limit=limit) if not tracks: return jsonify({ 'success': False, 'reason': 'no_tracks_found', 'source': primary_source, 'tracks': [], }) return jsonify({ 'success': True, 'source': primary_source, 'resolved_artist_id': resolved_id, 'tracks': tracks, }) except Exception as e: logger.exception("Error fetching artist top tracks for %s", artist_id) return jsonify({"success": False, "error": str(e), "tracks": []}), 500 @app.route('/api/artist//discography', methods=['GET']) def get_artist_discography(artist_id): """Get an artist's complete discography (albums and singles)""" try: # Get optional artist name for fallback searches artist_name = request.args.get('artist_name', '').strip() # Optional source override from multi-source search tabs source_override = request.args.get('source', '').strip().lower() # Mirror to Hydrabase P2P network if hydrabase_worker and dev_mode_enabled and artist_name: hydrabase_worker.enqueue(artist_name, 'artist.albums') effective_override_source = source_override if source_override == 'hydrabase': plugin = request.args.get('plugin', '').strip().lower() if plugin == 'deezer': effective_override_source = 'deezer' elif plugin == 'itunes' or artist_id.isdigit(): effective_override_source = 'itunes' else: effective_override_source = 'spotify' from core.metadata.lookup import MetadataLookupOptions from core.metadata_service import get_artist_discography as _get_artist_discography # Server-side per-source ID resolution. Look up the library row # by ANY of the IDs the frontend might send: library DB id, # spotify_artist_id, itunes_artist_id, deezer_id, or # musicbrainz_id. Once matched, pull every stored provider ID # and dispatch the right ID to each source via # ``artist_source_ids``. Mirrors what the watchlist scanner # already does. # # Without this, the frontend's ID choice fully decides which # source can answer correctly: # - sends DB id 194687 → Deezer accepts (wrong: it's a real # Deezer ID for a different artist) # - sends Spotify ID `1bDWGdIC...` → Deezer rejects → falls # back to fuzzy name search → may pick wrong artist # With server-side resolution, every source gets its OWN stored # ID regardless of which one the URL carries. artist_source_ids = {} try: _db = get_database() _conn = _db._get_connection() try: _cur = _conn.cursor() _cur.execute(""" SELECT spotify_artist_id, itunes_artist_id, deezer_id, musicbrainz_id FROM artists WHERE id = ? OR spotify_artist_id = ? OR itunes_artist_id = ? OR deezer_id = ? OR musicbrainz_id = ? LIMIT 1 """, (artist_id, artist_id, artist_id, artist_id, artist_id)) _row = _cur.fetchone() if _row: if _row['spotify_artist_id']: artist_source_ids['spotify'] = str(_row['spotify_artist_id']) if _row['itunes_artist_id']: artist_source_ids['itunes'] = str(_row['itunes_artist_id']) if _row['deezer_id']: artist_source_ids['deezer'] = str(_row['deezer_id']) if _row['musicbrainz_id']: artist_source_ids['musicbrainz'] = str(_row['musicbrainz_id']) logger.info( f"Discography: resolved per-source IDs for artist_id={artist_id} → " f"{artist_source_ids}" ) finally: _conn.close() except Exception as _id_exc: logger.debug(f"Could not resolve per-source artist IDs for {artist_id}: {_id_exc}") discography = _get_artist_discography( artist_id, artist_name=artist_name, options=MetadataLookupOptions( source_override=effective_override_source, allow_fallback=True, skip_cache=False, max_pages=0, # Discord report: prolific artists (Bach, Beatles # complete box, deep dance/electronic catalogues) # showed only ~50 entries in the Download Discography # modal. Spotify's `max_pages=0` already paginates # through everything (per-page is clamped to 10 # internally), but Deezer / iTunes / Discogs / # Hydrabase all honor the outer `limit` as a hard # cap. 200 lines up with iTunes's and Discogs's own # internal caps and covers near-everyone's full # catalogue. limit=200, artist_source_ids=artist_source_ids or None, ), ) album_list = discography['albums'] singles_list = discography['singles'] active_source = discography['source'] source_priority = discography['source_priority'] # Gather artist enrichment info from cache + library artist_info = {} try: cache = get_metadata_cache() cache_sources = [] if active_source: cache_sources.append(active_source) for source in source_priority: if source not in cache_sources: cache_sources.append(source) # Try metadata cache for genres, image, followers cached = None for src in cache_sources: cached = cache.get_entity(src, 'artist', artist_id) if cached: break if not cached and artist_name: # Try by name across all sources for src in cache_sources: db_tmp = get_database() conn_tmp = db_tmp._get_connection() try: cur = conn_tmp.cursor() cur.execute(""" SELECT genres, image_url, followers, popularity, external_urls FROM metadata_cache_entities WHERE entity_type = 'artist' AND name COLLATE NOCASE = ? AND source = ? LIMIT 1 """, (artist_name, src)) row = cur.fetchone() if row: cached = dict(row) break finally: conn_tmp.close() if cached: try: artist_info['genres'] = json.loads(cached.get('genres', '[]')) if isinstance(cached.get('genres'), str) else (cached.get('genres') or []) except Exception: artist_info['genres'] = [] artist_info['image_url'] = cached.get('image_url') artist_info['followers'] = cached.get('followers') artist_info['popularity'] = cached.get('popularity') try: artist_info['external_urls'] = json.loads(cached.get('external_urls', '{}')) if isinstance(cached.get('external_urls'), str) else (cached.get('external_urls') or {}) except Exception: artist_info['external_urls'] = {} # Try library for full enrichment (Last.fm bio, stats, service IDs) if artist_name: db_lib = get_database() conn_lib = db_lib._get_connection() try: cur_lib = conn_lib.cursor() cur_lib.execute(""" SELECT id, summary, genres, thumb_url, spotify_artist_id, musicbrainz_id, deezer_id, itunes_artist_id, audiodb_id, discogs_id, tidal_id, qobuz_id, genius_id, soul_id, lastfm_bio, lastfm_listeners, lastfm_playcount, lastfm_tags, lastfm_url, genius_url, style, mood, label FROM artists WHERE name COLLATE NOCASE = ? LIMIT 1 """, (artist_name,)) lib_row = cur_lib.fetchone() if lib_row: lib = dict(lib_row) artist_info['library_id'] = lib['id'] # Image fallback if not artist_info.get('image_url') and lib['thumb_url']: artist_info['image_url'] = fix_artist_image_url(lib['thumb_url']) # Genres fallback if not artist_info.get('genres') and lib['genres']: try: artist_info['genres'] = json.loads(lib['genres']) except Exception as e: logger.debug("genres json parse failed: %s", e) # Last.fm enrichment if lib.get('lastfm_bio'): artist_info['lastfm_bio'] = lib['lastfm_bio'] if lib.get('lastfm_listeners'): artist_info['lastfm_listeners'] = lib['lastfm_listeners'] if lib.get('lastfm_playcount'): artist_info['lastfm_playcount'] = lib['lastfm_playcount'] if lib.get('lastfm_tags'): try: artist_info['lastfm_tags'] = json.loads(lib['lastfm_tags']) if isinstance(lib['lastfm_tags'], str) else lib['lastfm_tags'] except Exception as e: logger.debug("lastfm_tags json parse failed: %s", e) if lib.get('lastfm_url'): artist_info['lastfm_url'] = lib['lastfm_url'] if lib.get('genius_url'): artist_info['genius_url'] = lib['genius_url'] # Service IDs for badges for key in ['spotify_artist_id', 'musicbrainz_id', 'deezer_id', 'itunes_artist_id', 'audiodb_id', 'discogs_id', 'tidal_id', 'qobuz_id', 'genius_id', 'soul_id']: if lib.get(key): artist_info[key] = lib[key] # Bio fallback from summary if not artist_info.get('lastfm_bio') and lib.get('summary'): artist_info['bio'] = lib['summary'] finally: conn_lib.close() except Exception as e: logger.debug(f"Artist info enrichment failed (non-fatal): {e}") return jsonify({ "albums": album_list, "singles": singles_list, "source": active_source or (source_priority[0] if source_priority else "unknown"), "artist_info": artist_info, }) except Exception as e: logger.exception("Error fetching artist discography for %s", artist_id) return jsonify({"error": str(e)}), 500 @app.route('/api/album//tracks', methods=['GET']) def get_album_tracks(album_id): """Get tracks for specific album formatted for download missing tracks modal""" try: album_name = request.args.get('name', '').strip() artist_name = request.args.get('artist', '').strip() source_override = request.args.get('source', '').strip().lower() if source_override == 'hydrabase': plugin = request.args.get('plugin', '').strip().lower() if plugin in ('itunes', 'deezer'): source_override = plugin elif album_id.isdigit(): source_override = 'itunes' else: source_override = 'spotify' from core.metadata_service import get_artist_album_tracks as _get_artist_album_tracks result = _get_artist_album_tracks( album_id, artist_name=artist_name, album_name=album_name, source_override=source_override or None, ) if not result.get('success'): return jsonify({"error": result.get('error', 'Album not found')}), result.get('status_code', 404) logger.info( "Successfully formatted %s tracks for album %s", len(result.get('tracks', [])), result.get('album', {}).get('name', album_name or album_id), ) return jsonify({ 'success': True, 'album': result['album'], 'tracks': result['tracks'], 'source': result.get('source'), 'source_priority': result.get('source_priority', []), 'resolved_album_id': result.get('resolved_album_id'), }) except Exception as e: logger.exception("Error fetching album tracks for album %s", album_id) return jsonify({"error": str(e)}), 500 @app.route('/api/artist//record', methods=['GET']) def get_artist_db_record(artist_id): """Return the COMPLETE database record for a library artist — every column of the ``artists`` row (all source IDs + match statuses, cached bios / tags / similar / urls, timestamps, soul_id, etc.) plus owned album/track counts. Powers the artist-detail "DB Record" inspector. JSON-encoded text columns (genres, aliases, lastfm_tags/similar, discogs_urls, …) are decoded into real arrays/objects so the dump is clean rather than escaped strings. """ try: database = get_database() conn = database._get_connection() try: cur = conn.cursor() cur.execute("SELECT * FROM artists WHERE id = ?", (str(artist_id),)) row = cur.fetchone() if row is None: return jsonify({"success": False, "error": "Artist not found in library"}), 404 record = {} for key in row.keys(): val = row[key] if isinstance(val, str): s = val.strip() if s and s[0] in '[{': try: val = json.loads(s) except Exception: # noqa: S110 — leave non-JSON text as-is pass record[key] = val counts = {} for label, table in (('albums', 'albums'), ('tracks', 'tracks')): try: cur.execute(f"SELECT COUNT(*) FROM {table} WHERE artist_id = ?", (str(artist_id),)) counts[label] = cur.fetchone()[0] except Exception: counts[label] = None finally: conn.close() return jsonify({ "success": True, "artist_id": str(artist_id), "counts": counts, "record": record, }) except Exception as e: logger.error(f"Artist DB record fetch failed for {artist_id}: {e}") return jsonify({"success": False, "error": str(e)}), 500 @app.route('/api/artist//download-discography', methods=['POST']) def download_discography(artist_id): """Add selected albums from an artist's discography to the wishlist. Resolves each album through the same source-aware path that the individual-album flow uses, so albums whose IDs come from a fallback/provider-specific source (e.g. Deezer-formatted IDs surfaced via Hydrabase) don't fail with "Album not found" when the primary source can't look them up directly. """ try: data = request.get_json() if not data: return jsonify({"success": False, "error": "request body required"}), 400 # Preferred payload: per-album metadata so each album can be resolved # through its own source. Falls back to the legacy album_ids list, # in which case every album is looked up under the artist-level source. albums_payload = data.get('albums') legacy_album_ids = data.get('album_ids') if not albums_payload and not legacy_album_ids: return jsonify({"success": False, "error": "albums or album_ids required"}), 400 artist_name = data.get('artist_name', 'Unknown Artist') artist_source = (data.get('source') or '').strip().lower() or None if albums_payload: album_entries = [ { 'id': str(a.get('id', '')), 'name': a.get('name') or a.get('title') or '', 'source': (a.get('source') or '').strip().lower() or artist_source, 'artist_name': a.get('artist_name') or artist_name, } for a in albums_payload if a.get('id') ] else: album_entries = [ { 'id': str(aid), 'name': '', 'source': artist_source, 'artist_name': artist_name, } for aid in legacy_album_ids if aid ] if not album_entries: return jsonify({"success": False, "error": "no valid albums in payload"}), 400 from database.music_database import MusicDatabase from core.metadata.album_tracks import get_artist_album_tracks from core.metadata.discography_filters import ( content_type_skip_reason, load_global_content_filter_settings, track_already_owned, track_artist_matches, ) db = MusicDatabase() profile_id = get_current_profile_id() # Honor the same content-type filters the watchlist scanner uses # (issue #559). One read at the top — settings don't change # mid-stream and the four bool reads aren't worth re-running per # track. content_settings = load_global_content_filter_settings(config_manager) # Library-ownership check uses the active media server so the # match is scoped to the same source whose tracks the user can # actually see in their library. None falls through to a # cross-server search inside check_track_exists. active_server = None try: active_server = config_manager.get_active_media_server() except Exception as e: logger.debug("active media server lookup failed: %s", e) total_added = 0 total_skipped = 0 total_skipped_artist = 0 total_skipped_filter = 0 total_skipped_owned = 0 def generate_ndjson(): nonlocal total_added, total_skipped, total_skipped_artist, total_skipped_filter, total_skipped_owned for entry in album_entries: album_id = entry['id'] hint_album_name = entry['name'] hint_artist = entry['artist_name'] source_override = entry['source'] try: result = get_artist_album_tracks( album_id, artist_name=hint_artist, album_name=hint_album_name, source_override=source_override, ) if not result.get('success'): message = result.get('error') or 'Album not found' yield json.dumps({ "album_id": album_id, "name": hint_album_name or album_id, "status": "error", "message": message, }) + '\n' continue album = result.get('album', {}) or {} tracks = result.get('tracks', []) or [] album_name = album.get('name') or hint_album_name or 'Unknown' album_images = album.get('images') or ( [{'url': album['image_url']}] if album.get('image_url') else [] ) album_type = album.get('album_type', 'album') release_date = album.get('release_date', '') or '' album_artists = album.get('artists') or [{'name': hint_artist}] resolved_album_id = result.get('resolved_album_id') or album.get('id') or album_id resolved_source = result.get('source') or source_override or 'unknown' if not tracks: yield json.dumps({ "album_id": album_id, "name": album_name, "status": "error", "message": "No tracks", }) + '\n' continue added = 0 skipped = 0 skipped_artist = 0 skipped_filter = 0 skipped_owned = 0 for track in tracks: track_name = track.get('name', '') if not track_name: continue track_artists = track.get('artists', []) or album_artists track_id = track.get('id', '') # Issue #559: drop tracks where the requested # artist isn't in the track's artists list # (cross-artist compilation / appears_on # contamination). Keeps features. if not track_artist_matches(track_artists, hint_artist): skipped_artist += 1 continue # Issue #559: honor watchlist global content-type # filters (live / remix / acoustic / instrumental) # for one-off discography downloads too — same # contract as the discography backfill repair job. skip_reason = content_type_skip_reason(track_name, album_name, content_settings) if skip_reason: skipped_filter += 1 continue # Skowl (Discord): clicking Download Discography # twice re-queued every track because add_to_wishlist # only dedups against the wishlist, not the library. # Same library-ownership check the discography # backfill repair job uses. Format-agnostic so # Blasphemy mode (FLAC→MP3) doesn't false-miss. if track_already_owned(db, track_name, hint_artist, album_name, active_server): skipped_owned += 1 continue spotify_track_data = { 'id': track_id, 'name': track_name, 'artists': track_artists if isinstance(track_artists, list) else [{'name': str(track_artists)}], 'album': { 'id': str(resolved_album_id), 'name': album_name, 'artists': album_artists, 'images': album_images, 'album_type': album_type, 'release_date': release_date, 'total_tracks': len(tracks), }, 'duration_ms': track.get('duration_ms', 0), 'explicit': track.get('explicit', False), 'track_number': track.get('track_number', 0), 'disc_number': track.get('disc_number', 1), 'uri': track.get('uri', ''), 'preview_url': track.get('preview_url'), 'external_urls': track.get('external_urls', {}), 'is_local': False, '_source': resolved_source, } try: was_added = db.add_to_wishlist( spotify_track_data=spotify_track_data, failure_reason="Added via Download Discography", source_type="discography", source_info=json.dumps({ 'artist_name': hint_artist, 'album_name': album_name, 'album_type': album_type, 'source': resolved_source, }), profile_id=profile_id, ) if was_added: added += 1 else: skipped += 1 except Exception: skipped += 1 total_added += added total_skipped += skipped total_skipped_artist += skipped_artist total_skipped_filter += skipped_filter total_skipped_owned += skipped_owned logger.warning( f"[Discography] {album_name} ({resolved_source}): {added} added, " f"{skipped} skipped (wishlist), {skipped_artist} skipped (artist mismatch), " f"{skipped_filter} skipped (content filter), " f"{skipped_owned} skipped (already in library)" ) yield json.dumps({ "album_id": album_id, "name": album_name, "status": "done", "tracks_added": added, "tracks_skipped": skipped, "tracks_skipped_artist": skipped_artist, "tracks_skipped_filter": skipped_filter, "tracks_skipped_owned": skipped_owned, "tracks_total": len(tracks), "source": resolved_source, }) + '\n' except Exception as album_err: yield json.dumps({ "album_id": album_id, "name": hint_album_name or album_id, "status": "error", "message": str(album_err), }) + '\n' logger.warning( f"[Discography] Complete for {artist_name}: {total_added} tracks added, " f"{total_skipped} skipped (wishlist), {total_skipped_artist} skipped (artist mismatch), " f"{total_skipped_filter} skipped (content filter), " f"{total_skipped_owned} skipped (already in library) across {len(album_entries)} albums" ) yield json.dumps({ "status": "complete", "total_added": total_added, "total_skipped": total_skipped, "total_skipped_artist": total_skipped_artist, "total_skipped_filter": total_skipped_filter, "total_skipped_owned": total_skipped_owned, "total_albums": len(album_entries), }) + '\n' return app.response_class(generate_ndjson(), mimetype='application/x-ndjson', headers={'X-Accel-Buffering': 'no'}) except Exception as e: logger.error(f"Error in download discography: {e}") return jsonify({"success": False, "error": str(e)}), 500 @app.route('/api/artist//completion', methods=['POST']) def check_artist_discography_completion(artist_id): """Check completion status for artist's albums and singles""" try: data = request.get_json() if not data or 'discography' not in data: return jsonify({"error": "Missing discography data"}), 400 from core.metadata_service import check_artist_discography_completion as _check_artist_discography_completion discography = data['discography'] source_override = (data.get('source') or '').strip().lower() or None result = _check_artist_discography_completion( discography, artist_name=data.get('artist_name', 'Unknown Artist'), source_override=source_override, ) return jsonify(result) except Exception as e: logger.error(f"Error checking discography completion: {e}") import traceback traceback.print_exc() return jsonify({"error": str(e)}), 500 @app.route('/api/artist//completion-stream', methods=['POST']) def check_artist_discography_completion_stream(artist_id): """Stream completion status for artist's albums and singles one by one""" # Capture request data BEFORE the generator function try: data = request.get_json() if not data or 'discography' not in data: return jsonify({"error": "Missing discography data"}), 400 except Exception as e: return jsonify({"error": "Invalid request data"}), 400 # Extract data for the generator discography = data['discography'] artist_name = data.get('artist_name', 'Unknown Artist') source_override = (data.get('source') or '').strip().lower() or None from core.metadata_service import iter_artist_discography_completion_events def generate_completion_stream(): try: logger.info(f"Starting streaming completion check for artist: {artist_name}") for event in iter_artist_discography_completion_events( discography, artist_name=artist_name, source_override=source_override, ): yield f"data: {json.dumps(event)}\n\n" if event.get('type') in ('album_completion', 'single_completion'): # Small delay to make the streaming effect visible time.sleep(0.1) # 100ms delay between items except Exception as e: logger.error(f"Error in streaming completion check: {e}") import traceback traceback.print_exc() yield f"data: {json.dumps({'type': 'error', 'error': str(e)})}\n\n" return Response( generate_completion_stream(), content_type='text/event-stream', headers={ 'Cache-Control': 'no-cache', 'Connection': 'keep-alive', 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': 'Cache-Control' } ) @app.route('/api/library/completion-stream', methods=['POST']) def library_completion_stream(): """Stream completion status for library artist detail view - checks ownership per release via SSE""" try: data = request.get_json() if not data or 'artist_name' not in data: return jsonify({"error": "Missing artist_name"}), 400 except Exception as e: return jsonify({"error": "Invalid request data"}), 400 artist_name = data['artist_name'] def generate(): try: from core.metadata_service import check_album_completion, check_single_completion db = get_database() source_override = (data.get('source') or '').strip().lower() or None categories = ['albums', 'eps', 'singles'] all_items = [] for cat in categories: for item in data.get(cat, []): all_items.append((cat, item)) # Pre-fetch the artist's library albums AND tracks ONCE so per-item # matching runs in-memory instead of firing per-item SQL searches. # Turns N*K queries into 2 broad fetches + N track-count lookups. from config.settings import config_manager as _cm_cs _active_server = _cm_cs.get_active_media_server() candidate_albums = None candidate_tracks = None _t0 = time.perf_counter() try: candidate_albums = db.get_candidate_albums_for_artist(artist_name, server_source=_active_server) except Exception as _cand_err: print(f"[completion-stream] Failed to pre-fetch album candidates for '{artist_name}': {_cand_err}") candidate_albums = None _t1 = time.perf_counter() print(f"[completion-stream] Pre-fetched {len(candidate_albums) if candidate_albums is not None else 0} library albums for '{artist_name}' in {(_t1 - _t0) * 1000:.0f}ms") if candidate_albums: _t2 = time.perf_counter() try: candidate_tracks = db.get_candidate_tracks_for_albums([a.id for a in candidate_albums]) except Exception as _tr_err: print(f"[completion-stream] Failed to pre-fetch track candidates for '{artist_name}': {_tr_err}") candidate_tracks = None _t3 = time.perf_counter() print(f"[completion-stream] Pre-fetched {len(candidate_tracks) if candidate_tracks is not None else 0} library tracks in {(_t3 - _t2) * 1000:.0f}ms") yield f"data: {json.dumps({'type': 'start', 'total_items': len(all_items)})}\n\n" _loop_start = time.perf_counter() for _i, (category, item) in enumerate(all_items): try: # Map Library field names to helper field names mapped = { 'id': item['id'], 'name': item['title'], 'total_tracks': item.get('track_count', 0), 'album_type': item.get('album_type', 'album') } if category == 'singles': result = check_single_completion(db, mapped, artist_name, source_override=source_override, candidate_albums=candidate_albums, candidate_tracks=candidate_tracks) else: result = check_album_completion(db, mapped, artist_name, source_override=source_override, candidate_albums=candidate_albums) result['id'] = item['id'] result['category'] = category result['type'] = 'completion' yield f"data: {json.dumps(result)}\n\n" except Exception as e: yield f"data: {json.dumps({'type': 'completion', 'category': category, 'id': item['id'], 'status': 'error', 'owned_tracks': 0, 'expected_tracks': item.get('track_count', 0), 'completion_percentage': 0, 'confidence': 0.0, 'error': str(e)})}\n\n" time.sleep(0.05) # 50ms between items for visible streaming _loop_elapsed = time.perf_counter() - _loop_start _sleep_floor = 0.05 * len(all_items) print(f"[completion-stream] Processed {len(all_items)} items for '{artist_name}' in {_loop_elapsed * 1000:.0f}ms (sleep floor: {_sleep_floor * 1000:.0f}ms)") yield f"data: {json.dumps({'type': 'complete', 'processed_count': len(all_items)})}\n\n" except Exception as e: logger.error(f"Error in library completion stream: {e}") import traceback traceback.print_exc() yield f"data: {json.dumps({'type': 'error', 'error': str(e)})}\n\n" return Response( generate(), content_type='text/event-stream', headers={ 'Cache-Control': 'no-cache', 'Connection': 'keep-alive', 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': 'Cache-Control' } ) @app.route('/api/library/check-tracks', methods=['POST']) def library_check_tracks(): """Check which tracks from a list are already owned in the library. Uses a single batch DB query + in-memory fuzzy matching for speed.""" try: data = request.get_json() if not data or 'artist_name' not in data or 'tracks' not in data: return jsonify({"success": False, "error": "Missing artist_name or tracks"}), 400 artist_name = data['artist_name'] tracks = data['tracks'] from database.music_database import MusicDatabase db = MusicDatabase() active_server = config_manager.get_active_media_server() # Single query: get ALL tracks by this artist from the DB db_tracks = db.search_tracks(artist=artist_name, limit=500, server_source=active_server) if not db_tracks: # No tracks by this artist in DB — none owned owned_map = {t.get('name', ''): {"owned": False} for t in tracks if t.get('name')} return jsonify({"success": True, "owned_tracks": owned_map}) # Pre-normalize all DB track titles for fast in-memory comparison from difflib import SequenceMatcher try: from unidecode import unidecode except ImportError: unidecode = lambda x: x def _normalize(text): if not text: return "" return unidecode(text).lower().strip() def _clean_title(text): import re cleaned = _normalize(text) # Remove parenthetical/bracket content, dashes, feat/ft, remaster tags cleaned = re.sub(r'\s*[\[\(].*?[\]\)]', '', cleaned) cleaned = re.sub(r'\s*-\s*', ' ', cleaned) cleaned = re.sub(r'\s*feat\..*', '', cleaned) cleaned = re.sub(r'\s*featuring.*', '', cleaned) cleaned = re.sub(r'\s*ft\..*', '', cleaned) cleaned = re.sub(r'\s*\d{4}\s*remaster.*', '', cleaned) cleaned = re.sub(r'\s*remaster(ed)?.*', '', cleaned) cleaned = re.sub(r'\s+', ' ', cleaned).strip() return cleaned # Pre-compute normalized DB titles once (keep reference to db_track for metadata) db_title_entries = [(_normalize(t.title), _clean_title(t.title), t) for t in db_tracks] # Album-aware matching: if album_name provided, prefer tracks from that album target_album = data.get('album_name', '') target_album_norm = _normalize(target_album) if target_album else '' def _match_title(search_norm, search_clean, candidates): """Find best matching track from a list of (norm, clean, db_track) candidates.""" for db_norm, db_clean, db_track in candidates: if search_norm == db_norm or search_clean == db_clean: return db_track sim = max( SequenceMatcher(None, search_norm, db_norm).ratio(), SequenceMatcher(None, search_clean, db_clean).ratio() ) if sim >= 0.7: return db_track return None # Split DB tracks by album if album-aware matching is active album_entries = [] other_entries = [] if target_album_norm: for entry in db_title_entries: db_album = _normalize(getattr(entry[2], 'album_title', '') or '') if db_album and SequenceMatcher(None, target_album_norm, db_album).ratio() >= 0.7: album_entries.append(entry) else: other_entries.append(entry) # #808: when the album gate narrows to NOTHING, the source's album # naming simply doesn't resemble the library's (Deezer's # 'Jillette Johnson | OurVinyl Sessions' vs the library's # 'Champagne Supernova (OurVinyl Sessions)' scores ~0.5). Marking # every track unowned off a failed ALBUM-name comparison is wrong — # fall back to artist-wide title matching, which is exactly the # pre-album-aware behavior and still holds the 0.7 title bar. if not album_entries: album_entries = other_entries else: other_entries = db_title_entries owned_map = {} for track in tracks: track_name = track.get('name', '') if not track_name: continue search_norm = _normalize(track_name) search_clean = _clean_title(track_name) # When album context provided, only match within that album — # prevents false positives where "Thriller" on Album A shows as owned # because it exists on Album B. Without album context, search all tracks. if target_album_norm: matched_db_track = _match_title(search_norm, search_clean, album_entries) else: matched_db_track = _match_title(search_norm, search_clean, other_entries) if matched_db_track: import os file_ext = os.path.splitext(matched_db_track.file_path or '')[1].lstrip('.').upper() or None owned_map[track_name] = { "owned": True, "track_id": getattr(matched_db_track, 'id', None), "title": getattr(matched_db_track, 'title', track_name), "file_path": getattr(matched_db_track, 'file_path', None), "format": file_ext, "bitrate": matched_db_track.bitrate, "album": getattr(matched_db_track, 'album_title', None) } else: owned_map[track_name] = {"owned": False} return jsonify({"success": True, "owned_tracks": owned_map}) except Exception as e: logger.error(f"Error checking track ownership: {e}") import traceback traceback.print_exc() return jsonify({"success": False, "error": str(e)}), 500 # ==================== Enhanced Library Management Endpoints ==================== @app.route('/api/library/artist//enhanced') def get_artist_enhanced_detail(artist_id): """Get full artist detail with all albums and tracks for enhanced library view.""" try: database = get_database() result = database.get_artist_full_detail(artist_id) if not result.get('success'): return jsonify(result), 404 # Fix image URLs for artist and all albums (resolve Plex/Jellyfin relative paths) if result.get('artist', {}).get('thumb_url'): result['artist']['thumb_url'] = fix_artist_image_url(result['artist']['thumb_url']) for album in result.get('albums', []): if album.get('thumb_url'): album['thumb_url'] = fix_artist_image_url(album['thumb_url']) # Include server type for sync option — engine routes to active client. active_server = config_manager.get_active_media_server() server_connected = media_server_engine.is_connected() if media_server_engine else False result['server_type'] = active_server if server_connected else None return jsonify(result) except Exception as e: return jsonify({"success": False, "error": str(e)}), 500 @app.route('/api/library/artist//quality-analysis') def get_artist_quality_analysis(artist_id): """Analyze track quality for an artist — returns tier classification for each track.""" try: database = get_database() result = database.get_artist_full_detail(artist_id) if not result.get('success'): return jsonify(result), 404 artist = result.get('artist', {}) albums = result.get('albums', []) # Get user's quality profile to determine min acceptable tier quality_profile = database.get_quality_profile() preferred_qualities = quality_profile.get('qualities', {}) min_acceptable_tier = 999 tier_map = {'flac': 'lossless', 'mp3_320': 'low_lossy', 'mp3_256': 'low_lossy', 'mp3_192': 'low_lossy'} for qname, qconfig in preferred_qualities.items(): if qconfig.get('enabled', False): tname = tier_map.get(qname) if tname and tname in QUALITY_TIERS: min_acceptable_tier = min(min_acceptable_tier, QUALITY_TIERS[tname]['tier']) tracks = [] summary = {'total': 0, 'lossless': 0, 'high_lossy': 0, 'standard_lossy': 0, 'low_lossy': 0, 'unknown': 0} for album in albums: album_title = album.get('title', 'Unknown Album') album_id = album.get('id') for track in album.get('tracks', []): file_path = track.get('file_path') if not file_path: continue tier_name, tier_num = _get_quality_tier_from_extension(file_path) ext = os.path.splitext(file_path)[1].lstrip('.').upper() # Use filename as fallback when title is empty title = track.get('title', '') or '' if not title.strip(): title = os.path.splitext(os.path.basename(file_path))[0] tracks.append({ 'track_id': track.get('id'), 'title': title, 'album_title': album_title, 'album_id': album_id, 'file_path': file_path, 'format': ext or 'Unknown', 'tier_name': tier_name, 'tier_num': tier_num, 'bitrate': track.get('bitrate'), 'spotify_track_id': track.get('spotify_track_id'), 'track_number': track.get('track_number'), 'disc_number': track.get('disc_number'), }) summary['total'] += 1 if tier_name in summary: summary[tier_name] += 1 else: summary['unknown'] += 1 return jsonify({ 'success': True, 'artist_name': artist.get('name', 'Unknown Artist'), 'artist_id': artist_id, 'tracks': tracks, 'quality_summary': summary, 'min_acceptable_tier': min_acceptable_tier }) except Exception as e: return jsonify({"success": False, "error": str(e)}), 500 # Artist quality enhancement logic lives in core/artists/quality.py. from core.artists import quality as _artists_quality def _build_artist_quality_deps(): """Build the ArtistQualityDeps bundle from web_server.py globals on each call.""" from core.wishlist_service import get_wishlist_service as _get_ws def _resolve_search_sources(): """Mirror the Track Redownload modal's source list. Every configured metadata source contributes to the parallel multi-source search — Spotify (only when authenticated), iTunes, Deezer, plus Discogs / Hydrabase when configured.""" sources = [] if spotify_client and spotify_client.is_authenticated(): sources.append(('spotify', spotify_client)) try: sources.append(('itunes', _get_itunes_client())) except Exception as e: logger.debug("itunes client init failed: %s", e) try: sources.append(('deezer', _get_deezer_client())) except Exception as e: logger.debug("deezer client init failed: %s", e) # Discogs needs an explicit token; only include when configured. try: _discogs_token = config_manager.get('discogs.token', '') if _discogs_token: sources.append(('discogs', _get_discogs_client(_discogs_token))) except Exception as e: logger.debug("discogs client init failed: %s", e) # Hydrabase only when connected (dev-mode + active client). try: if hydrabase_client and hydrabase_client.is_connected(): sources.append(('hydrabase', hydrabase_client)) except Exception as e: logger.debug("hydrabase client check failed: %s", e) return sources return _artists_quality.ArtistQualityDeps( matching_engine=matching_engine, get_database=get_database, get_wishlist_service=_get_ws, get_current_profile_id=get_current_profile_id, get_quality_tier_from_extension=_get_quality_tier_from_extension, get_metadata_search_sources=_resolve_search_sources, ) @app.route('/api/library/artist//enhance', methods=['POST']) def enhance_artist_quality(artist_id): """Add selected tracks to wishlist for quality enhancement re-download.""" try: data = request.get_json() or {} track_ids = data.get('track_ids', []) payload, status = _artists_quality.enhance_artist_quality( artist_id, track_ids, _build_artist_quality_deps() ) return jsonify(payload), status except Exception as e: logger.error(f"[Enhance] {e}") import traceback traceback.print_exc() return jsonify({"success": False, "error": str(e)}), 500 @app.route('/api/library/artist/', methods=['PUT']) def update_library_artist(artist_id): """Update artist metadata fields.""" try: database = get_database() data = request.get_json() if not data: return jsonify({"success": False, "error": "No data provided"}), 400 result = database.update_artist_fields(artist_id, data) if not result.get('success'): return jsonify(result), 400 return jsonify(result) except Exception as e: return jsonify({"success": False, "error": str(e)}), 500 @app.route('/api/library/album/', methods=['PUT']) def update_library_album(album_id): """Update album metadata fields.""" try: database = get_database() data = request.get_json() if not data: return jsonify({"success": False, "error": "No data provided"}), 400 result = database.update_album_fields(album_id, data) if not result.get('success'): return jsonify(result), 400 return jsonify(result) except Exception as e: return jsonify({"success": False, "error": str(e)}), 500 @app.route('/api/library/track/', methods=['PUT']) def update_library_track(track_id): """Update track metadata fields.""" try: database = get_database() data = request.get_json() if not data: return jsonify({"success": False, "error": "No data provided"}), 400 result = database.update_track_fields(track_id, data) if not result.get('success'): return jsonify(result), 400 return jsonify(result) except Exception as e: return jsonify({"success": False, "error": str(e)}), 500 @app.route('/api/library/tracks/batch', methods=['PUT']) def batch_update_library_tracks(): """Batch update multiple tracks with the same field values.""" try: database = get_database() data = request.get_json() if not data: return jsonify({"success": False, "error": "No data provided"}), 400 track_ids = data.get('track_ids', []) updates = data.get('updates', {}) if not track_ids or not updates: return jsonify({"success": False, "error": "track_ids and updates are required"}), 400 result = database.batch_update_tracks(track_ids, updates) if not result.get('success'): return jsonify(result), 400 return jsonify(result) except Exception as e: return jsonify({"success": False, "error": str(e)}), 500 # ── Write Tags to File endpoints ── def _build_library_tag_db_data(track_data, album_genres=None): """Build the metadata payload consumed by core.tag_writer.""" album_genres = album_genres or [] db_data = { 'title': track_data.get('title'), 'artist_name': track_data.get('artist_name'), 'track_artist': track_data.get('track_artist'), 'album_title': track_data.get('album_title'), 'year': track_data.get('year'), 'release_date': track_data.get('release_date'), # #824: full date wins over year when present 'genres': album_genres, 'track_number': track_data.get('track_number'), 'disc_number': track_data.get('disc_number'), 'bpm': track_data.get('bpm'), 'track_count': track_data.get('track_count'), 'thumb_url': track_data.get('album_thumb_url') or track_data.get('artist_thumb_url'), } track_artist = track_data.get('track_artist') if isinstance(track_artist, str) and ';' in track_artist: artists_list = [name.strip() for name in track_artist.split(';') if name.strip()] if artists_list: db_data['artists_list'] = artists_list # Carry the known source IDs through so they get embedded too (the writer # only acts on the ones present). These come from t.* on the track row. for _k in ('spotify_track_id', 'itunes_track_id', 'musicbrainz_recording_id'): _v = track_data.get(_k) if _v: db_data[_k] = _v return db_data @app.route('/api/library/track//tag-preview', methods=['GET']) def get_track_tag_preview(track_id): """Read current file tags and compare against DB metadata for a single track.""" try: from core.tag_writer import read_file_tags, build_tag_diff database = get_database() # Get track + album + artist data from DB conn = database._get_connection() cursor = conn.cursor() cursor.execute(""" SELECT t.*, a.name as artist_name, al.title as album_title, al.year, al.release_date, al.genres as album_genres, al.track_count, al.thumb_url as album_thumb_url, a.thumb_url as artist_thumb_url FROM tracks t JOIN artists a ON t.artist_id = a.id JOIN albums al ON t.album_id = al.id WHERE t.id = ? """, (str(track_id),)) row = cursor.fetchone() if not row: return jsonify({"success": False, "error": "Track not found"}), 404 track_data = dict(row) file_path = track_data.get('file_path') # Resolve path if needed resolved_path = _resolve_library_file_path(file_path) if not resolved_path: return jsonify({"success": False, "error": _get_file_not_found_error(file_path), "file_path": file_path}), 404 # Read current file tags file_tags = read_file_tags(resolved_path) if file_tags.get('error'): return jsonify({"success": False, "error": file_tags['error']}), 400 # Parse album genres for diff album_genres = [] if track_data.get('album_genres'): try: import json as _json parsed = _json.loads(track_data['album_genres']) album_genres = parsed if isinstance(parsed, list) else [str(parsed)] except (ValueError, TypeError): album_genres = [g.strip() for g in track_data['album_genres'].split(',') if g.strip()] # Build DB metadata dict for comparison db_data = _build_library_tag_db_data(track_data, album_genres) diff = build_tag_diff(file_tags, db_data) has_changes = any(d['changed'] for d in diff) # Include server type so frontend can offer server sync option active_server = config_manager.get_active_media_server() server_connected = media_server_engine.is_connected() if media_server_engine else False return jsonify({ "success": True, "file_path": resolved_path, "file_tags": file_tags, "db_data": db_data, "diff": diff, "has_changes": has_changes, "server_type": active_server if server_connected else None, }) except Exception as e: logger.error(f"Tag preview error for track {track_id}: {e}") return jsonify({"success": False, "error": str(e)}), 500 @app.route('/api/library/tracks/tag-preview-batch', methods=['POST']) def get_batch_tag_preview(): """Read current file tags and compare against DB metadata for multiple tracks.""" try: from core.tag_writer import read_file_tags, build_tag_diff data = request.get_json() track_ids = data.get('track_ids', []) if not track_ids: return jsonify({"success": False, "error": "No track IDs provided"}), 400 database = get_database() conn = database._get_connection() cursor = conn.cursor() placeholders = ','.join('?' for _ in track_ids) cursor.execute(f""" SELECT t.*, a.name as artist_name, al.title as album_title, al.year, al.release_date, al.genres as album_genres, al.track_count, al.thumb_url as album_thumb_url, a.thumb_url as artist_thumb_url FROM tracks t JOIN artists a ON t.artist_id = a.id JOIN albums al ON t.album_id = al.id WHERE t.id IN ({placeholders}) """, [str(tid) for tid in track_ids]) rows = [dict(r) for r in cursor.fetchall()] results = [] for track_data in rows: track_id = track_data['id'] file_path = track_data.get('file_path') resolved_path = _resolve_library_file_path(file_path) entry = { 'track_id': track_id, 'title': track_data.get('title', 'Unknown'), 'track_number': track_data.get('track_number'), } if not resolved_path: entry['error'] = _get_file_not_found_error(file_path) results.append(entry) continue try: file_tags = read_file_tags(resolved_path) if file_tags.get('error'): entry['error'] = file_tags['error'] results.append(entry) continue album_genres = [] if track_data.get('album_genres'): try: parsed = json.loads(track_data['album_genres']) album_genres = parsed if isinstance(parsed, list) else [str(parsed)] except (ValueError, TypeError): album_genres = [g.strip() for g in track_data['album_genres'].split(',') if g.strip()] db_data = _build_library_tag_db_data(track_data, album_genres) diff = build_tag_diff(file_tags, db_data) has_changes = any(d['changed'] for d in diff) changed_fields = [d for d in diff if d['changed']] entry['diff'] = diff entry['has_changes'] = has_changes entry['changed_count'] = len(changed_fields) except Exception as e: entry['error'] = str(e) results.append(entry) # Server type info — engine routes to active client. active_server = config_manager.get_active_media_server() server_connected = media_server_engine.is_connected() if media_server_engine else False return jsonify({ "success": True, "tracks": results, "server_type": active_server if server_connected else None, }) except Exception as e: logger.error(f"Batch tag preview error: {e}") return jsonify({"success": False, "error": str(e)}), 500 @app.route('/api/library/track//write-tags', methods=['POST']) def write_track_tags(track_id): """Write DB metadata into the audio file tags for a single track.""" try: from core.tag_writer import write_tags_to_file database = get_database() data = request.get_json() or {} embed_cover = data.get('embed_cover', True) # Get full track data conn = database._get_connection() cursor = conn.cursor() cursor.execute(""" SELECT t.*, a.name as artist_name, al.title as album_title, al.year, al.release_date, al.genres as album_genres, al.track_count, al.thumb_url as album_thumb_url, a.thumb_url as artist_thumb_url FROM tracks t JOIN artists a ON t.artist_id = a.id JOIN albums al ON t.album_id = al.id WHERE t.id = ? """, (str(track_id),)) row = cursor.fetchone() if not row: return jsonify({"success": False, "error": "Track not found"}), 404 track_data = dict(row) file_path = track_data.get('file_path') resolved_path = _resolve_library_file_path(file_path) if not resolved_path: return jsonify({"success": False, "error": _get_file_not_found_error(file_path)}), 404 # Parse genres album_genres = [] if track_data.get('album_genres'): try: import json as _json parsed = _json.loads(track_data['album_genres']) album_genres = parsed if isinstance(parsed, list) else [str(parsed)] except (ValueError, TypeError): album_genres = [g.strip() for g in track_data['album_genres'].split(',') if g.strip()] # Build data for writer db_data = _build_library_tag_db_data(track_data, album_genres) # Resolve cover URL cover_url = None if embed_cover: thumb = track_data.get('album_thumb_url') or track_data.get('artist_thumb_url') if thumb and thumb.startswith('http'): cover_url = thumb # Use file lock for thread safety file_lock = get_file_lock(resolved_path) with file_lock: result = write_tags_to_file(resolved_path, db_data, embed_cover=embed_cover, cover_url=cover_url) # Sync to media server if requested and write succeeded sync_result = None if result.get('success') and data.get('sync_to_server'): server_type = config_manager.get_active_media_server() sync_result = _sync_tracks_to_server([track_data], server_type) result['server_sync'] = sync_result return jsonify(result) except Exception as e: logger.error(f"Write tags error for track {track_id}: {e}") return jsonify({"success": False, "error": str(e)}), 500 _write_tags_batch_state = { 'status': 'idle', # idle | running | done 'total': 0, 'processed': 0, 'written': 0, 'failed': 0, 'current_track': '', 'errors': [], 'sync_phase': None, # None | 'syncing' | 'done' 'sync_server': None, 'sync_synced': 0, 'sync_failed': 0, } _write_tags_batch_lock = threading.Lock() @app.route('/api/library/tracks/write-tags-batch', methods=['POST']) def write_tracks_tags_batch(): """Write DB metadata into audio file tags for multiple tracks (runs in background).""" try: with _write_tags_batch_lock: if _write_tags_batch_state['status'] == 'running': return jsonify({"success": False, "error": "A batch tag write is already in progress"}), 409 database = get_database() data = request.get_json() if not data or not data.get('track_ids'): return jsonify({"success": False, "error": "track_ids required"}), 400 track_ids = data['track_ids'] embed_cover = data.get('embed_cover', True) # Fetch all track data upfront (in the request thread, fast DB query) conn = database._get_connection() cursor = conn.cursor() placeholders = ','.join('?' * len(track_ids)) cursor.execute(f""" SELECT t.*, a.name as artist_name, al.title as album_title, al.year, al.release_date, al.genres as album_genres, al.track_count, al.thumb_url as album_thumb_url, a.thumb_url as artist_thumb_url FROM tracks t JOIN artists a ON t.artist_id = a.id JOIN albums al ON t.album_id = al.id WHERE t.id IN ({placeholders}) """, [str(tid) for tid in track_ids]) rows = [dict(r) for r in cursor.fetchall()] sync_to_server = data.get('sync_to_server', False) # Initialize state with _write_tags_batch_lock: _write_tags_batch_state.update({ 'status': 'running', 'total': len(track_ids), 'processed': 0, 'written': 0, 'failed': 0, 'current_track': '', 'errors': [], 'sync_phase': None, 'sync_server': None, 'sync_synced': 0, 'sync_failed': 0, }) # Count missing DB rows found_ids = {str(r['id']) for r in rows} missing = [tid for tid in track_ids if str(tid) not in found_ids] if missing: with _write_tags_batch_lock: _write_tags_batch_state['failed'] += len(missing) _write_tags_batch_state['processed'] += len(missing) for tid in missing: _write_tags_batch_state['errors'].append({'track_id': tid, 'error': 'Track not found in database'}) # Run the actual writes in a background thread def _run_batch(): try: from core.tag_writer import write_tags_to_file, download_cover_art written_tracks = [] # Track dicts that were successfully written (for server sync) # Pre-download cover art once per unique album URL cover_cache = {} # url → (bytes, mime) or None if embed_cover: unique_urls = set() for td in rows: thumb = td.get('album_thumb_url') or td.get('artist_thumb_url') if thumb and thumb.startswith('http'): unique_urls.add(thumb) if unique_urls: with _write_tags_batch_lock: _write_tags_batch_state['current_track'] = f'Downloading cover art ({len(unique_urls)} album{"s" if len(unique_urls) != 1 else ""})...' for url in unique_urls: cover_cache[url] = download_cover_art(url) for track_data in rows: file_path = track_data.get('file_path') resolved_path = _resolve_library_file_path(file_path) track_title = track_data.get('title', 'Unknown') with _write_tags_batch_lock: _write_tags_batch_state['current_track'] = track_title if not resolved_path: with _write_tags_batch_lock: _write_tags_batch_state['failed'] += 1 _write_tags_batch_state['processed'] += 1 _write_tags_batch_state['errors'].append({'track_id': track_data['id'], 'error': _get_file_not_found_error(file_path)}) continue # Parse genres album_genres = [] if track_data.get('album_genres'): try: parsed = json.loads(track_data['album_genres']) album_genres = parsed if isinstance(parsed, list) else [str(parsed)] except (ValueError, TypeError): album_genres = [g.strip() for g in track_data['album_genres'].split(',') if g.strip()] db_data = _build_library_tag_db_data(track_data, album_genres) # Get pre-downloaded cover art for this track's album art_data = None if embed_cover: thumb = track_data.get('album_thumb_url') or track_data.get('artist_thumb_url') if thumb and thumb.startswith('http'): art_data = cover_cache.get(thumb) file_lock = get_file_lock(resolved_path) with file_lock: write_result = write_tags_to_file( resolved_path, db_data, embed_cover=embed_cover, cover_data=art_data ) with _write_tags_batch_lock: _write_tags_batch_state['processed'] += 1 if write_result.get('success'): _write_tags_batch_state['written'] += 1 written_tracks.append(track_data) else: _write_tags_batch_state['failed'] += 1 _write_tags_batch_state['errors'].append({ 'track_id': track_data['id'], 'error': write_result.get('error', 'Unknown') }) # Server sync phase if sync_to_server and written_tracks: server_type = config_manager.get_active_media_server() with _write_tags_batch_lock: _write_tags_batch_state['sync_phase'] = 'syncing' _write_tags_batch_state['sync_server'] = server_type _write_tags_batch_state['current_track'] = f'Syncing to {server_type.title()}...' sync_result = _sync_tracks_to_server(written_tracks, server_type) with _write_tags_batch_lock: _write_tags_batch_state['sync_phase'] = 'done' _write_tags_batch_state['sync_synced'] = sync_result['synced'] _write_tags_batch_state['sync_failed'] = sync_result['failed'] except Exception as e: logger.error(f"Batch write tags background error: {e}") finally: with _write_tags_batch_lock: _write_tags_batch_state['status'] = 'done' _write_tags_batch_state['current_track'] = '' thread = threading.Thread(target=_run_batch, daemon=True, name="WriteTagsBatch") thread.start() return jsonify({"success": True, "message": "Batch tag write started", "total": len(track_ids)}) except Exception as e: logger.error(f"Batch write tags error: {e}") with _write_tags_batch_lock: _write_tags_batch_state['status'] = 'idle' return jsonify({"success": False, "error": str(e)}), 500 @app.route('/api/library/tracks/write-tags-batch/status', methods=['GET']) def get_write_tags_batch_status(): """Poll the status of a running batch tag write.""" with _write_tags_batch_lock: state = dict(_write_tags_batch_state) state['errors'] = list(_write_tags_batch_state['errors']) # snapshot to avoid mutation during serialize return jsonify(state) # ── Reconcile embedded provider IDs (gap-fill DB from file tags) ── # # Files that SoulSync (or MusicBrainz Picard) already tagged carry Spotify / # iTunes / MusicBrainz / Deezer / Tidal / AudioDB / Genius IDs in their # metadata. Reading them back and gap-filling the {provider}_id + # {provider}_match_status='matched' columns lets the enrichment workers skip # the API lookup entirely — large API savings on an already-tagged library. # Gap-fill only: an existing id is never overwritten (see # core/library/embedded_id_reconcile.py). def _reconcile_library_tracks(conn, track_ids=None, on_progress=None, should_stop=None): """Run the embedded-ID reconcile with web-server path resolution injected. Thin wrapper over core.library.embedded_id_reconcile.reconcile_library that supplies the read_tags callable (docker/library path resolution + mutagen read). Used by the manual backfill job and available for any scoped reconcile. ``track_ids=None`` => whole library. """ from core.library.file_tags import read_embedded_tags from core.library.embedded_id_reconcile import reconcile_library def _read_tags(file_path): resolved = _resolve_library_file_path(file_path) if not resolved: return None info = read_embedded_tags(resolved) return info.get('tags') if info.get('available') else None return reconcile_library(conn, _read_tags, track_ids=track_ids, on_progress=on_progress, should_stop=should_stop) def _reconcile_after_scan(worker): """Gap-fill embedded provider IDs for tracks a scan newly inserted. Runs after a library scan/deep-scan completes, scoped to the rows the worker actually INSERTED this run (``worker._new_track_ids``). New files are written with empty provider-id columns, so this reads their tags and fills any IDs present — keeping the DB current without a manual backfill. Best-effort: never raises into the scan flow. """ try: new_ids = list(getattr(worker, '_new_track_ids', None) or []) if not new_ids: return n = len(new_ids) try: _db_update_phase_callback( f"Reading file tags for {n} new track{'s' if n != 1 else ''}…") except Exception: # noqa: S110 — best-effort UI phase, never block the reconcile pass def _on_progress(totals, title): try: pct = (totals.processed / totals.total * 100) if totals.total else 100 _db_update_progress_callback(title, totals.processed, totals.total, pct) except Exception: # noqa: S110 — best-effort UI progress tick pass database = get_database() conn = database._get_connection() try: totals = _reconcile_library_tracks(conn, track_ids=new_ids, on_progress=_on_progress) logger.info( "[Reconcile] Post-scan: filled %d id(s) across %d row(s) from %d new " "track(s) (%d unreadable, %d conflicts)", totals.ids_filled, totals.entities_updated, totals.processed, totals.unreadable, totals.conflicts, ) finally: conn.close() except Exception as e: logger.warning("[Reconcile] Post-scan reconcile failed (non-fatal): %s", e) _reconcile_ids_state = { 'status': 'idle', # idle | running | done 'total': 0, 'processed': 0, 'entities_updated': 0, # track/album/artist rows written 'ids_filled': 0, # individual id columns filled 'conflicts': 0, # embedded id disagreed with a stored id (not applied) 'unreadable': 0, # files missing / unreadable by mutagen 'current': '', } _reconcile_ids_lock = threading.Lock() @app.route('/api/library/reconcile-embedded-ids', methods=['POST']) def reconcile_embedded_ids(): """Scan every library file for embedded provider IDs and gap-fill them into the DB so enrichment workers skip the API lookup. Runs in the background; poll the status endpoint for progress.""" try: with _reconcile_ids_lock: if _reconcile_ids_state['status'] == 'running': return jsonify({"success": False, "error": "A reconcile is already in progress"}), 409 _reconcile_ids_state.update({ 'status': 'running', 'total': 0, 'processed': 0, 'entities_updated': 0, 'ids_filled': 0, 'conflicts': 0, 'unreadable': 0, 'current': 'Starting…', }) database = get_database() def _run(): conn = None try: conn = database._get_connection() def _on_progress(totals, title): with _reconcile_ids_lock: _reconcile_ids_state.update({ 'total': totals.total, 'processed': totals.processed, 'entities_updated': totals.entities_updated, 'ids_filled': totals.ids_filled, 'conflicts': totals.conflicts, 'unreadable': totals.unreadable, 'current': title, }) # Whole-library backfill (track_ids=None). Shares the exact # orchestration used by the on-scan auto-reconcile hook. _reconcile_library_tracks(conn, on_progress=_on_progress) except Exception as e: logger.error(f"Reconcile embedded IDs background error: {e}") finally: if conn is not None: try: conn.close() except Exception: # noqa: S110 — connection cleanup, nothing to recover pass with _reconcile_ids_lock: _reconcile_ids_state['status'] = 'done' _reconcile_ids_state['current'] = '' thread = threading.Thread(target=_run, daemon=True, name="ReconcileEmbeddedIds") thread.start() return jsonify({"success": True, "message": "Reconcile started"}) except Exception as e: logger.error(f"Reconcile embedded IDs kickoff error: {e}") with _reconcile_ids_lock: _reconcile_ids_state['status'] = 'idle' return jsonify({"success": False, "error": str(e)}), 500 @app.route('/api/library/reconcile-embedded-ids/status', methods=['GET']) def get_reconcile_embedded_ids_status(): """Poll the status of the embedded-ID reconcile job.""" with _reconcile_ids_lock: return jsonify(dict(_reconcile_ids_state)) # ── ReplayGain Analysis endpoints ── from core.replaygain import ( analyze_track as _rg_analyze_track, write_replaygain_tags as _rg_write_tags, is_ffmpeg_available as _rg_ffmpeg_available, RG_REFERENCE_LUFS as _RG_REFERENCE_LUFS, ) # State machine for album-level ReplayGain jobs _rg_album_state = { 'status': 'idle', # idle | running | done 'album_id': None, 'total': 0, 'processed': 0, 'analyzed': 0, 'failed': 0, 'current_track': '', 'errors': [], } _rg_album_lock = threading.Lock() # State machine for selected-tracks batch ReplayGain jobs _rg_batch_state = { 'status': 'idle', 'total': 0, 'processed': 0, 'analyzed': 0, 'failed': 0, 'current_track': '', 'errors': [], } _rg_batch_lock = threading.Lock() @app.route('/api/library/track//analyze-replaygain', methods=['POST']) def analyze_track_replaygain(track_id): """ Analyze a single track and write ReplayGain track-level tags immediately. Synchronous — runs FFmpeg inline (typically 1–3 s per track). """ if not _rg_ffmpeg_available(): return jsonify({'success': False, 'error': 'ffmpeg not found on PATH'}), 500 database = get_database() conn = database._get_connection() cursor = conn.cursor() cursor.execute("SELECT * FROM tracks WHERE id = ?", (str(track_id),)) row = cursor.fetchone() if not row: return jsonify({'success': False, 'error': 'Track not found'}), 404 file_path = _resolve_library_file_path(dict(row).get('file_path')) if not file_path: return jsonify({'success': False, 'error': 'File not found on disk'}), 404 try: lufs, peak_dbfs = _rg_analyze_track(file_path) except Exception as e: return jsonify({'success': False, 'error': str(e)}), 500 track_gain_db = _RG_REFERENCE_LUFS - lufs file_lock = get_file_lock(file_path) with file_lock: ok = _rg_write_tags(file_path, track_gain_db, peak_dbfs) if not ok: return jsonify({'success': False, 'error': 'Failed to write tags to file'}), 500 return jsonify({ 'success': True, 'track_gain': f"{track_gain_db:+.2f} dB", 'track_peak': f"{10 ** (peak_dbfs / 20.0):.6f}", 'lufs': round(lufs, 2), }) @app.route('/api/library/album//analyze-replaygain', methods=['POST']) def analyze_album_replaygain(album_id): """ Analyze all tracks in an album and write both track-level and album-level ReplayGain tags. Runs in a background thread — poll /status for progress. """ with _rg_album_lock: if _rg_album_state['status'] == 'running': return jsonify({'success': False, 'error': 'An album ReplayGain job is already running'}), 409 if not _rg_ffmpeg_available(): return jsonify({'success': False, 'error': 'ffmpeg not found on PATH'}), 500 database = get_database() conn = database._get_connection() cursor = conn.cursor() cursor.execute( "SELECT * FROM tracks WHERE album_id = ? ORDER BY track_number, title", (album_id,) ) tracks = [dict(r) for r in cursor.fetchall()] if not tracks: return jsonify({'success': False, 'error': 'No tracks found for this album'}), 404 with _rg_album_lock: _rg_album_state.update({ 'status': 'running', 'album_id': album_id, 'total': len(tracks), 'processed': 0, 'analyzed': 0, 'failed': 0, 'current_track': '', 'errors': [], }) def _run_album(): lufs_values = [] peak_values = [] track_results = [] # (file_path, track_gain_db, peak_dbfs) # Pass 1: analyze every track for track in tracks: file_path = _resolve_library_file_path(track.get('file_path')) title = track.get('title') or track.get('file_path') or '' with _rg_album_lock: _rg_album_state['current_track'] = title if not file_path: with _rg_album_lock: _rg_album_state['failed'] += 1 _rg_album_state['errors'].append({'track': title, 'error': 'File not found'}) _rg_album_state['processed'] += 1 track_results.append(None) continue try: lufs, peak_dbfs = _rg_analyze_track(file_path) lufs_values.append(lufs) peak_values.append(peak_dbfs) track_gain_db = _RG_REFERENCE_LUFS - lufs track_results.append((file_path, track_gain_db, peak_dbfs)) with _rg_album_lock: _rg_album_state['analyzed'] += 1 _rg_album_state['processed'] += 1 except Exception as e: with _rg_album_lock: _rg_album_state['failed'] += 1 _rg_album_state['errors'].append({'track': title, 'error': str(e)}) _rg_album_state['processed'] += 1 track_results.append(None) # Compute album gain from tracks that analyzed successfully album_gain_db = None album_peak_dbfs = None if lufs_values: mean_lufs = sum(lufs_values) / len(lufs_values) album_gain_db = _RG_REFERENCE_LUFS - mean_lufs album_peak_dbfs = max(peak_values) # Pass 2: write tags to every successfully analyzed track for i, track in enumerate(tracks): entry = track_results[i] if entry is None: continue file_path, track_gain_db, peak_dbfs = entry try: file_lock = get_file_lock(file_path) with file_lock: _rg_write_tags(file_path, track_gain_db, peak_dbfs, album_gain_db, album_peak_dbfs) except Exception as e: with _rg_album_lock: _rg_album_state['failed'] += 1 _rg_album_state['errors'].append({'track': track.get('title', ''), 'error': str(e)}) with _rg_album_lock: _rg_album_state['status'] = 'done' _rg_album_state['current_track'] = '' threading.Thread(target=_run_album, daemon=True, name='RgAlbum').start() return jsonify({'success': True}) @app.route('/api/library/album//analyze-replaygain/status', methods=['GET']) def get_album_replaygain_status(album_id): """Poll the status of a running album ReplayGain job.""" with _rg_album_lock: state = dict(_rg_album_state) state['errors'] = list(_rg_album_state['errors']) return jsonify(state) @app.route('/api/library/tracks/analyze-replaygain-batch', methods=['POST']) def analyze_tracks_replaygain_batch(): """ Analyze a set of selected tracks and write track-level ReplayGain tags. No album gain is computed (tracks may span multiple albums). Runs in a background thread — poll /status for progress. """ with _rg_batch_lock: if _rg_batch_state['status'] == 'running': return jsonify({'success': False, 'error': 'A batch ReplayGain job is already running'}), 409 if not _rg_ffmpeg_available(): return jsonify({'success': False, 'error': 'ffmpeg not found on PATH'}), 500 data = request.get_json() or {} track_ids = data.get('track_ids', []) if not track_ids: return jsonify({'success': False, 'error': 'No track IDs provided'}), 400 database = get_database() conn = database._get_connection() cursor = conn.cursor() placeholders = ','.join('?' for _ in track_ids) cursor.execute( f"SELECT * FROM tracks WHERE id IN ({placeholders})", [str(tid) for tid in track_ids] ) tracks = [dict(r) for r in cursor.fetchall()] if not tracks: return jsonify({'success': False, 'error': 'No valid tracks found'}), 404 with _rg_batch_lock: _rg_batch_state.update({ 'status': 'running', 'total': len(tracks), 'processed': 0, 'analyzed': 0, 'failed': 0, 'current_track': '', 'errors': [], }) def _run_batch(): for track in tracks: file_path = _resolve_library_file_path(track.get('file_path')) title = track.get('title') or track.get('file_path') or '' with _rg_batch_lock: _rg_batch_state['current_track'] = title if not file_path: with _rg_batch_lock: _rg_batch_state['failed'] += 1 _rg_batch_state['errors'].append({'track': title, 'error': 'File not found'}) _rg_batch_state['processed'] += 1 continue try: lufs, peak_dbfs = _rg_analyze_track(file_path) track_gain_db = _RG_REFERENCE_LUFS - lufs file_lock = get_file_lock(file_path) with file_lock: _rg_write_tags(file_path, track_gain_db, peak_dbfs) with _rg_batch_lock: _rg_batch_state['analyzed'] += 1 _rg_batch_state['processed'] += 1 except Exception as e: with _rg_batch_lock: _rg_batch_state['failed'] += 1 _rg_batch_state['errors'].append({'track': title, 'error': str(e)}) _rg_batch_state['processed'] += 1 with _rg_batch_lock: _rg_batch_state['status'] = 'done' _rg_batch_state['current_track'] = '' threading.Thread(target=_run_batch, daemon=True, name='RgBatch').start() return jsonify({'success': True}) @app.route('/api/library/tracks/analyze-replaygain-batch/status', methods=['GET']) def get_tracks_replaygain_batch_status(): """Poll the status of a running batch ReplayGain job.""" with _rg_batch_lock: state = dict(_rg_batch_state) state['errors'] = list(_rg_batch_state['errors']) return jsonify(state) # ── Reorganize Album Files endpoints ── # # Reorganize requests flow through ``core.reorganize_queue`` — a FIFO # queue with a single background worker. The endpoints here are thin # enqueue / snapshot / cancel wrappers; the heavy lifting is in # :mod:`core.library_reorganize`. @app.route('/api/library/reorganize/sources', methods=['GET']) def reorganize_sources_global(): """List metadata sources the user has authed on this instance. Used by the bulk "Reorganize All" modal where per-album ID coverage varies. No network calls.""" try: from core.library_reorganize import authed_sources return jsonify({"success": True, "sources": authed_sources()}) except Exception as e: logger.error(f"Reorganize sources (global) error: {e}") return jsonify({"success": False, "error": str(e)}), 500 @app.route('/api/library/album//reorganize/sources', methods=['GET']) def reorganize_album_sources(album_id): """List metadata sources the user can pick for this album's reorganize — every entry has both a stored album ID on the local row AND an authenticated client. No network calls.""" try: from core.library_reorganize import available_sources_for_album, load_album_and_tracks album_data, _tracks = load_album_and_tracks(get_database(), album_id) if album_data is None: return jsonify({"success": False, "error": "Album not found"}), 404 return jsonify({"success": True, "sources": available_sources_for_album(album_data)}) except Exception as e: logger.error(f"Reorganize sources error: {e}") return jsonify({"success": False, "error": str(e)}), 500 @app.route('/api/library/album//reorganize/preview', methods=['POST']) def reorganize_album_preview(album_id): """Preview file reorganization for an album — returns current vs proposed paths without moving anything. Implementation lives in :mod:`core.library_reorganize` and shares the planning logic with the apply endpoint, so the preview is guaranteed to match what apply would actually produce. Optional body params: source: when provided, only that metadata source is queried (no fallback chain). mode: 'api' (default — query metadata source) or 'tags' (read embedded file tags as the source of truth, issue #592).""" try: from core.library_reorganize import preview_album_reorganize data = request.get_json() or {} chosen_source = data.get('source') or None metadata_source = data.get('mode') or config_manager.get( 'library.reorganize_metadata_source', 'api' ) or 'api' if metadata_source not in ('api', 'tags'): metadata_source = 'api' transfer_dir = docker_resolve_path(config_manager.get('soulseek.transfer_path', './Transfer')) result = preview_album_reorganize( album_id=album_id, db=get_database(), transfer_dir=transfer_dir, resolve_file_path_fn=_resolve_library_file_path, build_final_path_fn=_build_final_path_for_track, primary_source=chosen_source, strict_source=bool(chosen_source), metadata_source=metadata_source, ) if result.get('status') == 'no_album': return jsonify({"success": False, "error": "Album not found"}), 404 if result.get('status') == 'no_tracks': return jsonify({"success": False, "error": "No tracks found for this album"}), 404 return jsonify(result) except Exception as e: logger.error(f"Reorganize preview error: {e}") return jsonify({"success": False, "error": str(e)}), 500 @app.route('/api/library/album//reorganize', methods=['POST']) def reorganize_album_files(album_id): """Enqueue an album for reorganize. Returns immediately — the queue worker processes items FIFO. Repeat clicks for an album that's already queued or running are deduped (returns ``{queued: false, reason: 'already_queued'}``). Body params: source (optional): per-album source pick (Spotify / iTunes / Deezer / Discogs / Hydrabase). When omitted, the orchestrator uses the configured primary with fallback. mode (optional): 'api' (default — query metadata source) or 'tags' (read embedded file tags as the source of truth, issue #592). When omitted, falls back to the ``library.reorganize_metadata_source`` config setting, then to 'api'. """ try: from core.reorganize_queue import get_queue data = request.get_json() or {} chosen_source = data.get('source') or None metadata_source = data.get('mode') or config_manager.get( 'library.reorganize_metadata_source', 'api' ) or 'api' if metadata_source not in ('api', 'tags'): metadata_source = 'api' # Capture display fields at enqueue time so the status panel # can render them without a DB lookup later. meta = get_database().get_album_display_meta(album_id) if meta is None: return jsonify({"success": False, "error": "Album not found"}), 404 result = get_queue().enqueue( album_id=str(album_id), album_title=meta['album_title'], artist_id=meta['artist_id'], artist_name=meta['artist_name'], source=chosen_source, metadata_source=metadata_source, ) return jsonify({"success": True, **result}) except Exception as e: logger.error(f"Reorganize enqueue error: {e}", exc_info=True) return jsonify({"success": False, "error": str(e)}), 500 @app.route('/api/library/artist//reorganize-all', methods=['POST']) def reorganize_all_artist_albums(artist_id): """Enqueue every album for an artist. Replaces the old frontend bulk-loop. Each album becomes its own queue item, processed FIFO. Albums already queued or running are deduped silently. Body params: source (optional): same pick applied to every album. Per-album overrides aren't supported here — use the per-album modal for that. mode (optional): 'api' or 'tags' applied to every album, same shape as the per-album endpoint. """ try: from core.reorganize_queue import get_queue data = request.get_json() or {} chosen_source = data.get('source') or None metadata_source = data.get('mode') or config_manager.get( 'library.reorganize_metadata_source', 'api' ) or 'api' if metadata_source not in ('api', 'tags'): metadata_source = 'api' albums = get_database().get_artist_albums_for_reorganize(artist_id) if not albums: return jsonify({"success": False, "error": "No albums found for this artist"}), 404 # Apply the user's chosen source + mode to every album, then # hand off to the queue's bulk-enqueue helper which owns the # loop+tally. for album in albums: album['source'] = chosen_source album['metadata_source'] = metadata_source result = get_queue().enqueue_many(albums) return jsonify({ "success": True, "enqueued": result['enqueued'], "already_queued": result['already_queued'], "total_albums": result['total'], }) except Exception as e: logger.error(f"Reorganize-all enqueue error: {e}", exc_info=True) return jsonify({"success": False, "error": str(e)}), 500 @app.route('/api/library/reorganize/queue', methods=['GET']) def reorganize_queue_snapshot(): """Snapshot of the reorganize queue — what's running, what's queued, recent completions. Polled by the status panel.""" try: from core.reorganize_queue import get_queue return jsonify({"success": True, **get_queue().snapshot()}) except Exception as e: logger.error(f"Reorganize queue snapshot error: {e}") return jsonify({"success": False, "error": str(e)}), 500 @app.route('/api/library/reorganize/queue//cancel', methods=['POST']) def reorganize_queue_cancel(queue_id): """Cancel a queued item (running items can't be cleanly cancelled — see the queue module's design rules).""" try: from core.reorganize_queue import get_queue result = get_queue().cancel(queue_id) status_code = 200 if result.get('cancelled') else 409 return jsonify({"success": result.get('cancelled', False), **result}), status_code except Exception as e: logger.error(f"Reorganize cancel error: {e}") return jsonify({"success": False, "error": str(e)}), 500 @app.route('/api/library/reorganize/queue/clear', methods=['POST']) def reorganize_queue_clear(): """Cancel all queued items at once (the running item continues).""" try: from core.reorganize_queue import get_queue cancelled = get_queue().clear_queued() return jsonify({"success": True, "cancelled": cancelled}) except Exception as e: logger.error(f"Reorganize clear error: {e}") return jsonify({"success": False, "error": str(e)}), 500 # Wire the reorganize queue worker to its runner at module load. The # runner factory lives in :mod:`core.reorganize_runner` so this monolith # stays small. Config (paths) is read **per run** inside the closure, # so changing your download path in Settings takes effect on the next # reorganize without a server restart. # # The injected callables are wrapped in lambdas because the underlying # helpers (``_resolve_library_file_path`` etc.) are defined LATER in # this file. Lambdas defer name resolution to call time so module-load # import order works regardless of definition order. try: from core.reorganize_queue import get_queue as _get_reorganize_queue from core.reorganize_runner import build_runner as _build_reorganize_runner _get_reorganize_queue().set_runner(_build_reorganize_runner( get_database=get_database, resolve_file_path_fn=lambda p: _resolve_library_file_path(p), post_process_fn=lambda *a, **kw: _post_process_matched_download(*a, **kw), cleanup_empty_directories_fn=lambda *a, **kw: _cleanup_empty_directories(*a, **kw), is_shutting_down_fn=lambda: bool(IS_SHUTTING_DOWN), get_download_path=lambda: docker_resolve_path( config_manager.get('soulseek.download_path', './downloads') ), get_transfer_path=lambda: docker_resolve_path( config_manager.get('soulseek.transfer_path', './Transfer') ), )) except Exception as _runner_init_err: logger.error(f"Failed to register reorganize queue runner: {_runner_init_err}") # ── Library Issues endpoints ── @app.route('/api/issues', methods=['GET']) def list_issues(): """List issues. Admin sees all; non-admin sees own only.""" try: database = get_database() profile_id = request.headers.get('X-Profile-Id', '1') try: profile_id = int(profile_id) except (ValueError, TypeError): profile_id = 1 # Determine admin status profile = database.get_profile(profile_id) is_admin = profile.get('is_admin', False) if profile else False status = request.args.get('status') category = request.args.get('category') entity_type = request.args.get('entity_type') try: limit = min(200, max(1, int(request.args.get('limit', 100)))) except (ValueError, TypeError): limit = 100 try: offset = max(0, int(request.args.get('offset', 0))) except (ValueError, TypeError): offset = 0 result = database.get_issues( profile_id=profile_id, status=status, category=category, entity_type=entity_type, limit=limit, offset=offset, is_admin=is_admin, ) # Fix Plex/Jellyfin relative thumb URLs in stored snapshots for issue in result.get('issues', []): snap = issue.get('snapshot_data') if isinstance(snap, dict): for key in ('thumb_url', 'artist_thumb', 'album_thumb'): if snap.get(key): snap[key] = fix_artist_image_url(snap[key]) or snap[key] return jsonify(result) except Exception as e: return jsonify({"success": False, "error": str(e)}), 500 @app.route('/api/issues', methods=['POST']) def create_issue(): """Create a new library issue.""" try: database = get_database() data = request.get_json() if not data: return jsonify({"success": False, "error": "No data provided"}), 400 # Use header for profile_id (not body) to prevent spoofing profile_id = request.headers.get('X-Profile-Id', '1') try: profile_id = int(profile_id) except (ValueError, TypeError): profile_id = 1 entity_type = data.get('entity_type') entity_id = data.get('entity_id') category = data.get('category') title = data.get('title', '').strip() description = data.get('description', '').strip() priority = data.get('priority', 'normal') if not entity_type or not entity_id or not category or not title: return jsonify({"success": False, "error": "entity_type, entity_id, category, and title are required"}), 400 valid_types = ('artist', 'album', 'track') if entity_type not in valid_types: return jsonify({"success": False, "error": f"entity_type must be one of: {', '.join(valid_types)}"}), 400 valid_categories = ('wrong_track', 'wrong_metadata', 'wrong_cover', 'duplicate_tracks', 'missing_tracks', 'audio_quality', 'wrong_artist', 'wrong_album', 'incomplete_album', 'other') if category not in valid_categories: return jsonify({"success": False, "error": f"Invalid category: {category}"}), 400 # Build snapshot of the entity's current state snapshot = _build_issue_snapshot(database, entity_type, str(entity_id)) result = database.create_issue( profile_id=profile_id, entity_type=entity_type, entity_id=str(entity_id), category=category, title=title, description=description, snapshot_data=snapshot, priority=priority, ) return jsonify(result), 201 if result.get('success') else 400 except Exception as e: return jsonify({"success": False, "error": str(e)}), 500 @app.route('/api/issues/', methods=['GET']) def get_issue(issue_id): """Get a single issue.""" try: database = get_database() issue = database.get_issue(issue_id) if not issue: return jsonify({"success": False, "error": "Issue not found"}), 404 # Fix Plex/Jellyfin relative thumb URLs in stored snapshot snap = issue.get('snapshot_data') if isinstance(snap, dict): for key in ('thumb_url', 'artist_thumb', 'album_thumb'): if snap.get(key): snap[key] = fix_artist_image_url(snap[key]) or snap[key] return jsonify({"success": True, "issue": issue}) except Exception as e: return jsonify({"success": False, "error": str(e)}), 500 @app.route('/api/issues/', methods=['PUT']) def update_issue(issue_id): """Update an issue (admin: respond/resolve; user: edit own description).""" try: database = get_database() data = request.get_json() if not data: return jsonify({"success": False, "error": "No data provided"}), 400 profile_id = request.headers.get('X-Profile-Id', '1') try: profile_id = int(profile_id) except (ValueError, TypeError): profile_id = 1 profile = database.get_profile(profile_id) is_admin = profile.get('is_admin', False) if profile else False # Non-admin can only edit their own issue's title/description if not is_admin: issue = database.get_issue(issue_id) if not issue: return jsonify({"success": False, "error": "Issue not found"}), 404 if issue['profile_id'] != profile_id: return jsonify({"success": False, "error": "Not authorized"}), 403 data = {k: v for k, v in data.items() if k in ('title', 'description')} # If resolving, stamp resolved_by and resolved_at if data.get('status') in ('resolved', 'dismissed') and is_admin: data['resolved_by'] = profile_id from datetime import datetime data['resolved_at'] = datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S') # If reopening, clear resolution metadata elif data.get('status') in ('open', 'in_progress') and is_admin: data['resolved_by'] = None data['resolved_at'] = None result = database.update_issue(issue_id, data) return jsonify(result) except Exception as e: return jsonify({"success": False, "error": str(e)}), 500 @app.route('/api/issues/', methods=['DELETE']) def delete_issue(issue_id): """Delete an issue (admin or issue owner).""" try: database = get_database() profile_id = request.headers.get('X-Profile-Id', '1') try: profile_id = int(profile_id) except (ValueError, TypeError): profile_id = 1 profile = database.get_profile(profile_id) is_admin = profile.get('is_admin', False) if profile else False if not is_admin: issue = database.get_issue(issue_id) if not issue: return jsonify({"success": False, "error": "Issue not found"}), 404 if issue['profile_id'] != profile_id: return jsonify({"success": False, "error": "Not authorized"}), 403 result = database.delete_issue(issue_id) return jsonify(result) except Exception as e: return jsonify({"success": False, "error": str(e)}), 500 @app.route('/api/issues/counts', methods=['GET']) def get_issue_counts(): """Get issue counts by status for badge display.""" try: database = get_database() profile_id = request.headers.get('X-Profile-Id', '1') try: profile_id = int(profile_id) except (ValueError, TypeError): profile_id = 1 profile = database.get_profile(profile_id) is_admin = profile.get('is_admin', False) if profile else False counts = database.get_issue_counts(is_admin=is_admin, profile_id=profile_id) return jsonify({"success": True, "counts": counts}) except Exception as e: return jsonify({"success": False, "error": str(e)}), 500 def _build_issue_snapshot(database, entity_type, entity_id): """Capture current state of the entity for the issue report.""" snapshot = {} try: conn = database._get_connection() cursor = conn.cursor() if entity_type == 'track': cursor.execute(""" SELECT t.id, t.title, t.track_number, t.duration, t.file_path, t.bitrate, t.bpm, t.spotify_track_id, t.musicbrainz_recording_id, t.deezer_id as track_deezer_id, a.name as artist_name, a.id as artist_id, a.spotify_artist_id, a.musicbrainz_id as artist_musicbrainz_id, a.deezer_id as artist_deezer_id, a.tidal_id as artist_tidal_id, a.qobuz_id as artist_qobuz_id, a.thumb_url as artist_thumb, al.title as album_title, al.year, al.thumb_url as album_thumb, al.id as album_id, al.spotify_album_id, al.musicbrainz_release_id, al.deezer_id as album_deezer_id, al.tidal_id as album_tidal_id, al.qobuz_id as album_qobuz_id, al.label, al.record_type, al.track_count as album_track_count FROM tracks t JOIN artists a ON t.artist_id = a.id JOIN albums al ON t.album_id = al.id WHERE t.id = ? """, (entity_id,)) row = cursor.fetchone() if row: d = dict(row) # Add format info if file exists resolved = _resolve_library_file_path(d.get('file_path')) if resolved: ext = os.path.splitext(resolved)[1].lower().lstrip('.') d['format'] = ext.upper() d['quality'] = _get_audio_quality_string(resolved) # Fix Plex/Jellyfin relative thumb URLs if d.get('artist_thumb'): d['artist_thumb'] = fix_artist_image_url(d['artist_thumb']) or d['artist_thumb'] if d.get('album_thumb'): d['album_thumb'] = fix_artist_image_url(d['album_thumb']) or d['album_thumb'] snapshot = d elif entity_type == 'album': cursor.execute(""" SELECT al.id, al.title, al.year, al.track_count, al.thumb_url, al.genres, al.label, al.record_type, al.duration, al.spotify_album_id, al.musicbrainz_release_id, al.deezer_id as album_deezer_id, al.tidal_id as album_tidal_id, al.qobuz_id as album_qobuz_id, al.upc, a.name as artist_name, a.id as artist_id, a.spotify_artist_id, a.musicbrainz_id as artist_musicbrainz_id, a.deezer_id as artist_deezer_id, a.tidal_id as artist_tidal_id, a.qobuz_id as artist_qobuz_id, a.thumb_url as artist_thumb FROM albums al JOIN artists a ON al.artist_id = a.id WHERE al.id = ? """, (entity_id,)) row = cursor.fetchone() if row: d = dict(row) # Fix Plex/Jellyfin relative thumb URLs if d.get('thumb_url'): d['thumb_url'] = fix_artist_image_url(d['thumb_url']) or d['thumb_url'] if d.get('artist_thumb'): d['artist_thumb'] = fix_artist_image_url(d['artist_thumb']) or d['artist_thumb'] # Parse genres if d.get('genres'): try: d['genres'] = json.loads(d['genres']) except (json.JSONDecodeError, TypeError): pass # Get track listing with enriched data cursor.execute(""" SELECT id, title, track_number, duration, file_path, bitrate, spotify_track_id, bpm FROM tracks WHERE album_id = ? ORDER BY track_number """, (entity_id,)) tracks_list = [] for r in cursor.fetchall(): td = dict(r) # Add format from file extension if td.get('file_path'): resolved = _resolve_library_file_path(td['file_path']) if resolved: ext = os.path.splitext(resolved)[1].lower().lstrip('.') td['format'] = ext.upper() tracks_list.append(td) d['tracks'] = tracks_list snapshot = d elif entity_type == 'artist': cursor.execute(""" SELECT id, name, thumb_url, genres, summary, spotify_artist_id, musicbrainz_id as artist_musicbrainz_id, deezer_id as artist_deezer_id, tidal_id as artist_tidal_id, qobuz_id as artist_qobuz_id FROM artists WHERE id = ? """, (entity_id,)) row = cursor.fetchone() if row: d = dict(row) # Fix Plex/Jellyfin relative thumb URL if d.get('thumb_url'): d['thumb_url'] = fix_artist_image_url(d['thumb_url']) or d['thumb_url'] if d.get('genres'): try: d['genres'] = json.loads(d['genres']) except (json.JSONDecodeError, TypeError): pass snapshot = d except Exception as e: logger.error(f"Error building issue snapshot: {e}") snapshot['_snapshot_error'] = str(e) return snapshot def _sync_tracks_to_server(track_rows, server_type): """Sync metadata for tracks to the active media server after writing file tags. Args: track_rows: list of track dicts (must include 'id', 'title', 'artist_name', 'album_title', 'year', 'server_source') server_type: 'plex', 'jellyfin', or 'navidrome' Returns: dict with 'synced', 'failed', 'skipped' counts and 'errors' list """ result = {'synced': 0, 'failed': 0, 'skipped': 0, 'errors': []} if server_type == 'navidrome': # Navidrome auto-detects file tag changes, no action needed result['synced'] = len(track_rows) return result if server_type == 'plex': for track_data in track_rows: # Only sync tracks that came from this server if track_data.get('server_source') and track_data['server_source'] != 'plex': result['skipped'] += 1 continue try: metadata = {} if track_data.get('title'): metadata['title'] = track_data['title'] if track_data.get('artist_name'): metadata['artist'] = track_data['artist_name'] if track_data.get('album_title'): metadata['album'] = track_data['album_title'] if track_data.get('year'): metadata['year'] = track_data['year'] if metadata: success = media_server_engine.client('plex').update_track_metadata(str(track_data['id']), metadata) if success: result['synced'] += 1 else: result['failed'] += 1 result['errors'].append({'track_id': track_data['id'], 'error': 'Plex update returned false'}) else: result['skipped'] += 1 except Exception as e: result['failed'] += 1 result['errors'].append({'track_id': track_data['id'], 'error': str(e)}) elif server_type == 'jellyfin': # Jellyfin: just trigger a library scan once after all file writes try: success = media_server_engine.client('jellyfin').trigger_library_scan() if success: result['synced'] = len(track_rows) else: result['failed'] = len(track_rows) result['errors'].append({'error': 'Jellyfin library scan failed'}) except Exception as e: result['failed'] = len(track_rows) result['errors'].append({'error': f'Jellyfin scan error: {e}'}) return result def _resolve_library_file_path(file_path): """Resolve a library file path to an actual file on disk.""" if not file_path: return None if os.path.exists(file_path): return file_path # Try resolving server-side paths to local directories # Check transfer path, download path, and media server library paths transfer_dir = docker_resolve_path(config_manager.get('soulseek.transfer_path', './Transfer')) download_dir = docker_resolve_path(config_manager.get('soulseek.download_path', './downloads')) # Also check the media server's music library path (handles Docker↔host path mismatch). # ``get_music_library_locations`` handles both single-library mode and # all-libraries mode (unions location paths across every music section). library_dirs = set() try: plex = media_server_engine.client('plex') if plex and plex.server: for loc in plex.get_music_library_locations(): library_dirs.add(loc) except Exception as e: logger.debug("plex library locations lookup failed: %s", e) # Check user-configured music library paths (Settings > Library) try: music_paths = config_manager.get('library.music_paths', []) if isinstance(music_paths, list): for p in music_paths: if p and isinstance(p, str): resolved_p = docker_resolve_path(p.strip()) if resolved_p: library_dirs.add(resolved_p) except Exception as e: logger.debug("library music paths read failed: %s", e) path_parts = file_path.replace('\\', '/').split('/') # Try progressively shorter path suffixes against each candidate directory # (skip index 0 to avoid drive letter issues). find_on_disk matches each # component exactly when present, else folds typographic confusables (#833: # curly U+2019 apostrophe in DB metadata vs ASCII U+0027 on disk) — exact # matches always win, so paths that already resolved are unaffected. from core.library.path_resolve import find_on_disk for base_dir in [transfer_dir, download_dir] + list(library_dirs): if not base_dir or not os.path.isdir(base_dir): continue for i in range(1, len(path_parts)): found = find_on_disk(base_dir, path_parts[i:]) if found: return found return None def _build_library_stream_url(track_id, file_path): """Build a media-server stream URL for a library track that isn't on the SoulSync filesystem (#809 — play via the server's API, no disk mount). Navidrome/Subsonic only for now (it has a clean token-authed stream endpoint). ``track_id`` is the server's song id (the tracks-table id for a Navidrome row); if missing, look it up by file_path. Returns None when the active server isn't Navidrome or no id resolves.""" try: if config_manager.get_active_media_server() != 'navidrome': return None client = media_server_engine.client('navidrome') if not client: return None song_id = str(track_id) if track_id else None if not song_id and file_path: # Fall back to a DB lookup by stored path (handles callers that # didn't pass the id). try: db = get_database() with db._get_connection() as conn: row = conn.cursor().execute( "SELECT id FROM tracks WHERE file_path = ? AND server_source = 'navidrome' LIMIT 1", (file_path,)).fetchone() if row: song_id = str(row[0]) except Exception as e: logger.debug("navidrome stream id lookup failed: %s", e) if not song_id: return None return client.build_stream_url(song_id) except Exception as e: logger.debug("build library stream url failed: %s", e) return None def _get_file_not_found_error(file_path): """Return a helpful error message when a library file can't be found.""" active_server = config_manager.get_active_media_server() if active_server == 'navidrome': # Check if path looks like a Navidrome fake path (no real filesystem root) # Fake paths look like: "Artist/Album/01 - Track.flac" or just "Track.flac" if file_path and ('/' not in file_path or not file_path.startswith('/')): return ('File not found — Navidrome may be sending virtual paths. ' 'Go to Navidrome → Profile → Players → select SoulSync → enable "Report Real Path", ' 'then run a full database refresh in SoulSync.') return ('File not found on disk — check that your Navidrome music folder ' 'is mounted in the SoulSync container and that "Report Real Path" is enabled ' 'in Navidrome\'s player settings.') return 'File not found on disk' @app.route('/api/library/play', methods=['POST']) def library_play_track(): """Start playing a track directly from the user's library (no download needed).""" try: data = request.get_json() if not data or not data.get('file_path'): return jsonify({"success": False, "error": "file_path is required"}), 400 file_path = data['file_path'] # Resolve server-side paths (e.g. /mnt/musicBackup/...) to local transfer path resolved = _resolve_library_file_path(file_path) stream_url = None if resolved: file_path = resolved else: # Not on disk. For a streaming server (Navidrome/Subsonic) we can # play it through the server's own stream API instead of requiring # the library to be mounted into the SoulSync container (#809). stream_url = _build_library_stream_url(data.get('track_id'), file_path) if not stream_url: return jsonify({"success": False, "error": _get_file_not_found_error(file_path)}), 404 if stream_url: logger.info("Library play request (server stream): %s", data.get('title') or os.path.basename(file_path or '')) else: logger.info(f"Library play request: {os.path.basename(file_path)}") # Set THIS listener's stream state to ready. Either a local file_path # (served from disk) or a stream_url (proxied from the media server). sess = _current_stream_state() with sess.lock: sess.update({ "status": "ready", "progress": 100, "track_info": { "title": data.get('title', os.path.basename(file_path or '')), "artist": data.get('artist', 'Unknown Artist'), "album": data.get('album', 'Unknown Album'), }, "file_path": None if stream_url else file_path, "stream_url": stream_url, "error_message": None, "is_library": True }) return jsonify({"success": True, "message": "Library track ready for playback"}) except Exception as e: logger.error(f"Error playing library track: {e}") return jsonify({"success": False, "error": str(e)}), 500 @app.route('/api/library/log-play', methods=['POST']) def library_log_play(): """Record a SoulSync web-player play into listening_history + bump the track's play_count/last_played (feeds 'recently played' and smart radio). Fire-and-forget from the frontend ~10s into a track. Best-effort: a logging failure never affects playback, so we always return 200-ish. """ try: from core.playback.play_log import build_play_event data = request.get_json(silent=True) or {} track = data.get('track') or data played_at = datetime.now().isoformat() duration_ms = data.get('duration_ms', 0) event = build_play_event(track, played_at, duration_ms) if not event: return jsonify({"success": False, "skipped": True}), 200 get_database().record_web_player_play(event) return jsonify({"success": True}) except Exception as e: logger.debug(f"log-play failed (non-fatal): {e}") return jsonify({"success": False, "error": str(e)}), 200 _enrichment_locks = {svc: threading.Lock() for svc in ('audiodb', 'deezer', 'musicbrainz', 'spotify', 'itunes', 'lastfm', 'genius', 'tidal', 'qobuz', 'discogs')} @app.route('/api/library/enrich', methods=['POST']) def library_enrich_entity(): """Trigger enrichment of a specific entity from a single service. Body: { entity_type: 'artist'|'album'|'track', entity_id: str, service: str, name: str, artist_name: str? } service: 'audiodb', 'deezer', 'musicbrainz', 'spotify', 'itunes', 'lastfm', 'genius', 'tidal', 'qobuz' """ try: data = request.get_json() if not data: return jsonify({"success": False, "error": "No data provided"}), 400 entity_type = data.get('entity_type') # artist, album, track entity_id = data.get('entity_id') service = data.get('service') name = data.get('name', '') artist_name = data.get('artist_name', '') if not entity_type or not entity_id or not service: return jsonify({"success": False, "error": "entity_type, entity_id, and service are required"}), 400 if entity_type not in ('artist', 'album', 'track'): return jsonify({"success": False, "error": "entity_type must be artist, album, or track"}), 400 valid_services = ('audiodb', 'deezer', 'musicbrainz', 'spotify', 'itunes', 'lastfm', 'genius', 'tidal', 'qobuz', 'discogs') if service not in valid_services: return jsonify({"success": False, "error": f"service must be one of: {', '.join(valid_services)}"}), 400 # Per-service lock to avoid thread-safety issues with shared worker clients lock = _enrichment_locks.get(service) if not lock: return jsonify({"success": False, "error": f"Unknown service: {service}"}), 400 acquired = lock.acquire(blocking=False) if not acquired: return jsonify({"success": False, "error": f"{service} enrichment already in progress. Please wait."}), 429 try: results = {} try: result = _run_single_enrichment(service, entity_type, entity_id, name, artist_name) results[service] = result except Exception as e: results[service] = {"success": False, "error": str(e)} finally: lock.release() # Re-fetch updated data to return fresh state database = get_database() updated = database.get_artist_full_detail( data.get('artist_id', entity_id) if entity_type == 'artist' else _get_artist_id_for_entity(database, entity_type, entity_id) ) # Fix image URLs in updated data if updated.get('success'): if updated.get('artist', {}).get('thumb_url'): updated['artist']['thumb_url'] = fix_artist_image_url(updated['artist']['thumb_url']) for album in updated.get('albums', []): if album.get('thumb_url'): album['thumb_url'] = fix_artist_image_url(album['thumb_url']) return jsonify({ "success": True, "results": results, "updated_data": updated if updated.get('success') else None }) except Exception as e: logger.error(f"Error enriching entity: {e}") import traceback traceback.print_exc() return jsonify({"success": False, "error": str(e)}), 500 def _get_artist_id_for_entity(database, entity_type, entity_id): """Look up the artist_id for an album or track.""" try: with database._get_connection() as conn: cursor = conn.cursor() if entity_type == 'album': cursor.execute("SELECT artist_id FROM albums WHERE id = ?", (entity_id,)) elif entity_type == 'track': cursor.execute("SELECT artist_id FROM tracks WHERE id = ?", (entity_id,)) else: return entity_id row = cursor.fetchone() return row['artist_id'] if row else entity_id except Exception: return entity_id def _run_single_enrichment(service, entity_type, entity_id, name, artist_name): """Run a single enrichment service on a single entity.""" if service == 'audiodb': if not audiodb_worker: return {"success": False, "error": "AudioDB worker not initialized"} item = {'type': entity_type, 'id': entity_id, 'name': name} if entity_type in ('album', 'track'): item['artist'] = artist_name audiodb_worker._process_item(item) return {"success": True, "message": f"AudioDB lookup complete for {entity_type}"} elif service == 'deezer': if not deezer_worker: return {"success": False, "error": "Deezer worker not initialized"} item = {'type': entity_type, 'id': entity_id, 'name': name, 'artist': artist_name} if entity_type == 'artist': deezer_worker._process_artist(entity_id, name) elif entity_type == 'album': deezer_worker._process_album(entity_id, name, artist_name, item) elif entity_type == 'track': deezer_worker._process_track(entity_id, name, artist_name, item) return {"success": True, "message": f"Deezer lookup complete for {entity_type}"} elif service == 'musicbrainz': if not mb_worker: return {"success": False, "error": "MusicBrainz worker not initialized"} item = {'type': entity_type, 'id': entity_id, 'name': name} if entity_type in ('album', 'track'): item['artist'] = artist_name mb_worker._process_item(item) return {"success": True, "message": f"MusicBrainz lookup complete for {entity_type}"} elif service == 'spotify': if not spotify_enrichment_worker: return {"success": False, "error": "Spotify worker not initialized"} if entity_type == 'artist': spotify_enrichment_worker._process_artist({'type': 'artist', 'id': entity_id, 'name': name}) elif entity_type == 'album': spotify_enrichment_worker._process_album_individual({'type': 'album_individual', 'id': entity_id, 'name': name, 'artist': artist_name}) elif entity_type == 'track': spotify_enrichment_worker._process_track_individual({'type': 'track_individual', 'id': entity_id, 'name': name, 'artist': artist_name}) return {"success": True, "message": f"Spotify lookup complete for {entity_type}"} elif service == 'itunes': if not itunes_enrichment_worker: return {"success": False, "error": "iTunes worker not initialized"} if entity_type == 'artist': itunes_enrichment_worker._process_artist({'type': 'artist', 'id': entity_id, 'name': name}) elif entity_type == 'album': itunes_enrichment_worker._process_album_individual({'type': 'album_individual', 'id': entity_id, 'name': name, 'artist': artist_name}) elif entity_type == 'track': itunes_enrichment_worker._process_track_individual({'type': 'track_individual', 'id': entity_id, 'name': name, 'artist': artist_name}) return {"success": True, "message": f"iTunes lookup complete for {entity_type}"} elif service == 'lastfm': if not lastfm_worker: return {"success": False, "error": "Last.fm worker not initialized"} item = {'type': entity_type, 'id': entity_id, 'name': name} if entity_type in ('album', 'track'): item['artist'] = artist_name lastfm_worker._process_item(item) return {"success": True, "message": f"Last.fm lookup complete for {entity_type}"} elif service == 'genius': if not genius_worker: return {"success": False, "error": "Genius worker not initialized"} item = {'type': entity_type, 'id': entity_id, 'name': name} if entity_type == 'track': item['artist'] = artist_name elif entity_type == 'album': return {"success": False, "error": "Genius does not support album enrichment"} genius_worker._process_item(item) return {"success": True, "message": f"Genius lookup complete for {entity_type}"} elif service == 'tidal': if not tidal_enrichment_worker: return {"success": False, "error": "Tidal worker not initialized"} item = {'type': entity_type, 'id': entity_id, 'name': name, 'artist': artist_name} if entity_type == 'artist': tidal_enrichment_worker._process_artist(entity_id, name) elif entity_type == 'album': tidal_enrichment_worker._process_album(entity_id, name, artist_name, item) elif entity_type == 'track': tidal_enrichment_worker._process_track(entity_id, name, artist_name, item) return {"success": True, "message": f"Tidal lookup complete for {entity_type}"} elif service == 'qobuz': if not qobuz_enrichment_worker: return {"success": False, "error": "Qobuz worker not initialized"} item = {'type': entity_type, 'id': entity_id, 'name': name, 'artist': artist_name} if entity_type == 'artist': qobuz_enrichment_worker._process_artist(entity_id, name) elif entity_type == 'album': qobuz_enrichment_worker._process_album(entity_id, name, artist_name, item) elif entity_type == 'track': qobuz_enrichment_worker._process_track(entity_id, name, artist_name, item) return {"success": True, "message": f"Qobuz lookup complete for {entity_type}"} elif service == 'discogs': if not discogs_worker: return {"success": False, "error": "Discogs worker not initialized"} item = {'type': entity_type, 'id': entity_id, 'name': name} if entity_type in ('album', 'track'): item['artist'] = artist_name if entity_type == 'track': return {"success": False, "error": "Discogs does not support track-level enrichment"} discogs_worker._process_item(item) return {"success": True, "message": f"Discogs lookup complete for {entity_type}"} else: return {"success": False, "error": f"Unknown service: {service}"} @app.route('/api/library/search-service', methods=['POST']) def library_search_service(): """Search a specific service for matching entities (for manual matching). Body: { service: str, entity_type: str, query: str } Returns normalized list of results. """ try: data = request.get_json() if not data: return jsonify({"success": False, "error": "Invalid or missing JSON body"}), 400 service = data.get('service', '') entity_type = data.get('entity_type', '') query = data.get('query', '').strip() if not service or not entity_type or not query: return jsonify({"success": False, "error": "service, entity_type, and query are required"}), 400 results = _search_service(service, entity_type, query) return jsonify({"success": True, "results": results}) except Exception as e: logger.error(f"Error searching service: {e}") import traceback traceback.print_exc() return jsonify({"success": False, "error": str(e)}), 500 from core.library.service_search import ( _detect_provider, _search_service, init as _init_service_search, ) # Column name mappings for manual matching _SERVICE_ID_COLUMNS = { 'spotify': {'artist': 'spotify_artist_id', 'album': 'spotify_album_id', 'track': 'spotify_track_id'}, 'musicbrainz': {'artist': 'musicbrainz_id', 'album': 'musicbrainz_release_id', 'track': 'musicbrainz_recording_id'}, 'deezer': {'artist': 'deezer_id', 'album': 'deezer_id', 'track': 'deezer_id'}, 'audiodb': {'artist': 'audiodb_id', 'album': 'audiodb_id', 'track': 'audiodb_id'}, 'discogs': {'artist': 'discogs_id', 'album': 'discogs_id'}, 'itunes': {'artist': 'itunes_artist_id', 'album': 'itunes_album_id', 'track': 'itunes_track_id'}, 'lastfm': {'artist': 'lastfm_url', 'album': 'lastfm_url', 'track': 'lastfm_url'}, 'genius': {'artist': 'genius_id', 'track': 'genius_id'}, 'tidal': {'artist': 'tidal_id', 'album': 'tidal_id', 'track': 'tidal_id'}, 'qobuz': {'artist': 'qobuz_id', 'album': 'qobuz_id', 'track': 'qobuz_id'}, 'amazon': {'artist': 'amazon_id', 'album': 'amazon_id', 'track': 'amazon_id'}, } @app.route('/api/library/manual-match', methods=['PUT']) def library_manual_match(): """Manually set a service ID for an entity. Body: { entity_type: str, entity_id: str, service: str, service_id: str, artist_id: str } """ try: data = request.get_json() entity_type = data.get('entity_type') entity_id = data.get('entity_id') service = data.get('service') service_id = data.get('service_id') if not all([entity_type, entity_id, service, service_id]): return jsonify({"success": False, "error": "entity_type, entity_id, service, and service_id are required"}), 400 id_col = _SERVICE_ID_COLUMNS.get(service, {}).get(entity_type) if not id_col: return jsonify({"success": False, "error": "Invalid service/entity_type combination"}), 400 status_col = f"{service}_match_status" attempted_col = f"{service}_last_attempted" table = {'artist': 'artists', 'album': 'albums', 'track': 'tracks'}[entity_type] database = get_database() with database._get_connection() as conn: cursor = conn.cursor() cursor.execute(f""" UPDATE {table} SET {id_col} = ?, {status_col} = 'matched', {attempted_col} = CURRENT_TIMESTAMP WHERE id = ? """, (service_id, entity_id)) conn.commit() if cursor.rowcount == 0: return jsonify({"success": False, "error": "Entity not found"}), 404 # #758 — a manual ALBUM match also pins (and LOCKS) the canonical album # version to the chosen release, so the auto resolve job and every tool # that reads the canonical pin (track-number repair, reorganize, # missing-tracks) honor the user's edition instead of re-resolving back # to the deluxe. The lock survives future enrichment/resolution cycles. try: from core.metadata.canonical_version import should_pin_manual_canonical if should_pin_manual_canonical(entity_type, service): database.set_album_canonical(entity_id, service, service_id, 1.0, locked=True) except Exception as e: logger.warning("Manual canonical pin failed for album %s: %s", entity_id, e) # Re-fetch fresh data artist_id = data.get('artist_id', entity_id) if entity_type != 'artist': artist_id = _get_artist_id_for_entity(database, entity_type, entity_id) updated = database.get_artist_full_detail(artist_id) if updated.get('success'): if updated.get('artist', {}).get('thumb_url'): updated['artist']['thumb_url'] = fix_artist_image_url(updated['artist']['thumb_url']) for album in updated.get('albums', []): if album.get('thumb_url'): album['thumb_url'] = fix_artist_image_url(album['thumb_url']) return jsonify({ "success": True, "message": f"Manually matched {entity_type} to {service} ID: {service_id}", "updated_data": updated if updated.get('success') else None }) except Exception as e: logger.error(f"Error manual matching: {e}") @app.route('/api/library/clear-match', methods=['PUT']) @admin_only def library_clear_match(): """Clear a service ID match for an entity, reverting it to not_found. Body: { entity_type: str, entity_id: str, service: str } """ try: data = request.get_json() entity_type = data.get('entity_type') entity_id = data.get('entity_id') service = data.get('service') if not all([entity_type, entity_id, service]): return jsonify({"success": False, "error": "entity_type, entity_id, and service are required"}), 400 id_col = _SERVICE_ID_COLUMNS.get(service, {}).get(entity_type) if not id_col: return jsonify({"success": False, "error": "Invalid service/entity_type combination"}), 400 status_col = f"{service}_match_status" attempted_col = f"{service}_last_attempted" table = {'artist': 'artists', 'album': 'albums', 'track': 'tracks'}.get(entity_type) if not table: return jsonify({"success": False, "error": "Invalid entity_type"}), 400 database = get_database() with database._get_connection() as conn: cursor = conn.cursor() cursor.execute(f""" UPDATE {table} SET {id_col} = NULL, {status_col} = 'not_found', {attempted_col} = NULL WHERE id = ? """, (entity_id,)) conn.commit() if cursor.rowcount == 0: return jsonify({"success": False, "error": "Entity not found"}), 404 # Re-fetch fresh data artist_id = data.get('artist_id', entity_id) if entity_type != 'artist': artist_id = _get_artist_id_for_entity(database, entity_type, entity_id) updated = database.get_artist_full_detail(artist_id) if updated.get('success'): if updated.get('artist', {}).get('thumb_url'): updated['artist']['thumb_url'] = fix_artist_image_url(updated['artist']['thumb_url']) for album in updated.get('albums', []): if album.get('thumb_url'): album['thumb_url'] = fix_artist_image_url(album['thumb_url']) return jsonify({ "success": True, "message": f"Cleared {service} match for {entity_type}", "updated_data": updated if updated.get('success') else None }) except Exception as e: logger.error(f"Error clearing match: {e}") return jsonify({"success": False, "error": str(e)}), 500 import traceback traceback.print_exc() return jsonify({"success": False, "error": str(e)}), 500 @app.route('/api/library/album//import-existing-track', methods=['POST']) @app.route('/api/library/album//missing-track/import-existing', methods=['POST']) def library_import_existing_track_for_missing_slot(album_id): """Use an existing library file as source audio for a missing album slot. The selected source file is copied to staging, then routed through the normal post-processing pipeline with the target album/track metadata. The original source file is never moved or deleted. """ try: from core.library.missing_track_import import ( MissingTrackImportDeps, MissingTrackImportError, import_existing_track_for_album_slot, ) data = request.get_json() or {} database = get_database() deps = MissingTrackImportDeps( database=database, config_manager=config_manager, post_process_fn=_post_process_matched_download, resolve_library_file_path_fn=_resolve_library_file_path, docker_resolve_path_fn=docker_resolve_path, sync_tracks_to_server_fn=_sync_tracks_to_server, service_id_columns=_SERVICE_ID_COLUMNS, ) result = import_existing_track_for_album_slot(album_id, data, deps) updated = database.get_artist_full_detail(result.get('artist_id')) if updated.get('success'): if updated.get('artist', {}).get('thumb_url'): updated['artist']['thumb_url'] = fix_artist_image_url(updated['artist']['thumb_url']) for album in updated.get('albums', []): if album.get('thumb_url'): album['thumb_url'] = fix_artist_image_url(album['thumb_url']) return jsonify({ "success": True, "message": "Imported existing track into album", "track_id": result.get('track_id'), "final_path": result.get('final_path'), "updated_data": updated if updated.get('success') else None, }) except MissingTrackImportError as e: return jsonify({"success": False, "error": str(e)}), e.status_code except Exception as e: logger.error(f"Error importing existing track for album slot: {e}", exc_info=True) return jsonify({"success": False, "error": str(e)}), 500 @app.route('/api/library/track/', methods=['DELETE']) @admin_only def library_delete_track(track_id): """Delete a track from the database, optionally deleting the file and blacklisting the source.""" try: delete_file = request.args.get('delete_file', 'false').lower() == 'true' add_blacklist = request.args.get('blacklist', 'false').lower() == 'true' database = get_database() file_deleted = False blacklisted = False with database._get_connection() as conn: cursor = conn.cursor() # Get track info before deleting (for file removal + blacklist) track_info = None if delete_file or add_blacklist: cursor.execute(""" SELECT t.file_path, t.title, ar.name AS artist_name FROM tracks t JOIN artists ar ON t.artist_id = ar.id WHERE t.id = ? """, (track_id,)) track_info = cursor.fetchone() # Delete file from disk if requested file_error = None if delete_file and track_info and track_info['file_path']: resolved = _resolve_library_file_path(track_info['file_path']) if resolved and os.path.exists(resolved): try: os.remove(resolved) file_deleted = True logger.info(f"Deleted file from disk: {resolved}") # Clean up sidecar files (.lrc, .txt lyrics, cover.jpg) base_no_ext = os.path.splitext(resolved)[0] for sidecar_ext in ('.lrc', '.txt'): sidecar = base_no_ext + sidecar_ext if os.path.exists(sidecar): try: os.remove(sidecar) logger.info(f"Deleted sidecar file: {sidecar}") except Exception as e: logger.debug("sidecar removal failed: %s", e) except Exception as e: logger.warning(f"Failed to delete file: {e}") file_error = str(e) else: logger.warning(f"Could not resolve file path for deletion: {track_info['file_path']} (resolved={resolved})") file_error = _get_file_not_found_error(track_info['file_path']) # Add to blacklist if requested if add_blacklist and track_info and track_info['file_path']: # Extract username and filename from the file path for blacklisting # Soulseek paths are stored as: username/path/to/file.ext or just the local path fp = track_info['file_path'].replace('\\', '/') database.add_to_blacklist( track_title=track_info['title'], track_artist=track_info['artist_name'], blocked_filename=os.path.basename(fp), blocked_username='', # Local files don't have a username reason='user_rejected' ) blacklisted = True # Delete DB record cursor.execute("DELETE FROM tracks WHERE id = ?", (track_id,)) conn.commit() if cursor.rowcount == 0: return jsonify({"success": False, "error": "Track not found"}), 404 result = {"success": True, "deleted_count": cursor.rowcount, "file_deleted": file_deleted, "blacklisted": blacklisted} if delete_file and not file_deleted and file_error: result["file_error"] = file_error return jsonify(result) except Exception as e: logger.error(f"Error deleting track {track_id}: {e}") import traceback traceback.print_exc() return jsonify({"success": False, "error": str(e)}), 500 # ================================================================================== # DOWNLOAD BLACKLIST API # ================================================================================== @app.route('/api/library/blacklist', methods=['GET']) def get_blacklist(): """Get all blacklisted download sources.""" try: database = get_database() entries = database.get_blacklist(limit=200) return jsonify({"success": True, "entries": entries}) except Exception as e: return jsonify({"success": False, "error": str(e)}), 500 @app.route('/api/library/blacklist', methods=['POST']) def add_to_blacklist(): """Add a download source to the blacklist.""" try: data = request.get_json() database = get_database() result = database.add_to_blacklist( track_title=data.get('track_title', ''), track_artist=data.get('track_artist', ''), blocked_filename=data.get('blocked_filename', ''), blocked_username=data.get('blocked_username', ''), reason=data.get('reason', 'user_rejected'), ) return jsonify({"success": result}) except Exception as e: return jsonify({"success": False, "error": str(e)}), 500 @app.route('/api/library/blacklist/', methods=['DELETE']) def remove_from_blacklist(blacklist_id): """Remove an entry from the blacklist.""" try: database = get_database() result = database.remove_from_blacklist(blacklist_id) return jsonify({"success": result}) except Exception as e: return jsonify({"success": False, "error": str(e)}), 500 # ================================================================================== # TRACK SOURCE INFO & PROVENANCE # ================================================================================== @app.route('/api/library/track//source-info', methods=['GET']) def get_track_source_info(track_id): """Get download provenance info for a library track.""" try: database = get_database() downloads = database.get_track_downloads(str(track_id)) if not downloads: # Try matching by file path as fallback (exact, then filename suffix) conn = database._get_connection() cursor = conn.cursor() cursor.execute("SELECT file_path FROM tracks WHERE id = ?", (track_id,)) row = cursor.fetchone() conn.close() if row and row['file_path']: dl = database.get_download_by_file_path(row['file_path']) if dl: downloads = [dl] else: # Path format mismatch (e.g. Plex path vs local Windows path) — # fall back to filename-only match and back-link the track_id import os as _os fname = _os.path.basename(row['file_path'].replace('\\', '/')) if fname: dl = database.get_download_by_filename(fname, link_track_id=track_id) if dl: downloads = [dl] return jsonify({"success": True, "downloads": downloads}) except Exception as e: logger.error(f"Error getting track source info: {e}") return jsonify({"success": False, "error": str(e)}), 500 # ================================================================================== # TRACK REDOWNLOAD — Search metadata, search download sources, start redownload # ================================================================================== @app.route('/api/library/track//redownload/search-metadata', methods=['POST']) def redownload_search_metadata(track_id): """Search all available metadata sources for a track to find the correct version.""" try: database = get_database() conn = database._get_connection() cursor = conn.cursor() cursor.execute(""" SELECT t.id, t.title, t.file_path, t.bitrate, t.duration, ar.name AS artist_name, al.title AS album_title, al.thumb_url AS album_thumb_url, t.spotify_track_id, t.deezer_id FROM tracks t JOIN artists ar ON t.artist_id = ar.id JOIN albums al ON t.album_id = al.id WHERE t.id = ? """, (track_id,)) row = cursor.fetchone() conn.close() if not row: return jsonify({"success": False, "error": "Track not found"}), 404 track_title = row['title'] artist_name = row['artist_name'] # Clean the title for better search results — strip version/edition suffixes import re as _re clean_title = _re.sub(r'\s*[\(\[](single version|album version|remaster|deluxe|bonus|explicit|clean|radio edit)[\)\]]', '', track_title, flags=_re.IGNORECASE).strip() query = f"{artist_name} {clean_title}" # Resolve file info file_path = row['file_path'] or '' ext = os.path.splitext(file_path)[1].lstrip('.').upper() if file_path else '' fmt = ext if ext in ('FLAC', 'MP3', 'OPUS', 'OGG', 'M4A', 'WAV') else '' # Resolve album thumb URL (may be relative Plex path) thumb_url = row['album_thumb_url'] or '' if thumb_url and not thumb_url.startswith('http'): _ab = '' _at = '' if media_server_engine.client('plex') and media_server_engine.client('plex').server: _ab = getattr(media_server_engine.client('plex').server, '_baseurl', '') or '' _at = getattr(media_server_engine.client('plex').server, '_token', '') or '' if not _ab: _pc = config_manager.get_plex_config() _ab = (_pc.get('base_url', '') or '').rstrip('/') _at = _at or _pc.get('token', '') if _ab and thumb_url.startswith('/'): thumb_url = f"{_ab}{thumb_url}?X-Plex-Token={_at}" if _at else f"{_ab}{thumb_url}" current_track = { 'id': row['id'], 'title': track_title, 'artist': artist_name, 'album': row['album_title'], 'duration_ms': row['duration'] or 0, 'file_path': file_path, 'format': fmt, 'bitrate': row['bitrate'] or 0, 'spotify_track_id': row['spotify_track_id'], 'deezer_id': row['deezer_id'], 'thumb_url': thumb_url, } # Search all available metadata sources in parallel via the # shared module — same code path the Enhance Quality flow uses # so both endpoints have identical scoring + per-source query # optimization + current-match flagging. from core.metadata.multi_source_search import ( TrackQuery, search_all_sources, ) sources_to_search = [] if spotify_client and spotify_client.is_authenticated(): sources_to_search.append(('spotify', spotify_client)) try: sources_to_search.append(('itunes', _get_itunes_client())) except Exception as e: logger.debug(f"iTunes client not available for redownload search: {e}") try: sources_to_search.append(('deezer', _get_deezer_client())) except Exception as e: logger.debug(f"Deezer client not available for redownload search: {e}") track_query = TrackQuery( title=track_title, artist=artist_name, album=row['album_title'] or '', duration_ms=row['duration'] or 0, spotify_track_id=row['spotify_track_id'], deezer_id=row['deezer_id'], ) result = search_all_sources( track_query, sources_to_search, clean_title=clean_title, ) return jsonify({ "success": True, "current_track": current_track, "metadata_results": result.metadata_results, "best_match": result.best_match, }) except Exception as e: logger.error(f"Error in redownload metadata search: {e}", exc_info=True) return jsonify({"success": False, "error": str(e)}), 500 @app.route('/api/library/track//redownload/search-sources', methods=['POST']) def redownload_search_sources(track_id): """Search all active download sources for a track using the selected metadata.""" try: data = request.get_json() metadata = data.get('metadata', {}) if not metadata.get('name'): return jsonify({"success": False, "error": "metadata with name required"}), 400 # Build a track-like object for query generation from core.itunes_client import Track as MetaTrack track_obj = MetaTrack( id=metadata.get('id', ''), name=metadata['name'], artists=[metadata.get('artist', '')], album=metadata.get('album', ''), duration_ms=metadata.get('duration_ms', 0), popularity=0, ) # Generate search queries search_queries = matching_engine.generate_download_queries(track_obj) if not search_queries: search_queries = [f"{metadata.get('artist', '')} {metadata['name']}".strip()] # Use first 2 queries for speed search_queries = search_queries[:2] # Search ALL configured download sources individually (not through hybrid which stops at first hit) candidates = [] database = get_database() # Get all available download source clients via the orchestrator's # generic accessor — replaces the old per-source if/hasattr chain # that Cin called out as defeating the purpose of the registry refactor. download_clients = {} try: if download_orchestrator and hasattr(download_orchestrator, 'configured_clients'): download_clients = dict(download_orchestrator.configured_clients()) except Exception as e: logger.warning(f"[Redownload] Error getting download clients: {e}") if not download_clients: # Fallback: use orchestrator directly download_clients = {'default': download_orchestrator} logger.info(f"[Redownload] Streaming search across {len(download_clients)} sources: {list(download_clients.keys())}") def _search_one_source(source_name, client): """Search a single download source and return formatted candidates.""" source_candidates = [] for _qi, q in enumerate(search_queries): try: tracks_result, _ = run_async(client.search(q, timeout=20)) if not tracks_result: continue valid = get_valid_candidates(tracks_result, track_obj, q) for candidate in valid: is_bl = database.is_blacklisted(candidate.username, candidate.filename) display_name = os.path.basename(candidate.filename.replace('\\', '/')) ext = os.path.splitext(display_name)[1].lstrip('.').upper() quality = ext if ext in ('FLAC', 'MP3', 'OPUS', 'OGG', 'M4A', 'WAV') else candidate.quality or '' svc = source_name if source_name != 'default' else 'hybrid' uname = candidate.username if uname in ('youtube', 'tidal', 'qobuz', 'hifi', 'deezer_dl', 'lidarr', 'soundcloud', 'amazon'): svc = uname source_candidates.append({ 'username': uname, 'filename': candidate.filename, 'display_name': display_name, 'size': candidate.size or 0, 'size_display': f"{(candidate.size or 0) / 1048576:.1f} MB", 'bitrate': candidate.bitrate or 0, 'quality': quality, 'duration': candidate.duration or 0, 'confidence': round(getattr(candidate, 'confidence', 0), 3), 'source_service': svc, 'source_query': q, 'blacklisted': is_bl, 'free_upload_slots': getattr(candidate, 'free_upload_slots', 0), 'upload_speed': getattr(candidate, 'upload_speed', 0), 'queue_length': getattr(candidate, 'queue_length', 0), }) except Exception as e: logger.debug(f"[Redownload] {source_name} search failed for query '{q}': {e}") # Deduplicate within source seen = set() unique = [] for c in source_candidates: key = f"{c['username']}|{c['filename']}" if key not in seen: seen.add(key) unique.append(c) unique.sort(key=lambda c: (-int(not c['blacklisted']), -c['confidence'])) return unique # Stream NDJSON — one line per source as it completes from concurrent.futures import ThreadPoolExecutor, as_completed def generate_stream(): with ThreadPoolExecutor(max_workers=4) as pool: futures = {pool.submit(_search_one_source, name, client): name for name, client in download_clients.items()} for future in as_completed(futures): source_name = futures[future] try: results = future.result() yield json.dumps({'source': source_name, 'candidates': results}) + '\n' except Exception as e: yield json.dumps({'source': source_name, 'candidates': [], 'error': str(e)}) + '\n' yield json.dumps({'done': True}) + '\n' return app.response_class(generate_stream(), mimetype='application/x-ndjson', headers={'X-Accel-Buffering': 'no'}) except Exception as e: logger.error(f"Error in redownload source search: {e}", exc_info=True) return jsonify({"success": False, "error": str(e)}), 500 from core.library.redownload import ( redownload_start as _redownload_start_impl, init as _init_redownload, ) @app.route('/api/library/track//redownload/start', methods=['POST']) def redownload_start(track_id): return _redownload_start_impl(track_id) @app.route('/api/library/artist//sync', methods=['POST']) @admin_only def sync_artist_library(artist_id): """Bidirectional sync: pull new content from media server AND remove stale entries.""" try: database = get_database() with database._get_connection() as conn: cursor = conn.cursor() # Resolve artist_id: could be a DB integer ID or a source artist ID (Spotify/iTunes/Deezer) db_artist_id = None # Try direct ID match first (works for both integer and text IDs) cursor.execute("SELECT id FROM artists WHERE id = ?", (artist_id,)) row = cursor.fetchone() if row: db_artist_id = row['id'] # Also try as integer (legacy integer PKs) if not db_artist_id: try: candidate = int(artist_id) cursor.execute("SELECT id FROM artists WHERE id = ?", (candidate,)) row = cursor.fetchone() if row: db_artist_id = row['id'] except (ValueError, TypeError): pass # Try source-specific ID columns if not db_artist_id: for col in ('spotify_artist_id', 'itunes_artist_id', 'deezer_id', 'discogs_id'): cursor.execute(f"SELECT id FROM artists WHERE {col} = ?", (artist_id,)) row = cursor.fetchone() if row: db_artist_id = row['id'] break if not db_artist_id: return jsonify({"success": False, "error": "Artist not found"}), 404 cursor.execute("SELECT name, server_source FROM artists WHERE id = ?", (db_artist_id,)) artist_row = cursor.fetchone() artist_name = artist_row['name'] if artist_row else f'ID {db_artist_id}' server_source = artist_row['server_source'] if artist_row else None # ── Phase 1: Pull new content from media server ── new_albums = 0 new_tracks = 0 name_updated = False # Single-artist deep scan: collect the server track IDs we see during the # pull. Stale removal (Phase 2) is a server-diff against this set — the SAME # mechanism the whole-library deep scan uses, just scoped to one artist. seen_track_ids = set() pull_succeeded = False if server_source: media_client = None if server_source == 'plex' and media_server_engine.client('plex') and media_server_engine.client('plex').server: media_client = media_server_engine.client('plex') elif server_source == 'jellyfin' and media_server_engine.client('jellyfin'): media_client = media_server_engine.client('jellyfin') elif server_source == 'navidrome' and media_server_engine.client('navidrome'): media_client = media_server_engine.client('navidrome') if media_client: try: from core.database_update_worker import DatabaseUpdateWorker worker = DatabaseUpdateWorker( media_client=media_client, full_refresh=False, server_type=server_source, force_sequential=True, ) worker.database = database # Use existing DB instance instead of creating new one # Fetch the artist object from the server server_artist = None logger.info(f"[Artist Sync] Fetching artist {db_artist_id} from {server_source}...") if server_source == 'plex' and hasattr(media_client, 'server'): try: server_artist = media_client.server.fetchItem(int(db_artist_id)) logger.info(f"[Artist Sync] Plex returned: {getattr(server_artist, 'title', 'None')}") except Exception as e: logger.error(f"[Artist Sync] Plex fetchItem failed: {e}") elif hasattr(media_client, 'get_artist_by_id'): try: server_artist = media_client.get_artist_by_id(str(db_artist_id)) logger.info(f"[Artist Sync] Server returned: {getattr(server_artist, 'title', None) or server_artist}") except Exception as e: logger.error(f"[Artist Sync] get_artist_by_id failed: {e}") else: logger.warning(f"[Artist Sync] No get_artist_by_id method on {type(media_client).__name__}") if not server_artist: logger.error("[Artist Sync] Could not fetch artist from server — skipping pull phase") if server_artist: # Check for name change new_name = getattr(server_artist, 'title', None) if new_name and new_name != artist_name: database.execute_query( "UPDATE artists SET name = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?", (new_name, db_artist_id) ) logger.info(f"[Artist Sync] Name updated: '{artist_name}' → '{new_name}'") artist_name = new_name name_updated = True # Process artist content (deep scan mode — skip existing, # preserve enrichment) and collect the server's track IDs # for this artist into seen_track_ids. success, details, new_albums, new_tracks = worker._process_artist_with_content( server_artist, skip_existing_tracks=True, seen_track_ids=seen_track_ids ) # Only a successful pull gives a trustworthy 'seen' set; a # failure/partial would make every track look stale. pull_succeeded = bool(success) logger.info(f"[Artist Sync] Server pull for {artist_name}: {details}") except Exception as e: logger.error(f"[Artist Sync] Server pull failed for {artist_name}: {e}") # ── Phase 2: Remove stale entries (tracks the server no longer has) ── # Server-diff, exactly like the whole-library deep scan: stale = this # artist's DB tracks that were NOT seen on the server during the pull. stale_removed = 0 empty_albums_removed = 0 removal_skipped = False if not pull_succeeded: # No trustworthy server view (no server configured, unreachable, or the # pull failed) — without it we can't tell stale from "server was down", # so we remove nothing rather than risk wiping the artist. removal_skipped = True logger.info(f"[Artist Sync] {artist_name}: server pull unavailable — skipping stale removal") else: with database._get_connection() as conn: cursor = conn.cursor() cursor.execute( "SELECT id FROM tracks WHERE artist_id = ? AND server_source = ?", (db_artist_id, server_source), ) artist_track_ids = {row['id'] for row in cursor.fetchall()} stale = artist_track_ids - seen_track_ids # Same safety net as deep scan (#828): if an implausibly large share # of the artist's tracks went unseen, treat it as a flaky server # response and skip rather than mass-delete. from core.library.stale_guard import is_implausible_stale_removal if is_implausible_stale_removal(len(stale), len(artist_track_ids)): removal_skipped = True logger.warning( f"[Artist Sync] {artist_name}: {len(stale)}/{len(artist_track_ids)} tracks " f"unseen on server — skipping stale removal (likely a flaky response)" ) elif stale: stale_removed = database.delete_stale_tracks(stale, server_source) if not removal_skipped: with database._get_connection() as conn: cursor = conn.cursor() # Prune albums left with no tracks. ``album_id IS NOT NULL`` # avoids the NOT IN-with-NULL gotcha that would otherwise no-op # this whenever a track has a null album_id. cursor.execute(""" DELETE FROM albums WHERE artist_id = ? AND id NOT IN (SELECT DISTINCT album_id FROM tracks WHERE album_id IS NOT NULL) """, (db_artist_id,)) empty_albums_removed = cursor.rowcount cursor.execute(""" UPDATE albums SET track_count = ( SELECT COUNT(*) FROM tracks WHERE tracks.album_id = albums.id ) WHERE artist_id = ? """, (db_artist_id,)) conn.commit() logger.warning(f"[Artist Sync] {artist_name}: +{new_albums} albums, +{new_tracks} tracks, " f"-{stale_removed} stale, -{empty_albums_removed} empty albums" f"{' (removal skipped — storage unreachable)' if removal_skipped else ''}") return jsonify({ "success": True, "artist_name": artist_name, "name_updated": name_updated, "new_albums": new_albums, "new_tracks": new_tracks, "stale_removed": stale_removed, "empty_albums_removed": empty_albums_removed, "removal_skipped": removal_skipped, }) except Exception as e: logger.error(f"Error syncing artist {artist_id}: {e}") import traceback traceback.print_exc() return jsonify({"success": False, "error": str(e)}), 500 @app.route('/api/library/album/', methods=['DELETE']) @admin_only def library_delete_album(album_id): """Delete an album and all its tracks from the database, optionally deleting files on disk.""" try: delete_files = request.args.get('delete_files', 'false').lower() == 'true' database = get_database() files_deleted = 0 files_failed = 0 with database._get_connection() as conn: cursor = conn.cursor() # If deleting files, resolve and remove each track's file first if delete_files: cursor.execute("SELECT id, file_path FROM tracks WHERE album_id = ?", (album_id,)) track_rows = cursor.fetchall() for row in track_rows: fp = row['file_path'] if not fp: continue resolved = _resolve_library_file_path(fp) if resolved and os.path.exists(resolved): try: os.remove(resolved) files_deleted += 1 # Clean up sidecar files (.lrc, .txt lyrics) base_no_ext = os.path.splitext(resolved)[0] for sidecar_ext in ('.lrc', '.txt'): sidecar = base_no_ext + sidecar_ext if os.path.exists(sidecar): try: os.remove(sidecar) except Exception as e: logger.debug("sidecar removal failed: %s", e) except Exception as e: logger.warning(f"Failed to delete track file: {e}") files_failed += 1 else: files_failed += 1 # Try to remove the album folder if it's now empty if track_rows: first_fp = track_rows[0]['file_path'] if first_fp: resolved_first = _resolve_library_file_path(first_fp) if resolved_first: album_dir = os.path.dirname(resolved_first) try: if os.path.isdir(album_dir) and not os.listdir(album_dir): os.rmdir(album_dir) logger.info(f"Removed empty album directory: {album_dir}") except Exception as e: logger.debug("empty album dir cleanup failed: %s", e) # Delete all tracks belonging to this album cursor.execute("DELETE FROM tracks WHERE album_id = ?", (album_id,)) tracks_deleted = cursor.rowcount # Delete the album itself cursor.execute("DELETE FROM albums WHERE id = ?", (album_id,)) if cursor.rowcount == 0: conn.rollback() return jsonify({"success": False, "error": "Album not found"}), 404 conn.commit() return jsonify({ "success": True, "deleted_count": 1, "tracks_deleted": tracks_deleted, "files_deleted": files_deleted, "files_failed": files_failed }) except Exception as e: logger.error(f"Error deleting album {album_id}: {e}") import traceback traceback.print_exc() return jsonify({"success": False, "error": str(e)}), 500 @app.route('/api/library/tracks/delete-batch', methods=['POST']) @admin_only def library_delete_tracks_batch(): """Delete multiple track records from the database (does NOT delete files on disk). Body: { track_ids: [int] } """ try: data = request.get_json() track_ids = data.get('track_ids', []) if not track_ids or not isinstance(track_ids, list): return jsonify({"success": False, "error": "track_ids array is required"}), 400 database = get_database() with database._get_connection() as conn: cursor = conn.cursor() placeholders = ','.join('?' for _ in track_ids) cursor.execute(f"DELETE FROM tracks WHERE id IN ({placeholders})", track_ids) conn.commit() return jsonify({"success": True, "deleted_count": cursor.rowcount}) except Exception as e: logger.error(f"Error batch deleting tracks: {e}") import traceback traceback.print_exc() return jsonify({"success": False, "error": str(e)}), 500 @app.route('/api/library/radio') def library_radio(): """Get a smart queue of similar tracks for radio mode auto-play.""" try: track_id = request.args.get('track_id') if not track_id: return jsonify({"success": False, "error": "track_id is required"}), 400 limit = request.args.get('limit', 20, type=int) exclude_raw = request.args.get('exclude', '') exclude_ids = [eid.strip() for eid in exclude_raw.split(',') if eid.strip()] if exclude_raw else None database = get_database() result = database.get_radio_tracks(track_id, limit=limit, exclude_ids=exclude_ids) if not result.get('success'): return jsonify(result), 404 # Fix image URLs (DB stores server-relative paths that need base URL + auth) for track in result.get('tracks', []): if track.get('image_url'): track['image_url'] = fix_artist_image_url(track['image_url']) return jsonify(result) except Exception as e: logger.error(f"Error getting radio tracks: {e}") import traceback traceback.print_exc() return jsonify({"success": False, "error": str(e)}), 500 # ==================== End Enhanced Library Management ==================== @app.route('/api/stream/start', methods=['POST']) def stream_start(): """Start streaming a track in the background (per-listener session).""" global stream_background_task data = request.get_json() if not data: return jsonify({"success": False, "error": "No track data provided"}), 400 logger.info(f"Web UI Stream request for: {data.get('filename')}") try: sid = _stream_session_id() sess = stream_state_store.get(sid) # Stop only THIS listener's existing task — others keep playing. prev = stream_tasks.get(sid) if prev and not prev.done(): prev.cancel() with sess.lock: sess.update({ "status": "stopped", "progress": 0, "track_info": None, "file_path": None, "error_message": None, "is_library": False }) # Start new background streaming task for this session. fut = stream_executor.submit(_prepare_stream_task, data, sess, sid) stream_tasks[sid] = fut if sid == _DEFAULT_STREAM_SESSION: stream_background_task = fut # keep legacy alias in sync return jsonify({"success": True, "message": "Streaming started"}) except Exception as e: logger.error(f"Error starting stream: {e}") return jsonify({"success": False, "error": str(e)}), 500 @app.route('/api/stream/status') def stream_status(): """Get current streaming status and progress for THIS listener.""" try: sess = _current_stream_state() with sess.lock: return jsonify({ "status": sess["status"], "progress": sess["progress"], "track_info": sess["track_info"], "error_message": sess["error_message"] }) except Exception as e: logger.error(f"Error getting stream status: {e}") return jsonify({ "status": "error", "progress": 0, "track_info": None, "error_message": str(e) }), 500 _AUDIO_MIME_TYPES = { '.mp3': 'audio/mpeg', '.flac': 'audio/flac', '.ogg': 'audio/ogg', '.aac': 'audio/aac', '.m4a': 'audio/mp4', '.wav': 'audio/wav', '.opus': 'audio/ogg', '.webm': 'audio/webm', '.wma': 'audio/x-ms-wma', } def _proxy_stream_url_with_range(stream_url): """Proxy a media-server stream URL to the browser, forwarding the Range header so HTML5 seeking works (#809 — Navidrome/Subsonic playback without a disk mount). Streams the upstream response body through without buffering the whole file.""" upstream_headers = {} range_header = request.headers.get('Range') if range_header: upstream_headers['Range'] = range_header try: upstream = requests.get(stream_url, headers=upstream_headers, stream=True, timeout=30) except Exception as e: logger.error(f"Stream proxy upstream error: {e}") return jsonify({"error": "Upstream stream failed"}), 502 # Pass through the bytes + the headers a media player needs for seeking. passthrough = {} for h in ('Content-Type', 'Content-Length', 'Content-Range', 'Accept-Ranges'): if h in upstream.headers: passthrough[h] = upstream.headers[h] passthrough.setdefault('Accept-Ranges', 'bytes') def _gen(): try: for chunk in upstream.iter_content(chunk_size=64 * 1024): if chunk: yield chunk finally: upstream.close() return Response(_gen(), status=upstream.status_code, headers=passthrough) def _serve_audio_file_with_range(file_path, mimetype_override=None): """Serve an on-disk audio file with HTTP range support (HTML5 seeking). Shared by /stream/audio (current track) and /stream/library-audio (the crossfade pre-loader, which plays the NEXT track on a second