diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 2f948a99..9ffbb51a 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -9,9 +9,9 @@ on: workflow_dispatch: inputs: version_tag: - description: 'Version tag (e.g. 2.7.1)' + description: 'Version tag (e.g. 2.7.2)' required: true - default: '2.7.1' + default: '2.7.2' jobs: build-and-push: diff --git a/config/settings.py b/config/settings.py index a522d4a7..f1482081 100644 --- a/config/settings.py +++ b/config/settings.py @@ -508,6 +508,22 @@ class ConfigManager: # its partial data, fail the download so the next source can # try) or "pause" (pause in the client, leave for the user). "torrent_stall_action": "abandon", + # Where THIS container can read completed torrent/usenet + # downloads (#857). The downloader (qBit/SAB) reports a save + # path from inside ITS OWN container — often a category folder + # like /data/downloads/music — which may be mounted at a + # different point here. Set these to the in-container path(s) + # where SoulSync sees those finished downloads; the resolver + # then finds the release by name under them. Empty = fall back + # to the soulseek download/transfer dirs (the shared-volume + # default). See core.download_plugins.album_bundle.resolve_reported_save_path. + "torrent_download_path": "", + "usenet_download_path": "", + # Explicit remote→local prefix mappings for non-shared / oddly + # mounted layouts (Sonarr/Radarr "Remote Path Mapping" style): + # a list of {"from": "", "to": ""}. + # Tried before the basename fallback above. + "usenet_path_mappings": [], }, "post_processing": { # When a download is quarantined (AcoustID mismatch, integrity / @@ -696,6 +712,17 @@ class ConfigManager: "enabled": False, "entry_base_path": "" }, + "playlists": { + # Where "Organize by playlist" materializes playlist folders. + # MUST be a separate root from the music library so the media + # server (and the maintenance jobs) never scan it — otherwise the + # same track would show up twice. Mapped separately for Docker. + "materialize_path": "./Playlists", + # "symlink" (relative links, ~zero disk) or "copy" (real + # duplicates for FAT/USB/DAPs that can't follow links). Symlink + # auto-falls back to copy when the filesystem can't link. + "materialize_mode": "symlink" + }, "youtube": { "cookies_browser": "", # "", "chrome", "firefox", "edge", "brave", "opera", "safari" "download_delay": 3, # seconds between sequential downloads diff --git a/core/automation/blocks.py b/core/automation/blocks.py index 2697af26..ecd88cd4 100644 --- a/core/automation/blocks.py +++ b/core/automation/blocks.py @@ -171,12 +171,7 @@ ACTIONS: list[dict] = [ {"type": "update_discovery_pool", "label": "Update Discovery", "icon": "compass", "description": "Refresh discovery pool with new tracks", "available": True}, {"type": "start_quality_scan", "label": "Run Quality Scan", "icon": "bar-chart", - "description": "Scan for low-quality audio files", "available": True, - "config_fields": [ - {"key": "scope", "type": "select", "label": "Scope", - "options": [{"value": "watchlist", "label": "Watchlist Artists"}, {"value": "library", "label": "Full Library"}], - "default": "watchlist"} - ]}, + "description": "Run the Quality Upgrade Finder (scope is set in Library Maintenance)", "available": True}, {"type": "backup_database", "label": "Backup Database", "icon": "save", "description": "Create timestamped database backup", "available": True}, {"type": "refresh_beatport_cache", "label": "Refresh Beatport Cache", "icon": "music", diff --git a/core/automation/deps.py b/core/automation/deps.py index 09c52a17..fb45bb42 100644 --- a/core/automation/deps.py +++ b/core/automation/deps.py @@ -123,10 +123,10 @@ class AutomationDeps: duplicate_cleaner_lock: Any duplicate_cleaner_executor: Any run_duplicate_cleaner: Callable[..., Any] - get_quality_scanner_state: Callable[[], dict] - quality_scanner_lock: Any - quality_scanner_executor: Any - run_quality_scanner: Callable[..., Any] + # Triggers a "Run Now" of a library-maintenance repair job by id (e.g. + # 'quality_upgrade'). Returns truthy if the job was queued. Replaces the old + # standalone quality-scanner executor/state (the scanner is now a repair job). + run_repair_job_now: Callable[[str], Any] # --- Download orchestrator + queue accessors --- download_orchestrator: Any diff --git a/core/automation/handlers/quality_scanner.py b/core/automation/handlers/quality_scanner.py index 69ec3f02..13f71ed9 100644 --- a/core/automation/handlers/quality_scanner.py +++ b/core/automation/handlers/quality_scanner.py @@ -1,83 +1,35 @@ """Automation handler: ``start_quality_scan`` action. -Lifted from ``web_server._register_automation_handlers`` (the -``_auto_start_quality_scan`` closure). Submits the quality scanner -to its executor with the configured scope (default: ``watchlist``) -then polls the shared state dict. +The quality scanner was redesigned from an auto-acting tool into the +``quality_upgrade`` library-maintenance repair job (findings-based, reviewed +before anything is wishlisted). This action now simply triggers a "Run Now" of +that job; its progress and findings surface in Library Maintenance. The action +name is kept so existing automation rules keep working. """ from __future__ import annotations -import time from typing import Any, Dict from core.automation.deps import AutomationDeps -_TIMEOUT_SECONDS = 7200 # 2 hours -_POLL_INTERVAL_SECONDS = 3 -_INITIAL_DELAY_SECONDS = 1 - - def auto_start_quality_scan(config: Dict[str, Any], deps: AutomationDeps) -> Dict[str, Any]: automation_id = config.get('_automation_id') - state = deps.get_quality_scanner_state() - if state.get('status') == 'running': - return {'status': 'skipped', 'reason': 'Quality scan already running'} - scope = config.get('scope', 'watchlist') - # Pre-set status before submit so the polling loop doesn't see a - # stale 'finished' from a previous run. - with deps.quality_scanner_lock: - state['status'] = 'running' - deps.quality_scanner_executor.submit(deps.run_quality_scanner, scope, deps.get_current_profile_id()) - deps.update_progress( - automation_id, log_line=f'Quality scan started (scope: {scope})', log_type='info', - ) - - # Monitor progress (max 2 hours). - time.sleep(_INITIAL_DELAY_SECONDS) - poll_start = time.time() - while time.time() - poll_start < _TIMEOUT_SECONDS: - time.sleep(_POLL_INTERVAL_SECONDS) - current_status = state.get('status', 'idle') - if current_status not in ('running',): - break + triggered = deps.run_repair_job_now('quality_upgrade') + if not triggered: deps.update_progress( - automation_id, - phase=state.get('phase', 'Scanning...'), - progress=state.get('progress', 0), - processed=state.get('processed', 0), - total=state.get('total', 0), - ) - else: - deps.update_progress( - automation_id, status='error', - phase='Timed out', log_line='Quality scan timed out after 2 hours', + automation_id, status='error', phase='Unavailable', + log_line='Quality Upgrade job could not be triggered (library worker unavailable)', log_type='error', ) - return {'status': 'error', 'reason': 'Timed out', '_manages_own_progress': True} + return {'status': 'error', 'reason': 'library worker unavailable', + '_manages_own_progress': True} - final_status = state.get('status', 'idle') - if final_status == 'error': - err = state.get('error_message', 'Unknown error') - deps.update_progress( - automation_id, status='error', progress=100, - phase='Error', log_line=err, log_type='error', - ) - return {'status': 'error', 'reason': err, '_manages_own_progress': True} - - issues = state.get('low_quality', 0) deps.update_progress( - automation_id, status='finished', progress=100, - phase='Complete', - log_line=f'Quality scan complete — {issues} issues found', + automation_id, status='finished', progress=100, phase='Triggered', + log_line='Quality Upgrade scan queued — findings appear in Library Maintenance', log_type='success', ) - return { - 'status': 'completed', 'scope': scope, '_manages_own_progress': True, - 'tracks_scanned': state.get('processed', 0), - 'quality_met': state.get('quality_met', 0), - 'low_quality': issues, - 'matched': state.get('matched', 0), - } + return {'status': 'completed', 'triggered': True, '_manages_own_progress': True} diff --git a/core/automation/handlers/refresh_mirrored.py b/core/automation/handlers/refresh_mirrored.py index 323b1205..0f1de31e 100644 --- a/core/automation/handlers/refresh_mirrored.py +++ b/core/automation/handlers/refresh_mirrored.py @@ -292,6 +292,18 @@ def _commit_refresh( image_url=pl.get('image_url'), ) + # Membership just changed — if this playlist is organize-by-playlist, rebuild + # its folder (with prune) so a track that LEFT the playlist has its symlink + # cleaned up now. Gated to organized playlists, non-fatal — never disturbs + # the refresh. (Additions are handled by the post-download reconcile.) + try: + from core.playlists.materialize_service import rebuild_mirrored_playlist_if_organized + rebuild_mirrored_playlist_if_organized( + db, deps.config_manager, pl.get('id'), profile_id=pl.get('profile_id', 1) + ) + except Exception as _mat_err: + deps.logger.debug(f"[Playlist Folder] mirror-refresh cleanup skipped: {_mat_err}") + if old_ids != new_ids: added = len(new_ids - old_ids) removed = len(old_ids - new_ids) diff --git a/core/automation/handlers/registration.py b/core/automation/handlers/registration.py index 94f23db6..3afbe4df 100644 --- a/core/automation/handlers/registration.py +++ b/core/automation/handlers/registration.py @@ -136,7 +136,7 @@ def register_all(deps: AutomationDeps) -> None: engine.register_action_handler( 'start_quality_scan', lambda config: auto_start_quality_scan(config, deps), - lambda: deps.get_quality_scanner_state().get('status') == 'running', + lambda: False, # repair worker dedupes Run-Now requests itself ) engine.register_action_handler( 'backup_database', diff --git a/core/automation/handlers/sync_playlist.py b/core/automation/handlers/sync_playlist.py index 509c25ef..854d44d3 100644 --- a/core/automation/handlers/sync_playlist.py +++ b/core/automation/handlers/sync_playlist.py @@ -203,10 +203,15 @@ def auto_sync_playlist(config: Dict[str, Any], deps: AutomationDeps) -> Dict[str except Exception as e: deps.logger.debug("mirror sync last-status read: %s", e) + # Sync under the user's custom alias when set, else the upstream name (#865 + # follow-up). The server-side playlist is named with this. + from core.playlists.naming import effective_mirrored_name + sync_name = effective_mirrored_name(pl) or pl.get('name') or 'Playlist' + deps.update_progress( auto_id, progress=50, - phase=f'Syncing "{pl["name"]}"', + phase=f'Syncing "{sync_name}"', log_line=f'{len(tracks_json)} discovered, {skipped_count} skipped', log_type='info', ) @@ -221,14 +226,14 @@ def auto_sync_playlist(config: Dict[str, Any], deps: AutomationDeps) -> Dict[str skip_wishlist_add = bool(pl.get('organize_by_playlist')) threading.Thread( target=deps.run_sync_task, - args=(sync_id, pl['name'], tracks_json, auto_id, 1, pl.get('image_url', '')), + args=(sync_id, sync_name, tracks_json, auto_id, 1, pl.get('image_url', '')), kwargs={'skip_wishlist_add': skip_wishlist_add}, daemon=True, name=f'auto-sync-{playlist_id}', ).start() return { 'status': 'started', - 'playlist_name': pl['name'], + 'playlist_name': sync_name, 'discovered_tracks': str(len(tracks_json)), 'skipped_tracks': str(skipped_count), '_manages_own_progress': True, diff --git a/core/connection_test.py b/core/connection_test.py index 745caa2a..f65a6fe2 100644 --- a/core/connection_test.py +++ b/core/connection_test.py @@ -82,7 +82,16 @@ def run_service_test(service, test_config): if temp_client.is_spotify_authenticated(): return True, "Spotify connection successful!" else: - # Using fallback metadata source + # Spotify-Free (no-auth) metadata path: officially unauthenticated, + # but the no-creds source is selected and available. Report it as the + # working source rather than the generic Deezer/Discogs/iTunes fallback. + try: + spotify_free_available = temp_client.is_spotify_metadata_available() + except Exception: + spotify_free_available = False + if spotify_free_available: + return True, "Spotify (no-auth) connection successful!" + # Using a different fallback metadata source fb_src = _get_metadata_fallback_source() fallback_name = 'Deezer' if fb_src == 'deezer' else 'Discogs' if fb_src == 'discogs' else 'iTunes' if spotify_configured: diff --git a/core/database_update_health.py b/core/database_update_health.py new file mode 100644 index 00000000..83b4fffb --- /dev/null +++ b/core/database_update_health.py @@ -0,0 +1,82 @@ +"""Stall detection for the database-update job. + +The DB updater keeps a single in-memory state dict whose ``status`` is set to +``running`` at start and only flipped to ``finished``/``error`` by the worker's +completion/error callbacks. If the worker thread hangs — e.g. a media-server API +call with no timeout, a DB lock — those callbacks never fire, so ``status`` stays +``running`` forever and the UI shows a frozen progress bar with no way to recover +(GitHub #859). + +This module is the single, *pure* decision for "is a running job stalled?". It +takes the state dict plus the current wall-clock time and a timeout, and answers +yes/no — no DB, no globals, no clock of its own. That keeps it unit-testable and +lets the watchdog wiring in web_server.py stay a thin call. The job carries a +``last_progress_at`` epoch timestamp that the start path and every progress/phase +callback bump; staleness is simply "running, and that timestamp is older than the +timeout". +""" + +from __future__ import annotations + +from typing import Any, Mapping + +# 5 minutes with zero forward progress = presumed hung. A healthy scan ticks +# progress (per-artist) far more often than this even for large libraries, so +# the timeout won't false-positive a slow-but-working run. +DEFAULT_STALL_TIMEOUT_SECONDS = 300 + + +def is_db_update_stalled( + state: Mapping[str, Any], + now: float, + timeout_seconds: float = DEFAULT_STALL_TIMEOUT_SECONDS, +) -> bool: + """Return True when the job is ``running`` but has made no progress within + ``timeout_seconds``. + + Conservative by design — it only ever reports a stall it can prove: + - Only a ``running`` job can stall (idle/finished/error never do). + - With no usable ``last_progress_at`` timestamp we cannot judge, so we return + False rather than risk killing a job we have no clock for. + - A non-positive timeout is treated as "disabled" (never stalls). + """ + if not isinstance(state, Mapping): + return False + if state.get("status") != "running": + return False + if timeout_seconds is None or timeout_seconds <= 0: + return False + last = state.get("last_progress_at") + if not last: + return False + try: + elapsed = float(now) - float(last) + except (TypeError, ValueError): + return False + return elapsed >= float(timeout_seconds) + + +def stalled_error_message(state: Mapping[str, Any], now: float) -> str: + """Build a clear, human-facing message for a stalled job, including how long + it has been silent and the phase it died in.""" + last = state.get("last_progress_at") if isinstance(state, Mapping) else None + phase = state.get("phase") if isinstance(state, Mapping) else None + try: + secs = int(float(now) - float(last)) if last else 0 + except (TypeError, ValueError): + secs = 0 + msg = "Update appears stuck — no progress" + if secs > 0: + msg += f" for {secs}s" + if phase: + msg += f" (last phase: {phase})" + msg += (". The worker may be hung on the media server. Start a new update " + "to try again, or restart SoulSync if it keeps stalling.") + return msg + + +__all__ = [ + "DEFAULT_STALL_TIMEOUT_SECONDS", + "is_db_update_stalled", + "stalled_error_message", +] diff --git a/core/deezer_worker.py b/core/deezer_worker.py index 15441c1e..80647754 100644 --- a/core/deezer_worker.py +++ b/core/deezer_worker.py @@ -8,7 +8,15 @@ from datetime import datetime, timedelta from utils.logging_config import get_logger from database.music_database import MusicDatabase from core.deezer_client import DeezerClient -from core.worker_utils import accept_artist_match, interruptible_sleep, set_album_api_track_count +from core.worker_utils import ( + accept_artist_match, + artist_name_matches, + interruptible_sleep, + owned_album_titles, + pick_artist_by_catalog, + release_titles, + set_album_api_track_count, +) from core.enrichment.manual_match_honoring import honor_stored_match logger = get_logger("deezer_worker") @@ -401,7 +409,20 @@ class DeezerWorker: logger.debug(f"Preserving existing Deezer ID for artist '{artist_name}': {existing_id}") return - result = self.client.search_artist(artist_name) + # Multi-candidate search (was single search_artist) so same-name artists + # can be disambiguated: gate by name, then pick the one whose catalog + # overlaps the albums this library owns. + results = self.client.search_artists(artist_name, limit=5) + gated = [a for a in (results or []) if artist_name_matches(artist_name, getattr(a, 'name', ''))] + chosen, _overlap = pick_artist_by_catalog( + gated, + owned_album_titles(self.db, artist_id), + lambda a: release_titles(self.client.get_artist_albums_list(a.id)), + ) + + # search_artists returns lean Artist objects; fetch the full dict (same + # shape the old search_artist returned) for storage. + result = self.client.get_artist_info(chosen.id) if chosen else None if result: result_name = result.get('name', '') ok, reason = accept_artist_match( diff --git a/core/discovery/quality_scanner.py b/core/discovery/quality_scanner.py index 27457ca7..7c689a38 100644 --- a/core/discovery/quality_scanner.py +++ b/core/discovery/quality_scanner.py @@ -1,40 +1,23 @@ -"""Background worker for the library quality scanner. +"""Shared metadata match + result-normalization helpers for quality matching. -`run_quality_scanner(scope, profile_id, deps)` is the function the -quality-scanner endpoint kicks off in a thread to scan the library -for low-quality tracks (below the user's configured quality profile) -and add provider matches to the wishlist: +These were the matching guts of the old auto-acting quality-scanner worker (now +removed — quality scanning is the ``quality_upgrade`` library-maintenance repair +job in ``core/repair_jobs/quality_upgrade.py``). They're kept here as a single +source of truth and imported by that job: -1. Reset scanner state, load quality profile + minimum acceptable tier. -2. Load tracks from DB based on scope: - - 'watchlist' → tracks for watchlisted artists only. - - other → all library tracks. -3. For each track: - - Stop-request gate (state['status'] != 'running'). - - Quality-tier check via _get_quality_tier_from_extension(file_path). - - Skip tracks meeting standards (tier_num <= min_acceptable_tier). - - For low-quality tracks: matching_engine search query gen, score - candidates against the configured metadata source priority - (artist + title similarity, album-type bonus), pick best match >= - 0.7 confidence. - - On match: add normalized track data to wishlist via - `wishlist_service.add_track_to_wishlist` with - source_type='quality_scanner' and a source_context that captures - original file_path, format tier, bitrate, and match confidence. -4. After all tracks: status='finished', progress=100, activity feed - entry, emit `quality_scan_completed` event for automation engine. -5. On critical exception: status='error', error message captured. +- ``_search_tracks_for_source`` — query one metadata source's ``search_tracks``. +- ``_normalize_track_match`` / ``_normalize_track_album`` / ``_normalize_track_artists`` + — turn a provider track into the wishlist-ready dict (typed Album converters + with legacy duck-typed fallback). +- ``_track_name`` / ``_track_artist_names`` / ``_extract_lookup_value`` — accessors. """ from __future__ import annotations import logging -import time -from dataclasses import dataclass -from datetime import datetime from typing import Any, Callable, Dict, Optional -from core.metadata.registry import get_client_for_source, get_primary_source, get_source_priority +from core.metadata.registry import get_client_for_source from core.metadata.types import Album from core.wishlist.payloads import ensure_wishlist_track_format @@ -56,16 +39,6 @@ _TYPED_ALBUM_CONVERTERS: Dict[str, Callable[[Dict[str, Any]], Album]] = { } -@dataclass -class QualityScannerDeps: - """Bundle of cross-cutting deps the quality scanner needs.""" - quality_scanner_state: dict - quality_scanner_lock: Any # threading.Lock - QUALITY_TIERS: dict - matching_engine: Any - automation_engine: Any - get_quality_tier_from_extension: Callable - add_activity_item: Callable def _extract_lookup_value(value: Any, *names: str, default: Any = None) -> Any: @@ -300,363 +273,3 @@ def _search_tracks_for_source(source: str, query: str, limit: int = 5, client: A except Exception as exc: logger.debug("Could not search %s for %s: %s", source, query, exc) return [] - - -def run_quality_scanner(scope='watchlist', profile_id=1, deps: QualityScannerDeps = None): - """Main quality scanner worker function""" - from core.wishlist_service import get_wishlist_service - from database.music_database import MusicDatabase - - try: - with deps.quality_scanner_lock: - deps.quality_scanner_state["status"] = "running" - deps.quality_scanner_state["phase"] = "Initializing scan..." - deps.quality_scanner_state["progress"] = 0 - deps.quality_scanner_state["processed"] = 0 - deps.quality_scanner_state["total"] = 0 - deps.quality_scanner_state["quality_met"] = 0 - deps.quality_scanner_state["low_quality"] = 0 - deps.quality_scanner_state["matched"] = 0 - deps.quality_scanner_state["results"] = [] - deps.quality_scanner_state["error_message"] = "" - - logger.info(f"[Quality Scanner] Starting scan with scope: {scope}") - - # Get database instance - db = MusicDatabase() - - # Get quality profile to determine preferred quality - quality_profile = db.get_quality_profile() - preferred_qualities = quality_profile.get('qualities', {}) - - # Determine minimum acceptable tier based on enabled qualities - min_acceptable_tier = 999 - for quality_name, quality_config in preferred_qualities.items(): - if quality_config.get('enabled', False): - # Map quality profile names to tier names - tier_map = { - 'flac': 'lossless', - 'mp3_320': 'low_lossy', - 'mp3_256': 'low_lossy', - 'mp3_192': 'low_lossy' - } - tier_name = tier_map.get(quality_name) - if tier_name: - tier_num = deps.QUALITY_TIERS[tier_name]['tier'] - min_acceptable_tier = min(min_acceptable_tier, tier_num) - - logger.info(f"[Quality Scanner] Minimum acceptable tier: {min_acceptable_tier}") - - # Get tracks to scan based on scope - with deps.quality_scanner_lock: - deps.quality_scanner_state["phase"] = "Loading tracks from database..." - - if scope == 'watchlist': - # Get watchlist artists - watchlist_artists = db.get_watchlist_artists(profile_id=profile_id) - if not watchlist_artists: - with deps.quality_scanner_lock: - deps.quality_scanner_state["status"] = "finished" - deps.quality_scanner_state["phase"] = "No watchlist artists found" - deps.quality_scanner_state["error_message"] = "Please add artists to watchlist first" - logger.warning("[Quality Scanner] No watchlist artists found") - return - - # Get artist names from watchlist - artist_names = [artist.artist_name for artist in watchlist_artists] - logger.info(f"[Quality Scanner] Scanning {len(artist_names)} watchlist artists") - - # Get all tracks for these artists by name - conn = db._get_connection() - placeholders = ','.join(['?' for _ in artist_names]) - tracks_to_scan = conn.execute( - f"SELECT t.id, t.title, t.artist_id, t.album_id, t.file_path, t.bitrate, a.name as artist_name, al.title as album_title " - f"FROM tracks t " - f"JOIN artists a ON t.artist_id = a.id " - f"JOIN albums al ON t.album_id = al.id " - f"WHERE a.name IN ({placeholders}) AND t.file_path IS NOT NULL", - artist_names - ).fetchall() - conn.close() - else: - # Scan all library tracks - with deps.quality_scanner_lock: - deps.quality_scanner_state["phase"] = "Loading all library tracks..." - - conn = db._get_connection() - tracks_to_scan = conn.execute( - "SELECT t.id, t.title, t.artist_id, t.album_id, t.file_path, t.bitrate, a.name as artist_name, al.title as album_title " - "FROM tracks t " - "JOIN artists a ON t.artist_id = a.id " - "JOIN albums al ON t.album_id = al.id " - "WHERE t.file_path IS NOT NULL" - ).fetchall() - conn.close() - - total_tracks = len(tracks_to_scan) - logger.info(f"[Quality Scanner] Found {total_tracks} tracks to scan") - - with deps.quality_scanner_lock: - deps.quality_scanner_state["total"] = total_tracks - deps.quality_scanner_state["phase"] = f"Scanning {total_tracks} tracks..." - - source_priority = get_source_priority(get_primary_source()) - if not source_priority: - with deps.quality_scanner_lock: - deps.quality_scanner_state["status"] = "error" - deps.quality_scanner_state["phase"] = "No metadata provider available" - deps.quality_scanner_state["error_message"] = "No metadata provider is available for quality scanning" - logger.info("[Quality Scanner] No metadata provider available") - return - - logger.info("[Quality Scanner] Using metadata source priority: %s", source_priority) - - wishlist_service = get_wishlist_service() - add_to_wishlist = getattr(wishlist_service, 'add_track_to_wishlist', None) - if add_to_wishlist is None: - add_to_wishlist = getattr(wishlist_service, 'add_spotify_track_to_wishlist', None) - if add_to_wishlist is None: - raise AttributeError("Wishlist service does not expose an add-to-wishlist method") - - # Scan each track - for idx, track_row in enumerate(tracks_to_scan, 1): - # Check for stop request - if deps.quality_scanner_state.get('status') != 'running': - logger.info(f"[Quality Scanner] Stop requested, halting at track {idx}/{total_tracks}") - break - - try: - track_id, title, artist_id, album_id, file_path, bitrate, artist_name, album_title = track_row - - # Check quality tier - tier_name, tier_num = deps.get_quality_tier_from_extension(file_path) - - # Update progress - with deps.quality_scanner_lock: - deps.quality_scanner_state["processed"] = idx - deps.quality_scanner_state["progress"] = (idx / total_tracks) * 100 - deps.quality_scanner_state["phase"] = f"Scanning: {artist_name} - {title}" - - # Check if meets quality standards - if tier_num <= min_acceptable_tier: - # Quality met - with deps.quality_scanner_lock: - deps.quality_scanner_state["quality_met"] += 1 - continue - - # Low quality track found - with deps.quality_scanner_lock: - deps.quality_scanner_state["low_quality"] += 1 - - logger.info(f"[Quality Scanner] Low quality: {artist_name} - {title} ({tier_name}, {file_path})") - - # Attempt to match using the active metadata provider - matched = False - matched_track_data = None - best_source = None - attempted_any_provider = False - - try: - # Generate search queries using matching engine - temp_track = type('TempTrack', (), { - 'name': title, - 'artists': [artist_name], - 'album': album_title - })() - - search_queries = deps.matching_engine.generate_download_queries(temp_track) - logger.info(f"[Quality Scanner] Generated {len(search_queries)} search queries for {artist_name} - {title}") - - # Find best match using confidence scoring - best_match = None - best_confidence = 0.0 - min_confidence = 0.7 # Match existing standard - - for _query_idx, search_query in enumerate(search_queries): - try: - for source in source_priority: - client = get_client_for_source(source) - if not client or not hasattr(client, 'search_tracks'): - continue - - attempted_any_provider = True - provider_matches = _search_tracks_for_source(source, search_query, limit=5, client=client) - time.sleep(0.5) # Rate limit metadata API calls - - if not provider_matches: - continue - - # Score each result using matching engine - for provider_track in provider_matches: - try: - # Calculate artist confidence - artist_confidence = 0.0 - provider_artists = _track_artist_names(provider_track) - if provider_artists: - for result_artist in provider_artists: - artist_sim = deps.matching_engine.similarity_score( - deps.matching_engine.normalize_string(artist_name), - deps.matching_engine.normalize_string(result_artist) - ) - artist_confidence = max(artist_confidence, artist_sim) - - # Calculate title confidence - title_confidence = deps.matching_engine.similarity_score( - deps.matching_engine.normalize_string(title), - deps.matching_engine.normalize_string(_track_name(provider_track)) - ) - - # Combined confidence (50% artist + 50% title) - combined_confidence = (artist_confidence * 0.5 + title_confidence * 0.5) - - # Small bonus for album tracks over singles - _at = _extract_lookup_value(provider_track, 'album_type', default='') or '' - if _at == 'album': - combined_confidence += 0.02 - elif _at == 'ep': - combined_confidence += 0.01 - - candidate_artist = provider_artists[0] if provider_artists else 'Unknown Artist' - candidate_name = _track_name(provider_track) - logger.info( - f"[Quality Scanner] Candidate ({source}): '{candidate_artist}' - " - f"'{candidate_name}' (confidence: {combined_confidence:.3f})" - ) - - # Update best match if this is better - if combined_confidence > best_confidence and combined_confidence >= min_confidence: - best_confidence = combined_confidence - best_match = provider_track - best_source = source - logger.info( - f"[Quality Scanner] New best match ({source}): {candidate_artist} - " - f"{candidate_name} (confidence: {combined_confidence:.3f})" - ) - - except Exception as e: - logger.error(f"[Quality Scanner] Error scoring result: {e}") - continue - - # If we found a very high confidence match, stop searching this query - if best_confidence >= 0.9: - logger.info(f"[Quality Scanner] High confidence match found ({best_confidence:.3f}), stopping search") - break - - except Exception as e: - logger.debug(f"[Quality Scanner] Error searching with query '{search_query}': {e}") - continue - - if not attempted_any_provider: - with deps.quality_scanner_lock: - deps.quality_scanner_state["status"] = "error" - deps.quality_scanner_state["phase"] = "No metadata provider available" - deps.quality_scanner_state["error_message"] = "No metadata provider is available for quality scanning" - logger.info("[Quality Scanner] No metadata provider available") - return - - # Process best match - if best_match: - matched = True - final_artist = _track_artist_names(best_match)[0] if _track_artist_names(best_match) else 'Unknown Artist' - final_name = _track_name(best_match) - final_source = best_source or 'metadata' - logger.info( - f"[Quality Scanner] Final match ({final_source}): {final_artist} - " - f"{final_name} (confidence: {best_confidence:.3f})" - ) - - # Build normalized track data for wishlist - matched_track_data = _normalize_track_match(best_match, final_source) - - # Add to wishlist - source_context = { - 'quality_scanner': True, - 'original_file_path': file_path, - 'original_format': tier_name, - 'original_bitrate': bitrate, - 'match_confidence': best_confidence, - 'scan_date': datetime.now().isoformat() - } - - success = add_to_wishlist( - track_data=matched_track_data, - failure_reason=f"Low quality - {tier_name.replace('_', ' ').title()} format", - source_type='quality_scanner', - source_context=source_context, - profile_id=profile_id - ) - - if success: - with deps.quality_scanner_lock: - deps.quality_scanner_state["matched"] += 1 - logger.info(f"[Quality Scanner] Matched and added to wishlist: {artist_name} - {title}") - else: - logger.error(f"[Quality Scanner] Failed to add to wishlist: {artist_name} - {title}") - else: - logger.warning( - f"[Quality Scanner] No suitable metadata match found " - f"(best confidence: {best_confidence:.3f}, required: {min_confidence:.3f})" - ) - - except Exception as matching_error: - logger.error(f"[Quality Scanner] Matching error for {artist_name} - {title}: {matching_error}") - - # Store result - result_entry = { - 'track_id': track_id, - 'title': title, - 'artist': artist_name, - 'album': album_title, - 'file_path': file_path, - 'current_format': tier_name, - 'bitrate': bitrate, - 'matched': matched, - 'match_id': matched_track_data['id'] if matched_track_data else None, - 'provider': best_source if matched else None, - 'spotify_id': matched_track_data['id'] if matched_track_data else None, - } - - with deps.quality_scanner_lock: - deps.quality_scanner_state["results"].append(result_entry) - - if not matched: - logger.warning(f"[Quality Scanner] No metadata match found for: {artist_name} - {title}") - - except Exception as track_error: - logger.error(f"[Quality Scanner] Error processing track: {track_error}") - continue - - # Scan complete (don't overwrite if already stopped by user) - with deps.quality_scanner_lock: - was_stopped = deps.quality_scanner_state["status"] != "running" - deps.quality_scanner_state["status"] = "finished" - deps.quality_scanner_state["progress"] = 100 - if not was_stopped: - deps.quality_scanner_state["phase"] = "Scan complete" - - logger.info(f"[Quality Scanner] Scan {'stopped' if was_stopped else 'complete'}: {deps.quality_scanner_state['processed']} processed, " - f"{deps.quality_scanner_state['low_quality']} low quality, {deps.quality_scanner_state['matched']} matched to metadata providers") - - # Add activity - deps.add_activity_item("", "Quality Scan Complete", - f"{deps.quality_scanner_state['matched']} tracks added to wishlist", "Now") - - try: - if deps.automation_engine: - deps.automation_engine.emit('quality_scan_completed', { - 'quality_met': str(deps.quality_scanner_state.get('quality_met', 0)), - 'low_quality': str(deps.quality_scanner_state.get('low_quality', 0)), - 'total_scanned': str(deps.quality_scanner_state.get('processed', 0)), - }) - except Exception as e: - logger.debug("emit quality_scan_completed failed: %s", e) - - except Exception as e: - logger.error(f"[Quality Scanner] Critical error: {e}") - import traceback - traceback.print_exc() - - with deps.quality_scanner_lock: - deps.quality_scanner_state["status"] = "error" - deps.quality_scanner_state["error_message"] = str(e) - deps.quality_scanner_state["phase"] = f"Error: {str(e)}" diff --git a/core/discovery/youtube.py b/core/discovery/youtube.py index d9830f38..97cf7fd4 100644 --- a/core/discovery/youtube.py +++ b/core/discovery/youtube.py @@ -52,6 +52,10 @@ class YoutubeDiscoveryDeps: build_discovery_wing_it_stub: Callable get_database: Callable[[], Any] add_activity_item: Callable + # Recover a YouTube track's artist from its own video page when flat playlist + # extraction left it "Unknown Artist" (#863). Takes a video id, returns a raw + # artist string or ''. Optional — discovery still works without it. + recover_youtube_artist: Callable[[str], str] = None def run_youtube_discovery_worker(url_hash, deps: YoutubeDiscoveryDeps): @@ -94,6 +98,30 @@ def run_youtube_discovery_worker(url_hash, deps: YoutubeDiscoveryDeps): cleaned_title = track['name'] cleaned_artist = track['artists'][0] if track['artists'] else 'Unknown Artist' + # Recover the artist from the track's own video page if flat + # playlist extraction left it Unknown (#863). Done here, in the + # background worker, rather than in the parse request (which would + # block for minutes on a big playlist). Per-track cost is hidden + # behind the discovery progress bar; the recovered artist makes the + # match below actually find the song. + if cleaned_artist == 'Unknown Artist' and track.get('id'): + if not deps.recover_youtube_artist: + logger.warning("[YT Discovery] artist recovery unavailable (dep not wired) " + "— '%s' stays Unknown", cleaned_title) + else: + try: + _rec = deps.recover_youtube_artist(track['id']) + except Exception as _rec_err: + logger.warning(f"[YT Discovery] artist recovery raised for {track.get('id')}: {_rec_err}") + _rec = '' + if _rec and _rec != 'Unknown Artist': + logger.info(f"[YT Discovery] recovered artist '{_rec}' for '{cleaned_title}' ({track['id']})") + cleaned_artist = _rec + track['artists'] = [_rec] # persist so retries/UI see it + else: + logger.info(f"[YT Discovery] artist recovery returned nothing for " + f"'{cleaned_title}' ({track['id']}) — leaving Unknown") + logger.info(f"Searching {discovery_source} for: '{cleaned_artist}' - '{cleaned_title}'") # Check discovery cache first diff --git a/core/downloads/candidates.py b/core/downloads/candidates.py index ddce91fe..d9f20777 100644 --- a/core/downloads/candidates.py +++ b/core/downloads/candidates.py @@ -339,7 +339,6 @@ def attempt_download_with_candidates(task_id, candidates, track, batch_id=None, ) logger.info(f"[Context] Set is_album_download: {is_album_context} (has clean data: {has_clean_spotify_data})") - logger.debug(f"[Debug] Context creation - track_info: {track_info is not None}, playlist_folder_mode: {track_info.get('_playlist_folder_mode', False) if track_info else False}") # Update task with successful download info with tasks_lock: diff --git a/core/downloads/lifecycle.py b/core/downloads/lifecycle.py index adec519e..cd7e38fc 100644 --- a/core/downloads/lifecycle.py +++ b/core/downloads/lifecycle.py @@ -549,6 +549,24 @@ def on_download_completed(batch_id: str, task_id: str, success: bool, deps: Life except Exception as m3u_err: logger.error(f"[M3U] Error regenerating M3U on batch complete: {m3u_err}") + # PLAYLIST MATERIALIZE: one path-independent reconcile — drop this + # batch's newly-resolved tracks into the right Playlists// + # folders. Covers an organize-by-playlist download AND a late + # wishlist arrival (via each track's playlist provenance). Built + # from the batch's own captured paths — non-fatal, derived view. + try: + from core.playlists.materialize_service import reconcile_batch_playlists + from database.music_database import MusicDatabase + for _pl_name, _mat in reconcile_batch_playlists(MusicDatabase(), batch, download_tasks, deps.config_manager): + logger.info( + f"[Playlist Folder] Rebuilt '{_mat.playlist_dir}': " + f"{_mat.linked} linked, {_mat.copied} copied, " + f"{_mat.unchanged} unchanged, {_mat.removed_stale} stale removed" + + (" (symlinks unsupported here → copied)" if _mat.fellback else "") + ) + except Exception as _mat_err: + logger.error(f"[Playlist Folder] Materialize failed (non-fatal): {_mat_err}") + # REPAIR: Scan all album folders from this batch for track number issues if deps.repair_worker: deps.repair_worker.process_batch(batch_id) @@ -735,6 +753,23 @@ def check_batch_completion_v2(batch_id: str, deps: LifecycleDeps) -> Optional[bo deps.download_monitor.stop_monitoring(batch_id) _cleanup_private_album_bundle_staging(batch_id, batch) + # PLAYLIST MATERIALIZE: same reconcile as the primary completion path + # (on_download_completed). Monitor-detected downloads complete via THIS + # V2 path, so the reconcile must run here too or playlist folders never + # get built for them. Path-independent, non-fatal, derived view. + try: + from core.playlists.materialize_service import reconcile_batch_playlists + from database.music_database import MusicDatabase + for _pl_name, _mat in reconcile_batch_playlists(MusicDatabase(), batch, download_tasks, deps.config_manager): + logger.info( + f"[Playlist Folder] Rebuilt '{_mat.playlist_dir}': " + f"{_mat.linked} linked, {_mat.copied} copied, " + f"{_mat.unchanged} unchanged, {_mat.removed_stale} stale removed" + + (" (symlinks unsupported here → copied)" if _mat.fellback else "") + ) + except Exception as _mat_err: + logger.error(f"[Playlist Folder] Materialize failed (non-fatal): {_mat_err}") + # REPAIR: Scan all album folders from this batch for track number issues if deps.repair_worker: deps.repair_worker.process_batch(batch_id) diff --git a/core/downloads/master.py b/core/downloads/master.py index ce30b8d5..e3d0cb57 100644 --- a/core/downloads/master.py +++ b/core/downloads/master.py @@ -505,13 +505,19 @@ def run_full_missing_tracks_process(batch_id, playlist_id, tracks_json, deps: Ma track_name = track_data.get('name', '') artists = track_data.get('artists', []) found, confidence = False, 0.0 + # Additive payload: the owned library track (DatabaseTrack) when this + # item is found in the library, so downstream (playlist materialization) + # knows WHERE the real file is without re-matching. None when not owned. + matched_track = None # Manual library matches are authoritative unless the user explicitly # requested a force re-download from the normal download modal. _stid = track_data.get('spotify_track_id') or track_data.get('source_track_id') or track_data.get('id', '') - if not ignore_manual_matches and _stid and _mlm.get_match_for_track( - db, batch_profile_id, track_data, default_source=batch_source - ): + _manual_match = ( + _mlm.get_match_for_track(db, batch_profile_id, track_data, default_source=batch_source) + if (not ignore_manual_matches and _stid) else None + ) + if _manual_match: logger.info(f"[Manual Match] '{track_name}' already matched in library — skipping download") try: deps.check_and_remove_track_from_wishlist_by_metadata(track_data) @@ -523,6 +529,8 @@ def run_full_missing_tracks_process(batch_id, playlist_id, tracks_json, deps: Ma 'found': True, 'confidence': 1.0, 'match_reason': 'manual_library_match', + 'matched_file_path': _manual_match.get('library_file_path'), + 'matched_track_id': _manual_match.get('library_track_id'), }) continue @@ -568,8 +576,10 @@ def run_full_missing_tracks_process(batch_id, playlist_id, tracks_json, deps: Ma # Direct title match (try both raw and normalized) if track_name_lower in album_tracks_map: found, confidence = True, 1.0 + matched_track = album_tracks_map[track_name_lower] elif _normalized_source_title and _normalized_source_title in album_tracks_map: found, confidence = True, 1.0 + matched_track = album_tracks_map[_normalized_source_title] else: # Fuzzy match against album tracks using string similarity. # Compare BOTH the raw and normalized source titles — @@ -577,14 +587,17 @@ def run_full_missing_tracks_process(batch_id, playlist_id, tracks_json, deps: Ma # matching when the album doesn't imply version # context (helper returns the input unchanged). best_sim = 0.0 + best_track = None for db_title_lower, _db_track in album_tracks_map.items(): sim_raw = db._string_similarity(track_name_lower, db_title_lower) sim_norm = db._string_similarity(_normalized_source_title, db_title_lower) if _normalized_source_title else 0.0 sim = max(sim_raw, sim_norm) if sim > best_sim: best_sim = sim + best_track = _db_track if best_sim >= 0.7: found, confidence = True, best_sim + matched_track = best_track else: # Fall back to global per-track search for this track # When allow_duplicates is on for album downloads, skip global @@ -605,6 +618,7 @@ def run_full_missing_tracks_process(batch_id, playlist_id, tracks_json, deps: Ma ) if db_track and track_confidence >= 0.7: found, confidence = True, track_confidence + matched_track = db_track break elif allow_duplicates and batch_is_album: # Allow duplicates + album download + album not in DB yet → treat all as missing @@ -624,10 +638,15 @@ def run_full_missing_tracks_process(batch_id, playlist_id, tracks_json, deps: Ma ) if db_track and track_confidence >= 0.7: found, confidence = True, track_confidence + matched_track = db_track break analysis_results.append({ - 'track_index': track_index, 'track': track_data, 'found': found, 'confidence': confidence + 'track_index': track_index, 'track': track_data, 'found': found, 'confidence': confidence, + # Additive: real on-disk location of the owned track (None when not + # owned), so playlist materialization links the right file. + 'matched_file_path': getattr(matched_track, 'file_path', None), + 'matched_track_id': getattr(matched_track, 'id', None), }) # WISHLIST REMOVAL: If track is found in database, check if it should be removed from wishlist @@ -766,6 +785,41 @@ def run_full_missing_tracks_process(batch_id, playlist_id, tracks_json, deps: Ma logger.warning("[Auto-Wishlist] No missing tracks found - calling auto-completion handler to toggle cycle and reschedule") deps.missing_download_executor.submit(deps.process_failed_tracks_to_wishlist_exact_with_auto_completion, batch_id) + # Organize-by-playlist with NOTHING to download (every track already + # owned): the batch never enters the download/lifecycle path, so build + # the playlist folder here from the owned files the analysis matched. + # Gated + non-fatal; runs once after analysis, not in the per-track loop. + if effective_playlist_folder_mode: + try: + from core.playlists.materialize_service import reconcile_batch_playlists + from database.music_database import MusicDatabase as _MDB + _batch = download_batches.get(batch_id) + if _batch is not None: + # We KNOW the intent is organize-by-playlist here (the gate + # above). The line-431 sync only writes the dict field when + # effective and NOT batch_playlist_folder_mode, so when the + # toggle itself drove it the dict field can still be falsy — + # which makes reconcile build no batch ref. Make the dict + # authoritative so reconcile sees the batch's own playlist. + _batch['playlist_folder_mode'] = True + if effective_playlist_name: + _batch['playlist_name'] = effective_playlist_name + _results = reconcile_batch_playlists(_MDB(), _batch, download_tasks, deps.config_manager) + if not _results: + logger.info( + f"[Playlist Folder] All-owned: nothing rebuilt for " + f"ref={_batch.get('source_playlist_ref') or _batch.get('playlist_id')} " + f"source={_batch.get('batch_source')}" + ) + for _pl_name, _mat in _results: + logger.info( + f"[Playlist Folder] Rebuilt '{_mat.playlist_dir}' (all owned): " + f"{_mat.linked} linked, {_mat.copied} copied, {_mat.removed_stale} stale removed" + + (" (symlinks unsupported here → copied)" if _mat.fellback else "") + ) + except Exception as _mat_err: + logger.error(f"[Playlist Folder] All-owned materialize failed (non-fatal): {_mat_err}") + return logger.warning(f" transitioning batch {batch_id} to download phase with {len(missing_tracks)} tracks.") @@ -1144,7 +1198,13 @@ def run_full_missing_tracks_process(batch_id, playlist_id, tracks_json, deps: Ma task_pl_folder_mode = True task_pl_name = wl_pl_name or wl_mirrored.get('name') or batch_playlist_name if task_pl_folder_mode: - track_info['_playlist_folder_mode'] = True + # Organize-by-playlist now imports each track NORMALLY into the + # Artist/Album library (i.e. exactly what a normal download does) + # — the playlist folder is built as links/copies AFTER the batch + # from the real library files. So we deliberately DON'T set + # `_playlist_folder_mode` (which routed the real file into a flat + # Music// dump). We keep `_playlist_name` + source_info + # — they're download provenance (core/downloads/origin.py). track_info['_playlist_name'] = task_pl_name if batch_source_playlist_ref: track_info['source_info'] = { @@ -1153,8 +1213,8 @@ def run_full_missing_tracks_process(batch_id, playlist_id, tracks_json, deps: Ma 'source': batch_source, } logger.info( - f"[Task Creation] Added playlist folder mode for: " - f"{track_info.get('name')} → {task_pl_name}" + f"[Task Creation] Organize-by-playlist (normal import + " + f"materialize after batch): {track_info.get('name')} → {task_pl_name}" ) else: logger.debug( diff --git a/core/enrichment/unmatched.py b/core/enrichment/unmatched.py index 0d63ac79..7a574438 100644 --- a/core/enrichment/unmatched.py +++ b/core/enrichment/unmatched.py @@ -216,7 +216,22 @@ def build_reset_query( table = meta['table'] ms = match_status_column(service) la = last_attempted_column(service) - set_clause = f"SET {ms} = NULL, {la} = NULL" + set_parts = [f"{ms} = NULL", f"{la} = NULL"] + + # Also forget the stored source ID so re-matching actually RE-RESOLVES the + # entity. Without this, the worker hits its existing-id short-circuit, sees + # the old (possibly WRONG) id and just re-confirms it — which is why "click + # to rematch" never fixed a mis-matched same-name artist (#868). Tracks keep + # their ids in file tags rather than a column, so only artist/album clear one. + if entity_type in ('artist', 'album'): + try: + from core.source_ids import id_column + id_col = id_column(service, entity_type) + except Exception: + id_col = None + if id_col: + set_parts.append(f"{id_col} = NULL") + set_clause = "SET " + ", ".join(set_parts) if scope == 'item': if not entity_id: diff --git a/core/exports/__init__.py b/core/exports/__init__.py new file mode 100644 index 00000000..41b21484 --- /dev/null +++ b/core/exports/__init__.py @@ -0,0 +1 @@ +"""Data export builders.""" diff --git a/core/exports/artist_export.py b/core/exports/artist_export.py new file mode 100644 index 00000000..ac022a42 --- /dev/null +++ b/core/exports/artist_export.py @@ -0,0 +1,109 @@ +"""Export an artist roster — watchlist OR library — to JSON / CSV / plain text +(corruption's request). + +Pure shaping + formatting so it's the single source of truth and unit-testable — +web_server fetches the artists (normalizing each source's fields onto the canonical +``*_artist_id`` keys below) and hands them here; the UI just picks options and +downloads. Always exports the name + whatever source IDs each artist has; +``include_links`` adds external discography URLs; ``extra_fields`` passes through +source-specific extras (e.g. library album/track counts) in a stable order. +""" + +from __future__ import annotations + +import csv +import io +import json +from typing import Any, Dict, List, Optional + +# Canonical id field → external URL builder. +_LINKS = { + 'spotify_artist_id': lambda i: f'https://open.spotify.com/artist/{i}', + 'musicbrainz_artist_id': lambda i: f'https://musicbrainz.org/artist/{i}', + 'deezer_artist_id': lambda i: f'https://www.deezer.com/artist/{i}', + 'discogs_artist_id': lambda i: f'https://www.discogs.com/artist/{i}', + 'itunes_artist_id': lambda i: f'https://music.apple.com/artist/{i}', + 'tidal_artist_id': lambda i: f'https://tidal.com/artist/{i}', + 'qobuz_artist_id': lambda i: f'https://www.qobuz.com/artist/{i}', +} +# Stable order so CSV columns + JSON keys are deterministic. amazon carries an id +# but no clean public URL. +_ID_FIELDS = ['spotify_artist_id', 'musicbrainz_artist_id', 'deezer_artist_id', + 'discogs_artist_id', 'itunes_artist_id', 'tidal_artist_id', + 'qobuz_artist_id', 'amazon_artist_id'] + +VALID_FORMATS = ('json', 'csv', 'txt') + + +def _name(a: Dict[str, Any]) -> str: + return str(a.get('artist_name') or a.get('name') or '').strip() + + +def _short(field: str) -> str: + return field.replace('_artist_id', '') + + +def _row(a: Dict[str, Any], include_links: bool, extra_fields: List[str]) -> Dict[str, Any]: + row: Dict[str, Any] = {'name': _name(a)} + for f in _ID_FIELDS: + if a.get(f): + row[f] = str(a[f]) + for f in extra_fields: + if a.get(f) not in (None, ''): + row[f] = a[f] + if include_links: + links = {_short(f): b(a[f]) for f, b in _LINKS.items() if a.get(f)} + if links: + row['links'] = links + return row + + +def build_artist_export(artists: Optional[List[Dict[str, Any]]], + fmt: str = 'json', include_links: bool = False, + extra_fields: Optional[List[str]] = None) -> str: + """Return the roster serialized in ``fmt`` (json | csv | txt). + + - ``txt`` → one artist name per line. + - ``csv`` → name + each source-id column + ``extra_fields`` columns (+ a + *_url column per service when ``include_links``). + - ``json`` → a list of objects: name, present source ids, present extras, and + a ``links`` map when ``include_links``. + """ + artists = artists or [] + extra_fields = list(extra_fields or []) + fmt = (fmt or 'json').lower() + if fmt not in VALID_FORMATS: + fmt = 'json' + + if fmt == 'txt': + return '\n'.join(n for n in (_name(a) for a in artists) if n) + + if fmt == 'csv': + cols = ['name'] + _ID_FIELDS + extra_fields + if include_links: + cols += [f'{_short(f)}_url' for f in _LINKS] + out = io.StringIO() + w = csv.writer(out) + w.writerow(cols) + for a in artists: + line = [_name(a)] + [str(a.get(f) or '') for f in _ID_FIELDS] + line += [str(a.get(f) if a.get(f) is not None else '') for f in extra_fields] + if include_links: + line += [_LINKS[f](a[f]) if a.get(f) else '' for f in _LINKS] + w.writerow(line) + return out.getvalue() + + return json.dumps([_row(a, include_links, extra_fields) for a in artists], + indent=2, ensure_ascii=False) + + +def export_mime_and_ext(fmt: str): + """(content-type, file extension) for a format.""" + return { + 'json': ('application/json', 'json'), + 'csv': ('text/csv', 'csv'), + 'txt': ('text/plain', 'txt'), + }.get((fmt or 'json').lower(), ('application/json', 'json')) + + +__all__ = ['build_artist_export', 'export_mime_and_ext', 'VALID_FORMATS'] diff --git a/core/hifi_client.py b/core/hifi_client.py index 242983ea..a1164e8f 100644 --- a/core/hifi_client.py +++ b/core/hifi_client.py @@ -92,8 +92,57 @@ DEFAULT_INSTANCES = [ 'https://hund.qqdl.site', 'https://katze.qqdl.site', 'https://arran.monochrome.tf', + 'https://us-west.monochrome.tf', # community-confirmed working (Sokhi) ] +# The default instances as they shipped BEFORE the auto-push mechanism below. +# Used as the one-time baseline for the "already offered" set so existing +# installs don't get pre-existing defaults they'd deliberately removed +# resurrected — only genuinely NEW defaults are pushed. +LEGACY_DEFAULTS = [ + 'https://triton.squid.wtf', + 'https://hifi-one.spotisaver.net', + 'https://hifi-two.spotisaver.net', + 'https://hund.qqdl.site', + 'https://katze.qqdl.site', + 'https://arran.monochrome.tf', +] + + +def compute_new_default_pushes(all_defaults, offered, legacy_baseline, existing): + """Decide which default instances to auto-add to an EXISTING install. + + A new working instance added to ``DEFAULT_INSTANCES`` should reach everyone, + not just fresh installs / people who click "Restore Defaults" — but we must + NOT re-add defaults a user deliberately removed. + + The ``offered`` set records every default ever presented to this install. + First run (``offered is None``) baselines to ``legacy_baseline`` (the defaults + that shipped before tracking), so those are treated as already-offered. Any + default NOT in the offered set is genuinely new → added once (unless already + present) and recorded. + + Pure: returns ``(urls_to_add, new_offered_list)``. The caller does the I/O. + """ + def _n(u): + return (u or '').rstrip('/') + base = list(legacy_baseline) if offered is None else list(offered) + offered_set = {_n(u) for u in base} + existing_set = {_n(u) for u in (existing or [])} + to_add, new_offered = [], list(base) + for u in all_defaults: + if _n(u) in offered_set: + continue + offered_set.add(_n(u)) + new_offered.append(u) + if _n(u) not in existing_set: + to_add.append(u) + return to_add, new_offered + + +# Run the new-default push at most once per process. +_pushed_new_defaults = False + from core.download_plugins.base import DownloadSourcePlugin @@ -138,11 +187,41 @@ class HiFiClient(DownloadSourcePlugin): def set_engine(self, engine): self._engine = engine + def _push_new_default_instances(self, db): + """One-time-per-process: auto-add any genuinely-new default instances to an + existing config so a newly-added working instance reaches everyone, not + just fresh installs / Restore-Defaults clickers. Never resurrects defaults + a user removed (tracked via the persisted 'offered' set).""" + global _pushed_new_defaults + if _pushed_new_defaults: + return + try: + from config.settings import config_manager + offered = config_manager.get('hifi.offered_defaults', None) + existing = db.get_all_hifi_instances() + to_add, new_offered = compute_new_default_pushes( + DEFAULT_INSTANCES, offered, LEGACY_DEFAULTS, + [i.get('url') for i in existing], + ) + if to_add: + priority = len(existing) + for url in to_add: + if db.add_hifi_instance(url.rstrip('/'), priority): + priority += 1 + logger.info(f"[HiFi] Auto-added {len(to_add)} new default instance(s) " + f"to existing config: {to_add}") + if offered is None or to_add: + config_manager.set('hifi.offered_defaults', new_offered) + _pushed_new_defaults = True + except Exception as e: + logger.warning(f"[HiFi] new-default auto-push skipped: {e}") + def _load_instances_from_db(self): try: from database.music_database import get_database db = get_database() db.seed_hifi_instances(DEFAULT_INSTANCES) + self._push_new_default_instances(db) rows = db.get_hifi_instances() urls = [r['url'] for r in rows if r['enabled']] if urls: diff --git a/core/imports/paths.py b/core/imports/paths.py index 6546509f..69de9cc7 100644 --- a/core/imports/paths.py +++ b/core/imports/paths.py @@ -438,7 +438,6 @@ def build_final_path_for_track(context, artist_context, album_info, file_ext, cr original_search = get_import_original_search(context) album_context = get_import_context_album(context) source = get_import_source(context) - playlist_folder_mode = track_info.get("_playlist_folder_mode", False) artist_name = extract_artist_name(artist_context) source_info = track_info.get("source_info") or {} @@ -466,40 +465,6 @@ def build_final_path_for_track(context, artist_context, album_info, file_ext, cr total_tracks = (album_context.get("total_tracks", 0) or 0) if album_context else 0 album_type_display = get_album_type_display(raw_album_type, total_tracks) - if playlist_folder_mode: - playlist_name = track_info.get("_playlist_name", "Unknown Playlist") - track_name = get_import_clean_title(context, default=original_search.get("title", "Unknown Track")) - _artists = original_search.get("artists") or track_info.get("artists") or [] - - template_context = { - "artist": artist_name, - "albumartist": artist_name, - "album": track_name, - "title": track_name, - "playlist_name": playlist_name, - "track_number": 1, - "disc_number": 1, - "year": year, - "quality": context.get("_audio_quality", ""), - "albumtype": album_type_display, - "_artists_list": _artists, - "_itunes_artist_id": str(artist_context.get("id", "")) if isinstance(artist_context, dict) and str(artist_context.get("id", "")).isdigit() and source == "itunes" else None, - } - - folder_path, filename_base = get_file_path_from_template(template_context, "playlist_path") - if folder_path and filename_base: - final_path = os.path.join(transfer_dir, folder_path, filename_base + file_ext) - _ensure_dir(os.path.join(transfer_dir, folder_path), exist_ok=True) - return final_path, True - - playlist_name_sanitized = sanitize_filename(playlist_name) - playlist_dir = os.path.join(transfer_dir, playlist_name_sanitized) - _ensure_dir(playlist_dir, exist_ok=True) - artist_name_sanitized = sanitize_filename(template_context["artist"]) - track_name_sanitized = sanitize_filename(track_name) - new_filename = f"{artist_name_sanitized} - {track_name_sanitized}{file_ext}" - return os.path.join(playlist_dir, new_filename), True - if album_info and album_info.get("is_album"): clean_track_name = get_import_clean_title(context, album_info=album_info, default=original_search.get("title", "Unknown Track")) track_number = _coerce_int(album_info.get("track_number", 1), 1) diff --git a/core/imports/pipeline.py b/core/imports/pipeline.py index ec027f06..b6a077a9 100644 --- a/core/imports/pipeline.py +++ b/core/imports/pipeline.py @@ -543,162 +543,10 @@ def post_process_matched_download(context_key, context, file_path, runtime, meta context['artist'] = artist_context - playlist_folder_mode = track_info.get("_playlist_folder_mode", False) logger.debug(f"[Debug] Post-processing - track_info type: {type(track_info)}, is None: {track_info is None}, is empty: {not track_info}") - logger.debug(f"[Debug] Post-processing - playlist_folder_mode: {playlist_folder_mode}") if track_info: logger.debug(f"[Debug] Post-processing - track_info keys: {list(track_info.keys())}") - if playlist_folder_mode: - playlist_name = track_info.get("_playlist_name", "Unknown Playlist") - logger.info(f"[Playlist Folder Mode] Organizing in playlist folder: {playlist_name}") - - file_ext = os.path.splitext(file_path)[1] - final_path, _ = build_final_path_for_track(context, artist_context, None, file_ext) - logger.info(f"Playlist mode final path: '{final_path}'") - - if not os.path.exists(file_path): - if os.path.exists(final_path): - logger.info( - f"[Playlist Folder Mode] Source gone but destination exists — already processed by another thread: " - f"{os.path.basename(final_path)}" - ) - context['_final_processed_path'] = final_path - return - pp_logger.info(f"[inner] EXCEPTION in post-processing for {context_key}: Source file not found and destination does not exist: {file_path}") - raise FileNotFoundError(f"Source file not found and destination does not exist: {file_path}") - - context['_audio_quality'] = get_audio_quality_string(file_path) - if context['_audio_quality']: - logger.info(f"Audio quality detected: {context['_audio_quality']}") - - _skip_bit_depth = _should_skip_quarantine_check(context, 'bit_depth') - rejection_reason = None if _skip_bit_depth else check_flac_bit_depth(file_path, context) - if _skip_bit_depth: - logger.info(f"[BitDepth] Skipped (user approval) for {_basename}") - if rejection_reason: - try: - quarantine_path = move_to_quarantine( - file_path, - context, - rejection_reason, - automation_engine, - trigger='bit_depth', - ) - _mark_task_quarantined(context, quarantine_path) - logger.info(f"File quarantined due to bit depth filter: {quarantine_path}") - except Exception as quarantine_error: - logger.error(f"Quarantine failed ({quarantine_error}), deleting file: {file_path}") - try: - os.remove(file_path) - except Exception as e: - logger.debug("delete quarantine fallback: %s", e) - - context['_bitdepth_rejected'] = True - with matched_context_lock: - if context_key in matched_downloads_context: - del matched_downloads_context[context_key] - - task_id = context.get('task_id') - batch_id = context.get('batch_id') - if task_id: - with tasks_lock: - if task_id in download_tasks: - download_tasks[task_id]['status'] = 'failed' - download_tasks[task_id]['error_message'] = f"Bit depth filter: {rejection_reason}" - if task_id and batch_id: - _notify_download_completed(batch_id, task_id, success=False) - return - - try: - logger.warning( - f"[Metadata Input] Playlist mode - artist: '{artist_context.get('name', 'MISSING')}' " - f"(id: {artist_context.get('id', 'MISSING')})" - ) - enhance_file_metadata(file_path, context, artist_context, None, runtime=metadata_runtime) - except Exception as meta_err: - import traceback - pp_logger.info(f"[inner] Metadata enhancement FAILED for {context_key}: {meta_err}\n{traceback.format_exc()}") - if should_wipe_tags_on_enhancement_failure(has_clean_metadata): - wipe_source_tags(file_path) - else: - logger.warning( - "[Metadata] Enhancement failed but import has clean/matched metadata — " - "preserving the file's existing tags (not wiping): %s", - os.path.basename(file_path)) - - logger.info(f"Moving '{os.path.basename(file_path)}' to '{final_path}'") - safe_move_file(file_path, final_path) - context['_final_processed_path'] = final_path - cleanup_slskd_dedup_siblings(file_path) - - if config_manager.get('post_processing.replaygain_enabled', False): - try: - from core.replaygain import analyze_track as _rg_analyze, write_replaygain_tags as _rg_write, is_ffmpeg_available as _rg_ffmpeg_ok, RG_REFERENCE_LUFS as _RG_REF - if _rg_ffmpeg_ok(): - lufs, peak_dbfs = _rg_analyze(final_path) - gain_db = _RG_REF - lufs - _rg_write(final_path, gain_db, peak_dbfs) - pp_logger.info(f"ReplayGain: {gain_db:+.2f} dB — {os.path.basename(final_path)}") - except Exception as rg_err: - pp_logger.debug(f"ReplayGain analysis skipped: {rg_err}") - - downsampled_path = downsample_hires_flac(final_path, context) - if downsampled_path: - final_path = downsampled_path - context['_final_processed_path'] = final_path - - _persist_verification_status(context, final_path) - - blasphemy_path = create_lossy_copy(final_path) - if blasphemy_path: - context['_final_processed_path'] = blasphemy_path - - downloads_path = docker_resolve_path(config_manager.get('soulseek.download_path', './downloads')) - cleanup_empty_directories(downloads_path, file_path) - - logger.info(f"[Playlist Folder Mode] Post-processing complete: {final_path}") - - try: - check_and_remove_from_wishlist(context) - except Exception as wishlist_error: - logger.error(f"[Playlist Folder] Error checking wishlist removal: {wishlist_error}") - - emit_track_downloaded(context, automation_engine) - record_library_history_download(context) - record_download_provenance(context) - - try: - pf_album_info = build_import_album_info(context, force_album=False) - if not pf_album_info or not pf_album_info.get("album_name"): - pf_album_info = { - "is_album": True, - "album_name": playlist_name, - "track_number": track_info.get("track_number", 1) or 1, - "disc_number": track_info.get("disc_number", 1) or 1, - "clean_track_name": get_import_clean_title( - context, - default=get_import_original_search(context).get("title", "Unknown"), - ), - "source": get_import_source(context) or "spotify", - } - elif not pf_album_info.get("is_album"): - pf_album_info["is_album"] = True - record_soulsync_library_entry(context, artist_context, pf_album_info) - except Exception as lib_err: - logger.error(f"[Playlist Folder] SoulSync library registration failed: {lib_err}") - - task_id = context.get('task_id') - batch_id = context.get('batch_id') - if task_id and batch_id: - with tasks_lock: - if task_id in download_tasks: - download_tasks[task_id]['stream_processed'] = True - download_tasks[task_id]['status'] = 'completed' - logger.info(f"[Playlist Folder Mode] Marked task {task_id} as completed") - _notify_download_completed(batch_id, task_id, success=True) - return - is_album_download = bool(context.get("is_album_download", False)) album_info = build_import_album_info(context, force_album=is_album_download) @@ -1026,6 +874,10 @@ def post_process_matched_download(context_key, context, file_path, runtime, meta if task_id in download_tasks: download_tasks[task_id]['stream_processed'] = True download_tasks[task_id]['status'] = 'completed' + # Additive: record where the imported file landed so downstream + # (playlist materialization) knows the real path of a freshly + # downloaded track without re-resolving it. + download_tasks[task_id]['final_file_path'] = context.get('_final_processed_path') logger.info(f"[Post-Process] Marked task {task_id} as completed") _notify_download_completed(batch_id, task_id, success=True) diff --git a/core/itunes_worker.py b/core/itunes_worker.py index 7e3e8a5d..50adba2a 100644 --- a/core/itunes_worker.py +++ b/core/itunes_worker.py @@ -9,7 +9,15 @@ from datetime import datetime, timedelta from utils.logging_config import get_logger from database.music_database import MusicDatabase from core.itunes_client import iTunesClient -from core.worker_utils import accept_artist_match, interruptible_sleep, set_album_api_track_count +from core.worker_utils import ( + accept_artist_match, + artist_name_matches, + interruptible_sleep, + owned_album_titles, + pick_artist_by_catalog, + release_titles, + set_album_api_track_count, +) from core.enrichment.manual_match_honoring import honor_stored_match logger = get_logger("itunes_worker") @@ -392,20 +400,30 @@ class iTunesWorker: logger.debug(f"No iTunes results for artist '{artist_name}'") return - for artist_obj in results: + # Candidates clearing the name gate (results are source-ranked, so [0] is + # the legacy "first passing" pick), then disambiguate same-name artists by + # which one's catalog overlaps the albums this library owns. + gated = [a for a in results if artist_name_matches(artist_name, a.name)] + chosen, _overlap = pick_artist_by_catalog( + gated, + owned_album_titles(self.db, artist_id), + lambda a: release_titles(self.client.get_artist_albums(a.id)), + ) + + if chosen: ok, reason = accept_artist_match( - self.db, 'itunes_artist_id', artist_obj.id, artist_id, - artist_name, artist_obj.name, + self.db, 'itunes_artist_id', chosen.id, artist_id, + artist_name, chosen.name, ) if ok: - if not self._is_itunes_id(artist_obj.id): - logger.warning(f"Rejecting non-iTunes ID '{artist_obj.id}' for artist '{artist_name}'") + if not self._is_itunes_id(chosen.id): + logger.warning(f"Rejecting non-iTunes ID '{chosen.id}' for artist '{artist_name}'") self._mark_status('artist', artist_id, 'error') self.stats['errors'] += 1 return - self._update_artist(artist_id, artist_obj) + self._update_artist(artist_id, chosen) self.stats['matched'] += 1 - logger.info(f"Matched artist '{artist_name}' -> iTunes ID: {artist_obj.id}") + logger.info(f"Matched artist '{artist_name}' -> iTunes ID: {chosen.id}") return self._mark_status('artist', artist_id, 'not_found') diff --git a/core/library/stale_guard.py b/core/library/stale_guard.py index c614934c..0d40ee68 100644 --- a/core/library/stale_guard.py +++ b/core/library/stale_guard.py @@ -42,4 +42,43 @@ def is_implausible_stale_removal( return missing_count > total_count * max_fraction -__all__ = ["is_implausible_stale_removal", "DEFAULT_MIN_TOTAL", "DEFAULT_MAX_MISSING_FRACTION"] +# The orphan detector walks the transfer folder and flags any audio file whose +# path/title doesn't resolve to a DB track. If the DB's stored paths share a base +# prefix the local filesystem no longer has (remount, Docker volume change, WSL +# hiccup), EVERY file misses and the whole library looks "orphaned" — and a user +# batch-applying "move to staging" on those findings would relocate their entire +# library. Same failure mode as stale-removal, so we skip the whole result when +# the orphan share is implausibly large. Needs an absolute floor too: 3/4 orphans +# in a tiny folder is normal, 4000/5000 is a path mismatch. +DEFAULT_MIN_ORPHANS = 20 +DEFAULT_MAX_ORPHAN_FRACTION = 0.5 + + +def is_implausible_orphan_flood( + orphan_count: int, + total_count: int, + *, + min_orphans: int = DEFAULT_MIN_ORPHANS, + max_fraction: float = DEFAULT_MAX_ORPHAN_FRACTION, +) -> bool: + """True when so many files look orphaned that the DB↔filesystem path mapping is + almost certainly broken (not real orphans) and the scan should create NO + findings — otherwise a batch "move to staging" / "delete" could wipe the + library. Below ``min_orphans`` (absolute) it always returns False so small, + genuine orphan sets still surface. + """ + if total_count <= 0 or orphan_count <= 0: + return False + if orphan_count <= min_orphans: + return False + return orphan_count > total_count * max_fraction + + +__all__ = [ + "is_implausible_stale_removal", + "is_implausible_orphan_flood", + "DEFAULT_MIN_TOTAL", + "DEFAULT_MAX_MISSING_FRACTION", + "DEFAULT_MIN_ORPHANS", + "DEFAULT_MAX_ORPHAN_FRACTION", +] diff --git a/core/matching_engine.py b/core/matching_engine.py index 15f91f02..bd60aafe 100644 --- a/core/matching_engine.py +++ b/core/matching_engine.py @@ -115,7 +115,8 @@ class MusicMatchingEngine: # Replace common separators with spaces to preserve word boundaries. # Include hyphen in separator replacement for artist names like "AC/DC" vs "AC-DC" # Include '&' so "Pig&Dan" becomes "Pig Dan" (matches "Pig & Dan" on Soulseek) - text = re.sub(r'[._/&-]', ' ', text) + # Include ':' so "T:T" becomes "T T" (matches "T_T" stored with underscores on Soulseek) + text = re.sub(r'[._/&:\-]', ' ', text) # Keep alphanumeric characters, spaces, AND the '$' sign. # When CJK was detected upstream, also preserve CJK Unified diff --git a/core/metadata/registry.py b/core/metadata/registry.py index 4e6f343d..0d6035e7 100644 --- a/core/metadata/registry.py +++ b/core/metadata/registry.py @@ -442,10 +442,18 @@ def get_primary_source_status( connected = bool(client and client.is_spotify_authenticated()) # No-auth composite (fallback_source='spotify' + metadata.spotify_free): # works without authentication, so treat the free path's availability - # as "connected" too. + # as "connected" too. get_client_for_source() returns None when not + # officially authed, so fetch the client directly to probe the free + # path — otherwise this check can never fire for a no-auth user. if not connected and _get_config_value("metadata.spotify_free", False): + free_client = client + if free_client is None: + try: + free_client = get_spotify_client(client_factory=spotify_client_factory) + except Exception: + free_client = None try: - connected = bool(client and client.is_spotify_metadata_available()) + connected = bool(free_client and free_client.is_spotify_metadata_available()) except Exception: connected = False elif source == "hydrabase": diff --git a/core/musicbrainz_service.py b/core/musicbrainz_service.py index 4508618d..a8a633dd 100644 --- a/core/musicbrainz_service.py +++ b/core/musicbrainz_service.py @@ -5,6 +5,7 @@ from datetime import datetime, timedelta from difflib import SequenceMatcher from utils.logging_config import get_logger from core.musicbrainz_client import MusicBrainzClient +from core.worker_utils import catalog_overlap_score, pick_artist_by_catalog from database.music_database import MusicDatabase logger = get_logger("musicbrainz_service") @@ -117,23 +118,51 @@ class MusicBrainzService: if conn: conn.close() - def match_artist(self, artist_name: str) -> Optional[Dict[str, Any]]: + def _candidate_release_titles(self, mbid: str) -> list: + """Release-group titles for a candidate MBID — the catalog side of + same-name artist disambiguation.""" + if not mbid: + return [] + try: + data = self.mb_client.get_artist(mbid, includes=['release-groups']) + except Exception: + return [] + groups = (data or {}).get('release-groups') or [] + return [g.get('title') for g in groups if isinstance(g, dict) and g.get('title')] + + def match_artist(self, artist_name: str, owned_titles: Optional[list] = None) -> Optional[Dict[str, Any]]: """ - Match an artist by name to MusicBrainz - + Match an artist by name to MusicBrainz. + + ``owned_titles`` — the library artist's owned album titles. When given and + more than one strong same-name candidate exists, the one whose release + groups overlap those owned titles is chosen (disambiguates the ~5 "Rone"s); + omitted → falls back to the highest-confidence candidate as before. + Returns: Dict with 'mbid', 'name', 'confidence' or None if no good match """ # Check cache first cached = self._check_cache('artist', artist_name) if cached: - logger.debug(f"Cache hit for artist '{artist_name}'") - return { - 'mbid': cached['musicbrainz_id'], - 'name': artist_name, - 'confidence': cached['confidence'], - 'cached': True - } + cached_mbid = cached.get('musicbrainz_id') + # Don't trust a cached mbid whose catalog has ZERO overlap with the + # albums this library owns — that's the wrong same-name artist (and a + # re-match would otherwise be blocked for up to the 90-day cache TTL, + # #868). Fall through to a fresh, disambiguated resolve in that case. + stale_wrong_match = bool( + cached_mbid and owned_titles + and catalog_overlap_score(owned_titles, self._candidate_release_titles(cached_mbid)) == 0 + ) + if not stale_wrong_match: + logger.debug(f"Cache hit for artist '{artist_name}'") + return { + 'mbid': cached_mbid, + 'name': artist_name, + 'confidence': cached['confidence'], + 'cached': True + } + logger.debug(f"Cached MB match for '{artist_name}' has no owned-catalog overlap — re-resolving") # Search MusicBrainz try: @@ -144,25 +173,30 @@ class MusicBrainzService: self._save_to_cache('artist', artist_name, None, None, None, 0) return None - # Find best match - best_match = None - best_confidence = 0 - + # Score every candidate (name similarity 60% + MB's own relevance 40%). + scored = [] for result in results: mb_name = result.get('name', '') mb_score = result.get('score', 0) # MusicBrainz search score - - # Calculate our own similarity similarity = self._calculate_similarity(artist_name, mb_name) - - # Combine MusicBrainz score with our similarity (weighted) # Cap at 100 to prevent edge cases where MB score > 100 confidence = min(100, int((similarity * 60) + (mb_score / 100 * 40))) - - if confidence > best_confidence: - best_confidence = confidence - best_match = result - + scored.append((confidence, result)) + scored.sort(key=lambda s: s[0], reverse=True) + + # Among the strong (>=70) candidates, disambiguate same-name artists by + # which one's release groups overlap the albums this library owns. + gated = [r for conf, r in scored if conf >= 70] + best_match = None + best_confidence = scored[0][0] if scored else 0 + if gated: + chosen, _overlap = pick_artist_by_catalog( + gated, owned_titles or [], + lambda r: self._candidate_release_titles(r.get('id')), + ) + best_match = chosen + best_confidence = next(conf for conf, r in scored if r is chosen) + # Only return matches with confidence >= 70% if best_match and best_confidence >= 70: mbid = best_match.get('id') diff --git a/core/musicbrainz_worker.py b/core/musicbrainz_worker.py index 8f7c1241..d197bf74 100644 --- a/core/musicbrainz_worker.py +++ b/core/musicbrainz_worker.py @@ -5,7 +5,7 @@ from datetime import datetime, timedelta from utils.logging_config import get_logger from database.music_database import MusicDatabase from core.musicbrainz_service import MusicBrainzService -from core.worker_utils import interruptible_sleep, source_id_conflict +from core.worker_utils import interruptible_sleep, owned_album_titles, source_id_conflict logger = get_logger("musicbrainz_worker") @@ -366,7 +366,8 @@ class MusicBrainzWorker: return if item_type == 'artist': - result = self.mb_service.match_artist(item_name) + result = self.mb_service.match_artist( + item_name, owned_titles=owned_album_titles(self.db, item_id)) mbid = result.get('mbid') if result else None # MB's combined score can match a weak name ("Grant" -> "Amy # Grant") when its own relevance rank is high. Guard against diff --git a/core/navidrome_client.py b/core/navidrome_client.py index 5a0677ee..edfc7ffc 100644 --- a/core/navidrome_client.py +++ b/core/navidrome_client.py @@ -1,6 +1,7 @@ import requests import hashlib import secrets +import time from typing import List, Optional, Dict, Any from datetime import datetime from urllib.parse import urlencode @@ -161,6 +162,7 @@ class NavidromeClient(MediaServerClient): self.music_folder_id: Optional[str] = None self._connection_attempted = False self._is_connecting = False + self._last_connect_attempt = 0.0 # monotonic time of the last connect try # Cache for performance self._artist_cache = {} @@ -245,15 +247,32 @@ class NavidromeClient(MediaServerClient): logger.error(f"Error setting music folder: {e}") return False + # A failed connect used to latch the client "disconnected" until the user hit + # the manual Test button (a transient ping failure nukes the creds in + # _setup_client). Re-attempt at most this often so it self-heals on its own. + _RECONNECT_THROTTLE_S = 20.0 + def ensure_connection(self) -> bool: - """Ensure connection to Navidrome server with lazy initialization.""" - if self._connection_attempted: - return self.base_url is not None and self.username is not None + """Ensure connection to Navidrome with lazy init + self-healing retry. + + Already connected → return True. A prior FAILED attempt no longer latches + forever: once _RECONNECT_THROTTLE_S has elapsed it re-attempts, so a + transient ping failure (network blip, Navidrome busy mid-scan) recovers by + itself instead of needing the manual "Test" reconnect.""" + if self.base_url is not None and self.username is not None: + return True if self._is_connecting: return False + # Disconnected but attempted recently → don't hammer; let it heal on the + # next check past the throttle window. + if self._connection_attempted and \ + (time.monotonic() - self._last_connect_attempt) < self._RECONNECT_THROTTLE_S: + return False + self._is_connecting = True + self._last_connect_attempt = time.monotonic() try: self._setup_client() return self.base_url is not None and self.username is not None @@ -462,10 +481,11 @@ class NavidromeClient(MediaServerClient): return None def is_connected(self) -> bool: - """Check if connected to Navidrome server""" - if not self._connection_attempted: - if not self._is_connecting: - self.ensure_connection() + """Connected = configured + last connect OK. When NOT connected, trigger a + (throttled) reconnect attempt so the status self-heals instead of staying + latched disconnected until a manual Test.""" + if not (self.base_url and self.username and self.password) and not self._is_connecting: + self.ensure_connection() return (self.base_url is not None and self.username is not None and self.password is not None) diff --git a/core/playlists/materialize.py b/core/playlists/materialize.py new file mode 100644 index 00000000..f48a9001 --- /dev/null +++ b/core/playlists/materialize.py @@ -0,0 +1,214 @@ +"""Materialize a playlist as a folder of links into the real music library. + +A playlist folder is a **view**, not storage. Every entry points at the one real +file that already lives in the library (``Artist/Album/track.ext``); a track is +never stored twice no matter how many playlists it's in. Two modes: + +- ``symlink`` (default): a *relative* symlink to the real file — ~zero disk, and + relative so the tree stays valid if the parent folder is moved. +- ``copy``: a real duplicate of the file — for filesystems/players that can't + follow symlinks (FAT USB sticks, some DAPs) or when a self-contained, + portable folder is wanted. + +Symlinks silently fail or are unsupported on a lot of real setups (Windows +without the privilege, SMB/CIFS shares, FAT/exFAT). So when a symlink can't be +created we **fall back to a copy automatically** — the folder is always fully +populated, never left with dangling links. + +This module is pure filesystem mechanics: no DB, no app state. Given a list of +real file paths, a playlists root, a playlist name and a mode, it (re)builds the +folder to match. That makes it the single unit-tested source of truth for "how a +playlist folder looks on disk", and means the folder is a *derived view* that can +be rebuilt from scratch at any time. Filesystem ops are injectable so the +behaviour — including the symlink→copy fallback — is testable without depending +on the host filesystem's symlink support. +""" + +from __future__ import annotations + +import os +import shutil +from dataclasses import dataclass, field +from typing import Callable, List, Optional, Sequence + +from core.imports.paths import sanitize_filename + +MATERIALIZE_MODES = ("symlink", "copy") +DEFAULT_MODE = "symlink" + + +def normalize_mode(mode: Optional[str]) -> str: + """Coerce a config value to a valid mode (``symlink`` default).""" + m = (mode or "").strip().lower() + return m if m in MATERIALIZE_MODES else DEFAULT_MODE + + +@dataclass +class RebuildSummary: + """Outcome of rebuilding one playlist folder. ``copied`` may be non-zero even + in symlink mode when the fallback kicked in (flagged by ``fellback``).""" + playlist_dir: str = "" + linked: int = 0 + copied: int = 0 + unchanged: int = 0 + removed_stale: int = 0 + missing_source: int = 0 + failed: int = 0 + fellback: bool = False + mode_requested: str = DEFAULT_MODE + errors: List[str] = field(default_factory=list) + + +def playlist_dir_for(playlists_root: str, playlist_name: str) -> str: + """Absolute path of one playlist's folder, sanitized and guaranteed to stay + directly under ``playlists_root`` (defends against ``..`` / separators in a + playlist name).""" + root = os.path.abspath(playlists_root) + safe_name = sanitize_filename(playlist_name or "").strip() or "Unnamed Playlist" + candidate = os.path.abspath(os.path.join(root, safe_name)) + if os.path.dirname(candidate) != root: + safe_name = sanitize_filename(os.path.basename(candidate)) or "Unnamed Playlist" + candidate = os.path.join(root, safe_name) + return candidate + + +def _desired_entries(playlist_dir: str, real_paths: Sequence[str]) -> "list[tuple[str, str]]": + """Map each real file to a flat destination inside ``playlist_dir``, preserving + the source filename. On a basename collision between two *different* sources, + disambiguate with a numeric suffix rather than silently overwriting.""" + entries: list[tuple[str, str]] = [] + used: dict[str, str] = {} # dest basename -> source real path + for real in real_paths: + if not real: + continue + base = os.path.basename(real) + name = base + stem, ext = os.path.splitext(base) + counter = 1 + while name in used and used[name] != os.path.abspath(real): + counter += 1 + name = f"{stem} ({counter}){ext}" + used[name] = os.path.abspath(real) + entries.append((os.path.abspath(real), os.path.join(playlist_dir, name))) + return entries + + +def _symlink_is_current(dest: str, rel_target: str) -> bool: + try: + return os.path.islink(dest) and os.readlink(dest) == rel_target + except OSError: + return False + + +def _remove_entry(path: str) -> None: + """Remove an existing file/symlink at ``path`` (incl. a broken symlink).""" + if os.path.islink(path) or os.path.exists(path): + try: + os.remove(path) + except OSError: + pass + + +def materialize_one( + real_path: str, + dest_path: str, + mode: str = DEFAULT_MODE, + *, + symlink_fn: Callable[[str, str], None] = os.symlink, + copy_fn: Callable[[str, str], object] = shutil.copy2, +) -> str: + """Create one playlist entry at ``dest_path`` pointing at ``real_path``. + + Idempotent: a correct existing entry is left alone. In ``symlink`` mode a + relative link is used; if it can't be created (unsupported FS, no privilege) + it falls back to a copy so the entry is never left broken. Returns one of: + ``'linked'``, ``'copied'``, ``'unchanged'``, ``'fellback'`` (symlink + requested but copied), ``'missing'`` (source gone).""" + if not real_path or not os.path.exists(real_path): + return "missing" + + dest_dir = os.path.dirname(dest_path) + os.makedirs(dest_dir, exist_ok=True) + rel_target = os.path.relpath(os.path.abspath(real_path), start=dest_dir) + + if mode == "copy": + if os.path.isfile(dest_path) and not os.path.islink(dest_path): + return "unchanged" + _remove_entry(dest_path) + copy_fn(real_path, dest_path) + return "copied" + + # symlink mode + if _symlink_is_current(dest_path, rel_target): + return "unchanged" + _remove_entry(dest_path) + try: + symlink_fn(rel_target, dest_path) + return "linked" + except (OSError, NotImplementedError): + copy_fn(real_path, dest_path) + return "fellback" + + +def rebuild_playlist_folder( + playlists_root: str, + playlist_name: str, + real_paths: Sequence[str], + mode: str = DEFAULT_MODE, + *, + prune_stale: bool = True, + symlink_fn: Callable[[str, str], None] = os.symlink, + copy_fn: Callable[[str, str], object] = shutil.copy2, +) -> RebuildSummary: + """(Re)build ``playlists_root//`` so it contains exactly one + entry per real file in ``real_paths`` — adding missing entries, leaving correct + ones untouched, and (when ``prune_stale``) removing entries no longer present. + Idempotent and safe to re-run any time. Filesystem ops are injectable.""" + mode = normalize_mode(mode) + pdir = playlist_dir_for(playlists_root, playlist_name) + summary = RebuildSummary(playlist_dir=pdir, mode_requested=mode) + os.makedirs(pdir, exist_ok=True) + + entries = _desired_entries(pdir, real_paths) + keep = {dest for _real, dest in entries} + + for real, dest in entries: + try: + outcome = materialize_one(real, dest, mode, symlink_fn=symlink_fn, copy_fn=copy_fn) + except OSError as e: + summary.failed += 1 + summary.errors.append(f"{os.path.basename(dest)}: {e}") + continue + if outcome == "linked": + summary.linked += 1 + elif outcome == "copied": + summary.copied += 1 + elif outcome == "fellback": + summary.copied += 1 + summary.fellback = True + elif outcome == "unchanged": + summary.unchanged += 1 + elif outcome == "missing": + summary.missing_source += 1 + + if prune_stale and os.path.isdir(pdir): + for name in os.listdir(pdir): + full = os.path.join(pdir, name) + if full in keep: + continue + if os.path.islink(full) or os.path.isfile(full): + _remove_entry(full) + summary.removed_stale += 1 + + return summary + + +__all__ = [ + "MATERIALIZE_MODES", + "DEFAULT_MODE", + "normalize_mode", + "RebuildSummary", + "playlist_dir_for", + "materialize_one", + "rebuild_playlist_folder", +] diff --git a/core/playlists/materialize_service.py b/core/playlists/materialize_service.py new file mode 100644 index 00000000..c8121b86 --- /dev/null +++ b/core/playlists/materialize_service.py @@ -0,0 +1,215 @@ +"""Build a playlist's materialized folder from a FINISHED organize-by-playlist +download batch. + +The batch already carries the real on-disk location of every resolved track: + + - **owned** tracks → ``analysis_results[*]['matched_file_path']`` (the library + matcher's result, captured during analysis), and + - **downloaded** tracks → ``download_tasks[tid]['final_file_path']`` (where the + import landed). + +So this is pure stitching + filesystem work: **no re-matching, no source-ID +lookup, no mirrored-playlist resolution**. It works for any organize-by-playlist +download, mirrored or not, and for the all-owned case (where nothing downloads). + +Each stored path is run through ``resolve_library_file_path`` — the same resolver +playback uses — so a DB path that's host-formatted (Docker) maps to the real file +the container can see; a freshly-downloaded path already exists and passes +through unchanged. +""" + +from __future__ import annotations + +from typing import Any, List, Optional + +from core.imports.paths import docker_resolve_path +from core.playlists.materialize import ( + RebuildSummary, + normalize_mode, + rebuild_playlist_folder, +) +from utils.logging_config import get_logger + +logger = get_logger("playlists.materialize") + + +def collect_batch_real_paths(batch: dict, download_tasks: dict, *, config_manager) -> List[str]: + """Real on-disk paths of every track in a finished batch that resolved — + owned (from analysis) + downloaded (from completed tasks) — resolved to this + process's filesystem and de-duplicated, in playlist order then download order.""" + from core.library.path_resolver import resolve_library_file_path + + out: List[str] = [] + seen = set() + + def _add(stored_path: Any) -> None: + if not stored_path: + return + real = resolve_library_file_path(str(stored_path), config_manager=config_manager) + if real and real not in seen: + seen.add(real) + out.append(real) + + for res in (batch.get("analysis_results") or []): + if res.get("found"): + _add(res.get("matched_file_path")) + + for tid in (batch.get("queue") or []): + task = (download_tasks or {}).get(tid) or {} + if task.get("status") == "completed": + _add(task.get("final_file_path")) + + return out + + +def materialize_playlist_from_batch(batch: dict, download_tasks: dict, config_manager) -> Optional[RebuildSummary]: + """(Re)build the playlist folder for a finished organize-by-playlist batch. + + Returns the :class:`RebuildSummary`, or ``None`` when the batch isn't an + organize-by-playlist batch. Reads ``playlists.materialize_path`` / + ``playlists.materialize_mode`` from config.""" + if not batch or not batch.get("playlist_folder_mode"): + return None + name = batch.get("playlist_name") or "Unknown Playlist" + real_paths = collect_batch_real_paths(batch, download_tasks, config_manager=config_manager) + root = docker_resolve_path(config_manager.get("playlists.materialize_path", "./Playlists")) + mode = normalize_mode(config_manager.get("playlists.materialize_mode", "symlink")) + return rebuild_playlist_folder(root, name, real_paths, mode) + + +def reconcile_batch_playlists(db, batch: dict, download_tasks: dict, config_manager, *, profile_id: int = 1): + """One post-batch step: rebuild every organize-by-playlist playlist this batch + TOUCHED, from CURRENT library ownership. + + Touched playlists = the batch's own organize playlist + any playlist a completed + track belongs to (via the per-track ``source_info`` provenance — covers a + wishlist batch fulfilling a track that belongs to an organize playlist). Each is + rebuilt via ``_rebuild_one_from_db`` (``check_track_exists`` over its membership), + so it's robust to HOW a track imported — modal worker, slskd monitor, or the + verification worker, which don't all set the same task fields. It simply asks the + library what's owned, and prunes tracks that have left the playlist. + + Returns ``[(playlist_name, RebuildSummary)]``. Callers wrap non-fatally.""" + import json as _json + + # Collect the (ref, source) of every organize playlist this batch touched. + # (ref, source, force). force=True for the batch's OWN playlist: the per-download + # "organize by playlist" toggle (batch playlist_folder_mode) is the user's + # intent, so it rebuilds regardless of the saved row preference. Provenance + # playlists (force=False) require the saved organize_by_playlist flag. + wanted, seen_ref = [], set() + + def _want(ref, source, force): + ref = str(ref or "").strip() + if not ref: + return + key = (ref, source or "spotify") + if key not in seen_ref: + seen_ref.add(key) + wanted.append((ref, source or "spotify", force)) + + if batch.get("playlist_folder_mode"): + _want(batch.get("source_playlist_ref") or batch.get("playlist_id"), + batch.get("batch_source") or "spotify", True) + + for tid in (batch.get("queue") or []): + task = (download_tasks or {}).get(tid) or {} + if task.get("status") != "completed": + continue + si = (task.get("track_info") or {}).get("source_info") or {} + if isinstance(si, str): + try: + si = _json.loads(si) + except Exception: + si = {} + if isinstance(si, dict) and si.get("playlist_id"): + _want(si["playlist_id"], si.get("source") or "spotify", False) + + results, seen_id = [], set() + for ref, source, force in wanted: + try: + pl = db.resolve_mirrored_playlist(ref, profile_id, default_source=source) + except Exception as e: + logger.debug("[Playlist Folder] resolve failed for ref=%s source=%s: %s", ref, source, e) + pl = None + if not pl: + logger.info("[Playlist Folder] no mirrored playlist resolved for ref=%s source=%s " + "(force=%s) — folder not built", ref, source, force) + continue + if pl.get("id") in seen_id: + continue + if not force and not pl.get("organize_by_playlist"): + continue + seen_id.add(pl.get("id")) + try: + results.append(_rebuild_one_from_db(db, config_manager, pl)) + except Exception as e: + logger.error("[Playlist Folder] rebuild failed for '%s': %s", pl.get("name"), e) + continue + return results + + +def _rebuild_one_from_db(db, config_manager, playlist: dict): + """Rebuild ONE playlist's folder from its CURRENT membership × ownership — + re-matching each member via the app's own ``check_track_exists`` (by name, not + source IDs), resolving to disk, and rebuilding WITH prune. Because it's driven + by current membership, a track that has LEFT the playlist drops out of the set + and its symlink is pruned. Returns ``(playlist_name, RebuildSummary)``.""" + from core.library.path_resolver import resolve_library_file_path + + root = docker_resolve_path(config_manager.get("playlists.materialize_path", "./Playlists")) + mode = normalize_mode(config_manager.get("playlists.materialize_mode", "symlink")) + real_paths: List[str] = [] + seen = set() + for t in (db.get_mirrored_playlist_tracks(playlist["id"]) or []): + title = (t.get("track_name") or "").strip() + artist = (t.get("artist_name") or "").strip() + if not title: + continue + try: + db_track, conf = db.check_track_exists(title, artist, confidence_threshold=0.7) + except Exception: + continue + if db_track is None or conf < 0.7: + continue + real = resolve_library_file_path(getattr(db_track, "file_path", None), config_manager=config_manager) + if real and real not in seen: + seen.add(real) + real_paths.append(real) + name = playlist.get("name") or "Unnamed Playlist" + return name, rebuild_playlist_folder(root, name, real_paths, mode) # prune_stale=True + + +def rebuild_organized_playlists_from_db(db, config_manager, *, profile_id: int = 1): + """Rebuild EVERY "organize by playlist" folder from current library ownership — + for the manual "Rebuild" button. Self-heals after a library reorganize moves + files or membership changes. Returns a list of ``(playlist_name, summary)``.""" + return [ + _rebuild_one_from_db(db, config_manager, pl) + for pl in (db.get_mirrored_playlists(profile_id) or []) + if pl.get("organize_by_playlist") + ] + + +def rebuild_mirrored_playlist_if_organized(db, config_manager, playlist_id, *, profile_id: int = 1): + """Mirror-update hook: after a playlist's membership is re-synced, rebuild its + folder (with prune) IF it's organize-by-playlist — so a track that just LEFT + the playlist has its symlink cleaned up the instant membership changes (the + mirror image of the post-download reconcile that handles additions). Returns + the summary, or ``None`` when the playlist isn't organized / can't be found.""" + if playlist_id is None: + return None + pl = db.get_mirrored_playlist(playlist_id) + if not pl or not pl.get("organize_by_playlist"): + return None + _name, summary = _rebuild_one_from_db(db, config_manager, pl) + return summary + + +__all__ = [ + "collect_batch_real_paths", + "materialize_playlist_from_batch", + "reconcile_batch_playlists", + "rebuild_organized_playlists_from_db", + "rebuild_mirrored_playlist_if_organized", +] diff --git a/core/playlists/naming.py b/core/playlists/naming.py new file mode 100644 index 00000000..a588b122 --- /dev/null +++ b/core/playlists/naming.py @@ -0,0 +1,30 @@ +"""Effective name for a mirrored playlist. + +A mirrored playlist tracks an upstream playlist, so its ``name`` column is +rewritten from upstream on every refresh (see ``mirror_playlist``). Users can set +a ``custom_name`` alias that overrides what's shown in the UI and what's used when +syncing the playlist to the media server — while staying tied to the original +(the upstream ``name`` keeps tracking; the alias just overrides the visible/synced +label, and lives in its own column so refresh never clobbers it). + +This module is the single, pure source of truth for "which name wins", so the API +payload, the card, and the sync path all agree. +""" + +from __future__ import annotations + +from typing import Any, Mapping + + +def effective_mirrored_name(playlist: Mapping[str, Any]) -> str: + """Return the name to DISPLAY + SYNC: the user's ``custom_name`` alias when + set (non-blank), otherwise the upstream ``name``. Always returns a string.""" + if not isinstance(playlist, Mapping): + return '' + custom = str(playlist.get('custom_name') or '').strip() + if custom: + return custom + return str(playlist.get('name') or '').strip() + + +__all__ = ['effective_mirrored_name'] diff --git a/core/playlists/source_refs.py b/core/playlists/source_refs.py index 22909895..4cf03534 100644 --- a/core/playlists/source_refs.py +++ b/core/playlists/source_refs.py @@ -19,6 +19,31 @@ from urllib.parse import parse_qs, urlparse _SPOTIFY_ID_RE = re.compile(r"^[A-Za-z0-9]{16,32}$") +# Synthetic batch playlist_id prefixes that wrap a mirrored_playlists PK. +# Download/discovery flows build a batch playlist_id as f"{prefix}{pk}" — e.g. +# auto_mirror_ (core/automation/handlers/sync_playlist.py), youtube_mirrored_ +# (YouTube discovery), and mirrored_ (web_server url hashes). The trailing +# digits are the mirrored_playlists primary key, NOT an upstream source id, so a +# (source, source_playlist_id) lookup will never match them. +_MIRRORED_PK_PREFIXES = ("youtube_mirrored_", "auto_mirror_", "mirrored_") + + +def extract_mirrored_pk(playlist_ref: object) -> Optional[int]: + """Return the mirrored_playlists PK from a synthetic batch ref, else None. + + Handles the synthetic forms above plus a bare numeric ref. Anything else + (a real upstream source id) returns None so the caller falls back to a + (source, source_playlist_id) lookup. + """ + ref = str(playlist_ref or "").strip() + if not ref: + return None + for prefix in _MIRRORED_PK_PREFIXES: + if ref.startswith(prefix): + tail = ref[len(prefix):] + return int(tail) if tail.isdigit() else None + return int(ref) if ref.isdigit() else None + @dataclass(frozen=True) class MirroredSourceRef: diff --git a/core/repair_jobs/__init__.py b/core/repair_jobs/__init__.py index 4a60b2e0..35a236bd 100644 --- a/core/repair_jobs/__init__.py +++ b/core/repair_jobs/__init__.py @@ -34,6 +34,8 @@ _JOB_MODULES = [ 'core.repair_jobs.acoustid_scanner', 'core.repair_jobs.missing_cover_art', 'core.repair_jobs.missing_lyrics', + 'core.repair_jobs.replaygain_filler', + 'core.repair_jobs.empty_folder_cleaner', 'core.repair_jobs.expired_download_cleaner', 'core.repair_jobs.metadata_gap_filler', 'core.repair_jobs.album_completeness', @@ -48,6 +50,7 @@ _JOB_MODULES = [ 'core.repair_jobs.discography_backfill', 'core.repair_jobs.canonical_version_resolve', 'core.repair_jobs.library_retag', + 'core.repair_jobs.quality_upgrade', ] diff --git a/core/repair_jobs/empty_folder_cleaner.py b/core/repair_jobs/empty_folder_cleaner.py new file mode 100644 index 00000000..0f8f16e3 --- /dev/null +++ b/core/repair_jobs/empty_folder_cleaner.py @@ -0,0 +1,195 @@ +"""Empty Folder Cleaner maintenance job (corruption's request). + +After imports, relocations, and deletions, the music library accumulates empty +artist/album folders (and folders left holding only OS junk like .DS_Store). This +scans the library root and flags folders that are safe to remove, so the library +stays tidy. + +Safety is the whole point — deleting directories is destructive: + - only TRULY empty folders (no real files) are ever flagged; a folder with a + cover.jpg or any audio is never touched, + - optionally folders holding *only* OS-junk files (.DS_Store, Thumbs.db, …), + - the library root itself is never removed, nor symlinked directories, + - it walks bottom-up so a parent left empty by its (removable) children cascades, + - and the apply handler RE-CHECKS emptiness at delete time, so anything that + gained a file between scan and apply is left alone. + +``dir_is_removable`` is the pure decision seam — unit-tested independent of the FS. +""" + +from __future__ import annotations + +import os +from typing import Iterable, List + +from core.repair_jobs import register_job +from core.repair_jobs.base import JobContext, JobResult, RepairJob +from utils.logging_config import get_logger + +logger = get_logger("repair_jobs.empty_folder_cleaner") + +# Files that don't count as real content — safe to delete along with the folder. +JUNK_FILES = {'.ds_store', 'thumbs.db', 'desktop.ini', '.directory', 'album.nfo~'} + + +def is_junk(name: str) -> bool: + return (name or '').lower() in JUNK_FILES + + +def dir_is_removable(files: Iterable[str], surviving_subdirs: Iterable[str], + *, ignore_junk: bool = True) -> bool: + """Pure: is a directory safe to remove? + + Removable iff it has **no surviving subdirectories** and **no real files** — + where "no real files" means literally empty, or (when ``ignore_junk``) only + OS-junk files. ``surviving_subdirs`` is the list of child dirs that are NOT + themselves being removed (i.e. still hold content). + """ + if list(surviving_subdirs): + return False + files = list(files) + if not files: + return True + if not ignore_junk: + return False + return all(is_junk(f) for f in files) + + +@register_job +class EmptyFolderCleanerJob(RepairJob): + job_id = 'empty_folder_cleaner' + display_name = 'Empty Folder Cleaner' + description = 'Finds empty (or junk-only) folders in the library and removes them' + help_text = ( + 'Scans your music library for empty folders left behind after imports, ' + 'relocations, and deletions — empty artist/album folders, or folders that ' + 'hold only OS junk like .DS_Store / Thumbs.db.\n\n' + 'A finding is created for each. Applying one deletes the folder (after ' + 're-checking it is still empty). Folders that contain any real file — a ' + 'cover image, an audio track, anything — are never touched, the library ' + 'root is never removed, and it cascades: a folder left empty once its ' + 'empty children are removed is cleaned too.' + ) + icon = 'repair-icon-folder' + default_enabled = False + default_interval_hours = 168 # weekly — empties accrue slowly + default_settings = {'remove_junk_files': True} + auto_fix = False + + def scan(self, context: JobContext) -> JobResult: + result = JobResult() + + root = context.transfer_folder + if not root or not os.path.isdir(root): + logger.info("[Empty Folder Cleaner] library root not available — skipping") + return result + root = os.path.realpath(root) + + ignore_junk = True + try: + if context.config_manager: + ignore_junk = bool(context.config_manager.get( + 'repair.jobs.empty_folder_cleaner.remove_junk_files', True)) + except Exception: # noqa: S110 — setting read is best-effort; defaults to True + pass + + flagged = set() # dir paths we'd remove → a parent sees them as "gone" + + # topdown=False ⇒ deepest first, so children are decided before parents. + for dirpath, dirnames, filenames in os.walk(root, topdown=False): + if context.check_stop(): + return result + real = os.path.realpath(dirpath) + if real == root: + continue # never the library root itself + if os.path.islink(dirpath): + continue # don't delete symlinked dirs + result.scanned += 1 + + surviving = [d for d in dirnames + if os.path.join(dirpath, d) not in flagged] + if not dir_is_removable(filenames, surviving, ignore_junk=ignore_junk): + result.skipped += 1 + continue + + flagged.add(dirpath) + junk = [f for f in filenames if is_junk(f)] + rel = os.path.relpath(dirpath, root) + if context.report_progress: + context.report_progress(log_line=f'Empty folder: {rel}', log_type='info') + if context.create_finding: + try: + inserted = context.create_finding( + job_id=self.job_id, + finding_type='empty_folder', + severity='info', + entity_type='folder', + entity_id=dirpath, + file_path=dirpath, + title=f'Empty folder: {os.path.basename(dirpath) or rel}', + description=(f'"{rel}" holds no music' + + (f' (only {len(junk)} junk file(s))' if junk else '') + + ' — safe to remove.'), + details={ + 'folder_path': dirpath, + 'junk_files': junk, + 'remove_junk': ignore_junk, + }) + if inserted: + result.findings_created += 1 + else: + result.findings_skipped_dedup += 1 + except Exception as e: + logger.debug("[Empty Folder Cleaner] create finding failed for %s: %s", dirpath, e) + result.errors += 1 + + logger.info("[Empty Folder Cleaner] %d folders scanned, %d empty flagged", + result.scanned, result.findings_created) + return result + + def estimate_scope(self, context: JobContext) -> int: + root = context.transfer_folder + if not root or not os.path.isdir(root): + return 0 + total = 0 + for _dp, dirnames, _f in os.walk(root): + total += len(dirnames) + return total + + +def remove_empty_folder(folder_path: str, *, junk_files: List[str], remove_junk: bool, + root: str, listdir, isdir, islink, remove_file, rmdir) -> dict: + """Pure-ish orchestration for the apply handler — RE-CHECKS the folder is still + removable, then deletes any junk + the folder. Effects injected for testing. + + Returns ``{'removed': bool, 'error': str|None}``. Refuses to touch the root, a + symlink, a non-dir, or a folder that gained real content since the scan. + """ + if not folder_path or not isdir(folder_path): + return {'removed': False, 'error': 'Folder no longer exists'} + if islink(folder_path): + return {'removed': False, 'error': 'Refusing to remove a symlinked folder'} + if root and os.path.realpath(folder_path) == os.path.realpath(root): + return {'removed': False, 'error': 'Refusing to remove the library root'} + + # Re-check at apply time: only junk/empty now? (Anything else = leave it.) + entries = list(listdir(folder_path)) + real_entries = [e for e in entries if not (remove_junk and is_junk(e))] + if real_entries: + return {'removed': False, 'error': 'Folder is no longer empty — left untouched'} + + if remove_junk: + for j in entries: + if is_junk(j): + try: + remove_file(os.path.join(folder_path, j)) + except Exception: # noqa: S110 — junk best-effort; rmdir below fails loudly if blocked + pass + try: + rmdir(folder_path) + except Exception as e: + return {'removed': False, 'error': f'Could not remove folder: {e}'} + return {'removed': True, 'error': None} + + +__all__ = ['dir_is_removable', 'is_junk', 'remove_empty_folder', 'EmptyFolderCleanerJob'] diff --git a/core/repair_jobs/library_reorganize.py b/core/repair_jobs/library_reorganize.py index 6d7e68d2..845e8319 100644 --- a/core/repair_jobs/library_reorganize.py +++ b/core/repair_jobs/library_reorganize.py @@ -161,6 +161,16 @@ class LibraryReorganizeJob(RepairJob): album_id = album_row['id'] album_title = album_row['title'] or 'Unknown Album' + # Default to the API planner (authoritative metadata via source IDs). + # But media-server libraries usually have NO source IDs, so that path + # dead-ends at 'no_source_id' and the job only ever reports "needs + # enrichment" without moving anything (#862). Reorganizing to match + # the template only needs the metadata already on the files — so when + # the API planner can't resolve a source, fall back to TAG mode, which + # reads each file's embedded title/artist/album/year (the year is what + # the user's "($year) $album" template needs). Only genuinely tag-poor + # albums then fall through to a finding. + reorg_metadata_source = 'api' try: preview = preview_album_reorganize( album_id=str(album_id), @@ -169,6 +179,18 @@ class LibraryReorganizeJob(RepairJob): resolve_file_path_fn=_resolve, build_final_path_fn=build_final_path_for_track, ) + if preview.get('status') == 'no_source_id': + tags_preview = preview_album_reorganize( + album_id=str(album_id), + db=context.db, + transfer_dir=transfer_dir, + resolve_file_path_fn=_resolve, + build_final_path_fn=build_final_path_for_track, + metadata_source='tags', + ) + if tags_preview.get('status') == 'planned': + preview = tags_preview + reorg_metadata_source = 'tags' except Exception as exc: logger.warning( "Reorganize preview failed for album %s ('%s'): %s", @@ -189,9 +211,10 @@ class LibraryReorganizeJob(RepairJob): continue if status == 'no_source_id': - # Can't compute destinations without a metadata source — - # skip cleanly with a single finding rather than 12 per-track - # "no source" findings that would clutter the UI. + # Reached only when BOTH the API planner (no source ID) AND the + # tag-mode fallback (files missing essential title/artist/album + # tags, or not on disk) failed — so no destination can be computed. + # One album-level finding rather than N per-track ones (UI clutter). result.skipped += len(tracks) or 1 if dry_run and context.create_finding and tracks: inserted = context.create_finding( @@ -201,12 +224,13 @@ class LibraryReorganizeJob(RepairJob): entity_type='album', entity_id=str(album_id), file_path=None, - title=f'Needs enrichment: {album_title}', + title=f'Cannot place: {album_title}', description=( f"Album '{album_title}' by {preview.get('artist', '?')} " - "has no metadata source ID — run enrichment first to " - "populate at least one of spotify_album_id / " - "itunes_album_id / deezer_id / discogs_id / soul_id." + "couldn't be reorganized: it has no metadata source ID " + "AND its files are missing essential tags (title / artist " + "/ album) or aren't on disk. Re-tag the files or run " + "'Fix Unknown Artists', then run this job again." ), details={'album_id': str(album_id), 'reason': 'no_source_id'}, ) @@ -280,6 +304,10 @@ class LibraryReorganizeJob(RepairJob): 'artist_id': str(album_row.get('artist_id') or ''), 'artist_name': preview.get('artist') or album_row.get('artist_name') or 'Unknown Artist', 'source': preview.get('source'), + # Carry the mode the preview actually used so the live move + # matches it — otherwise the queue runner defaults to 'api' + # and a tag-mode-only album would fail at apply time (#862). + 'metadata_source': reorg_metadata_source, }) if context.update_progress and (i + 1) % 25 == 0: diff --git a/core/repair_jobs/orphan_file_detector.py b/core/repair_jobs/orphan_file_detector.py index 5c6e69cd..1b2b5b5f 100644 --- a/core/repair_jobs/orphan_file_detector.py +++ b/core/repair_jobs/orphan_file_detector.py @@ -218,17 +218,33 @@ class OrphanFileDetectorJob(RepairJob): if context.update_progress and (i + 1) % 50 == 0: context.update_progress(i + 1, total) - # Safety check: if most files look like orphans, it's probably a path - # mismatch between the DB and filesystem — not actual orphans. - orphan_ratio = len(orphan_files) / total if total else 0 - mass_orphan = orphan_ratio > 0.5 and len(orphan_files) > 20 - - if mass_orphan: + # Safety: if most files look like orphans, it's almost certainly a path + # mismatch between the DB and filesystem (remount / Docker volume change), + # NOT real orphans. Creating findings anyway is dangerous — a user batch- + # applying "move to staging" / "delete" on them would relocate or wipe the + # whole library. So we create NO findings here, the same hard skip the + # stale-removal paths use. Fix the path mismatch and real orphans surface. + from core.library.stale_guard import is_implausible_orphan_flood + if is_implausible_orphan_flood(len(orphan_files), total): + pct = (len(orphan_files) / total * 100) if total else 0 logger.warning( - "Mass orphan warning: %d of %d files (%.0f%%) flagged as orphans — " - "this likely indicates a DB path mismatch, not actual orphans", - len(orphan_files), total, orphan_ratio * 100 + "Mass orphan guard: %d of %d files (%.0f%%) flagged as orphans — " + "almost certainly a DB↔filesystem path mismatch, not real orphans. " + "Creating no findings so a batch move/delete can't wipe the library.", + len(orphan_files), total, pct, ) + if context.report_progress: + context.report_progress( + log_line=(f'Skipped: {len(orphan_files)} of {total} files look ' + 'orphaned — likely a DB path mismatch, not real orphans. ' + 'No findings created.'), + log_type='skip', + ) + if context.update_progress: + context.update_progress(total, total) + logger.info("Orphan file scan: %d files scanned, mass-orphan guard tripped " + "(0 findings)", result.scanned) + return result for fpath in orphan_files: if context.report_progress: @@ -243,16 +259,12 @@ class OrphanFileDetectorJob(RepairJob): inserted = context.create_finding( job_id=self.job_id, finding_type='orphan_file', - severity='warning' if mass_orphan else 'info', + severity='info', entity_type='file', entity_id=None, file_path=fpath, title=f'Orphan file: {os.path.basename(fpath)}', description=( - 'Audio file in transfer folder is not tracked in the database. ' - 'WARNING: Mass orphan detection triggered — this may be a path ' - 'mismatch, not actual orphans. Verify before deleting!' - ) if mass_orphan else ( 'Audio file in transfer folder is not tracked in the database' ), details={ @@ -261,7 +273,6 @@ class OrphanFileDetectorJob(RepairJob): 'modified': time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(stat.st_mtime)), 'folder': os.path.dirname(fpath), - 'mass_orphan': mass_orphan, } ) if inserted: diff --git a/core/repair_jobs/quality_upgrade.py b/core/repair_jobs/quality_upgrade.py new file mode 100644 index 00000000..f0ad8c53 --- /dev/null +++ b/core/repair_jobs/quality_upgrade.py @@ -0,0 +1,720 @@ +"""Quality Upgrade Finder maintenance job. + +Replaces the old auto-acting "Quality Scanner" tool. That tool decided quality +purely by file EXTENSION (so a 128 kbps MP3 and a 320 kbps MP3 looked identical), +ignored the bitrate-based quality profile, and silently dumped every match +straight into the wishlist with no review — which, on the default profile, meant +flagging an entire non-lossless library at once. + +This job does it the way the rest of the app works: it SCANS (watchlist artists +or the whole library), judges each track against the user's quality profile using +BOTH format and bitrate, and for anything below the preferred quality it searches +the configured metadata source for a better version and emits a FINDING. Nothing +is queued until you review and Apply the finding — at which point the matched +track (carrying its album context) is added to the wishlist, exactly like every +other acquisition path. + +The quality decision (``meets_preferred_quality``) is a pure function so it can be +unit-tested without a database or network. Transcode/"fake lossless" detection is +intentionally NOT done here — that's the separate Fake Lossless Detector job. +""" + +from __future__ import annotations + +import os +import time +from typing import Any, Dict, List, Optional, Tuple + +from core.metadata.registry import get_client_for_source, get_primary_source, get_source_priority +from core.repair_jobs import register_job +from core.repair_jobs.base import JobContext, JobResult, RepairJob +# Reuse the (tested) provider search + result-normalization helpers from the old +# scanner module so matching stays a single source of truth. +from core.discovery.quality_scanner import ( + _extract_lookup_value, + _normalize_track_match, + _search_tracks_for_source, + _track_artist_names, + _track_name, +) +from core.library.file_tags import read_embedded_tags +from core.library.path_resolver import resolve_library_file_path +from utils.logging_config import get_logger + +logger = get_logger("repair_jobs.quality_upgrade") + + +# Quality ranks — higher is better. Lossless tops everything; lossy tiers fall out +# of bitrate. 0 means "below the lowest tracked tier / unknown". +RANK_LOSSLESS = 4 +RANK_320 = 3 +RANK_256 = 2 +RANK_192 = 1 +RANK_BELOW = 0 + +LOSSLESS_EXTENSIONS = {'.flac', '.alac', '.ape', '.wav', '.aiff', '.aif', '.dsf', '.dff', '.m4a'} +# NB: .m4a is ambiguous (ALAC vs AAC); we treat the *format* as lossy-capable and +# rely on bitrate below — a true ALAC .m4a reports a lossless-scale bitrate. + +# Quality-profile bucket key -> rank. +_PROFILE_KEY_RANK = { + 'flac': RANK_LOSSLESS, + 'mp3_320': RANK_320, + 'mp3_256': RANK_256, + 'mp3_192': RANK_192, +} + +# Per-source file-tag key holding that source's own track ID (written by enrichment). +_SOURCE_TRACK_ID_TAG = { + 'spotify': 'spotify_track_id', + 'deezer': 'deezer_track_id', + 'itunes': 'itunes_track_id', + 'audiodb': 'audiodb_track_id', + 'musicbrainz': 'musicbrainz_releasetrackid', + 'tidal': 'tidal_track_id', +} + +# Reject a fuzzy candidate whose length differs from ours by more than this (ms) — +# catches wrong versions (live/edit/remix) that share a title. Exact tiers skip it. +_DURATION_TOLERANCE_MS = 5000 + + +def _normalize_kbps(bitrate: Optional[int]) -> Optional[int]: + """Library bitrate may be stored in bps (e.g. 320000) or kbps (320). + Normalize to kbps. Returns None when unknown/zero.""" + if not bitrate: + return None + try: + b = int(bitrate) + except (TypeError, ValueError): + return None + if b <= 0: + return None + return b // 1000 if b > 4000 else b + + +def classify_track_quality(file_path: str, bitrate: Optional[int]) -> Optional[int]: + """Rank a file by format + bitrate. Returns a RANK_* value, or None when it + can't be judged (a lossy file with no known bitrate).""" + ext = os.path.splitext(file_path or '')[1].lower() + kbps = _normalize_kbps(bitrate) + + # Lossless containers: a real lossless file has a high bitrate; a low one is a + # lossy stream in a lossless container — but flagging that is the Fake Lossless + # Detector's job, so here we treat the lossless *format* as top rank. + if ext in {'.flac', '.alac', '.ape', '.wav', '.aiff', '.aif', '.dsf', '.dff'}: + return RANK_LOSSLESS + # .m4a / lossy: judge purely by bitrate. A lossless-scale bitrate (ALAC in m4a, + # or a mislabeled lossless) ranks as lossless. + if kbps is None: + return None + if kbps >= 800: + return RANK_LOSSLESS + if kbps >= 280: + return RANK_320 + if kbps >= 200: + return RANK_256 + if kbps >= 150: + return RANK_192 + return RANK_BELOW + + +def preferred_quality_floor(quality_profile: Dict[str, Any]) -> Optional[int]: + """The lowest acceptable quality rank from the profile's ENABLED buckets — the + floor a track must meet. Returns None when nothing is enabled (caller should + then flag nothing, rather than flagging everything).""" + qualities = (quality_profile or {}).get('qualities', {}) or {} + enabled_ranks = [ + _PROFILE_KEY_RANK[key] + for key, cfg in qualities.items() + if isinstance(cfg, dict) and cfg.get('enabled') and key in _PROFILE_KEY_RANK + ] + if not enabled_ranks: + return None + return min(enabled_ranks) + + +def meets_preferred_quality(file_path: str, bitrate: Optional[int], + quality_profile: Dict[str, Any]) -> bool: + """Pure decision: does this track already meet the user's preferred quality? + + A track meets quality when its format+bitrate rank is at least the profile's + floor (the worst quality the user still accepts). This honors a profile that + enables, say, FLAC *and* MP3-320: a 320 kbps MP3 passes, a 128 kbps MP3 does + not. With nothing enabled, everything passes (we never flag the whole library + on an empty profile).""" + floor = preferred_quality_floor(quality_profile) + if floor is None: + return True + + file_rank = classify_track_quality(file_path, bitrate) + if file_rank is None: + # Lossy file with unknown bitrate: only judgeable when the floor is + # lossless (then any lossy file is below it). Otherwise don't flag. + ext = os.path.splitext(file_path or '')[1].lower() + if floor == RANK_LOSSLESS and ext not in LOSSLESS_EXTENSIONS: + return False + return True + + return file_rank >= floor + + +def _rank_label(rank: Optional[int]) -> str: + return { + RANK_LOSSLESS: 'Lossless', RANK_320: 'MP3 320', RANK_256: 'MP3 256', + RANK_192: 'MP3 192', RANK_BELOW: 'low bitrate', + }.get(rank, 'unknown') + + +def _norm_isrc(value: Any) -> str: + """Canonicalize an ISRC for comparison: uppercase, strip dashes/spaces.""" + if not value: + return '' + return str(value).upper().replace('-', '').replace(' ', '').strip() + + +def _read_file_ids(file_path: str) -> Dict[str, str]: + """Read the identifiers enrichment embedded in the file's tags. + + Enrichment matches every track to the metadata sources and writes the IDs + (ISRC + per-source track IDs) into the file — so an already-enriched track + carries its exact identity. Returns a dict with a normalized ``isrc`` plus any + ``_track_id`` tags present; empty dict when unreadable / not enriched.""" + resolved = resolve_library_file_path(file_path) if file_path else None + if not resolved and file_path and os.path.isfile(file_path): + resolved = file_path + if not resolved: + return {} + try: + info = read_embedded_tags(resolved) + except Exception: + return {} + if not info or not info.get('available'): + return {} + tags = info.get('tags') or {} + out: Dict[str, str] = {} + isrc = _norm_isrc(tags.get('isrc')) + if isrc: + out['isrc'] = isrc + for tag_key in set(_SOURCE_TRACK_ID_TAG.values()): + val = tags.get(tag_key) + if val: + out[tag_key] = str(val) + return out + + +def _duration_ok(want_ms: Any, got_ms: Any, tolerance_ms: int = _DURATION_TOLERANCE_MS) -> bool: + """Wrong-version guard: True when the candidate's length is within tolerance of + ours — or when either length is unknown (never reject on missing data).""" + try: + w, g = int(want_ms or 0), int(got_ms or 0) + except (TypeError, ValueError): + return True + if w <= 0 or g <= 0: + return True + return abs(w - g) <= tolerance_ms + + +def _match_via_track_id(file_ids: Dict[str, str], + source_priority: List[str]) -> Tuple[Optional[Any], Optional[str]]: + """Most-direct path: enrichment already wrote this track's per-source IDs into + the file. If we have the active source's own track ID, fetch that exact track by + ID — no search at all. Returns (track, source) or (None, None).""" + for source in source_priority: + tag_key = _SOURCE_TRACK_ID_TAG.get(source) + track_id = file_ids.get(tag_key) if tag_key else None + if not track_id: + continue + client = get_client_for_source(source) + if not client or not hasattr(client, 'get_track_details'): + continue + try: + track = client.get_track_details(str(track_id)) + except Exception: + track = None + if track: + return track, source + return None, None + + +def _candidate_isrc(cand: Any) -> str: + """Pull an ISRC off a provider search result (Track / dict), checking the + common shapes: a flat ``isrc`` or a nested ``external_ids.isrc``.""" + direct = _extract_lookup_value(cand, 'isrc') + if direct: + return _norm_isrc(direct) + ext = _extract_lookup_value(cand, 'external_ids') + if isinstance(ext, dict): + return _norm_isrc(ext.get('isrc')) + return '' + + +def _match_via_isrc(isrc: str, source_priority: List[str]) -> Tuple[Optional[Any], Optional[str]]: + """Exact-match a track by its ISRC via each source's ``isrc:`` search. + + ISRC is the universal cross-source recording key, so this resolves the EXACT + track (with its real album) instead of fuzzy-matching by name. Guarded: only + a candidate whose own ISRC equals ours is accepted, so a source that ignores + the ``isrc:`` syntax and returns unrelated hits can't produce a false match. + Returns (track, source) or (None, None).""" + if not isrc: + return None, None + for source in source_priority: + client = get_client_for_source(source) + if not client or not hasattr(client, 'search_tracks'): + continue + try: + results = _search_tracks_for_source(source, f'isrc:{isrc}', limit=5, client=client) + except Exception: + results = [] + for cand in results or []: + if _candidate_isrc(cand) == isrc: + return cand, source + return None, None + + +# Column order for the _load_tracks SELECT — rows come back as dicts keyed by these. +_TRACK_COLS = ( + 'id', 'title', 'file_path', 'bitrate', 'duration', 'artist_name', 'album_title', + 'album_id', 'track_number', 'spotify_album_id', 'itunes_album_id', 'deezer_id', + 'musicbrainz_release_id', 'audiodb_id', +) + +# Human-readable note per match tier (search uses a confidence % instead). +_MATCH_NOTE = { + 'track_id': 'exact track ID', 'isrc': 'exact ISRC match', + 'album': 'matched within album', +} + +# Per-source column holding that source's album ID on the albums table. +_SOURCE_ALBUM_ID_COL = { + 'spotify': 'spotify_album_id', + 'itunes': 'itunes_album_id', + 'deezer': 'deezer_id', + 'musicbrainz': 'musicbrainz_release_id', + 'audiodb': 'audiodb_id', +} + + +def _norm_title(value: Any) -> str: + """Collapse a title to alphanumerics for tolerant comparison.""" + return ''.join(ch for ch in str(value or '').lower() if ch.isalnum()) + + +def _find_track_in_album(items: Any, title: str, track_number: Any, engine: Any, + want_duration_ms: Any = None) -> Optional[Any]: + """Pick the track in an album's tracklist that matches ours — exact normalized + title first (track_number then duration break ties), then a high-similarity + fuzzy fallback that respects the duration guard.""" + want = _norm_title(title) + exact = [] + best, best_score = None, 0.0 + for it in items or []: + it_name = _extract_lookup_value(it, 'name', 'title', default='') + if want and _norm_title(it_name) == want: + exact.append(it) + continue + if engine and it_name: + if not _duration_ok(want_duration_ms, _extract_lookup_value(it, 'duration_ms', 'duration')): + continue + score = engine.similarity_score( + engine.normalize_string(title), engine.normalize_string(it_name)) + if score > best_score and score >= 0.85: + best, best_score = it, score + if exact: + if track_number: + for it in exact: + if _extract_lookup_value(it, 'track_number') == track_number: + return it + # Multiple same-title cuts (e.g. album + live): prefer the closest length. + if want_duration_ms and len(exact) > 1: + exact.sort(key=lambda it: abs(int(want_duration_ms) - int( + _extract_lookup_value(it, 'duration_ms', 'duration', default=0) or 0))) + return exact[0] + return best + + +def _match_via_album(engine: Any, source_priority: List[str], artist: str, album_title: str, + title: str, track_number: Any, stored_album_ids: Dict[str, str], + want_duration_ms: Any = None) -> Tuple[Optional[Any], Optional[str]]: + """Structured artist → album → track match. For each source: use the album's + stored source ID if we already have it (enriched album), else find the album + by searching ``artist album``; then pull that album's tracklist and locate our + track in it. This pins the right album (exact context) without needing the + track itself to be enriched. Returns (track, source) or (None, None).""" + if not album_title: + return None, None + for source in source_priority: + client = get_client_for_source(source) + if not client or not hasattr(client, 'get_album_tracks'): + continue + + album_id = stored_album_ids.get(source) + album_name = album_title + if not album_id and hasattr(client, 'search_albums'): + try: + albums = client.search_albums(f'{artist} {album_title}'.strip(), limit=5) + except Exception: + albums = [] + best_alb, best_s = None, 0.0 + for alb in albums or []: + aname = _extract_lookup_value(alb, 'name', 'title', default='') + s = engine.similarity_score( + engine.normalize_string(album_title), engine.normalize_string(aname)) + if s > best_s and s >= 0.80: + best_alb, best_s = alb, s + if best_alb is not None: + album_id = _extract_lookup_value(best_alb, 'id') + album_name = _extract_lookup_value(best_alb, 'name', 'title', default=album_title) + if not album_id: + continue + + try: + resp = client.get_album_tracks(str(album_id)) + except Exception: + resp = None + items = resp.get('items') if isinstance(resp, dict) else None + match = _find_track_in_album(items, title, track_number, engine, want_duration_ms) + if match is None: + continue + # The album tracklist's tracks usually omit the album object — attach it so + # the wishlist add carries the correct album context. + if isinstance(match, dict): + alb = match.get('album') + if not isinstance(alb, dict) or not alb.get('name'): + match['album'] = {'name': album_name, 'images': []} + return match, source + return None, None + + +def _find_best_match(engine: Any, source_priority: List[str], title: str, artist: str, + album: str, min_confidence: float, + want_duration_ms: Any = None) -> Tuple[Optional[Any], float, Optional[str], bool]: + """Search the configured metadata sources for the best replacement match. + Returns (best_track, confidence, source, attempted_any_provider).""" + temp_track = type('TempTrack', (), {'name': title, 'artists': [artist], 'album': album})() + queries = engine.generate_download_queries(temp_track) + + best, best_conf, best_src = None, 0.0, None + attempted = False + for query in queries: + for source in source_priority: + client = get_client_for_source(source) + if not client or not hasattr(client, 'search_tracks'): + continue + attempted = True + matches = _search_tracks_for_source(source, query, limit=5, client=client) + time.sleep(0.5) # be gentle on metadata APIs + for cand in matches or []: + # Wrong-version guard: a candidate whose length is way off is a + # different cut (live/edit/remix) — reject before it can win. + if not _duration_ok(want_duration_ms, _extract_lookup_value(cand, 'duration_ms', 'duration')): + continue + cand_artists = _track_artist_names(cand) + artist_conf = max( + (engine.similarity_score(engine.normalize_string(artist), + engine.normalize_string(n)) for n in cand_artists), + default=0.0, + ) + title_conf = engine.similarity_score( + engine.normalize_string(title), engine.normalize_string(_track_name(cand))) + conf = artist_conf * 0.5 + title_conf * 0.5 + album_type = _extract_lookup_value(cand, 'album_type', default='') or '' + if album_type == 'album': + conf += 0.02 + elif album_type == 'ep': + conf += 0.01 + if conf > best_conf and conf >= min_confidence: + best, best_conf, best_src = cand, conf, source + if best_conf >= 0.9: + break + if best_conf >= 0.9: + break + return best, best_conf, best_src, attempted + + +@register_job +class QualityUpgradeJob(RepairJob): + job_id = 'quality_upgrade' + display_name = 'Quality Upgrade Finder' + description = 'Finds library tracks below your preferred quality and proposes a better version' + help_text = ( + 'Scans your library (or just your watchlist artists) and compares each ' + "track against your Quality Profile using BOTH the file format and its " + 'bitrate — so a 128 kbps MP3 is no longer treated the same as a 320 kbps ' + 'one, and enabling MP3-320/256 in your profile actually counts.\n\n' + 'For every track below your preferred quality it resolves the exact better ' + 'version using the most precise identity available, in order: the source ' + "track ID enrichment wrote into the file → the file's ISRC → the album's " + 'tracklist (by stored album ID or album search) → a name/artist search. The ' + 'fuzzy steps also reject candidates whose length is off (wrong live/edit cut). ' + 'It skips tracks it already proposed, so re-runs are cheap. Nothing is queued ' + 'automatically: applying a finding adds that matched track — with its album ' + 'context — to the wishlist, the same as any other download.\n\n' + 'Settings:\n' + '- Scope: "watchlist" (watchlisted artists only) or "all" (whole library)\n' + '- Min confidence: minimum match confidence (0-1) to surface a finding\n\n' + 'Note: detecting fake/transcoded lossless files is handled by the separate ' + 'Fake Lossless Detector job.' + ) + icon = 'repair-icon-lossy' + default_enabled = False + default_interval_hours = 168 + default_settings = {'scope': 'watchlist', 'min_confidence': 0.7} + setting_options = {'scope': ['watchlist', 'all']} + auto_fix = False + + def _get_settings(self, context: JobContext) -> Dict[str, Any]: + cfg = context.config_manager + scope = 'watchlist' + min_conf = 0.7 + if cfg: + scope = cfg.get(self.get_config_key('settings.scope'), 'watchlist') or 'watchlist' + try: + min_conf = float(cfg.get(self.get_config_key('settings.min_confidence'), 0.7)) + except (TypeError, ValueError): + min_conf = 0.7 + return {'scope': scope, 'min_confidence': min_conf} + + def _load_tracks(self, db: Any, scope: str) -> List[dict]: + conn = db._get_connection() + try: + base = ( + "SELECT t.id, t.title, t.file_path, t.bitrate, t.duration, " + "a.name AS artist_name, al.title AS album_title, t.album_id, t.track_number, " + "al.spotify_album_id, al.itunes_album_id, al.deezer_id, " + "al.musicbrainz_release_id, al.audiodb_id " + "FROM tracks t " + "JOIN artists a ON t.artist_id = a.id " + "JOIN albums al ON t.album_id = al.id " + "WHERE t.file_path IS NOT NULL AND t.file_path != ''" + ) + if scope == 'watchlist': + artists = db.get_watchlist_artists(profile_id=1) + names = [getattr(ar, 'artist_name', None) for ar in artists] + names = [n for n in names if n] + if not names: + return [] + placeholders = ','.join('?' for _ in names) + rows = conn.execute( + base + f" AND a.name IN ({placeholders})", names).fetchall() + else: + rows = conn.execute(base).fetchall() + return [dict(zip(_TRACK_COLS, r, strict=False)) for r in rows] + finally: + conn.close() + + def _load_existing_finding_ids(self, db: Any) -> set: + """Track IDs that already have a finding for this job (any status). Lets a + re-run skip tracks we've already proposed/dismissed without re-hitting the + metadata API — pending stays deduped, and a dismissed track stays dismissed.""" + conn = db._get_connection() + try: + rows = conn.execute( + "SELECT entity_id FROM repair_findings WHERE job_id = ? AND entity_type = 'track'", + (self.job_id,)).fetchall() + return {str(r[0]) for r in rows if r and r[0] is not None} + except Exception: + return set() + finally: + conn.close() + + def estimate_scope(self, context: JobContext) -> int: + try: + return len(self._load_tracks(context.db, self._get_settings(context)['scope'])) + except Exception: + return 0 + + def scan(self, context: JobContext) -> JobResult: + result = JobResult() + settings = self._get_settings(context) + scope = settings['scope'] + min_conf = settings['min_confidence'] + + db = context.db + quality_profile = db.get_quality_profile() + if preferred_quality_floor(quality_profile) is None: + logger.info("[Quality Upgrade] No quality buckets enabled in profile — nothing to flag") + return result + + try: + tracks = self._load_tracks(db, scope) + except Exception as e: + logger.error("[Quality Upgrade] Error loading tracks: %s", e, exc_info=True) + result.errors += 1 + return result + + total = len(tracks) + if context.update_progress: + context.update_progress(0, total) + if context.report_progress: + context.report_progress(phase=f'Checking quality on {total} tracks...', total=total) + + # Tracks we've already proposed/dismissed — skip them so a re-run doesn't + # re-resolve the same tracks against the metadata API. + already_found = self._load_existing_finding_ids(db) + + # Metadata source for matching — resolved lazily so we only fail if we + # actually find a low-quality track that needs a match. + engine = None + source_priority: List[str] = [] + + for i, row in enumerate(tracks): + if context.check_stop(): + return result + if i % 10 == 0 and context.wait_if_paused(): + return result + + track_id = row['id'] + title = row['title'] + file_path = row['file_path'] + bitrate = row['bitrate'] + duration_ms = row.get('duration') + artist_name = row['artist_name'] + album_title = row['album_title'] + album_id = row['album_id'] + track_number = row.get('track_number') + stored_album_ids = { + src: row[col] for src, col in _SOURCE_ALBUM_ID_COL.items() if row.get(col) + } + result.scanned += 1 + + if str(track_id) in already_found: + result.findings_skipped_dedup += 1 + continue + + if meets_preferred_quality(file_path, bitrate, quality_profile): + result.skipped += 1 + if context.update_progress and (i + 1) % 25 == 0: + context.update_progress(i + 1, total) + continue + + # Below preferred quality — find a better version to propose. + if engine is None: + from core.matching_engine import MusicMatchingEngine + engine = MusicMatchingEngine() + source_priority = get_source_priority(get_primary_source()) or [] + if not source_priority: + logger.warning("[Quality Upgrade] No metadata provider available — cannot propose upgrades") + return result + + if context.is_spotify_rate_limited(): + logger.info("[Quality Upgrade] Spotify rate-limited — stopping scan early") + return result + + current_rank = classify_track_quality(file_path, bitrate) + current_label = _rank_label(current_rank) + if context.report_progress: + context.report_progress( + scanned=i + 1, total=total, + log_line=f'Low quality ({current_label}): {artist_name} - {title}', + log_type='info') + + # Read the identifiers enrichment embedded in the file once (ISRC + + # per-source track IDs), used by the two most-exact tiers below. + file_ids = _read_file_ids(file_path) + + # Tiered match, best identity first, loosest last: + # 0. The active source's OWN track ID, embedded in the file by + # enrichment → fetch that exact track by ID. No search at all. + # 1. ISRC (also in the tags) → exact track on any source. + # 2. Album → track: stored album source ID if we have it (enriched + # album), else find the album by search, then locate our track in + # its tracklist. Pins the right album even when the track itself + # isn't enriched. (artist → album → track) + # 3. Plain artist+title search with similarity scoring. (artist → track) + # The fuzzy tiers (2-3) also apply a duration guard to reject wrong cuts. + best, source, conf, attempted = None, None, 0.0, False + + matched_via = 'track_id' + best, source = _match_via_track_id(file_ids, source_priority) + if best: + conf, attempted = 1.0, True + + if not best: + matched_via = 'isrc' + best, source = _match_via_isrc(file_ids.get('isrc', ''), source_priority) + if best: + conf, attempted = 1.0, True + + if not best: + matched_via = 'album' + try: + best, source = _match_via_album( + engine, source_priority, artist_name or '', album_title or '', + title, track_number, stored_album_ids, duration_ms) + except Exception as e: + logger.debug("[Quality Upgrade] Album match error for %s - %s: %s", artist_name, title, e) + best = None + if best: + conf, attempted = 1.0, True + + if not best: + matched_via = 'search' + try: + best, conf, source, attempted = _find_best_match( + engine, source_priority, title, artist_name or '', album_title or '', + min_conf, duration_ms) + except Exception as e: + logger.debug("[Quality Upgrade] Match error for %s - %s: %s", artist_name, title, e) + result.errors += 1 + continue + + if not best: + if matched_via == 'search' and not attempted: + logger.warning("[Quality Upgrade] No metadata provider responded — stopping") + return result + result.skipped += 1 + continue + + matched = _normalize_track_match(best, source or 'metadata') + # Carry album context: prefer the matched album, fall back to the + # library album the low-quality track came from. + alb = matched.get('album') + if (not isinstance(alb, dict) or not alb.get('name')) and album_title: + matched['album'] = {'name': album_title, 'images': (alb or {}).get('images', []) if isinstance(alb, dict) else []} + + if context.create_finding: + try: + inserted = context.create_finding( + job_id=self.job_id, + finding_type='quality_upgrade', + severity='info', + entity_type='track', + entity_id=str(track_id), + file_path=file_path, + title=f'Upgrade: {artist_name} - {title} ({current_label})', + description=( + f'"{title}" by {artist_name} is {current_label}, below your preferred ' + f'quality. Best match: "{_track_name(best)}" via {source} ' + f'({_MATCH_NOTE.get(matched_via, "matched") if matched_via != "search" else f"confidence {conf:.0%}"}). ' + 'Apply to add it to the wishlist.'), + details={ + 'track_id': track_id, + 'track_title': title, + 'artist': artist_name, + 'album_id': album_id, + 'album_title': album_title, + 'current_format': current_label, + 'current_bitrate': bitrate, + 'match_confidence': conf, + 'matched_via': matched_via, + 'provider': source, + 'matched_track_data': matched, + }) + if inserted: + result.findings_created += 1 + else: + result.findings_skipped_dedup += 1 + except Exception as e: + logger.debug("[Quality Upgrade] create finding failed for track %s: %s", track_id, e) + result.errors += 1 + + if context.update_progress and (i + 1) % 10 == 0: + context.update_progress(i + 1, total) + + if context.update_progress: + context.update_progress(total, total) + logger.info("[Quality Upgrade] %d scanned, %d upgrades found, %d met/skip", + result.scanned, result.findings_created, result.skipped) + return result diff --git a/core/repair_jobs/replaygain_filler.py b/core/repair_jobs/replaygain_filler.py new file mode 100644 index 00000000..bcda36bc --- /dev/null +++ b/core/repair_jobs/replaygain_filler.py @@ -0,0 +1,197 @@ +"""ReplayGain Filler maintenance job (#437) — the loudness sibling of the Lyrics +and Cover Art fillers. + +Post-processing applies ReplayGain to slskd/WebUI downloads, but content that +enters the library another way — Lidarr, the REST API, manual adds — never gets +it, and there was no way to (re)apply RG to existing tracks or fix the ones where +analysis failed (a recurring ask on #437). + +This scans the library for tracks with no ReplayGain track-gain tag and creates a +finding for each. Applying a finding runs the same ffmpeg ebur128 analysis the +import pipeline uses and writes the RG tags in place — no moves, no re-matching. + +Scan only READS tags (cheap); the expensive ffmpeg analysis happens on apply. +Requires ffmpeg (RG analysis can't run without it), so the scan no-ops when ffmpeg +isn't on PATH rather than surfacing findings that could never be applied. +""" + +from __future__ import annotations + +import os +from typing import Any, Dict, Optional + +from core.library.path_resolver import resolve_library_file_path +from core.repair_jobs import register_job +from core.repair_jobs.base import JobContext, JobResult, RepairJob +from utils.logging_config import get_logger + +logger = get_logger("repair_jobs.replaygain_filler") + + +def needs_replaygain(rg_tags: Optional[Dict[str, Any]]) -> bool: + """Pure decision: does this track need ReplayGain written? + + True when the track-gain tag is absent or blank. ``rg_tags`` is the dict from + ``core.replaygain.read_replaygain_tags`` (keys: track_gain, track_peak, …). + A present track_gain — even "+0.00 dB" — counts as already-tagged. + """ + if not rg_tags: + return True + val = rg_tags.get('track_gain') + return val is None or str(val).strip() == '' + + +def _resolve(file_path: str) -> Optional[str]: + """Resolve a stored library path to one this process can read (Docker/host + prefix mapping), falling back to the raw path if it's already a real file.""" + resolved = resolve_library_file_path(file_path) if file_path else None + if not resolved and file_path and os.path.isfile(file_path): + resolved = file_path + return resolved + + +@register_job +class ReplayGainFillerJob(RepairJob): + job_id = 'replaygain_filler' + display_name = 'ReplayGain Filler' + description = 'Finds tracks with no ReplayGain tag and analyzes + writes loudness tags' + help_text = ( + 'Scans your library for tracks that have no ReplayGain track-gain tag — ' + 'common for albums added by Lidarr, the REST API, or by hand, which skip ' + "the download post-processing where ReplayGain normally runs.\n\n" + 'A finding is created for each. Applying one runs the same ffmpeg loudness ' + 'analysis (EBU R128) the import pipeline uses and writes the ReplayGain ' + 'tags in place — no files are moved or renamed. This also lets you re-fill ' + 'tracks where the original analysis failed.\n\n' + 'Requires ffmpeg to be installed (the analysis cannot run without it).' + ) + icon = 'repair-icon-replaygain' + default_enabled = False + default_interval_hours = 48 + default_settings = {} + auto_fix = False + + def scan(self, context: JobContext) -> JobResult: + result = JobResult() + + try: + from core.replaygain import is_ffmpeg_available, read_replaygain_tags + except Exception as e: + logger.warning("[ReplayGain Filler] replaygain module unavailable: %s", e) + return result + if not is_ffmpeg_available(): + logger.info("[ReplayGain Filler] ffmpeg not available — skipping scan " + "(analysis cannot run without it)") + return result + + rows = [] + conn = None + try: + conn = context.db._get_connection() + cursor = conn.cursor() + cursor.execute(""" + SELECT t.id, t.title, ar.name, t.file_path + FROM tracks t + LEFT JOIN artists ar ON ar.id = t.artist_id + WHERE t.file_path IS NOT NULL AND t.file_path != '' + """) + rows = cursor.fetchall() + except Exception as e: + logger.error("[ReplayGain Filler] Error reading tracks: %s", e, exc_info=True) + result.errors += 1 + return result + finally: + if conn: + conn.close() + + total = len(rows) + if context.update_progress: + context.update_progress(0, total) + if context.report_progress: + context.report_progress(phase=f'Checking ReplayGain on {total} tracks...', total=total) + + for i, row in enumerate(rows): + if context.check_stop(): + return result + if i % 10 == 0 and context.wait_if_paused(): + return result + + track_id, title, artist_name, file_path = row[:4] + result.scanned += 1 + + resolved = _resolve(file_path) + if not resolved: + # Can't read the file from here → can't analyze it on apply either. + result.skipped += 1 + continue + + try: + rg = read_replaygain_tags(resolved) + except Exception as e: + logger.debug("[ReplayGain Filler] tag read failed for '%s': %s", title, e) + result.skipped += 1 + continue + + if not needs_replaygain(rg): + result.skipped += 1 + if context.update_progress and (i + 1) % 25 == 0: + context.update_progress(i + 1, total) + continue + + if context.report_progress: + context.report_progress( + scanned=i + 1, total=total, + log_line=f'No ReplayGain: {title} — {artist_name or "Unknown"}', + log_type='info') + + if context.create_finding: + try: + inserted = context.create_finding( + job_id=self.job_id, + finding_type='missing_replaygain', + severity='info', + entity_type='track', + entity_id=str(track_id), + file_path=file_path, + title=f'No ReplayGain: {title or "Unknown"}', + description=(f'"{title}" by {artist_name or "Unknown"} has no ' + 'ReplayGain tag — loudness can be analyzed + written.'), + details={ + 'track_id': track_id, + 'track_title': title, + 'artist': artist_name, + 'file_path': file_path, + }) + if inserted: + result.findings_created += 1 + else: + result.findings_skipped_dedup += 1 + except Exception as e: + logger.debug("[ReplayGain Filler] create finding failed for track %s: %s", track_id, e) + result.errors += 1 + + if context.update_progress and (i + 1) % 10 == 0: + context.update_progress(i + 1, total) + + if context.update_progress: + context.update_progress(total, total) + logger.info("[ReplayGain Filler] %d tracks checked, %d missing ReplayGain, %d skipped", + result.scanned, result.findings_created, result.skipped) + return result + + def estimate_scope(self, context: JobContext) -> int: + conn = None + try: + conn = context.db._get_connection() + cursor = conn.cursor() + cursor.execute(""" + SELECT COUNT(*) FROM tracks + WHERE file_path IS NOT NULL AND file_path != '' + """) + row = cursor.fetchone() + return row[0] if row else 0 + except Exception: + return 0 + finally: + if conn: + conn.close() diff --git a/core/repair_worker.py b/core/repair_worker.py index 27dffe3b..519b04ed 100644 --- a/core/repair_worker.py +++ b/core/repair_worker.py @@ -964,6 +964,8 @@ class RepairWorker: 'track_number_mismatch': self._fix_track_number, 'missing_cover_art': self._fix_missing_cover_art, 'missing_lyrics': self._fix_missing_lyrics, + 'missing_replaygain': self._fix_missing_replaygain, + 'empty_folder': self._fix_empty_folder, 'expired_download': self._fix_expired_download, 'metadata_gap': self._fix_metadata_gap, 'duplicate_tracks': self._fix_duplicates, @@ -979,6 +981,7 @@ class RepairWorker: 'acoustid_mismatch': self._fix_acoustid_mismatch, 'missing_discography_track': self._fix_discography_backfill, 'library_retag': self._fix_library_retag, + 'quality_upgrade': self._fix_quality_upgrade, } handler = handlers.get(finding_type) if not handler: @@ -1005,6 +1008,36 @@ class RepairWorker: except Exception as e: return {'success': False, 'error': str(e)} + def _fix_quality_upgrade(self, entity_type, entity_id, file_path, details): + """Add the matched higher-quality version to the wishlist (with album + context). Applying a Quality Upgrade finding is the user-approved step + that the old auto-acting Quality Scanner did without review.""" + track_data = details.get('matched_track_data') + if not track_data: + return {'success': False, 'error': 'No matched track in finding'} + try: + success = self.db.add_to_wishlist( + spotify_track_data=track_data, + failure_reason=f"Quality upgrade — current file is {details.get('current_format', 'low quality')}", + source_type='repair', + source_info={ + 'job': 'quality_upgrade', + 'original_file_path': file_path, + 'original_format': details.get('current_format'), + 'original_bitrate': details.get('current_bitrate'), + 'album_title': details.get('album_title'), + 'match_confidence': details.get('match_confidence'), + 'provider': details.get('provider'), + }, + ) + track_name = track_data.get('name', '?') + if success: + return {'success': True, 'action': 'added_to_wishlist', + 'message': f"Added '{track_name}' to wishlist for re-download"} + return {'success': False, 'error': f"Could not add '{track_name}' to wishlist (may already exist or be blocklisted)"} + except Exception as e: + return {'success': False, 'error': str(e)} + def _fix_dead_file(self, entity_type, entity_id, file_path, details): """Fix a dead file reference. Action depends on details['_fix_action']: 'redownload' (default) — add to wishlist + remove DB entry @@ -1479,6 +1512,56 @@ class RepairWorker: return {'success': False, 'error': 'Could not fetch lyrics (no longer available?)'} return {'success': True, 'action': 'applied_lyrics', 'message': 'Wrote lyrics (.lrc) + embedded'} + def _fix_missing_replaygain(self, entity_type, entity_id, file_path, details): + """Apply a missing-ReplayGain finding: run the same ffmpeg ebur128 loudness + analysis the import pipeline uses and write the RG tags in place (#437).""" + raw_path = details.get('file_path') or file_path + if not raw_path: + return {'success': False, 'error': 'No file path in finding'} + download_folder = self._config_manager.get('soulseek.download_path', '') if self._config_manager else None + resolved = _resolve_file_path(raw_path, self.transfer_folder, download_folder, + config_manager=self._config_manager) or raw_path + if not os.path.isfile(resolved): + return {'success': False, 'error': f'File not found on disk: {os.path.basename(raw_path)}'} + try: + from core.replaygain import (analyze_track, write_replaygain_tags, + is_ffmpeg_available, RG_REFERENCE_LUFS) + if not is_ffmpeg_available(): + return {'success': False, 'error': 'ffmpeg not available — cannot analyze ReplayGain'} + lufs, peak_dbfs = analyze_track(resolved) + gain_db = RG_REFERENCE_LUFS - lufs # same formula as the import pipeline + ok = write_replaygain_tags(resolved, gain_db, peak_dbfs) + except Exception as e: + logger.error("ReplayGain fix failed for %s: %s", os.path.basename(raw_path), e) + return {'success': False, 'error': str(e)} + if not ok: + return {'success': False, 'error': 'Could not write ReplayGain tags'} + return {'success': True, 'action': 'applied_replaygain', + 'message': f'Wrote ReplayGain ({gain_db:+.2f} dB)'} + + def _fix_empty_folder(self, entity_type, entity_id, file_path, details): + """Apply an empty-folder finding: re-check the folder is still empty/junk- + only (anything that gained a real file since the scan is left alone), then + remove it. The library root + symlinked dirs are refused.""" + from core.repair_jobs.empty_folder_cleaner import remove_empty_folder + raw = details.get('folder_path') or file_path + if not raw: + return {'success': False, 'error': 'No folder path in finding'} + resolved = self._resolve_path(raw) if hasattr(self, '_resolve_path') else raw + res = remove_empty_folder( + resolved, + junk_files=details.get('junk_files') or [], + remove_junk=bool(details.get('remove_junk', True)), + root=self.transfer_folder, + listdir=os.listdir, isdir=os.path.isdir, islink=os.path.islink, + remove_file=os.remove, rmdir=os.rmdir, + ) + if not res.get('removed'): + return {'success': False, 'error': res.get('error') or 'Could not remove folder'} + _name = os.path.basename(resolved.rstrip('/\\')) or resolved + return {'success': True, 'action': 'removed_empty_folder', + 'message': f'Removed empty folder: {_name}'} + def _fix_expired_download(self, entity_type, entity_id, file_path, details): """Apply an expired-download finding: delete the file + library row + history entry, via the same helper the cleaner's auto mode uses.""" @@ -3284,7 +3367,7 @@ class RepairWorker: 'album_mbid_mismatch', 'album_tag_inconsistency', 'incomplete_album', 'path_mismatch', - 'missing_lossy_copy', + 'missing_lossy_copy', 'missing_replaygain', 'empty_folder', 'missing_discography_track', 'acoustid_mismatch') placeholders = ','.join(['?'] * len(fixable_types)) where_parts = [f"finding_type IN ({placeholders})", "status = 'pending'"] diff --git a/core/security/login_provisioning.py b/core/security/login_provisioning.py new file mode 100644 index 00000000..3102f77d --- /dev/null +++ b/core/security/login_provisioning.py @@ -0,0 +1,45 @@ +"""Login-mode password provisioning policy. + +Invariant: while ``security.require_login`` is on, every profile must have a login +password — otherwise it's fail-closed locked out (usable only after the admin +provisions one). That's not a security hole (no-password = can't get in), but it's +a usability gap, and the point here is to make it impossible to OPEN one from any +write-point: creating a profile, clearing a password, or flipping login mode on. + +These are pure decisions so they're the single source of truth + unit-testable; +web_server wires them into the create / set-password / enable-login endpoints, and +the UI mirrors them. +""" + +from __future__ import annotations + +from typing import Any, Dict, List, Optional + + +def members_without_password(profiles: Optional[List[Dict[str, Any]]]) -> List[Dict[str, Any]]: + """Non-admin profiles with no login password — they can't sign in once login + mode is on. The admin is covered separately by its own anti-lockout, so it's + excluded here. Returns ``[{'id', 'name'}, …]`` (empty = no gap).""" + out: List[Dict[str, Any]] = [] + for p in (profiles or []): + if not p.get('is_admin') and not p.get('has_password'): + out.append({'id': p.get('id'), 'name': p.get('name')}) + return out + + +def create_needs_password(require_login: bool, is_admin: bool = False) -> bool: + """A non-admin profile created while login mode is on must carry a password, + or it's born unable to sign in.""" + return bool(require_login) and not is_admin + + +def removing_password_strands(require_login: bool) -> bool: + """Clearing a profile's password while login mode is on would lock it out.""" + return bool(require_login) + + +__all__ = [ + "members_without_password", + "create_needs_password", + "removing_password_strands", +] diff --git a/core/soundcloud_client.py b/core/soundcloud_client.py index 530d6f72..caf25ce7 100644 --- a/core/soundcloud_client.py +++ b/core/soundcloud_client.py @@ -29,6 +29,7 @@ import uuid import time from typing import List, Optional, Dict, Any, Tuple, Callable from pathlib import Path +from urllib.parse import urlparse try: import yt_dlp @@ -45,6 +46,33 @@ from core.download_plugins.types import TrackResult, AlbumResult, DownloadStatus logger = get_logger("soundcloud_client") +def is_soundcloud_url(value: Any) -> bool: + """True when ``value`` is a SoundCloud URL (track, set/playlist, or a + public-but-unlisted ``/s-`` share link), incl. ``on.soundcloud.com`` + short links and scheme-less ``soundcloud.com/...`` forms. + + Pure + network-free. Used so a pasted SoundCloud link is RESOLVED directly + (it carries its own access token) instead of being run through scsearch, + which can't find unlisted/private tracks (#865). A normal text query — even + one mentioning "soundcloud" — is not a URL and returns False.""" + if not value or not isinstance(value, str): + return False + raw = value.strip() + if not raw: + return False + candidate = raw if '://' in raw else f'https://{raw}' + try: + host = (urlparse(candidate).netloc or '').lower() + except Exception: + return False + host = host.split('@')[-1].split(':')[0] # strip any userinfo / port + return ( + host == 'soundcloud.com' + or host.endswith('.soundcloud.com') + or host.endswith('soundcloud.app.goo.gl') + ) + + # Quality tiers — SoundCloud anonymous access only really delivers one # quality, but we keep the structure consistent with other clients so # UI/settings can reference a familiar shape later. @@ -173,6 +201,12 @@ class SoundcloudClient(DownloadSourcePlugin): logger.warning(f"Invalid SoundCloud search query: {query!r}") return ([], []) + # A pasted SoundCloud link resolves directly — scsearch can't find an + # unlisted/private track, but its share URL carries an access token that + # yt-dlp can resolve (#865). + if is_soundcloud_url(query): + return await self.resolve_url(query, timeout=timeout, progress_callback=progress_callback) + # SoundCloud or a transient yt-dlp parse can fail; the caller still # gets an empty list, never a raised exception. limit = min(MAX_SEARCH_LIMIT, max(1, DEFAULT_SEARCH_LIMIT)) @@ -225,6 +259,70 @@ class SoundcloudClient(DownloadSourcePlugin): entries = info.get('entries') or [] return [e for e in entries if isinstance(e, dict)] + async def resolve_url( + self, + url: str, + timeout: Optional[int] = None, + progress_callback: Optional[Callable] = None, + ) -> Tuple[List[TrackResult], List[AlbumResult]]: + """Resolve a direct SoundCloud track/set URL into downloadable + TrackResult(s) — including public-but-unlisted/private share links that + scsearch can't find, because the URL itself carries the access token + (#865). A set/playlist link resolves to all its tracks. Returns + ``(tracks, [])`` and never raises (empty on any failure).""" + if not self.is_available(): + logger.warning("SoundCloud not available for URL resolve (yt-dlp missing)") + return ([], []) + url = (url or '').strip() + if not url: + return ([], []) + + logger.info(f"Resolving SoundCloud URL: {url}") + loop = asyncio.get_event_loop() + try: + info = await loop.run_in_executor(None, self._extract_url_info, url) + except Exception as exc: + logger.error(f"SoundCloud URL resolve failed: {exc}") + return ([], []) + if not isinstance(info, dict): + return ([], []) + + # A set/playlist link yields `entries`; a single track is the dict itself. + entries = info.get('entries') + raw_entries = [e for e in entries if isinstance(e, dict)] if entries else [info] + + track_results: List[TrackResult] = [] + for entry in raw_entries: + # Full (non-flat) extraction puts a transient media-stream URL in + # `url`; the stable permalink lives in `webpage_url`. Pin `url` to the + # permalink so the download encoding matches what search() produces + # (the worker re-resolves the permalink at download time). + permalink = entry.get('webpage_url') or entry.get('url') + if permalink: + entry = {**entry, 'url': permalink} + try: + converted = self._sc_to_track_result(entry) + if converted is not None: + track_results.append(converted) + except Exception as exc: + logger.debug(f"Skipping SoundCloud URL entry conversion error: {exc}") + + logger.info(f"Resolved {len(track_results)} SoundCloud track(s) from URL") + return (track_results, []) + + def _extract_url_info(self, url: str) -> Optional[Dict[str, Any]]: + """Full (non-flat) yt-dlp extraction of a direct SoundCloud URL — needed + to get the real id/permalink/title for a single track or set. Anonymous; + the share token (if any) is already in the URL. Injectable for tests.""" + opts = { + 'quiet': True, + 'no_warnings': True, + 'skip_download': True, + 'noplaylist': False, + } + with yt_dlp.YoutubeDL(opts) as ydl: + return ydl.extract_info(url, download=False) + def _sc_to_track_result(self, entry: Dict[str, Any]) -> Optional[TrackResult]: """Convert a yt-dlp SoundCloud entry into the standard TrackResult. diff --git a/core/spotify_worker.py b/core/spotify_worker.py index ffc6b07d..0f2fcd54 100644 --- a/core/spotify_worker.py +++ b/core/spotify_worker.py @@ -12,6 +12,9 @@ from core.spotify_client import SpotifyClient, SpotifyRateLimitError from core.worker_utils import ( ARTIST_NAME_MATCH_THRESHOLD, interruptible_sleep, + owned_album_titles, + pick_artist_by_catalog, + release_titles, set_album_api_track_count, source_id_conflict, ) @@ -563,16 +566,23 @@ class SpotifyWorker: logger.debug(f"No Spotify results for artist '{artist_name}'") return - # Find best fuzzy match — score all candidates, pick highest above the - # (stricter, artist-specific) threshold so short-name false positives - # like "ODESZA"/"odessa" don't slip through. - best_obj = None - best_score = 0 - for artist_obj in results: - score = self._name_similarity(artist_name, artist_obj.name) - if score >= ARTIST_NAME_MATCH_THRESHOLD and score > best_score: - best_obj = artist_obj - best_score = score + # Candidates clearing the (stricter, artist-specific) name gate, best + # name-score first so [0] is the legacy "first/highest" pick. + scored = [ + (self._name_similarity(artist_name, a.name), a) + for a in results + ] + gated = [a for score, a in sorted(scored, key=lambda s: s[0], reverse=True) + if score >= ARTIST_NAME_MATCH_THRESHOLD] + + # Same-name disambiguation: when more than one "Rone" clears the gate, + # pick the one whose catalog overlaps the albums this library owns. + best_obj, _overlap = pick_artist_by_catalog( + gated, + owned_album_titles(self.db, artist_id), + lambda a: release_titles(self.client.get_artist_albums(a.id)), + ) + best_score = self._name_similarity(artist_name, best_obj.name) if best_obj else 0 if best_obj: if not self._is_spotify_id(best_obj.id): diff --git a/core/tidal_client.py b/core/tidal_client.py index a983944c..a18ab04f 100644 --- a/core/tidal_client.py +++ b/core/tidal_client.py @@ -1324,13 +1324,20 @@ class TidalClient: track_ids.append(item.get("id")) if track_ids: - # Batch fetch full track details with artists and albums - try: - batch_tracks = self._get_tracks_batch(track_ids) - except Exception as e: - logger.error(f"Error fetching track details for page {page_num}: {e}") - # Continue pagination — we lose this batch but can still get remaining - batch_tracks = [] + # Batch fetch full track details with artists and albums. + # Chunk to the filter[id] page cap — Tidal returns at most + # _COLLECTION_BATCH_SIZE tracks per request, so a relationships + # page larger than the cap would be silently truncated if sent + # in one shot. Mirrors get_album_tracks. (#867) + batch_tracks = [] + for j in range(0, len(track_ids), self._COLLECTION_BATCH_SIZE): + chunk_ids = track_ids[j:j + self._COLLECTION_BATCH_SIZE] + try: + batch_tracks.extend(self._get_tracks_batch(chunk_ids)) + except Exception as e: + logger.error(f"Error fetching track details for page {page_num}: {e}") + # Lose this chunk but keep going — remaining chunks/pages can still load + continue if len(batch_tracks) < len(track_ids): logger.warning(f"Page {page_num}: requested {len(track_ids)} tracks but only {len(batch_tracks)} returned (some may be unavailable in your region)") diff --git a/core/torrent_clients/__init__.py b/core/torrent_clients/__init__.py index ce23698c..bbcc61d0 100644 --- a/core/torrent_clients/__init__.py +++ b/core/torrent_clients/__init__.py @@ -15,6 +15,7 @@ from typing import Optional from config.settings import config_manager +from core.torrent_clients.aria2 import Aria2Adapter from core.torrent_clients.base import TorrentClientAdapter, TorrentStatus from core.torrent_clients.deluge import DelugeAdapter from core.torrent_clients.qbittorrent import QBittorrentAdapter @@ -26,6 +27,7 @@ __all__ = [ "QBittorrentAdapter", "TransmissionAdapter", "DelugeAdapter", + "Aria2Adapter", "get_active_adapter", "adapter_for_type", ] @@ -43,6 +45,8 @@ def adapter_for_type(client_type: str) -> Optional[TorrentClientAdapter]: return TransmissionAdapter() if client_type == "deluge": return DelugeAdapter() + if client_type == "aria2": + return Aria2Adapter() return None diff --git a/core/torrent_clients/aria2.py b/core/torrent_clients/aria2.py new file mode 100644 index 00000000..e9310a70 --- /dev/null +++ b/core/torrent_clients/aria2.py @@ -0,0 +1,218 @@ +"""Aria2 JSON-RPC adapter (Shdjfgatdif's request). + +Auth model: aria2 uses a single RPC ``--rpc-secret`` token (no username). It is +passed as the FIRST positional param of every call as the string +``token:``. The RPC endpoint is ``:6800/jsonrpc``. + +A few aria2 quirks the adapter smooths over: +- the secret maps onto SoulSync's ``password`` field (aria2 has no username), +- ``addUri`` returns a GID — that's our torrent id, +- aria2 does NOT delete files on remove; for ``delete_files`` we read the file + paths first and unlink them ourselves, +- a finished/errored download is removed via ``removeDownloadResult`` (force/Remove + only work on active/waiting/paused), so the adapter picks the right one. + +Reference: https://aria2.github.io/manual/en/html/aria2c.html#rpc-interface +""" + +from __future__ import annotations + +import asyncio +import base64 +import os +from typing import List, Optional + +import requests as http_requests + +from config.settings import config_manager +from core.torrent_clients.base import TorrentStatus, normalize_client_url +from utils.logging_config import get_logger + +logger = get_logger("torrent.aria2") + + +# aria2 download status → adapter-uniform state. 'active' is resolved separately +# (it covers both downloading and post-complete seeding for BT). +_ARIA2_STATE = { + 'waiting': 'queued', + 'paused': 'paused', + 'error': 'error', + 'removed': 'error', + 'complete': 'completed', +} + + +def _map_state(status: str, completed: int, total: int) -> str: + if status == 'active': + # A BT download that finished its payload keeps reporting 'active' while + # it seeds — differentiate by progress. + if total and completed >= total: + return 'seeding' + return 'downloading' + return _ARIA2_STATE.get(status, 'error') + + +class Aria2Adapter: + """Aria2 JSON-RPC adapter.""" + + DEFAULT_TIMEOUT = 15 + _STATUS_KEYS = ['gid', 'status', 'totalLength', 'completedLength', 'downloadSpeed', + 'uploadSpeed', 'connections', 'numSeeders', 'dir', 'files', + 'errorMessage', 'bittorrent'] + + def __init__(self) -> None: + self._load_config() + + def _load_config(self) -> None: + url = normalize_client_url(config_manager.get('torrent_client.url', '')) + # aria2's RPC endpoint is /jsonrpc — append if the user pasted a bare host. + if url and not url.rstrip('/').endswith('/jsonrpc'): + url = url.rstrip('/') + '/jsonrpc' + self._url = url + # aria2 has no username; the RPC secret maps onto the password field. + self._secret = config_manager.get('torrent_client.password', '') or '' + self._category = config_manager.get('torrent_client.category', 'soulsync') or 'soulsync' + self._save_path = config_manager.get('torrent_client.save_path', '') or '' + + def reload_settings(self) -> None: + self._load_config() + + def is_configured(self) -> bool: + return bool(self._url) + + def _params(self, *params) -> list: + """token: must lead every call when a secret is configured.""" + return ([f"token:{self._secret}"] if self._secret else []) + list(params) + + def _rpc(self, method: str, *params): + if not self._url: + return None + payload = {'jsonrpc': '2.0', 'id': 'soulsync', 'method': method, + 'params': self._params(*params)} + try: + resp = http_requests.post(self._url, json=payload, timeout=self.DEFAULT_TIMEOUT) + if not resp.ok: + logger.warning("aria2 RPC %s returned HTTP %s", method, resp.status_code) + return None + data = resp.json() + if isinstance(data, dict) and data.get('error'): + logger.warning("aria2 RPC %s error: %s", method, data.get('error')) + return None + return data.get('result') if isinstance(data, dict) else None + except Exception as e: + logger.error("aria2 RPC %s failed: %s", method, e) + return None + + async def check_connection(self) -> bool: + if not self.is_configured(): + return False + loop = asyncio.get_event_loop() + return await loop.run_in_executor(None, lambda: self._rpc('aria2.getVersion') is not None) + + # ── add ── + async def add_torrent(self, url_or_magnet: str, category: str = "soulsync", + save_path: Optional[str] = None) -> Optional[str]: + loop = asyncio.get_event_loop() + return await loop.run_in_executor(None, self._add_uri_sync, url_or_magnet, save_path) + + def _opts(self, save_path: Optional[str]) -> dict: + opts: dict = {} + if save_path or self._save_path: + opts['dir'] = save_path or self._save_path + return opts + + def _add_uri_sync(self, uri: str, save_path: Optional[str]) -> Optional[str]: + result = self._rpc('aria2.addUri', [uri], self._opts(save_path)) + return str(result) if result else None # GID + + async def add_torrent_file(self, file_bytes: bytes, category: str = "soulsync", + save_path: Optional[str] = None) -> Optional[str]: + loop = asyncio.get_event_loop() + return await loop.run_in_executor(None, self._add_file_sync, file_bytes, save_path) + + def _add_file_sync(self, file_bytes: bytes, save_path: Optional[str]) -> Optional[str]: + b64 = base64.b64encode(file_bytes).decode('ascii') + result = self._rpc('aria2.addTorrent', b64, [], self._opts(save_path)) + return str(result) if result else None + + # ── status ── + async def get_status(self, torrent_id: str) -> Optional[TorrentStatus]: + loop = asyncio.get_event_loop() + return await loop.run_in_executor(None, self._get_status_sync, torrent_id) + + def _get_status_sync(self, gid: str) -> Optional[TorrentStatus]: + result = self._rpc('aria2.tellStatus', gid, self._STATUS_KEYS) + return self._parse_status(result) if result else None + + async def get_all(self) -> List[TorrentStatus]: + loop = asyncio.get_event_loop() + return await loop.run_in_executor(None, self._get_all_sync) + + def _get_all_sync(self) -> List[TorrentStatus]: + active = self._rpc('aria2.tellActive', self._STATUS_KEYS) or [] + waiting = self._rpc('aria2.tellWaiting', 0, 1000, self._STATUS_KEYS) or [] + stopped = self._rpc('aria2.tellStopped', 0, 1000, self._STATUS_KEYS) or [] + return [self._parse_status(i) for i in (list(active) + list(waiting) + list(stopped))] + + def _parse_status(self, item: dict) -> TorrentStatus: + total = int(item.get('totalLength') or 0) + completed = int(item.get('completedLength') or 0) + status = item.get('status') or 'error' + progress = (completed / total) if total > 0 else 0.0 + # Name: BT info name, else the first file's basename. + name = '' + bt = item.get('bittorrent') + if isinstance(bt, dict): + name = (bt.get('info') or {}).get('name') or '' + files = item.get('files') or [] + if not name and files: + name = os.path.basename((files[0] or {}).get('path') or '') + file_paths = [f.get('path') for f in files if f.get('path')] or None + return TorrentStatus( + id=str(item.get('gid') or ''), + name=name, + state=_map_state(status, completed, total), + progress=progress, + size=total, + downloaded=completed, + download_speed=int(item.get('downloadSpeed') or 0), + upload_speed=int(item.get('uploadSpeed') or 0), + seeders=int(item.get('numSeeders') or 0), + peers=int(item.get('connections') or 0), + save_path=item.get('dir'), + files=file_paths, + error=item.get('errorMessage') or None, + ) + + # ── remove / pause / resume ── + async def remove(self, torrent_id: str, delete_files: bool = False) -> bool: + loop = asyncio.get_event_loop() + return await loop.run_in_executor(None, self._remove_sync, torrent_id, delete_files) + + def _remove_sync(self, gid: str, delete_files: bool) -> bool: + st = self._rpc('aria2.tellStatus', gid, ['status', 'files']) or {} + status = st.get('status') + paths = ([f.get('path') for f in (st.get('files') or []) if f.get('path')] + if delete_files else []) + if status in ('active', 'waiting', 'paused'): + ok = self._rpc('aria2.forceRemove', gid) is not None + self._rpc('aria2.removeDownloadResult', gid) # clear the result row + else: + # complete / error / removed → force/Remove would error; clear the row. + ok = self._rpc('aria2.removeDownloadResult', gid) is not None + if delete_files: + for p in paths: + try: + if p and os.path.isfile(p): + os.remove(p) + except Exception: # noqa: S110 — partial data delete is best-effort + pass + return ok + + async def pause(self, torrent_id: str) -> bool: + loop = asyncio.get_event_loop() + return await loop.run_in_executor(None, lambda: self._rpc('aria2.pause', torrent_id) is not None) + + async def resume(self, torrent_id: str) -> bool: + loop = asyncio.get_event_loop() + return await loop.run_in_executor(None, lambda: self._rpc('aria2.unpause', torrent_id) is not None) diff --git a/core/watchlist_scanner.py b/core/watchlist_scanner.py index c99841eb..d4584519 100644 --- a/core/watchlist_scanner.py +++ b/core/watchlist_scanner.py @@ -1376,10 +1376,17 @@ class WatchlistScanner: if scan_state is not None: scan_state['tracks_found_this_scan'] += 1 - added = self.add_track_to_wishlist( - track, album_data, artist, - scan_run_id=(scan_state or {}).get('scan_run_id', ''), - ) + # "Follow only" artists (auto_download off): discover and + # surface the release exactly like normal, but skip the one + # call that adds it to the wishlist — so nothing auto-downloads + # and the user can pick what to grab (corruption's request). + if getattr(artist, 'auto_download', True): + added = self.add_track_to_wishlist( + track, album_data, artist, + scan_run_id=(scan_state or {}).get('scan_run_id', ''), + ) + else: + added = False track_artists = track.get('artists', []) track_artist_name = track_artists[0].get('name', 'Unknown Artist') if track_artists else 'Unknown Artist' diff --git a/core/worker_utils.py b/core/worker_utils.py index 7a8b57cb..949d387b 100644 --- a/core/worker_utils.py +++ b/core/worker_utils.py @@ -101,6 +101,108 @@ def accept_artist_match(database, id_column: str, source_id, artist_id, return True, "" +# --- Same-name artist disambiguation by owned-catalog overlap ------------- +# The name gate (above) can't separate two artists who share a name ("Rone" has +# ~5). The decisive signal is the library itself: the user owns albums by the +# RIGHT one. So when several candidates clear the name gate, fetch each one's +# catalog and pick the one whose releases overlap the albums actually owned. + +def normalize_release_title(title: str) -> str: + """Collapse an album/release title for tolerant comparison — drop edition + suffixes ('(Deluxe)', ' - Remastered'), punctuation, and case.""" + t = (title or '').lower().strip() + t = re.sub(r'\s*\(.*?\)\s*', ' ', t) + t = re.sub(r'\s*\[.*?\]\s*', ' ', t) + t = re.sub(r'\s+[-–—]\s+.*$', '', t) + t = re.sub(r'[^\w\s]', '', t) + t = re.sub(r'\s+', ' ', t).strip() + return t + + +def catalog_overlap_score(owned_titles, candidate_titles, threshold: float = 0.85) -> int: + """How many OWNED album titles appear in the candidate's catalog (fuzzy, + edition-insensitive). The disambiguation signal — higher = better match.""" + owned = {normalize_release_title(t) for t in (owned_titles or []) if t} + owned.discard('') + cand = {normalize_release_title(t) for t in (candidate_titles or []) if t} + cand.discard('') + if not owned or not cand: + return 0 + score = 0 + for o in owned: + if o in cand or any(SequenceMatcher(None, o, c).ratio() >= threshold for c in cand): + score += 1 + return score + + +def pick_artist_by_catalog(candidates, owned_titles, fetch_titles) -> tuple: + """Choose, among same-name candidates, the one whose catalog best overlaps the + OWNED albums. Returns ``(chosen, overlap_score)``. + + ``candidates`` — artist objects already past the name gate (order = the + worker's existing best-by-name order; candidates[0] is the + current behavior's pick). + ``owned_titles`` — the library artist's owned album titles. + ``fetch_titles(candidate) -> list[str]`` — that candidate's album titles; + called ONLY when disambiguation is actually needed (2+ + candidates and we have owned albums), so the common + single-candidate path costs no extra API calls. + + Falls back to candidates[0] (unchanged behavior) when there's nothing to + disambiguate or no candidate overlaps the owned catalog. + """ + candidates = list(candidates or []) + if not candidates: + return None, 0 + if len(candidates) == 1: + return candidates[0], 0 + owned = [t for t in (owned_titles or []) if t] + if not owned: + return candidates[0], 0 + + best, best_score = None, 0 + for cand in candidates: + try: + titles = fetch_titles(cand) or [] + except Exception as exc: + logger.debug("catalog disambiguation: fetch_titles failed: %s", exc) + titles = [] + score = catalog_overlap_score(owned, titles) + if score > best_score: + best, best_score = cand, score + if best is not None and best_score > 0: + return best, best_score + return candidates[0], 0 # no overlap signal → keep the best-by-name pick + + +def owned_album_titles(database, artist_id) -> list: + """The album titles the library actually has for this artist — the ground + truth used to disambiguate same-name source artists.""" + try: + with database._get_connection() as conn: + rows = conn.execute( + "SELECT title FROM albums WHERE artist_id = ?", (artist_id,) + ).fetchall() + return [r[0] for r in rows if r and r[0]] + except Exception as exc: + logger.debug("owned_album_titles(%s) failed: %s", artist_id, exc) + return [] + + +def release_titles(albums) -> list: + """Extract titles from a list of album objects/dicts (handles ``.title``/ + ``.name`` / dict keys) — the candidate side of catalog disambiguation.""" + out = [] + for al in albums or []: + if isinstance(al, dict): + t = al.get('title') or al.get('name') + else: + t = getattr(al, 'title', None) or getattr(al, 'name', None) + if t: + out.append(t) + return out + + def interruptible_sleep(stop_event: threading.Event, seconds: float, step: float = 0.5) -> bool: """Sleep in chunks so shutdown can interrupt long waits.""" if seconds <= 0: diff --git a/core/youtube_track_meta.py b/core/youtube_track_meta.py new file mode 100644 index 00000000..c1742beb --- /dev/null +++ b/core/youtube_track_meta.py @@ -0,0 +1,91 @@ +"""Derive a track's artist + title from a yt-dlp playlist entry. + +Flat playlist extraction (used to dodge YouTube rate limits) gives sparse +per-entry data: often just ``title``, ``id``, ``duration``, and an +``uploader``/``channel`` that — for a playlist like "Likes" — is the PLAYLIST +OWNER, not the track artist. GitHub #863: every track came out as the owner +("Wing It"), or "Unknown Artist" when ``uploader`` was absent, because the +parser used ``entry['uploader']`` as the artist. + +The artist is usually recoverable from one of, in priority order: + +1. yt-dlp music-metadata fields (``artists`` / ``artist`` / ``creator``), + populated for YouTube Music tracks. +2. An auto-generated ``" - Topic"`` channel name. +3. The classic ``" - "`` form embedded in the video title. + +This module is the single, pure place that decides which signal wins, so the +precedence is unit-testable instead of buried in the web_server endpoint. It +deliberately does NOT fall back to the channel/uploader as the artist — on a +playlist that's the owner, and mislabelling every track is worse than an honest +"Unknown Artist" (which downstream MusicBrainz discovery can still try to fix). +""" + +from __future__ import annotations + +import re +from typing import Any, Mapping, Tuple + +# Trailing "- Topic" on an auto-generated YouTube Music channel. +_TOPIC_RE = re.compile(r'\s*-\s*topic\s*$', re.IGNORECASE) + +# "Artist - Title": a hyphen/en-dash/em-dash flanked by spaces, both sides +# non-empty. Splits on the FIRST such separator so "A - B (C Remix)" → ("A", +# "B (C Remix)"). Spaces around the dash are required so hyphenated names like +# "Jean-Michel Jarre" aren't split. +_TITLE_SPLIT_RE = re.compile(r'^\s*(?P<artist>.+?)\s+[-–—]\s+(?P<title>.+?)\s*$') + + +def _first_music_field(entry: Mapping[str, Any]) -> str: + """First non-empty value from yt-dlp's music-metadata fields.""" + artists = entry.get('artists') + if isinstance(artists, (list, tuple)): + for a in artists: + s = str(a or '').strip() + if s: + return s + for key in ('artist', 'creator'): + v = entry.get(key) + if isinstance(v, str) and v.strip(): + return v.strip() + return '' + + +def derive_artist_and_title(entry: Mapping[str, Any]) -> Tuple[str, str]: + """Return ``(artist, title)`` from a yt-dlp (flat) playlist entry. + + ``artist`` is ``''`` when no reliable signal exists — the caller defaults + that to "Unknown Artist" rather than using the playlist owner's channel + (#863). ``title`` is the raw video title, except when an "Artist - Title" + split provided the artist, in which case it's the right-hand side. + """ + if not isinstance(entry, Mapping): + return '', 'Unknown Track' + + title = str(entry.get('title') or '').strip() or 'Unknown Track' + + # 1. Music-metadata fields (YouTube Music). + field_artist = _first_music_field(entry) + if field_artist: + return field_artist, title + + # 2. "<Artist> - Topic" auto-channel — the channel name IS the artist. + channel = str(entry.get('uploader') or entry.get('channel') or '').strip() + if _TOPIC_RE.search(channel): + stripped = _TOPIC_RE.sub('', channel).strip() + if stripped: + return stripped, title + + # 3. "<Artist> - <Title>" embedded in the title. + m = _TITLE_SPLIT_RE.match(title) + if m: + artist = m.group('artist').strip() + rest = m.group('title').strip() + if artist and rest: + return artist, rest + + # 4. No reliable artist signal — caller defaults to "Unknown Artist". + return '', title + + +__all__ = ['derive_artist_and_title'] diff --git a/database/music_database.py b/database/music_database.py index a3b2fb03..0d06a6db 100644 --- a/database/music_database.py +++ b/database/music_database.py @@ -102,6 +102,10 @@ class WatchlistArtist: include_instrumentals: bool = False lookback_days: Optional[int] = None # Per-artist override; None = use global setting preferred_metadata_source: Optional[str] = None # Per-artist override; None = use global setting + # When False ("follow only"), the watchlist scan still discovers + surfaces new + # releases for this artist but does NOT auto-add them to the wishlist (so they + # don't auto-download). Default True = current behaviour. + auto_download: bool = True profile_id: int = 1 @dataclass @@ -467,6 +471,12 @@ class MusicDatabase: self._add_soul_id_columns(cursor) self._add_listening_history_table(cursor) + # Per-artist auto_download ("follow only") column. MUST run after the + # profile-support migrations above — those recreate watchlist_artists + # from an explicit column list, so any column added before them gets + # dropped. Adding it here (after the last recreate) makes it stick. + self._add_watchlist_auto_download_column(cursor) + # Spotify library cache self._add_spotify_library_cache_table(cursor) @@ -579,6 +589,7 @@ class MusicDatabase: # Add explored_at to mirrored_playlists (migration) self._add_mirrored_playlist_explored_column(cursor) self._add_mirrored_playlist_organize_column(cursor) + self._add_mirrored_playlist_custom_name_column(cursor) # Add notification columns to automations (migration) self._add_automation_notify_columns(cursor) @@ -1251,6 +1262,23 @@ class MusicDatabase: except Exception as e: logger.error(f"Error adding organize_by_playlist column to mirrored_playlists: {e}") + def _add_mirrored_playlist_custom_name_column(self, cursor): + """Add custom_name (a user alias) for a mirrored playlist. + + Overrides the upstream ``name`` for both UI display and sync-to-server, + while staying tied to the original — the upstream ``name`` keeps tracking + on refresh, ``custom_name`` just overrides what's shown/synced. Stored in + its OWN column (not ``name``) precisely so ``mirror_playlist`` — which + rewrites ``name`` from upstream on every refresh — never clobbers it.""" + try: + cursor.execute("PRAGMA table_info(mirrored_playlists)") + cols = [c[1] for c in cursor.fetchall()] + if 'custom_name' not in cols: + cursor.execute("ALTER TABLE mirrored_playlists ADD COLUMN custom_name TEXT DEFAULT NULL") + logger.info("Added custom_name column to mirrored_playlists table") + except Exception as e: + logger.error(f"Error adding custom_name column to mirrored_playlists: {e}") + def _add_automation_notify_columns(self, cursor): """Add notification and result columns to automations table.""" try: @@ -2140,6 +2168,21 @@ class MusicDatabase: logger.error(f"Error adding content type filter columns to watchlist_artists: {e}") # Don't raise - this is a migration, database can still function + def _add_watchlist_auto_download_column(self, cursor): + """Add per-artist auto_download column ("follow only" toggle). + + Default 1 (auto-download) = existing behaviour. When 0, the watchlist scan + still finds + surfaces new releases for the artist but skips adding them to + the wishlist.""" + try: + cursor.execute("PRAGMA table_info(watchlist_artists)") + columns = [column[1] for column in cursor.fetchall()] + if 'auto_download' not in columns: + cursor.execute("ALTER TABLE watchlist_artists ADD COLUMN auto_download INTEGER NOT NULL DEFAULT 1") + logger.info("Added auto_download column to watchlist_artists table") + except Exception as e: + logger.error(f"Error adding auto_download column to watchlist_artists: {e}") + def _add_watchlist_lookback_days_column(self, cursor): """Add per-artist lookback_days column to watchlist_artists table""" try: @@ -6883,6 +6926,31 @@ class MusicDatabase: logger.error(f"API: Error searching tracks with title='{title}', artist='{artist}': {e}") return [] + def get_tracks_for_m3u_resolution(self, server_source: Optional[str] = None) -> List[Dict[str, str]]: + """Bulk-load (artist, title, file_path) for in-memory M3U path resolution. + + ONE indexed read instead of a per-artist search loop. SQLite WAL allows it + to run concurrently with the enrichment/scan writers, so M3U export no + longer blocks behind them (the 'Export M3U hangs forever' report). Only + rows that actually have a file_path are returned (the rest can't go in an + M3U anyway).""" + try: + conn = self._get_connection() + cursor = conn.cursor() + sql = ("SELECT tracks.title AS title, artists.name AS artist_name, tracks.file_path AS file_path " + "FROM tracks JOIN artists ON tracks.artist_id = artists.id " + "WHERE tracks.file_path IS NOT NULL AND tracks.file_path != ''") + params: list = [] + if server_source: + sql += " AND tracks.server_source = ?" + params.append(server_source) + cursor.execute(sql, params) + return [{'title': r['title'] or '', 'artist': r['artist_name'] or '', 'file_path': r['file_path']} + for r in cursor.fetchall()] + except Exception as e: + logger.error(f"Error bulk-loading tracks for M3U resolution: {e}") + return [] + def _search_tracks_basic(self, cursor, title: str, artist: str, limit: int, server_source: str = None, rank_artist: str = None) -> List[DatabaseTrack]: """Basic SQL LIKE search - fastest method""" @@ -9275,7 +9343,8 @@ class MusicDatabase: 'last_scan_timestamp', 'created_at', 'updated_at'] optional_columns = ['image_url', 'itunes_artist_id', 'deezer_artist_id', 'discogs_artist_id', 'musicbrainz_artist_id', 'include_albums', 'include_eps', 'include_singles', 'include_live', 'include_remixes', 'include_acoustic', 'include_compilations', - 'include_instrumentals', 'lookback_days', 'preferred_metadata_source'] + 'include_instrumentals', 'lookback_days', 'preferred_metadata_source', + 'auto_download'] columns_to_select = base_columns + [col for col in optional_columns if col in existing_columns] @@ -9313,6 +9382,7 @@ class MusicDatabase: include_instrumentals = bool(row['include_instrumentals']) if 'include_instrumentals' in existing_columns else False lookback_days = row['lookback_days'] if 'lookback_days' in existing_columns else None preferred_metadata_source = row['preferred_metadata_source'] if 'preferred_metadata_source' in existing_columns else None + auto_download = bool(row['auto_download']) if 'auto_download' in existing_columns else True watchlist_artists.append(WatchlistArtist( id=row['id'], @@ -9337,6 +9407,7 @@ class MusicDatabase: include_instrumentals=include_instrumentals, lookback_days=lookback_days, preferred_metadata_source=preferred_metadata_source, + auto_download=auto_download, profile_id=profile_id )) @@ -13704,8 +13775,12 @@ class MusicDatabase: row = self.get_mirrored_playlist_by_source(default_source, ref, profile_id) if row: return row - if ref.isdigit(): - return self.get_mirrored_playlist(int(ref)) + # Fallback: bare numeric ref or a synthetic batch id (auto_mirror_<pk>, + # youtube_mirrored_<pk>, mirrored_<pk>) whose trailing digits are the PK. + from core.playlists.source_refs import extract_mirrored_pk + pk = extract_mirrored_pk(ref) + if pk is not None: + return self.get_mirrored_playlist(pk) return None def set_mirrored_playlist_organize_by_playlist( @@ -13731,6 +13806,32 @@ class MusicDatabase: logger.error(f"Error updating organize_by_playlist for playlist {playlist_id}: {e}") return False + def set_mirrored_playlist_custom_name(self, playlist_id: int, custom_name) -> bool: + """Set or clear a user alias for a mirrored playlist. + + A blank/None value CLEARS the alias (display + sync fall back to the + upstream name). Touches only ``custom_name`` + ``updated_at``, leaving the + upstream ``name`` and the tracks untouched — so the alias survives upstream + refresh and never disturbs anything else (mirrors the source-ref/organize + update pattern).""" + value = (str(custom_name).strip() or None) if custom_name is not None else None + try: + with self._get_connection() as conn: + cursor = conn.cursor() + cursor.execute( + """ + UPDATE mirrored_playlists + SET custom_name = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ? + """, + (value, playlist_id), + ) + conn.commit() + return cursor.rowcount > 0 + except Exception as e: + logger.error(f"Error updating custom_name for playlist {playlist_id}: {e}") + return False + def get_mirrored_playlist_tracks(self, playlist_id: int) -> List[Dict]: """Return all tracks for a mirrored playlist ordered by position.""" try: diff --git a/dev.py b/dev.py index 95ca24bd..ea86e1bc 100755 --- a/dev.py +++ b/dev.py @@ -316,6 +316,14 @@ def watch_and_run_backend() -> None: def main() -> int: + # `--lan` exposes the dev server on the network (binds 0.0.0.0) so you can + # reach it from another device at http://<this-pc-ip>:8008. Default stays + # localhost-only. Set before build_backend_env() so both backend modes see it. + if '--lan' in sys.argv: + os.environ['SOULSYNC_WEB_BIND_HOST'] = '0.0.0.0' + print('LAN mode ON — dev server reachable from other devices on your network ' + '(http://<this-pc-ip>:8008). Allow port 8008 through the firewall if needed.') + if not (ROOT_DIR / 'webui' / 'node_modules').is_dir(): print('webui/node_modules is missing.') print('Run: cd webui && npm ci') diff --git a/gunicorn.dev.conf.py b/gunicorn.dev.conf.py index 43068ff3..026d003c 100644 --- a/gunicorn.dev.conf.py +++ b/gunicorn.dev.conf.py @@ -3,7 +3,11 @@ from pathlib import Path import os -bind = "127.0.0.1:8008" +# Localhost-only by default (a dev server shouldn't expose itself to the LAN +# without asking). To reach it from another device on your network, opt in with +# SOULSYNC_WEB_BIND_HOST=0.0.0.0 python dev.py +# then browse to http://<this-pc-lan-ip>:8008 (and allow port 8008 in the firewall). +bind = f"{os.environ.get('SOULSYNC_WEB_BIND_HOST', '127.0.0.1')}:{os.environ.get('SOULSYNC_WEB_BIND_PORT', '8008')}" worker_class = "gthread" workers = 1 threads = 4 diff --git a/tests/automation/test_handler_registration.py b/tests/automation/test_handler_registration.py index 357ebc06..7b23c67a 100644 --- a/tests/automation/test_handler_registration.py +++ b/tests/automation/test_handler_registration.py @@ -142,10 +142,7 @@ def _build_deps(engine, scan_mgr=None) -> AutomationDeps: duplicate_cleaner_lock=threading.Lock(), duplicate_cleaner_executor=None, run_duplicate_cleaner=lambda: None, - get_quality_scanner_state=lambda: {}, - quality_scanner_lock=threading.Lock(), - quality_scanner_executor=None, - run_quality_scanner=lambda *a, **k: None, + run_repair_job_now=lambda *a, **k: True, download_orchestrator=None, run_async=lambda coro: None, tasks_lock=threading.Lock(), @@ -360,7 +357,6 @@ class TestHandlerInvocation: **{f.name: getattr(deps, f.name) for f in deps.__dataclass_fields__.values()}, 'get_db_update_state': lambda: running_state, 'get_duplicate_cleaner_state': lambda: running_state, - 'get_quality_scanner_state': lambda: running_state, 'get_download_batches': lambda: active_batches, # forces clean_completed_downloads to skip 'get_database': lambda: _StubDB(), 'get_app': lambda: _StubApp(), diff --git a/tests/automation/test_handlers_maintenance.py b/tests/automation/test_handlers_maintenance.py index e9c62a05..36ca357b 100644 --- a/tests/automation/test_handlers_maintenance.py +++ b/tests/automation/test_handlers_maintenance.py @@ -89,10 +89,7 @@ def _build_deps(**overrides) -> AutomationDeps: duplicate_cleaner_lock=threading.Lock(), duplicate_cleaner_executor=None, run_duplicate_cleaner=lambda: None, - get_quality_scanner_state=lambda: {}, - quality_scanner_lock=threading.Lock(), - quality_scanner_executor=None, - run_quality_scanner=lambda *a, **k: None, + run_repair_job_now=lambda *a, **k: True, download_orchestrator=None, run_async=lambda coro: None, tasks_lock=threading.Lock(), @@ -185,11 +182,18 @@ class TestDuplicateCleaner: class TestQualityScanner: - def test_already_running_returns_skipped(self): - state = {'status': 'running'} - deps = _build_deps(get_quality_scanner_state=lambda: state) + def test_triggers_quality_upgrade_repair_job(self): + triggered = [] + deps = _build_deps(run_repair_job_now=lambda job_id: triggered.append(job_id) or True) result = auto_start_quality_scan({}, deps) - assert result == {'status': 'skipped', 'reason': 'Quality scan already running'} + assert triggered == ['quality_upgrade'] + assert result['status'] == 'completed' + assert result['triggered'] is True + + def test_error_when_worker_unavailable(self): + deps = _build_deps(run_repair_job_now=lambda job_id: None) + result = auto_start_quality_scan({}, deps) + assert result['status'] == 'error' # ─── clear_quarantine ──────────────────────────────────────────────── diff --git a/tests/automation/test_handlers_personalized_pipeline.py b/tests/automation/test_handlers_personalized_pipeline.py index af91bb03..019a2250 100644 --- a/tests/automation/test_handlers_personalized_pipeline.py +++ b/tests/automation/test_handlers_personalized_pipeline.py @@ -63,10 +63,7 @@ def _build_deps(**overrides) -> AutomationDeps: duplicate_cleaner_lock=threading.Lock(), duplicate_cleaner_executor=None, run_duplicate_cleaner=lambda: None, - get_quality_scanner_state=lambda: {}, - quality_scanner_lock=threading.Lock(), - quality_scanner_executor=None, - run_quality_scanner=lambda *a, **k: None, + run_repair_job_now=lambda *a, **k: True, download_orchestrator=None, run_async=lambda coro: None, tasks_lock=threading.Lock(), diff --git a/tests/automation/test_handlers_playlist.py b/tests/automation/test_handlers_playlist.py index e13179e3..a905c1e7 100644 --- a/tests/automation/test_handlers_playlist.py +++ b/tests/automation/test_handlers_playlist.py @@ -125,10 +125,7 @@ def _build_deps(**overrides) -> AutomationDeps: duplicate_cleaner_lock=threading.Lock(), duplicate_cleaner_executor=None, run_duplicate_cleaner=lambda: None, - get_quality_scanner_state=lambda: {}, - quality_scanner_lock=threading.Lock(), - quality_scanner_executor=None, - run_quality_scanner=lambda *a, **k: None, + run_repair_job_now=lambda *a, **k: True, download_orchestrator=None, run_async=lambda coro: None, tasks_lock=threading.Lock(), diff --git a/tests/automation/test_handlers_simple.py b/tests/automation/test_handlers_simple.py index 9320b96d..f34d3004 100644 --- a/tests/automation/test_handlers_simple.py +++ b/tests/automation/test_handlers_simple.py @@ -76,10 +76,7 @@ def _build_deps(**overrides: Any) -> AutomationDeps: duplicate_cleaner_lock=threading.Lock(), duplicate_cleaner_executor=None, run_duplicate_cleaner=lambda: None, - get_quality_scanner_state=lambda: {}, - quality_scanner_lock=threading.Lock(), - quality_scanner_executor=None, - run_quality_scanner=lambda *a, **k: None, + run_repair_job_now=lambda *a, **k: True, download_orchestrator=None, run_async=lambda coro: None, tasks_lock=threading.Lock(), diff --git a/tests/automation/test_playlist_pipeline_folder_mode.py b/tests/automation/test_playlist_pipeline_folder_mode.py index 9c412fce..e727ec95 100644 --- a/tests/automation/test_playlist_pipeline_folder_mode.py +++ b/tests/automation/test_playlist_pipeline_folder_mode.py @@ -41,10 +41,7 @@ def _minimal_deps(**overrides): duplicate_cleaner_lock=threading.Lock(), duplicate_cleaner_executor=None, run_duplicate_cleaner=lambda: None, - get_quality_scanner_state=lambda: {}, - quality_scanner_lock=threading.Lock(), - quality_scanner_executor=None, - run_quality_scanner=lambda *a, **k: None, + run_repair_job_now=lambda *a, **k: True, download_orchestrator=None, run_async=lambda coro: None, tasks_lock=threading.Lock(), diff --git a/tests/automation/test_progress_callbacks.py b/tests/automation/test_progress_callbacks.py index 16728255..cdceac05 100644 --- a/tests/automation/test_progress_callbacks.py +++ b/tests/automation/test_progress_callbacks.py @@ -62,10 +62,7 @@ def _build_deps(**overrides) -> AutomationDeps: duplicate_cleaner_lock=threading.Lock(), duplicate_cleaner_executor=None, run_duplicate_cleaner=lambda: None, - get_quality_scanner_state=lambda: {}, - quality_scanner_lock=threading.Lock(), - quality_scanner_executor=None, - run_quality_scanner=lambda *a, **k: None, + run_repair_job_now=lambda *a, **k: True, download_orchestrator=None, run_async=lambda coro: None, tasks_lock=threading.Lock(), diff --git a/tests/conftest.py b/tests/conftest.py index 75c3de68..288f439b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -113,12 +113,6 @@ _DEFAULT_STREAM_STATE = { "error_message": None, } -_DEFAULT_QUALITY_SCANNER_STATE = { - "status": "running", "phase": "Scanning...", "progress": 35, - "processed": 35, "total": 100, "quality_met": 30, - "low_quality": 5, "matched": 2, "error_message": "", "results": [], -} - _DEFAULT_DUPLICATE_CLEANER_STATE = { "status": "running", "phase": "Scanning...", "progress": 50, "files_scanned": 500, "total_files": 1000, "duplicates_found": 10, @@ -257,7 +251,6 @@ enrichment_status = copy.deepcopy(_DEFAULT_ENRICHMENT_STATUS) # Phase 4: Tool progress state stream_state = copy.deepcopy(_DEFAULT_STREAM_STATE) -quality_scanner_state = copy.deepcopy(_DEFAULT_QUALITY_SCANNER_STATE) duplicate_cleaner_state = copy.deepcopy(_DEFAULT_DUPLICATE_CLEANER_STATE) retag_state = copy.deepcopy(_DEFAULT_RETAG_STATE) db_update_state = copy.deepcopy(_DEFAULT_DB_UPDATE_STATE) @@ -350,13 +343,12 @@ ENRICHMENT_ENDPOINTS = { # Phase 4 helpers TOOL_NAMES = [ - 'stream', 'quality-scanner', 'duplicate-cleaner', + 'stream', 'duplicate-cleaner', 'retag', 'db-update', 'metadata', 'logs', ] TOOL_ENDPOINTS = { 'stream': '/api/stream/status', - 'quality-scanner': '/api/quality-scanner/status', 'duplicate-cleaner': '/api/duplicate-cleaner/status', 'retag': '/api/retag/status', 'db-update': '/api/database/update/status', @@ -374,10 +366,6 @@ def _build_stream_status(): } -def _build_quality_scanner_status(): - return dict(quality_scanner_state) - - def _build_duplicate_cleaner_status(): state_copy = duplicate_cleaner_state.copy() state_copy["space_freed_mb"] = duplicate_cleaner_state["space_freed"] / (1024 * 1024) @@ -419,7 +407,6 @@ def _build_tool_status(tool_name): """Dispatcher that returns the correct status payload for any tool.""" builders = { 'stream': _build_stream_status, - 'quality-scanner': _build_quality_scanner_status, 'duplicate-cleaner': _build_duplicate_cleaner_status, 'retag': _build_retag_status, 'db-update': _build_db_update_status, @@ -635,10 +622,6 @@ def test_app(): def stream_status_endpoint(): return jsonify(_build_stream_status()) - @app.route('/api/quality-scanner/status') - def quality_scanner_status_endpoint(): - return jsonify(_build_quality_scanner_status()) - @app.route('/api/duplicate-cleaner/status') def duplicate_cleaner_status_endpoint(): return jsonify(_build_duplicate_cleaner_status()) @@ -961,7 +944,6 @@ def shared_state(): 'enrichment_endpoints': ENRICHMENT_ENDPOINTS, # Phase 4 state 'stream_state': stream_state, - 'quality_scanner_state': quality_scanner_state, 'duplicate_cleaner_state': duplicate_cleaner_state, 'retag_state': retag_state, 'db_update_state': db_update_state, @@ -969,7 +951,6 @@ def shared_state(): 'logs_activities': logs_activities, 'build_tool_status': _build_tool_status, 'build_stream_status': _build_stream_status, - 'build_quality_scanner_status': _build_quality_scanner_status, 'build_duplicate_cleaner_status': _build_duplicate_cleaner_status, 'build_retag_status': _build_retag_status, 'build_db_update_status': _build_db_update_status, @@ -1019,8 +1000,6 @@ def reset_state(): # Phase 4 resets stream_state.clear() stream_state.update(copy.deepcopy(_DEFAULT_STREAM_STATE)) - quality_scanner_state.clear() - quality_scanner_state.update(copy.deepcopy(_DEFAULT_QUALITY_SCANNER_STATE)) duplicate_cleaner_state.clear() duplicate_cleaner_state.update(copy.deepcopy(_DEFAULT_DUPLICATE_CLEANER_STATE)) retag_state.clear() @@ -1061,8 +1040,6 @@ def reset_state(): enrichment_status.update(copy.deepcopy(_DEFAULT_ENRICHMENT_STATUS)) stream_state.clear() stream_state.update(copy.deepcopy(_DEFAULT_STREAM_STATE)) - quality_scanner_state.clear() - quality_scanner_state.update(copy.deepcopy(_DEFAULT_QUALITY_SCANNER_STATE)) duplicate_cleaner_state.clear() duplicate_cleaner_state.update(copy.deepcopy(_DEFAULT_DUPLICATE_CLEANER_STATE)) retag_state.clear() diff --git a/tests/discovery/test_discovery_quality_scanner.py b/tests/discovery/test_discovery_quality_scanner.py deleted file mode 100644 index f3395ad4..00000000 --- a/tests/discovery/test_discovery_quality_scanner.py +++ /dev/null @@ -1,492 +0,0 @@ -"""Tests for core/discovery/quality_scanner.py — library quality scanner.""" - -from __future__ import annotations - -import threading -from dataclasses import dataclass - -import pytest - -from core.discovery import quality_scanner as qs - - -# --------------------------------------------------------------------------- -# Fakes -# --------------------------------------------------------------------------- - -@dataclass -class _FakeSpotifyTrack: - id: str = 'spt-1' - name: str = 'Found' - artists: list = None - album: str = 'Found Album' - duration_ms: int = 200000 - popularity: int = 50 - preview_url: str = '' - external_urls: dict = None - album_type: str = 'album' - release_date: str = '2024-01-01' - - def __post_init__(self): - if self.artists is None: - self.artists = ['Found Artist'] - if self.external_urls is None: - self.external_urls = {} - - -class _FakeMetadataClient: - def __init__(self, results=None): - self._results = results if results is not None else [] - self.search_calls = [] - - def search_tracks(self, query, limit=5, allow_fallback=True): - self.search_calls.append((query, limit, allow_fallback)) - return self._results - - -_TEST_PRIMARY_SOURCE = 'spotify' -_TEST_SOURCE_CLIENTS = {} - - -@pytest.fixture(autouse=True) -def _patch_source_resolution(monkeypatch): - monkeypatch.setattr(qs, 'get_primary_source', lambda: _TEST_PRIMARY_SOURCE) - monkeypatch.setattr(qs, 'get_client_for_source', lambda source, **_kwargs: _TEST_SOURCE_CLIENTS.get(source)) - monkeypatch.setattr(qs.time, 'sleep', lambda *_args, **_kwargs: None) - yield - _TEST_SOURCE_CLIENTS.clear() - globals()['_TEST_PRIMARY_SOURCE'] = 'spotify' - - -class _FakeMatchingEngine: - def generate_download_queries(self, t): - return [f"{t.artists[0]} {t.name}"] - - def normalize_string(self, s): - return (s or '').lower().strip() - - def similarity_score(self, a, b): - if a == b: - return 1.0 - if not a or not b: - return 0.0 - return 0.95 if a in b or b in a else 0.0 - - -class _MultiQueryMatchingEngine(_FakeMatchingEngine): - def generate_download_queries(self, t): - return [ - f"{t.artists[0]} {t.name} first", - f"{t.artists[0]} {t.name} second", - ] - - -class _FakeAutomationEngine: - def __init__(self): - self.events = [] - - def emit(self, event_type, data): - self.events.append((event_type, data)) - - -class _FakeWishlistService: - def __init__(self): - self.added = [] - - def add_spotify_track_to_wishlist(self, **kwargs): - self.added.append(kwargs) - return True - - -class _FakeMusicDB: - def __init__(self, watchlist_artists=None, tracks=None, profile=None): - self._watchlist_artists = watchlist_artists if watchlist_artists is not None else [] - self._tracks = tracks if tracks is not None else [] - self._profile = profile or {'qualities': {'flac': {'enabled': True}}} - - def get_quality_profile(self): - return self._profile - - def get_watchlist_artists(self, profile_id=1): - return self._watchlist_artists - - def _get_connection(self): - rows = self._tracks - return _FakeConn(rows) - - -class _FakeConn: - def __init__(self, rows): - self._rows = rows - - def execute(self, query, params=None): - return _FakeCursor(self._rows) - - def close(self): - pass - - -class _FakeCursor: - def __init__(self, rows): - self._rows = rows - - def fetchall(self): - return self._rows - - -@dataclass -class _WatchlistArtist: - artist_name: str - - -def _build_deps( - *, - state=None, - source_clients=None, - primary_source='spotify', - quality_tier_result=('lossless', 1), - automation=None, -): - globals()['_TEST_PRIMARY_SOURCE'] = primary_source - _TEST_SOURCE_CLIENTS.clear() - if source_clients is not None: - _TEST_SOURCE_CLIENTS.update(source_clients) - elif primary_source: - _TEST_SOURCE_CLIENTS[primary_source] = _FakeMetadataClient(results=[]) - - deps = qs.QualityScannerDeps( - quality_scanner_state=state if state is not None else {}, - quality_scanner_lock=threading.Lock(), - QUALITY_TIERS={'lossless': {'tier': 1}, 'low_lossy': {'tier': 4}, 'lossy': {'tier': 3}}, - matching_engine=_FakeMatchingEngine(), - automation_engine=automation or _FakeAutomationEngine(), - get_quality_tier_from_extension=lambda fp: quality_tier_result, - add_activity_item=lambda *a, **kw: None, - ) - return deps - - -def _track_row(track_id=1, title='Track', artist_id=1, album_id=1, - file_path='/x.mp3', bitrate=128, artist_name='Artist', - album_title='Album'): - return (track_id, title, artist_id, album_id, file_path, bitrate, artist_name, album_title) - - -@pytest.fixture -def mock_db_and_wishlist(monkeypatch): - """Patches MusicDatabase and get_wishlist_service used inside the worker.""" - db = _FakeMusicDB() - ws = _FakeWishlistService() - monkeypatch.setattr('database.music_database.MusicDatabase', lambda: db) - monkeypatch.setattr('core.wishlist_service.get_wishlist_service', lambda: ws) - return db, ws - - -# --------------------------------------------------------------------------- -# State init + DB load -# --------------------------------------------------------------------------- - -def test_state_initialized_on_run(mock_db_and_wishlist): - """Scanner resets state to running with cleared counters.""" - db, _ = mock_db_and_wishlist - db._watchlist_artists = [] # no artists → exits early but after init - state = {} - deps = _build_deps(state=state) - - qs.run_quality_scanner('watchlist', 1, deps) - - assert state['status'] == 'finished' # exited early since no artists - assert state['error_message'] == 'Please add artists to watchlist first' - - -def test_no_watchlist_artists_short_circuit(mock_db_and_wishlist): - """Scope=watchlist with no artists → status=finished, error message.""" - db, _ = mock_db_and_wishlist - db._watchlist_artists = [] - state = {} - deps = _build_deps(state=state) - - qs.run_quality_scanner('watchlist', 1, deps) - - assert state['status'] == 'finished' - assert 'add artists' in state['error_message'] - - -# --------------------------------------------------------------------------- -# Provider availability gate -# --------------------------------------------------------------------------- - -def test_no_available_provider_marks_error(mock_db_and_wishlist): - """No available metadata providers → state['status']='error'.""" - db, _ = mock_db_and_wishlist - db._watchlist_artists = [_WatchlistArtist('A')] - db._tracks = [_track_row()] - state = {} - deps = _build_deps(state=state, source_clients={}, quality_tier_result=('low_lossy', 4)) - - qs.run_quality_scanner('watchlist', 1, deps) - - assert state['status'] == 'error' - assert 'metadata provider' in state['error_message'].lower() - - -# --------------------------------------------------------------------------- -# Quality tier check + skip -# --------------------------------------------------------------------------- - -def test_high_quality_tracks_skipped(mock_db_and_wishlist): - """Tracks meeting quality (tier_num <= min_acceptable) → quality_met += 1.""" - db, _ = mock_db_and_wishlist - db._watchlist_artists = [_WatchlistArtist('A')] - db._tracks = [_track_row(file_path='/x.flac')] - state = {} - # Default min_acceptable is from {flac: enabled} → tier 1 (lossless) - # quality_tier_result=('lossless', 1) → 1 <= 1 → skip - deps = _build_deps(state=state, quality_tier_result=('lossless', 1)) - - qs.run_quality_scanner('watchlist', 1, deps) - - assert state['quality_met'] == 1 - assert state['low_quality'] == 0 - - -def test_low_quality_tracks_attempted(mock_db_and_wishlist): - """Low-quality tracks (tier_num > min) trigger a metadata search.""" - db, _ = mock_db_and_wishlist - db._watchlist_artists = [_WatchlistArtist('Artist')] - db._tracks = [_track_row(file_path='/x.mp3', artist_name='Artist', title='Track')] - state = {} - match = _FakeSpotifyTrack(name='Track', artists=['Artist']) - spotify_client = _FakeMetadataClient(results=[match]) - deps = _build_deps( - state=state, - quality_tier_result=('low_lossy', 4), - source_clients={'spotify': spotify_client}, - primary_source='spotify', - ) - - qs.run_quality_scanner('watchlist', 1, deps) - - assert state['low_quality'] == 1 - assert spotify_client.search_calls - assert spotify_client.search_calls[0][2] is False - - -def test_client_lookup_happens_per_query(mock_db_and_wishlist, monkeypatch): - """Each generated query re-resolves the source client.""" - db, _ = mock_db_and_wishlist - db._watchlist_artists = [_WatchlistArtist('Artist')] - db._tracks = [_track_row(file_path='/x.mp3', artist_name='Artist', title='Track')] - state = {} - spotify_client = _FakeMetadataClient(results=[]) - lookups = [] - - monkeypatch.setattr( - qs, - 'get_client_for_source', - lambda source, **_kwargs: (lookups.append(source), _TEST_SOURCE_CLIENTS.get(source))[1], - ) - - deps = _build_deps( - state=state, - quality_tier_result=('low_lossy', 4), - source_clients={'spotify': spotify_client}, - primary_source='spotify', - ) - deps.matching_engine = _MultiQueryMatchingEngine() - - qs.run_quality_scanner('watchlist', 1, deps) - - assert len(lookups) % 2 == 0 - midpoint = len(lookups) // 2 - assert lookups[:midpoint] == lookups[midpoint:] - assert len(spotify_client.search_calls) == 2 - - -def test_low_quality_tracks_follow_source_priority(mock_db_and_wishlist): - """Primary source is searched before Spotify.""" - db, ws = mock_db_and_wishlist - db._watchlist_artists = [_WatchlistArtist('Artist')] - db._tracks = [_track_row(file_path='/x.mp3', artist_name='Artist', title='Track')] - state = {} - match = _FakeSpotifyTrack(name='Track', artists=['Artist']) - deezer_client = _FakeMetadataClient(results=[match]) - spotify_client = _FakeMetadataClient(results=[]) - deps = _build_deps( - state=state, - source_clients={'deezer': deezer_client, 'spotify': spotify_client}, - primary_source='deezer', - quality_tier_result=('low_lossy', 4), - ) - - qs.run_quality_scanner('watchlist', 1, deps) - - assert state['matched'] == 1 - assert deezer_client.search_calls - assert spotify_client.search_calls == [] - assert len(ws.added) == 1 - add_args = ws.added[0] - assert add_args['track_data']['provider'] == 'deezer' - assert add_args['track_data']['source'] == 'deezer' - - -# --------------------------------------------------------------------------- -# Match → wishlist add -# --------------------------------------------------------------------------- - -def test_match_adds_to_wishlist(mock_db_and_wishlist): - """High-confidence match → wishlist_service.add_spotify_track_to_wishlist called.""" - db, ws = mock_db_and_wishlist - db._watchlist_artists = [_WatchlistArtist('Artist')] - db._tracks = [_track_row(artist_name='Artist', title='Track', file_path='/x.mp3', bitrate=128)] - state = {} - match = _FakeSpotifyTrack(name='Track', artists=['Artist']) - deps = _build_deps( - state=state, - quality_tier_result=('low_lossy', 4), - source_clients={'spotify': _FakeMetadataClient(results=[match])}, - primary_source='spotify', - ) - - qs.run_quality_scanner('watchlist', 1, deps) - - assert state['matched'] == 1 - assert len(ws.added) == 1 - add_args = ws.added[0] - assert add_args['source_type'] == 'quality_scanner' - assert add_args['source_context']['original_file_path'] == '/x.mp3' - - -def test_match_preserves_album_and_artist_images(mock_db_and_wishlist): - """Image metadata from the provider payload should survive the wishlist handoff.""" - db, ws = mock_db_and_wishlist - db._watchlist_artists = [_WatchlistArtist('Artist')] - db._tracks = [_track_row(artist_name='Artist', title='Track', file_path='/x.mp3', bitrate=128)] - state = {} - match = { - 'id': 'sp-1', - 'name': 'Track', - 'artists': [{'name': 'Artist', 'image_url': 'https://example.test/artist.jpg'}], - 'album': 'Album', - 'image_url': 'https://example.test/cover.jpg', - 'duration_ms': 200000, - 'popularity': 50, - 'external_urls': {}, - 'album_type': 'album', - 'release_date': '2024-01-01', - } - deps = _build_deps( - state=state, - quality_tier_result=('low_lossy', 4), - source_clients={'spotify': _FakeMetadataClient(results=[match])}, - primary_source='spotify', - ) - - qs.run_quality_scanner('watchlist', 1, deps) - - assert state['matched'] == 1 - assert len(ws.added) == 1 - add_args = ws.added[0] - assert add_args['track_data']['image_url'] == 'https://example.test/cover.jpg' - assert add_args['track_data']['album']['image_url'] == 'https://example.test/cover.jpg' - assert add_args['track_data']['album']['images'] == [{'url': 'https://example.test/cover.jpg'}] - assert add_args['track_data']['artists'][0]['image_url'] == 'https://example.test/artist.jpg' - - -def test_no_match_no_wishlist_add(mock_db_and_wishlist): - """No match found → no wishlist add, matched stays 0.""" - db, ws = mock_db_and_wishlist - db._watchlist_artists = [_WatchlistArtist('A')] - db._tracks = [_track_row(artist_name='A', title='Z', file_path='/x.mp3')] - state = {} - # No spotify results → no match - deps = _build_deps( - state=state, - quality_tier_result=('low_lossy', 4), - source_clients={'spotify': _FakeMetadataClient(results=[])}, - primary_source='spotify', - ) - - qs.run_quality_scanner('watchlist', 1, deps) - - assert state['matched'] == 0 - assert ws.added == [] - - -# --------------------------------------------------------------------------- -# Stop request gate -# --------------------------------------------------------------------------- - -def test_stop_request_halts_loop(mock_db_and_wishlist): - """Setting state['status'] != 'running' mid-loop halts processing.""" - db, _ = mock_db_and_wishlist - db._watchlist_artists = [_WatchlistArtist('A')] - db._tracks = [_track_row(track_id=1), _track_row(track_id=2)] - state = {} - deps = _build_deps(state=state, quality_tier_result=('lossless', 1)) - - # Override get_quality_tier_from_extension to set stop after first track - call_count = [0] - - def stop_after_first(fp): - call_count[0] += 1 - if call_count[0] == 1: - # Set status to non-running BEFORE second track iter checks - with deps.quality_scanner_lock: - state['status'] = 'stopping' - return ('lossless', 1) - - deps.get_quality_tier_from_extension = stop_after_first - - qs.run_quality_scanner('watchlist', 1, deps) - - # Only first track processed - assert state['quality_met'] == 1 - - -# --------------------------------------------------------------------------- -# Completion -# --------------------------------------------------------------------------- - -def test_completion_marks_finished(mock_db_and_wishlist): - """All tracks processed → status='finished', progress=100.""" - db, _ = mock_db_and_wishlist - db._watchlist_artists = [_WatchlistArtist('A')] - db._tracks = [_track_row()] - state = {} - deps = _build_deps(state=state) - - qs.run_quality_scanner('watchlist', 1, deps) - - assert state['status'] == 'finished' - assert state['progress'] == 100 - - -def test_automation_event_emitted(mock_db_and_wishlist): - """Successful completion emits 'quality_scan_completed' on automation engine.""" - db, _ = mock_db_and_wishlist - db._watchlist_artists = [_WatchlistArtist('A')] - db._tracks = [_track_row()] - automation = _FakeAutomationEngine() - state = {} - deps = _build_deps(state=state, automation=automation) - - qs.run_quality_scanner('watchlist', 1, deps) - - assert any(name == 'quality_scan_completed' for name, _ in automation.events) - - -# --------------------------------------------------------------------------- -# All-library scope -# --------------------------------------------------------------------------- - -def test_scope_all_loads_all_tracks(mock_db_and_wishlist): - """scope != 'watchlist' loads all tracks (no watchlist filter).""" - db, _ = mock_db_and_wishlist - db._tracks = [_track_row(track_id=1), _track_row(track_id=2)] - state = {} - deps = _build_deps(state=state) - - qs.run_quality_scanner('all', 1, deps) - - assert state['total'] == 2 diff --git a/tests/downloads/test_downloads_master.py b/tests/downloads/test_downloads_master.py index c2c5b758..b425e4dc 100644 --- a/tests/downloads/test_downloads_master.py +++ b/tests/downloads/test_downloads_master.py @@ -1048,8 +1048,12 @@ def test_wishlist_album_grouping_uses_shared_rich_album_context(monkeypatch): assert images == {'http://img'} -def test_playlist_folder_mode_propagates(monkeypatch): - """Playlist folder mode flag carried through to track_info.""" +def test_organize_by_playlist_keeps_provenance_not_routing(monkeypatch): + """Organize-by-playlist no longer routes the file per-track. Each track imports + NORMALLY into Artist/Album (what a normal download does); the playlist folder + is built as links AFTER the batch. So the old routing flag + `_playlist_folder_mode` is NOT set, but `_playlist_name` provenance IS kept + (core/downloads/origin.py).""" db = _FakeDB() monkeypatch.setattr('database.music_database.MusicDatabase', lambda: db) @@ -1062,8 +1066,8 @@ def test_playlist_folder_mode_propagates(monkeypatch): task_id = download_batches['B15']['queue'][0] info = download_tasks[task_id]['track_info'] - assert info['_playlist_folder_mode'] is True - assert info['_playlist_name'] == 'My Mix' + assert '_playlist_folder_mode' not in info # routing retired → normal import + assert info['_playlist_name'] == 'My Mix' # provenance preserved # --------------------------------------------------------------------------- diff --git a/tests/enrichment/test_unmatched.py b/tests/enrichment/test_unmatched.py index 1f749568..432fb21a 100644 --- a/tests/enrichment/test_unmatched.py +++ b/tests/enrichment/test_unmatched.py @@ -187,6 +187,24 @@ def test_reset_builder_nulls_status_not_just_attempted(): assert "WHERE spotify_match_status = 'not_found'" in sql +def test_reset_builder_also_clears_artist_source_id(): + # #868: a re-match must forget the stored id so the worker actually + # re-resolves (otherwise its existing-id short-circuit re-confirms the wrong + # same-name artist). + for service, col in [('spotify', 'spotify_artist_id'), ('itunes', 'itunes_artist_id'), + ('deezer', 'deezer_id'), ('musicbrainz', 'musicbrainz_id')]: + sql, _ = build_reset_query(service, 'artist', 'item', entity_id=5) + assert f'{col} = NULL' in sql, f'{service}: expected {col} cleared' + assert f'{service}_match_status = NULL' in sql + + +def test_reset_builder_does_not_clear_track_id(): + # Tracks have no source-id column (ids live in tags) — must not emit one. + sql, _ = build_reset_query('spotify', 'track', 'item', entity_id=5) + assert 'spotify_match_status = NULL' in sql + assert 'spotify_track_id = NULL' not in sql + + def test_reset_item_requeues_to_pending(db): n = db.reset_enrichment('spotify', 'artist', 'item', entity_id='a2') # was not_found assert n == 1 diff --git a/tests/repair_jobs/test_quality_upgrade.py b/tests/repair_jobs/test_quality_upgrade.py new file mode 100644 index 00000000..d1ff35fa --- /dev/null +++ b/tests/repair_jobs/test_quality_upgrade.py @@ -0,0 +1,391 @@ +"""Quality Upgrade Finder job — the findings-based replacement for the old +auto-acting Quality Scanner. + +The old tool judged quality by file EXTENSION only and used min() of the enabled +tiers, so with the default profile (FLAC + MP3-320 + MP3-256 enabled) it flagged +EVERY non-lossless file — a 320 kbps MP3 included — and dumped them all into the +wishlist with no review. These tests pin the corrected behavior: bitrate-aware, +honors every enabled bucket, and only proposes (findings) rather than auto-acting. +""" + +from __future__ import annotations + +import types + +import core.repair_jobs.quality_upgrade as qu +from core.repair_jobs.base import JobContext, JobResult + + +# Profiles ------------------------------------------------------------------ + +BALANCED = { # default: FLAC + MP3-320 + MP3-256 enabled, MP3-192 off + 'qualities': { + 'flac': {'enabled': True, 'min_kbps': 500}, + 'mp3_320': {'enabled': True, 'min_kbps': 280}, + 'mp3_256': {'enabled': True, 'min_kbps': 200}, + 'mp3_192': {'enabled': False, 'min_kbps': 150}, + } +} +LOSSLESS_ONLY = { + 'qualities': { + 'flac': {'enabled': True, 'min_kbps': 500}, + 'mp3_320': {'enabled': False, 'min_kbps': 280}, + 'mp3_256': {'enabled': False, 'min_kbps': 200}, + 'mp3_192': {'enabled': False, 'min_kbps': 150}, + } +} +NOTHING_ENABLED = {'qualities': {'flac': {'enabled': False}, 'mp3_320': {'enabled': False}}} + + +# --- pure quality decision ------------------------------------------------- + +def test_balanced_profile_accepts_320_mp3_REGRESSION(): + """The headline bug: with FLAC+320+256 enabled, a 320 kbps MP3 is acceptable. + The old min()-tier logic flagged it (and every other MP3) for re-download.""" + assert meets('song.mp3', 320, BALANCED) is True + + +def test_balanced_profile_accepts_256_mp3(): + assert meets('song.mp3', 256, BALANCED) is True + + +def test_balanced_profile_flags_low_bitrate_mp3(): + assert meets('song.mp3', 128, BALANCED) is False + assert meets('song.mp3', 192, BALANCED) is False # below the 256 floor + + +def test_flac_always_meets_when_flac_enabled(): + assert meets('song.flac', 900, BALANCED) is True + assert meets('song.flac', 900, LOSSLESS_ONLY) is True + + +def test_lossless_only_flags_every_lossy_regardless_of_bitrate(): + assert meets('song.mp3', 320, LOSSLESS_ONLY) is False + assert meets('song.m4a', 256, LOSSLESS_ONLY) is False + + +def test_nothing_enabled_flags_nothing(): + """Empty/disabled profile must NOT flag the whole library.""" + assert meets('song.mp3', 64, NOTHING_ENABLED) is True + + +def test_bitrate_in_bps_is_normalized(): + """Library bitrate stored as bps (320000) classifies the same as 320 kbps.""" + assert qu.classify_track_quality('song.mp3', 320000) == qu.RANK_320 + assert meets('song.mp3', 320000, BALANCED) is True + + +def test_unknown_lossy_bitrate_not_flagged_under_lossy_floor(): + """A lossy file with no bitrate can't be judged against a lossy floor → don't + flag (avoid false positives); but under a lossless floor it's clearly below.""" + assert meets('song.mp3', None, BALANCED) is True + assert meets('song.mp3', None, LOSSLESS_ONLY) is False + + +def test_floor_is_worst_enabled_not_best(): + # FLAC+320+256 enabled → floor is MP3-256 (rank 2), not FLAC. + assert qu.preferred_quality_floor(BALANCED) == qu.RANK_256 + assert qu.preferred_quality_floor(LOSSLESS_ONLY) == qu.RANK_LOSSLESS + assert qu.preferred_quality_floor(NOTHING_ENABLED) is None + + +def meets(path, bitrate, profile): + return qu.meets_preferred_quality(path, bitrate, profile) + + +# --- scan produces a finding (seam) ---------------------------------------- + +class _FakeConn: + def __init__(self, rows, finding_ids=()): + self._rows = rows + self._finding_ids = list(finding_ids) + self._sql = '' + + def execute(self, sql='', *a, **k): + self._sql = sql or '' + return self + + def fetchall(self): + # The existing-findings query reads repair_findings; everything else is the + # track load. + if 'repair_findings' in self._sql: + return [(fid,) for fid in self._finding_ids] + return self._rows + + def close(self): + pass + + +class _FakeDB: + def __init__(self, rows, profile, finding_ids=()): + self._rows = rows + self._profile = profile + self._finding_ids = finding_ids + + def get_quality_profile(self): + return self._profile + + def _get_connection(self): + return _FakeConn(self._rows, self._finding_ids) + + def get_watchlist_artists(self, profile_id=1): + return [types.SimpleNamespace(artist_name='Artist A')] + + +def _ctx(db, findings): + return JobContext( + db=db, + transfer_folder='/tmp', + config_manager=None, + create_finding=lambda **kw: findings.append(kw) or True, + should_stop=lambda: False, + is_paused=lambda: False, + ) + + +def _row(track_id=1, title='Song One', path='/music/a.mp3', bitrate=128, duration=180000, + artist='Artist A', album='Album X', album_id=10, track_number=6): + """A track row in _TRACK_COLS order (album source-id columns default to None).""" + return (track_id, title, path, bitrate, duration, artist, album, album_id, track_number) + + +def _stub_engine(monkeypatch): + monkeypatch.setattr(qu, 'get_primary_source', lambda: 'spotify') + monkeypatch.setattr(qu, 'get_source_priority', lambda src: ['spotify']) + monkeypatch.setattr( + 'core.matching_engine.MusicMatchingEngine', + lambda: types.SimpleNamespace( + generate_download_queries=lambda t: ['q'], + similarity_score=lambda a, b: 1.0, + normalize_string=lambda s: s, + ), + ) + + +def test_scan_creates_finding_for_low_quality_track(monkeypatch): + db = _FakeDB([_row(bitrate=128)], BALANCED) + _stub_engine(monkeypatch) + fake_match = {'id': 'sp1', 'name': 'Song One', 'artists': ['Artist A'], + 'album': {'name': 'Album X', 'images': []}} + # No track-id / ISRC / album hit → exercise the search tier. + monkeypatch.setattr(qu, '_read_file_ids', lambda fp: {}) + monkeypatch.setattr(qu, '_match_via_track_id', lambda *a, **k: (None, None)) + monkeypatch.setattr(qu, '_match_via_album', lambda *a, **k: (None, None)) + monkeypatch.setattr(qu, '_find_best_match', + lambda *a, **k: (fake_match, 0.95, 'spotify', True)) + monkeypatch.setattr(qu, '_normalize_track_match', lambda track, src: dict(fake_match)) + monkeypatch.setattr(qu, '_track_name', lambda t: 'Song One') + + findings = [] + result = qu.QualityUpgradeJob().scan(_ctx(db, findings)) + + assert result.findings_created == 1 + assert len(findings) == 1 + f = findings[0] + assert f['finding_type'] == 'quality_upgrade' + assert f['entity_id'] == '1' + # Album context + matched track carried for the apply step. + assert f['details']['matched_track_data']['id'] == 'sp1' + assert f['details']['album_title'] == 'Album X' + assert f['details']['provider'] == 'spotify' + + +def test_match_via_track_id_fetches_exact_by_id(monkeypatch): + """Most-direct tier: a per-source track ID in the tags → get_track_details by ID.""" + track = {'id': 'sp9', 'name': 'Song One', 'album': {'name': 'Album X'}} + client = types.SimpleNamespace(get_track_details=lambda tid: track if tid == 'sp9' else None) + monkeypatch.setattr(qu, 'get_client_for_source', lambda src: client) + best, source = qu._match_via_track_id({'spotify_track_id': 'sp9'}, ['spotify']) + assert best['id'] == 'sp9' + assert source == 'spotify' + assert qu._match_via_track_id({}, ['spotify']) == (None, None) # no ID → nothing + + +def test_duration_ok_guard(): + assert qu._duration_ok(180000, 181000) is True # within 5s + assert qu._duration_ok(180000, 200000) is False # 20s off — wrong cut + assert qu._duration_ok(None, 200000) is True # unknown → lenient + assert qu._duration_ok(180000, 0) is True # unknown → lenient + + +def test_scan_prefers_track_id_tier(monkeypatch): + """The source's own track ID (from file tags) wins over every other tier.""" + db = _FakeDB([_row()], BALANCED) + _stub_engine(monkeypatch) + monkeypatch.setattr(qu, '_read_file_ids', lambda fp: {'spotify_track_id': 'sp9', 'isrc': 'X'}) + fake = {'id': 'sp9', 'name': 'Song One', 'album': {'name': 'Album X'}} + monkeypatch.setattr(qu, '_match_via_track_id', lambda ids, sp: (fake, 'spotify')) + monkeypatch.setattr(qu, '_normalize_track_match', lambda t, s: dict(fake)) + monkeypatch.setattr(qu, '_track_name', lambda t: 'Song One') + + def _boom(*a, **k): + raise AssertionError("no lower tier should run when the track-ID tier matches") + monkeypatch.setattr(qu, '_match_via_isrc', _boom) + monkeypatch.setattr(qu, '_match_via_album', _boom) + monkeypatch.setattr(qu, '_find_best_match', _boom) + + findings = [] + result = qu.QualityUpgradeJob().scan(_ctx(db, findings)) + assert result.findings_created == 1 + assert findings[0]['details']['matched_via'] == 'track_id' + + +def test_scan_skips_already_proposed_tracks(monkeypatch): + """A re-run must not re-resolve a track that already has a finding.""" + db = _FakeDB([_row(track_id=1)], BALANCED, finding_ids=['1']) + monkeypatch.setattr(qu, 'get_primary_source', lambda: 'spotify') + monkeypatch.setattr(qu, 'get_source_priority', lambda src: ['spotify']) + + def _boom(*a, **k): + raise AssertionError("no matching for an already-proposed track") + monkeypatch.setattr(qu, '_match_via_track_id', _boom) + monkeypatch.setattr(qu, '_find_best_match', _boom) + + findings = [] + result = qu.QualityUpgradeJob().scan(_ctx(db, findings)) + assert findings == [] + assert result.findings_skipped_dedup == 1 + + +def test_match_via_isrc_accepts_exact_match(monkeypatch): + """The guard accepts only a candidate whose own ISRC equals ours (dash/case + insensitive), so it survives a source returning unrelated hits first.""" + monkeypatch.setattr(qu, 'get_client_for_source', + lambda src: types.SimpleNamespace(search_tracks=lambda *a, **k: [])) + monkeypatch.setattr(qu, '_search_tracks_for_source', lambda *a, **k: [ + {'id': 'x', 'name': 'Wrong', 'isrc': 'ZZISRC000000'}, + {'id': 'sp1', 'name': 'Right', 'isrc': 'US-RC1-76-07839'}, # dashed form + ]) + best, source = qu._match_via_isrc('USRC17607839', ['spotify']) + assert best['id'] == 'sp1' + assert source == 'spotify' + + +def test_match_via_isrc_rejects_all_mismatches(monkeypatch): + monkeypatch.setattr(qu, 'get_client_for_source', + lambda src: types.SimpleNamespace(search_tracks=lambda *a, **k: [])) + monkeypatch.setattr(qu, '_search_tracks_for_source', lambda *a, **k: [ + {'id': 'x', 'name': 'Wrong', 'external_ids': {'isrc': 'ZZISRC000000'}}, + ]) + assert qu._match_via_isrc('USRC17607839', ['spotify']) == (None, None) + + +def test_scan_prefers_isrc_exact_match_over_fuzzy(monkeypatch): + """No track-ID, but the file carries an ISRC that resolves → use the exact match + and do NOT run the album/search tiers.""" + db = _FakeDB([_row()], BALANCED) + _stub_engine(monkeypatch) + monkeypatch.setattr(qu, '_read_file_ids', lambda fp: {'isrc': 'USRC17607839'}) + monkeypatch.setattr(qu, '_match_via_track_id', lambda *a, **k: (None, None)) + fake = {'id': 'sp1', 'name': 'Song One', 'artists': ['Artist A'], 'album': {'name': 'Album X'}} + monkeypatch.setattr(qu, '_match_via_isrc', lambda isrc, sp: (fake, 'spotify')) + monkeypatch.setattr(qu, '_normalize_track_match', lambda t, s: dict(fake)) + monkeypatch.setattr(qu, '_track_name', lambda t: 'Song One') + + def _boom(*a, **k): + raise AssertionError("fuzzy search must not run when an ISRC match exists") + monkeypatch.setattr(qu, '_find_best_match', _boom) + + findings = [] + result = qu.QualityUpgradeJob().scan(_ctx(db, findings)) + assert result.findings_created == 1 + assert findings[0]['details']['matched_via'] == 'isrc' + assert findings[0]['details']['match_confidence'] == 1.0 + + +def test_scan_falls_back_to_search_without_ids(monkeypatch): + """No track-ID / ISRC / album hit → fall back to fuzzy search.""" + db = _FakeDB([_row()], BALANCED) + _stub_engine(monkeypatch) + monkeypatch.setattr(qu, '_read_file_ids', lambda fp: {}) # un-enriched + monkeypatch.setattr(qu, '_match_via_track_id', lambda *a, **k: (None, None)) + monkeypatch.setattr(qu, '_match_via_album', lambda *a, **k: (None, None)) + fake = {'id': 'sp1', 'name': 'Song One', 'artists': ['Artist A'], 'album': {'name': 'Album X'}} + monkeypatch.setattr(qu, '_find_best_match', lambda *a, **k: (fake, 0.88, 'spotify', True)) + monkeypatch.setattr(qu, '_normalize_track_match', lambda t, s: dict(fake)) + monkeypatch.setattr(qu, '_track_name', lambda t: 'Song One') + + findings = [] + result = qu.QualityUpgradeJob().scan(_ctx(db, findings)) + assert result.findings_created == 1 + assert findings[0]['details']['matched_via'] == 'search' + + +def test_scan_uses_album_tier_when_no_ids(monkeypatch): + """No track-ID / ISRC, but the album→track lookup resolves it → matched_via + 'album', and the fuzzy search is never reached.""" + db = _FakeDB([_row()], BALANCED) + _stub_engine(monkeypatch) + monkeypatch.setattr(qu, '_read_file_ids', lambda fp: {}) + monkeypatch.setattr(qu, '_match_via_track_id', lambda *a, **k: (None, None)) + fake = {'id': 'sp1', 'name': 'Song One', 'artists': ['Artist A'], 'album': {'name': 'Album X'}} + monkeypatch.setattr(qu, '_match_via_album', lambda *a, **k: (fake, 'spotify')) + monkeypatch.setattr(qu, '_normalize_track_match', lambda t, s: dict(fake)) + monkeypatch.setattr(qu, '_track_name', lambda t: 'Song One') + + def _boom(*a, **k): + raise AssertionError("fuzzy search must not run when the album tier matches") + monkeypatch.setattr(qu, '_find_best_match', _boom) + + findings = [] + result = qu.QualityUpgradeJob().scan(_ctx(db, findings)) + assert result.findings_created == 1 + assert findings[0]['details']['matched_via'] == 'album' + assert findings[0]['details']['match_confidence'] == 1.0 + + +def test_find_track_in_album_exact_title_with_track_number(monkeypatch): + items = [ + {'id': 'a', 'name': 'Intro', 'track_number': 1}, + {'id': 'b', 'name': 'Karma Police', 'track_number': 6}, + {'id': 'c', 'name': 'Karma Police (Live)', 'track_number': 12}, + ] + eng = types.SimpleNamespace(similarity_score=lambda a, b: 0.0, normalize_string=lambda s: s) + got = qu._find_track_in_album(items, 'Karma Police', 6, eng) + assert got['id'] == 'b' + + +def test_scan_skips_tracks_meeting_quality(monkeypatch): + # A 320 kbps MP3 meets the balanced profile → no finding, no metadata calls. + db = _FakeDB([_row(track_id=2, title='Good Song', bitrate=320)], BALANCED) + + def _boom(*a, **k): # must never be called for an acceptable track + raise AssertionError("matching should not run for an acceptable track") + + monkeypatch.setattr(qu, '_find_best_match', _boom) + + findings = [] + result = qu.QualityUpgradeJob().scan(_ctx(db, findings)) + assert result.findings_created == 0 + assert result.skipped == 1 + assert findings == [] + + +# --- fix handler adds to wishlist ------------------------------------------ + +def test_fix_handler_adds_matched_track_to_wishlist(): + from core.repair_worker import RepairWorker + + captured = {} + + class _DB: + def add_to_wishlist(self, **kw): + captured.update(kw) + return True + + worker = object.__new__(RepairWorker) + worker.db = _DB() + + details = { + 'matched_track_data': {'id': 'sp1', 'name': 'Song One', + 'album': {'name': 'Album X'}}, + 'current_format': 'MP3 192', 'current_bitrate': 192, + 'album_title': 'Album X', 'provider': 'spotify', 'match_confidence': 0.9, + } + res = worker._fix_quality_upgrade('track', '1', '/music/a.mp3', details) + + assert res['success'] is True + assert captured['spotify_track_data']['id'] == 'sp1' + assert captured['source_type'] == 'repair' + assert captured['source_info']['job'] == 'quality_upgrade' + assert captured['source_info']['album_title'] == 'Album X' diff --git a/tests/test_album_bundle.py b/tests/test_album_bundle.py index 8b85e6c4..5c7e0eed 100644 --- a/tests/test_album_bundle.py +++ b/tests/test_album_bundle.py @@ -551,6 +551,26 @@ def test_resolve_skips_mapping_when_target_missing_then_tries_basename(tmp_path: assert resolved == str(tmp_path / "MyAlbum") +def test_resolve_uses_custom_torrent_download_path(tmp_path: Path) -> None: + """#857: the user's torrent client saves to a category folder (e.g. a + 'Music' category) mounted here at a custom in-container path. Setting + download_source.torrent_download_path lets SoulSync find the release there.""" + music_mount = tmp_path / "downloads" / "music" + (music_mount / "MyAlbum").mkdir(parents=True) + cfg = _cfg({'download_source.torrent_download_path': str(music_mount)}) + resolved = resolve_reported_save_path('/data/Downloads/Music/MyAlbum', config_get=cfg) + assert resolved == str(music_mount / "MyAlbum") + + +def test_resolve_uses_custom_usenet_download_path(tmp_path: Path) -> None: + """#857: same for the usenet source's custom completed-downloads path.""" + nzb_mount = tmp_path / "nzb" / "music" + (nzb_mount / "MyAlbum").mkdir(parents=True) + cfg = _cfg({'download_source.usenet_download_path': str(nzb_mount)}) + resolved = resolve_reported_save_path('/config/Downloads/complete/MyAlbum', config_get=cfg) + assert resolved == str(nzb_mount / "MyAlbum") + + # --------------------------------------------------------------------------- # poll_album_download — lifted poll loop for both torrent + usenet plugins. # --------------------------------------------------------------------------- diff --git a/tests/test_aria2_adapter.py b/tests/test_aria2_adapter.py new file mode 100644 index 00000000..669c7aa7 --- /dev/null +++ b/tests/test_aria2_adapter.py @@ -0,0 +1,90 @@ +"""Aria2 JSON-RPC adapter (Shdjfgatdif's request) — state mapping, token-prefixed +params, the /jsonrpc URL fixup, status parsing, and registry wiring. No network.""" + +from __future__ import annotations + +from unittest.mock import patch + +import pytest + +from core.torrent_clients import adapter_for_type +from core.torrent_clients.aria2 import Aria2Adapter, _map_state + + +def _adapter(url='http://nas:6800', secret='sekret', save_path=''): + cfg = {'torrent_client.url': url, 'torrent_client.password': secret, + 'torrent_client.category': 'soulsync', 'torrent_client.save_path': save_path} + with patch('core.torrent_clients.aria2.config_manager') as cm: + cm.get.side_effect = lambda k, d=None: cfg.get(k, d) + return Aria2Adapter() + + +# ── registry ── +def test_registered_in_factory(): + a = adapter_for_type('aria2') + assert isinstance(a, Aria2Adapter) + + +# ── state mapping (aria2 native → adapter-uniform) ── +def test_state_map(): + assert _map_state('waiting', 0, 100) == 'queued' + assert _map_state('paused', 0, 100) == 'paused' + assert _map_state('error', 0, 100) == 'error' + assert _map_state('complete', 100, 100) == 'completed' + assert _map_state('active', 40, 100) == 'downloading' + assert _map_state('active', 100, 100) == 'seeding' # finished payload, still seeding + assert _map_state('removed', 0, 100) == 'error' + + +# ── URL fixup + token-prefixed params ── +def test_jsonrpc_appended_to_bare_host(): + a = _adapter(url='http://nas:6800') + assert a._url == 'http://nas:6800/jsonrpc' + + +def test_jsonrpc_not_double_appended(): + a = _adapter(url='http://nas:6800/jsonrpc') + assert a._url == 'http://nas:6800/jsonrpc' + + +def test_secret_leads_params_as_token(): + a = _adapter(secret='sekret') + assert a._params('gid123', ['status']) == ['token:sekret', 'gid123', ['status']] + + +def test_no_token_when_no_secret(): + a = _adapter(secret='') + assert a._params('gid123') == ['gid123'] + + +def test_is_configured(): + assert _adapter(url='http://nas:6800').is_configured() is True + assert _adapter(url='').is_configured() is False + + +# ── status parse ── +def test_parse_status_torrent(): + a = _adapter() + item = { + 'gid': 'abc123', 'status': 'active', + 'totalLength': '1000', 'completedLength': '250', + 'downloadSpeed': '500', 'uploadSpeed': '10', + 'connections': '7', 'numSeeders': '3', 'dir': '/downloads', + 'files': [{'path': '/downloads/Album/01.flac'}], + 'bittorrent': {'info': {'name': 'Some Album'}}, + } + s = a._parse_status(item) + assert s.id == 'abc123' and s.name == 'Some Album' + assert s.state == 'downloading' + assert s.size == 1000 and s.downloaded == 250 + assert abs(s.progress - 0.25) < 1e-9 + assert s.download_speed == 500 and s.peers == 7 and s.seeders == 3 + assert s.save_path == '/downloads' and s.files == ['/downloads/Album/01.flac'] + + +def test_parse_status_name_falls_back_to_file_basename(): + a = _adapter() + s = a._parse_status({'gid': 'g', 'status': 'active', 'totalLength': '0', + 'completedLength': '0', 'files': [{'path': '/d/song.mp3'}]}) + assert s.name == 'song.mp3' + assert s.progress == 0.0 # no division by zero when totalLength is 0 diff --git a/tests/test_artist_catalog_disambiguation.py b/tests/test_artist_catalog_disambiguation.py new file mode 100644 index 00000000..23d8a502 --- /dev/null +++ b/tests/test_artist_catalog_disambiguation.py @@ -0,0 +1,98 @@ +"""Same-name artist disambiguation by owned-catalog overlap (#868). + +Enrichment matched artists by NAME ONLY, so for a common name ("Rone" has ~5 +artists) it grabbed whichever the source ranked first — often the wrong one, +which then drove a wrong/sparse library discography. The fix: when several +candidates clear the name gate, pick the one whose catalog overlaps the albums +the user actually OWNS. These pin the source-agnostic selector. +""" + +from __future__ import annotations + +from core.worker_utils import ( + catalog_overlap_score, + normalize_release_title, + pick_artist_by_catalog, +) + + +# --- normalization --------------------------------------------------------- + +def test_normalize_strips_editions_and_punctuation(): + assert normalize_release_title('Tohu Bohu (Deluxe Edition)') == 'tohu bohu' + assert normalize_release_title('Mirapolis - Remastered') == 'mirapolis' + assert normalize_release_title('Room with a View [2020]') == 'room with a view' + assert normalize_release_title('') == '' + + +# --- overlap scoring ------------------------------------------------------- + +def test_overlap_counts_matching_owned_titles(): + owned = ['Tohu Bohu', 'Creatures', 'Mirapolis'] + cand = ['Tohu Bohu (Deluxe)', 'Creatures', 'Spanish Breakfast', 'Motion'] + assert catalog_overlap_score(owned, cand) == 2 # Tohu Bohu + Creatures + + +def test_overlap_zero_for_a_different_artists_catalog(): + owned = ['Tohu Bohu', 'Creatures', 'Mirapolis'] + cand = ['Some Other Record', 'Unrelated Album'] + assert catalog_overlap_score(owned, cand) == 0 + + +def test_overlap_zero_when_either_side_empty(): + assert catalog_overlap_score([], ['A']) == 0 + assert catalog_overlap_score(['A'], []) == 0 + + +# --- the selector ---------------------------------------------------------- + +def _cand(cid, titles): + return {'id': cid, '_titles': titles} + + +def _fetch(cand): + return cand['_titles'] + + +def test_single_candidate_returns_without_fetching(): + calls = [] + chosen, score = pick_artist_by_catalog( + [_cand('only', ['X'])], ['Tohu Bohu'], + lambda c: calls.append(c) or c['_titles']) + assert chosen['id'] == 'only' + assert calls == [] # never fetched — nothing to disambiguate + + +def test_no_owned_albums_keeps_name_order(): + calls = [] + chosen, score = pick_artist_by_catalog( + [_cand('first', ['A']), _cand('second', ['B'])], [], + lambda c: calls.append(c) or c['_titles']) + assert chosen['id'] == 'first' # candidates[0] — current behavior + assert calls == [] + + +def test_picks_the_candidate_overlapping_owned_catalog(): + # The WRONG Rone is ranked first; the right one overlaps the owned albums. + wrong = _cand('wrong', ['Rap Mixtape Vol 1', 'Some Single']) + right = _cand('right', ['Tohu Bohu', 'Creatures', 'Mirapolis']) + chosen, score = pick_artist_by_catalog( + [wrong, right], ['Tohu Bohu', 'Creatures', 'Spanish Breakfast'], _fetch) + assert chosen['id'] == 'right' + assert score == 2 + + +def test_no_overlap_anywhere_falls_back_to_first(): + a = _cand('a', ['Nope']); b = _cand('b', ['Also Nope']) + chosen, score = pick_artist_by_catalog([a, b], ['Tohu Bohu'], _fetch) + assert chosen['id'] == 'a' + assert score == 0 + + +def test_fetch_failure_is_tolerated(): + def _boom(_c): + raise RuntimeError('api down') + chosen, score = pick_artist_by_catalog( + [_cand('a', []), _cand('b', [])], ['Tohu Bohu'], _boom) + assert chosen['id'] == 'a' # both fail → fall back to first + assert score == 0 diff --git a/tests/test_artist_db_record_endpoint.py b/tests/test_artist_db_record_endpoint.py new file mode 100644 index 00000000..d2671019 --- /dev/null +++ b/tests/test_artist_db_record_endpoint.py @@ -0,0 +1,59 @@ +"""GET /api/artist/<id>/record — the artist-detail "DB Record" inspector source. +Returns the full artists row (JSON text columns decoded) + owned counts, 404 if +the artist isn't in the library.""" + +from __future__ import annotations + +import os +import sqlite3 +import tempfile + +import pytest + +_TMP = tempfile.mkdtemp(prefix='soulsync-testdb-arec-') +os.environ['DATABASE_PATH'] = os.path.join(_TMP, 'a.db') +os.environ['SOULSYNC_TEST_DB_READY'] = '1' + +web_server = pytest.importorskip('web_server') + + +@pytest.fixture +def client(): + return web_server.app.test_client() + + +def _insert_artist(): + db = web_server.get_database() + conn = db._get_connection() + try: + conn.execute( + "INSERT OR REPLACE INTO artists (id, name, genres, musicbrainz_id, " + "musicbrainz_match_status, lastfm_listeners) VALUES (?,?,?,?,?,?)", + ('99001', 'Test Artist', '["rock", "metal"]', + 'mbid-123', 'matched', 4242), + ) + conn.commit() + finally: + conn.close() + + +def test_record_returns_full_row_with_decoded_json(client): + _insert_artist() + r = client.get('/api/artist/99001/record') + assert r.status_code == 200 + body = r.get_json() + assert body['success'] is True + rec = body['record'] + assert rec['name'] == 'Test Artist' + assert rec['genres'] == ['rock', 'metal'] # JSON text decoded to a list + assert rec['musicbrainz_id'] == 'mbid-123' + assert rec['musicbrainz_match_status'] == 'matched' + assert rec['lastfm_listeners'] == 4242 + assert 'counts' in body and 'albums' in body['counts'] and 'tracks' in body['counts'] + assert body['artist_id'] == '99001' + + +def test_missing_artist_is_404(client): + r = client.get('/api/artist/does-not-exist-77777/record') + assert r.status_code == 404 + assert r.get_json()['success'] is False diff --git a/tests/test_artist_export.py b/tests/test_artist_export.py new file mode 100644 index 00000000..2bb255dc --- /dev/null +++ b/tests/test_artist_export.py @@ -0,0 +1,126 @@ +"""Watchlist roster export builder (corruption's request) — JSON / CSV / txt, +optional external links, deterministic columns.""" + +from __future__ import annotations + +import csv +import io +import json + +from core.exports.artist_export import build_artist_export, export_mime_and_ext + + +_ARTISTS = [ + {'artist_name': 'Rob Zombie', 'spotify_artist_id': 'sp1', + 'musicbrainz_artist_id': 'mb1', 'deezer_artist_id': 'dz1'}, + {'artist_name': 'Nobody IDs', 'spotify_artist_id': None}, +] + + +def test_txt_is_names_one_per_line(): + out = build_artist_export(_ARTISTS, fmt='txt') + assert out == 'Rob Zombie\nNobody IDs' + + +def test_json_includes_present_ids_only(): + out = json.loads(build_artist_export(_ARTISTS, fmt='json')) + assert out[0]['name'] == 'Rob Zombie' + assert out[0]['spotify_artist_id'] == 'sp1' and out[0]['musicbrainz_artist_id'] == 'mb1' + assert 'deezer_artist_id' in out[0] + assert out[1] == {'name': 'Nobody IDs'} # null id dropped, no links key + + +def test_json_links_when_requested(): + out = json.loads(build_artist_export(_ARTISTS, fmt='json', include_links=True)) + assert out[0]['links']['spotify'] == 'https://open.spotify.com/artist/sp1' + assert out[0]['links']['musicbrainz'] == 'https://musicbrainz.org/artist/mb1' + assert 'links' not in out[1] # no ids → no links + + +def test_csv_header_and_rows(): + out = build_artist_export(_ARTISTS, fmt='csv') + rows = list(csv.reader(io.StringIO(out))) + assert rows[0][0] == 'name' and 'spotify_artist_id' in rows[0] + assert rows[1][0] == 'Rob Zombie' + assert rows[2][0] == 'Nobody IDs' + + +def test_csv_adds_url_columns_with_links(): + out = build_artist_export(_ARTISTS, fmt='csv', include_links=True) + header = next(csv.reader(io.StringIO(out))) + assert 'spotify_url' in header and 'discogs_url' in header + + +def test_empty_and_bad_format(): + assert build_artist_export([], fmt='txt') == '' + assert build_artist_export(None, fmt='json') == '[]' + assert build_artist_export(_ARTISTS, fmt='nonsense').startswith('[') # falls back to json + + +def test_mime_and_ext(): + assert export_mime_and_ext('csv') == ('text/csv', 'csv') + assert export_mime_and_ext('txt') == ('text/plain', 'txt') + assert export_mime_and_ext('weird') == ('application/json', 'json') + + +# ── endpoint wiring (empty watchlist → valid shapes + headers) ────────────── +import os, tempfile # noqa: E402 +os.environ['DATABASE_PATH'] = os.path.join(tempfile.mkdtemp(prefix='soulsync-testdb-wlexp-'), 'w.db') +os.environ['SOULSYNC_TEST_DB_READY'] = '1' +import pytest # noqa: E402 +web_server = pytest.importorskip('web_server') + + +@pytest.fixture +def client(): + return web_server.app.test_client() + + +def test_export_endpoint_wiring(client): + # Don't assume an empty DB (a shared test run may have rows) — just verify the + # endpoint returns a valid JSON array + the right headers/columns. + r = client.get('/api/watchlist/export?format=json') + assert r.status_code == 200 + assert isinstance(json.loads(r.data.decode()), list) + assert r.headers.get('X-Export-Ext') == 'json' + + r2 = client.get('/api/watchlist/export?format=csv&links=1') + assert r2.status_code == 200 and r2.headers.get('X-Export-Ext') == 'csv' + assert 'spotify_url' in r2.data.decode().splitlines()[0] # header row with links + + +# ── library-side: extra services + extra_fields passthrough ───────────────── +_LIB = [{ + 'name': 'Rob Zombie', 'spotify_artist_id': 'sp1', 'tidal_artist_id': 'td1', + 'qobuz_artist_id': 'qz1', 'lastfm_url': 'https://last.fm/x', 'soul_id': 'soul_abc', + 'album_count': 10, 'track_count': 159, +}] + + +def test_tidal_qobuz_links_and_extra_fields_json(): + out = json.loads(build_artist_export(_LIB, fmt='json', include_links=True, + extra_fields=['lastfm_url', 'soul_id', 'album_count', 'track_count'])) + a = out[0] + assert a['tidal_artist_id'] == 'td1' and a['qobuz_artist_id'] == 'qz1' + assert a['links']['tidal'] == 'https://tidal.com/artist/td1' + assert a['links']['qobuz'] == 'https://www.qobuz.com/artist/qz1' + assert a['lastfm_url'] == 'https://last.fm/x' and a['soul_id'] == 'soul_abc' + assert a['album_count'] == 10 and a['track_count'] == 159 + + +def test_extra_fields_become_csv_columns(): + out = build_artist_export(_LIB, fmt='csv', extra_fields=['album_count', 'track_count']) + header = next(csv.reader(io.StringIO(out))) + assert 'album_count' in header and 'track_count' in header + assert 'tidal_artist_id' in header # new service column present + + +def test_library_export_endpoint_wiring(client): + # Robust to a shared DB that may already hold artist rows. + r = client.get('/api/library/artists/export?format=json&contents=1&links=1') + assert r.status_code == 200 + assert isinstance(json.loads(r.data.decode()), list) + assert r.headers.get('X-Export-Ext') == 'json' + r2 = client.get('/api/library/artists/export?format=csv&contents=1') + header = r2.data.decode().splitlines()[0] + assert 'album_count' in header and 'track_count' in header diff --git a/tests/test_database_update_health.py b/tests/test_database_update_health.py new file mode 100644 index 00000000..770a34a7 --- /dev/null +++ b/tests/test_database_update_health.py @@ -0,0 +1,92 @@ +"""Seam tests for the database-update stall watchdog (GitHub #859). + +A DB-update job can hang (media-server call with no timeout, DB lock) and sit at +status='running' forever because the worker's finished/error callbacks never +fire. `is_db_update_stalled` is the pure decision that lets the watchdog flip +such a job to 'error' so the UI recovers. These tests pin that decision — +including the conservative cases where it must NOT false-positive. +""" + +from __future__ import annotations + +from core.database_update_health import ( + DEFAULT_STALL_TIMEOUT_SECONDS, + is_db_update_stalled, + stalled_error_message, +) + + +def _state(**over): + base = {"status": "running", "phase": "Incremental: scanning", + "processed": 2, "total": 3, "progress": 66.7, "last_progress_at": 1000.0} + base.update(over) + return base + + +def test_running_and_heartbeat_stale_is_stalled(): + # last tick at t=1000, now=1000+timeout → exactly at the boundary counts. + now = 1000.0 + DEFAULT_STALL_TIMEOUT_SECONDS + assert is_db_update_stalled(_state(), now) is True + + +def test_running_and_heartbeat_fresh_is_not_stalled(): + now = 1000.0 + 5 # ticked 5s ago, well within timeout + assert is_db_update_stalled(_state(), now) is False + + +def test_just_under_timeout_is_not_stalled(): + now = 1000.0 + DEFAULT_STALL_TIMEOUT_SECONDS - 0.001 + assert is_db_update_stalled(_state(), now) is False + + +def test_non_running_statuses_never_stall(): + now = 1000.0 + 10_000 # very stale heartbeat + for status in ("idle", "finished", "error"): + assert is_db_update_stalled(_state(status=status), now) is False, status + + +def test_missing_heartbeat_cannot_judge(): + # No usable timestamp → we refuse to kill a job we have no clock for. + now = 1_000_000.0 + assert is_db_update_stalled(_state(last_progress_at=0), now) is False + assert is_db_update_stalled(_state(last_progress_at=None), now) is False + s = _state() + del s["last_progress_at"] + assert is_db_update_stalled(s, now) is False + + +def test_non_positive_timeout_disables_watchdog(): + now = 1000.0 + 10_000 + assert is_db_update_stalled(_state(), now, timeout_seconds=0) is False + assert is_db_update_stalled(_state(), now, timeout_seconds=-1) is False + + +def test_bad_inputs_are_safe(): + assert is_db_update_stalled(None, 123.0) is False + assert is_db_update_stalled("not a dict", 123.0) is False + assert is_db_update_stalled(_state(last_progress_at="oops"), 1_000_000.0) is False + + +def test_custom_timeout_respected(): + now = 1000.0 + 120 + assert is_db_update_stalled(_state(), now, timeout_seconds=60) is True + assert is_db_update_stalled(_state(), now, timeout_seconds=180) is False + + +def test_stalled_message_is_informative(): + now = 1000.0 + 360 + msg = stalled_error_message(_state(phase="Incremental: scanning"), now) + assert "stuck" in msg.lower() + assert "360s" in msg + assert "Incremental: scanning" in msg + + +def test_issue_859_frozen_running_job_is_caught(): + """Regression: the reported state — running, frozen at 2/3 (66.7%), heartbeat + long stale — is detected as stalled so the card can self-heal.""" + state = _state(status="running", processed=2, total=3, progress=66.7, + last_progress_at=1000.0) + now = 1000.0 + DEFAULT_STALL_TIMEOUT_SECONDS + 30 + assert is_db_update_stalled(state, now) is True + # And a healthy job that's actively ticking is left alone. + assert is_db_update_stalled(_state(last_progress_at=now - 2), now) is False diff --git a/tests/test_empty_folder_cleaner.py b/tests/test_empty_folder_cleaner.py new file mode 100644 index 00000000..54f65ca0 --- /dev/null +++ b/tests/test_empty_folder_cleaner.py @@ -0,0 +1,74 @@ +"""Empty Folder Cleaner (corruption's request) — pure removable decision + the +apply handler's re-check safety (never deletes a folder that gained content, the +library root, or a symlink).""" + +from __future__ import annotations + +import os + +from core.repair_jobs.empty_folder_cleaner import dir_is_removable, remove_empty_folder, is_junk + + +# ── pure decision ─────────────────────────────────────────────────────────── +def test_empty_dir_is_removable(): + assert dir_is_removable([], []) is True + + +def test_dir_with_real_file_is_not_removable(): + assert dir_is_removable(['cover.jpg'], []) is False + assert dir_is_removable(['song.flac'], []) is False + + +def test_dir_with_surviving_subdir_is_not_removable(): + assert dir_is_removable([], ['Album']) is False + + +def test_junk_only_dir_removable_when_ignore_junk(): + assert dir_is_removable(['.DS_Store', 'Thumbs.db'], []) is True + assert dir_is_removable(['.DS_Store'], [], ignore_junk=False) is False # strict mode keeps it + + +def test_junk_plus_real_file_not_removable(): + assert dir_is_removable(['.DS_Store', 'cover.jpg'], []) is False + + +def test_is_junk(): + assert is_junk('.DS_Store') and is_junk('thumbs.db') and not is_junk('cover.jpg') + + +# ── apply re-check (real FS) ──────────────────────────────────────────────── +def _fx(): + return dict(listdir=os.listdir, isdir=os.path.isdir, islink=os.path.islink, + remove_file=os.remove, rmdir=os.rmdir) + + +def test_apply_removes_truly_empty_folder(tmp_path): + root = tmp_path / 'lib'; root.mkdir() + empty = root / 'Artist' / 'Album'; empty.mkdir(parents=True) + res = remove_empty_folder(str(empty), junk_files=[], remove_junk=True, root=str(root), **_fx()) + assert res['removed'] is True + assert not empty.exists() + + +def test_apply_deletes_junk_then_folder(tmp_path): + root = tmp_path / 'lib'; root.mkdir() + d = root / 'Empty'; d.mkdir() + (d / '.DS_Store').write_text('x') + res = remove_empty_folder(str(d), junk_files=['.DS_Store'], remove_junk=True, root=str(root), **_fx()) + assert res['removed'] is True and not d.exists() + + +def test_apply_refuses_folder_that_gained_a_file(tmp_path): + root = tmp_path / 'lib'; root.mkdir() + d = root / 'NowFull'; d.mkdir() + (d / 'new.flac').write_text('audio') # appeared between scan and apply + res = remove_empty_folder(str(d), junk_files=[], remove_junk=True, root=str(root), **_fx()) + assert res['removed'] is False and 'no longer empty' in res['error'].lower() + assert d.exists() # left untouched + + +def test_apply_refuses_library_root(tmp_path): + root = tmp_path / 'lib'; root.mkdir() + res = remove_empty_folder(str(root), junk_files=[], remove_junk=True, root=str(root), **_fx()) + assert res['removed'] is False and 'root' in res['error'].lower() + assert root.exists() diff --git a/tests/test_hifi_default_push.py b/tests/test_hifi_default_push.py new file mode 100644 index 00000000..d0f70587 --- /dev/null +++ b/tests/test_hifi_default_push.py @@ -0,0 +1,53 @@ +"""One-time auto-push of NEW default HiFi instances to existing installs. + +A working instance added to DEFAULT_INSTANCES should reach everyone — but a +default a user deliberately removed must NOT come back. compute_new_default_pushes +is the pure decision; these pin both guarantees. +""" + +from __future__ import annotations + +from core.hifi_client import compute_new_default_pushes + +LEGACY = ['https://a.tf', 'https://b.tf'] # shipped before tracking +DEFAULTS = ['https://a.tf', 'https://b.tf', 'https://new.tf'] # 'new.tf' added later + + +def test_first_run_pushes_only_the_new_default(): + """offered=None → baseline to legacy, so existing user gets ONLY new.tf, + not a re-seed of a.tf/b.tf (which they may have curated).""" + existing = ['https://a.tf'] # user removed b.tf at some point + to_add, new_offered = compute_new_default_pushes(DEFAULTS, None, LEGACY, existing) + assert to_add == ['https://new.tf'] # only the genuinely-new one + assert 'https://b.tf' not in to_add # removed default NOT resurrected + assert set(new_offered) == {'https://a.tf', 'https://b.tf', 'https://new.tf'} + + +def test_already_offered_new_default_not_re_added(): + """Once new.tf has been offered, removing it must stick (no re-add).""" + offered = ['https://a.tf', 'https://b.tf', 'https://new.tf'] + existing = ['https://a.tf', 'https://b.tf'] # user removed new.tf + to_add, new_offered = compute_new_default_pushes(DEFAULTS, offered, LEGACY, existing) + assert to_add == [] + assert set(new_offered) == set(offered) + + +def test_present_new_default_recorded_not_duplicated(): + """If the new default is already present (fresh install / user added it), + record it as offered but don't add a duplicate.""" + existing = ['https://a.tf', 'https://b.tf', 'https://new.tf'] + to_add, new_offered = compute_new_default_pushes(DEFAULTS, None, LEGACY, existing) + assert to_add == [] + assert 'https://new.tf' in new_offered + + +def test_trailing_slash_insensitive(): + existing = ['https://a.tf/', 'https://new.tf'] # already has new.tf (slash variant) + to_add, _ = compute_new_default_pushes(DEFAULTS, None, LEGACY, existing) + assert to_add == [] # new.tf seen as present + + +def test_no_new_defaults_is_noop(): + to_add, new_offered = compute_new_default_pushes(LEGACY, None, LEGACY, ['https://a.tf']) + assert to_add == [] + assert set(new_offered) == set(LEGACY) diff --git a/tests/test_library_reorganize.py b/tests/test_library_reorganize.py index 6c34c285..18dab60f 100644 --- a/tests/test_library_reorganize.py +++ b/tests/test_library_reorganize.py @@ -463,6 +463,77 @@ def test_scan_apply_mode_enqueues_albums_via_reorganize_queue(make_context, monk assert result.findings_created == 0 +def _stub_preview_by_mode(monkeypatch, api_resp, tags_resp): + """Patch preview to return different responses for api vs tag mode, so the + #862 api→tags fallback can be exercised.""" + from core import library_reorganize as core_lr + + def _fake_preview(*, album_id, metadata_source='api', **kwargs): + return tags_resp if metadata_source == 'tags' else api_resp + monkeypatch.setattr(core_lr, 'preview_album_reorganize', _fake_preview) + + +def test_scan_falls_back_to_tag_mode_when_api_has_no_source_id(make_context, monkeypatch): + """#862: media-server albums have no source ID, so the API planner returns + no_source_id. The job must fall back to TAG mode and, when that plans, emit + real path_mismatch findings — NOT a dead-end 'needs enrichment' finding.""" + db = _FakeDB([_make_album_row(id_='A1', title='Tagged Album')]) + _stub_preview_by_mode( + monkeypatch, + api_resp={'success': False, 'status': 'no_source_id', 'source': None, + 'album': 'Tagged Album', 'artist': 'A', + 'tracks': [{'track_id': 't1', 'title': 'X', 'matched': False}]}, + tags_resp={'success': True, 'status': 'planned', 'source': 'tags', + 'album': 'Tagged Album', 'artist': 'A', + 'tracks': [{'track_id': 't1', 'title': 'X', + 'current_path': 'old/X.flac', 'new_path': 'A/(2008) Tagged Album/01 - X.flac', + 'matched': True, 'unchanged': False, 'file_exists': True}]}, + ) + ctx = make_context(db=db, dry_run=True) + result = LibraryReorganizeJob().scan(ctx) + + findings = ctx._captured_findings # type: ignore[attr-defined] + assert result.findings_created == 1 + assert findings[0]['finding_type'] == 'path_mismatch' + # Crucially NOT the enrichment dead-end the user reported. + assert all(f['finding_type'] != 'album_needs_enrichment' for f in findings) + + +def test_apply_mode_enqueues_tag_metadata_source_on_fallback(make_context, monkeypatch): + """#862: when the album reorganizes via the tag-mode fallback, the enqueued + item must carry metadata_source='tags' so the live move uses tags too (the + queue runner otherwise defaults to 'api' and would fail again).""" + db = _FakeDB([_make_album_row(id_='A1', title='Tagged Album', artist_id=10, artist_name='A')]) + _stub_preview_by_mode( + monkeypatch, + api_resp={'success': False, 'status': 'no_source_id', 'source': None, + 'album': 'Tagged Album', 'artist': 'A', + 'tracks': [{'track_id': 't1', 'title': 'X', 'matched': False}]}, + tags_resp={'success': True, 'status': 'planned', 'source': 'tags', + 'album': 'Tagged Album', 'artist': 'A', + 'tracks': [{'track_id': 't1', 'title': 'X', + 'current_path': 'old/X.flac', 'new_path': 'A/(2008) Tagged Album/01 - X.flac', + 'matched': True, 'unchanged': False, 'file_exists': True}]}, + ) + + enqueue_calls = [] + + class _StubQueue: + def enqueue_many(self, items): + enqueue_calls.append(items) + return {'enqueued': len(items), 'already_queued': 0, 'total': len(items)} + + import core.reorganize_queue as queue_mod + monkeypatch.setattr(queue_mod, 'get_queue', lambda: _StubQueue()) + + ctx = make_context(db=db, dry_run=False) + LibraryReorganizeJob().scan(ctx) + + assert len(enqueue_calls) == 1 + assert enqueue_calls[0][0]['metadata_source'] == 'tags' + assert enqueue_calls[0][0]['source'] == 'tags' + + def test_scan_only_iterates_albums_for_active_server(make_context, monkeypatch): """Pin: multi-server users (Plex + Jellyfin etc) — the job only iterates albums on the ACTIVE server. Inactive server's rows are diff --git a/tests/test_login_provisioning.py b/tests/test_login_provisioning.py new file mode 100644 index 00000000..fd9d2e70 --- /dev/null +++ b/tests/test_login_provisioning.py @@ -0,0 +1,97 @@ +"""No-gaps invariant: while login mode is on, every profile must have a login +password. Pure policy seam + endpoint enforcement at every write-point (create, +clear, enable-login).""" + +from __future__ import annotations + +import os +import tempfile + +import pytest + +from core.security.login_provisioning import ( + members_without_password, create_needs_password, removing_password_strands) + + +# ── pure policy ───────────────────────────────────────────────────────────── +def test_members_without_password_flags_only_passwordless_nonadmins(): + profiles = [ + {'id': 1, 'name': 'Admin', 'is_admin': True, 'has_password': False}, # admin: own anti-lockout + {'id': 2, 'name': 'HasPw', 'is_admin': False, 'has_password': True}, # fine + {'id': 3, 'name': 'NoPw', 'is_admin': False, 'has_password': False}, # stranded + ] + out = members_without_password(profiles) + assert out == [{'id': 3, 'name': 'NoPw'}] + + +def test_members_without_password_empty_when_all_set(): + assert members_without_password([{'id': 2, 'is_admin': False, 'has_password': True}]) == [] + assert members_without_password(None) == [] + + +def test_create_needs_password_only_when_login_on_and_nonadmin(): + assert create_needs_password(True) is True + assert create_needs_password(False) is False + assert create_needs_password(True, is_admin=True) is False + + +def test_removing_password_strands_only_when_login_on(): + assert removing_password_strands(True) is True + assert removing_password_strands(False) is False + + +# ── endpoint enforcement ──────────────────────────────────────────────────── +_TMP = tempfile.mkdtemp(prefix='soulsync-testdb-prov-') +os.environ['DATABASE_PATH'] = os.path.join(_TMP, 'p.db') +os.environ['SOULSYNC_TEST_DB_READY'] = '1' +web_server = pytest.importorskip('web_server') + + +@pytest.fixture +def client(): + return web_server.app.test_client() + + +def _login_on(monkeypatch, on=True): + real = web_server.config_manager.get + monkeypatch.setattr(web_server.config_manager, 'get', + lambda k, d=None: on if k == 'security.require_login' else real(k, d)) + + +def _auth(c): + # Turning login mode on activates the HTTP gate — authenticate the session as + # admin so the request reaches the endpoint (we're testing the endpoint logic). + with c.session_transaction() as sess: + sess['login_authenticated'] = True + sess['profile_id'] = 1 + + +def test_create_without_password_blocked_when_login_on(monkeypatch, client): + _login_on(monkeypatch, True); _auth(client) + r = client.post('/api/profiles', json={'name': 'NoPwMember'}) + assert r.status_code == 400 + assert 'login' in r.get_json()['error'].lower() + + +def test_create_with_password_succeeds_when_login_on(monkeypatch, client): + _login_on(monkeypatch, True); _auth(client) + r = client.post('/api/profiles', json={'name': 'PwMember', 'password': 'secret9'}) + assert r.status_code == 200 and r.get_json()['success'] is True + pid = r.get_json()['profile_id'] + assert web_server.get_database().verify_profile_password(pid, 'secret9') is True + + +def test_create_without_password_fine_when_login_off(monkeypatch, client): + _login_on(monkeypatch, False) + r = client.post('/api/profiles', json={'name': 'PinOnlyMember'}) + assert r.status_code == 200 and r.get_json()['success'] is True # no friction when off + + +def test_clear_password_blocked_when_login_on(monkeypatch, client): + db = web_server.get_database() + r = client.post('/api/profiles', json={'name': 'Clearable', 'password': 'x12345'}) + pid = r.get_json()['profile_id'] + _login_on(monkeypatch, True); _auth(client) + r2 = client.post(f'/api/profiles/{pid}/set-password', json={'password': ''}) + assert r2.status_code == 400 and 'login mode' in r2.get_json()['error'].lower() + assert db.verify_profile_password(pid, 'x12345') is True # still set diff --git a/tests/test_m3u_track_resolution.py b/tests/test_m3u_track_resolution.py new file mode 100644 index 00000000..6cd97949 --- /dev/null +++ b/tests/test_m3u_track_resolution.py @@ -0,0 +1,60 @@ +"""Bulk track-load used by M3U export path resolution. + +M3U export used to resolve each track with a per-artist search_tracks() loop, +which could block for a long time behind the enrichment/scan writers (the +"Export M3U hangs forever" report). It now bulk-loads (artist, title, file_path) +in one WAL-concurrent read; this pins that method's contract. +""" + +from __future__ import annotations + +from database.music_database import MusicDatabase + + +def _db_with_track(tmp_path, *, title, artist, file_path, server='jellyfin'): + db = MusicDatabase(str(tmp_path / 'm.db')) + with db._get_connection() as c: + c.execute("INSERT INTO artists (id, name) VALUES (1, ?)", (artist,)) + c.execute("INSERT INTO albums (id, title, artist_id) VALUES (1, 'Album', 1)") + c.execute( + "INSERT INTO tracks (id, title, artist_id, album_id, file_path, server_source) " + "VALUES (1, ?, 1, 1, ?, ?)", + (title, file_path, server), + ) + c.commit() + return db + + +def test_returns_artist_title_path(tmp_path): + db = _db_with_track(tmp_path, title='How You Remind Me', artist='Nickelback', + file_path='/music/nb/how.flac') + rows = db.get_tracks_for_m3u_resolution(server_source='jellyfin') + assert rows == [{'title': 'How You Remind Me', 'artist': 'Nickelback', + 'file_path': '/music/nb/how.flac'}] + + +def test_filters_by_server_source(tmp_path): + db = _db_with_track(tmp_path, title='X', artist='Y', file_path='/m/x.flac', server='jellyfin') + assert db.get_tracks_for_m3u_resolution(server_source='jellyfin') # match + assert db.get_tracks_for_m3u_resolution(server_source='plex') == [] # other server + + +def test_excludes_rows_without_file_path(tmp_path): + db = MusicDatabase(str(tmp_path / 'm.db')) + with db._get_connection() as c: + c.execute("INSERT INTO artists (id, name) VALUES (1, 'A')") + c.execute("INSERT INTO albums (id, title, artist_id) VALUES (1, 'Al', 1)") + # one with a path, one without — only the first should come back. + c.execute("INSERT INTO tracks (id, title, artist_id, album_id, file_path, server_source) " + "VALUES (1, 'Has Path', 1, 1, '/m/a.flac', 'jellyfin')") + c.execute("INSERT INTO tracks (id, title, artist_id, album_id, file_path, server_source) " + "VALUES (2, 'No Path', 1, 1, NULL, 'jellyfin')") + c.commit() + rows = db.get_tracks_for_m3u_resolution() + titles = {r['title'] for r in rows} + assert titles == {'Has Path'} + + +def test_empty_db_safe(tmp_path): + db = MusicDatabase(str(tmp_path / 'm.db')) + assert db.get_tracks_for_m3u_resolution() == [] diff --git a/tests/test_manual_search_endpoint.py b/tests/test_manual_search_endpoint.py index 3ab85f99..78c92f87 100644 --- a/tests/test_manual_search_endpoint.py +++ b/tests/test_manual_search_endpoint.py @@ -130,6 +130,10 @@ def manual_search_client(monkeypatch): 'qobuz': _make_plugin(), 'hifi': _make_plugin(), 'deezer': _make_plugin(), + # Present in the registry but deliberately NOT in the default + # hybrid_order, so the #865 SoundCloud-link test can select it + # without changing the 'all' fan-out tests. + 'soundcloud': _make_plugin(), } class _FakeSpec: @@ -217,6 +221,46 @@ def manual_search_client(monkeypatch): # --------------------------------------------------------------------------- +def test_manual_search_soundcloud_link_forces_soundcloud_source(manual_search_client): + """#865: pasting a SoundCloud URL forces the SoundCloud source and passes the + URL straight through (so its search resolves the link, incl. unlisted/private) + — it must NOT be turned into a text query or fan out to other sources.""" + client, ctx = manual_search_client + # Hybrid order = soundcloud only, so SoundCloud is the lone available source. + from config.settings import config_manager + original = config_manager.get + + def _cfg(key, default=None): + if key == 'download_source.mode': + return 'hybrid' + if key == 'download_source.hybrid_order': + return ['soundcloud'] + return original(key, default) + ctx['config_get_setter'](_cfg) + + url = 'https://soundcloud.com/artist/secret-track/s-AbC123' + resp = client.post('/api/downloads/task/task-abc/manual-search', + json={'query': url, 'source': 'all'}) + assert resp.status_code == 200 + msgs = _consume_ndjson(resp) + header = next(m for m in msgs if m.get('type') == 'header') + # The link forced the SoundCloud source, with the raw URL kept as the query. + assert header['sources_queried'] == ['soundcloud'] + assert header['query'] == url + + +def test_manual_search_soundcloud_link_errors_when_not_connected(manual_search_client): + """A SoundCloud link with SoundCloud not configured → clear 400, not a + useless text search of the raw URL.""" + client, ctx = manual_search_client + # Default hybrid_order has no soundcloud → it's not an available source. + resp = client.post('/api/downloads/task/task-abc/manual-search', + json={'query': 'https://soundcloud.com/artist/track', 'source': 'all'}) + assert resp.status_code == 400 + assert 'soundcloud' in resp.get_data(as_text=True).lower() + ctx['plugins']['soundcloud'].search.assert_not_called() + + def test_manual_search_validates_query_length(manual_search_client): """Empty / 1-char query returns 400 — frontend hint says ≥2 chars.""" client, _ctx = manual_search_client diff --git a/tests/test_member_login_password.py b/tests/test_member_login_password.py new file mode 100644 index 00000000..f6248cdf --- /dev/null +++ b/tests/test_member_login_password.py @@ -0,0 +1,54 @@ +"""Admin sets a member's LOGIN password (the gap behind 'non-admins can't log in +when Require Login is on'). The endpoint already allowed admin→anyone; this locks +that the round-trip actually lets the member authenticate.""" + +from __future__ import annotations + +import os +import tempfile + +import pytest + +_TMP = tempfile.mkdtemp(prefix='soulsync-testdb-memberpw-') +os.environ['DATABASE_PATH'] = os.path.join(_TMP, 'm.db') +os.environ['SOULSYNC_TEST_DB_READY'] = '1' + +web_server = pytest.importorskip('web_server') + + +@pytest.fixture +def client(): + return web_server.app.test_client() + + +def _make_member(db, pid=77): + conn = db._get_connection() + try: + conn.execute("INSERT OR REPLACE INTO profiles (id, name, is_admin) VALUES (?,?,0)", (pid, 'Member')) + conn.commit() + finally: + conn.close() + + +def test_admin_sets_member_password_then_member_can_authenticate(client): + db = web_server.get_database() + _make_member(db) + assert db.verify_profile_password(77, 'secret123') is False # no password → can't log in + + r = client.post('/api/profiles/77/set-password', json={'password': 'secret123'}) + assert r.status_code == 200 + body = r.get_json() + assert body['success'] is True and body['has_password'] is True + + assert db.verify_profile_password(77, 'secret123') is True # member can now authenticate + assert db.verify_profile_password(77, 'wrong') is False + + +def test_admin_can_clear_member_password(client): + db = web_server.get_database() + _make_member(db, pid=78) + client.post('/api/profiles/78/set-password', json={'password': 'pw12345'}) + assert db.verify_profile_password(78, 'pw12345') is True + r = client.post('/api/profiles/78/set-password', json={'password': ''}) + assert r.status_code == 200 + assert db.verify_profile_password(78, 'pw12345') is False # cleared → no login again diff --git a/tests/test_mirrored_custom_name.py b/tests/test_mirrored_custom_name.py new file mode 100644 index 00000000..0a69f1d1 --- /dev/null +++ b/tests/test_mirrored_custom_name.py @@ -0,0 +1,86 @@ +"""Mirrored-playlist custom name (alias) — seam + DB regression tests. + +Users can rename a mirrored playlist; the alias overrides the name shown in the +UI and used when syncing, while the playlist stays tied to its upstream source. +The non-negotiable guarantee: the alias must SURVIVE an upstream refresh (which +rewrites the upstream `name`). +""" + +from __future__ import annotations + +from core.playlists.naming import effective_mirrored_name +from database.music_database import MusicDatabase + + +# ── pure seam ──────────────────────────────────────────────────────────────── + +def test_custom_name_wins_when_set(): + assert effective_mirrored_name({'name': 'Discover Weekly', 'custom_name': 'My Jams'}) == 'My Jams' + + +def test_falls_back_to_upstream_name_when_no_alias(): + assert effective_mirrored_name({'name': 'Discover Weekly', 'custom_name': None}) == 'Discover Weekly' + assert effective_mirrored_name({'name': 'Discover Weekly', 'custom_name': ''}) == 'Discover Weekly' + assert effective_mirrored_name({'name': 'Discover Weekly', 'custom_name': ' '}) == 'Discover Weekly' + assert effective_mirrored_name({'name': 'Discover Weekly'}) == 'Discover Weekly' + + +def test_safe_on_bad_input(): + assert effective_mirrored_name(None) == '' + assert effective_mirrored_name('nope') == '' + assert effective_mirrored_name({}) == '' + + +# ── DB behaviour ───────────────────────────────────────────────────────────── + +def _mk(tmp_path): + db = MusicDatabase(str(tmp_path / 'm.db')) + pk = db.mirror_playlist(source='spotify', source_playlist_id='PL1', + name='Original Name', tracks=[], profile_id=1) + assert pk + return db, pk + + +def test_set_and_clear_custom_name(tmp_path): + db, pk = _mk(tmp_path) + assert db.get_mirrored_playlist(pk).get('custom_name') in (None, '') + + assert db.set_mirrored_playlist_custom_name(pk, 'My Alias') is True + assert db.get_mirrored_playlist(pk)['custom_name'] == 'My Alias' + + # Blank clears it back to upstream. + assert db.set_mirrored_playlist_custom_name(pk, ' ') is True + assert db.get_mirrored_playlist(pk).get('custom_name') is None + + # None clears too. + db.set_mirrored_playlist_custom_name(pk, 'Again') + assert db.set_mirrored_playlist_custom_name(pk, None) is True + assert db.get_mirrored_playlist(pk).get('custom_name') is None + + +def test_alias_survives_upstream_refresh(tmp_path): + """THE regression: re-mirroring (refresh) rewrites the upstream `name` but + must NOT touch the custom alias.""" + db, pk = _mk(tmp_path) + db.set_mirrored_playlist_custom_name(pk, 'My Alias') + + # Upstream renamed the playlist + added tracks → refresh. + db.mirror_playlist(source='spotify', source_playlist_id='PL1', + name='Upstream Renamed', tracks=[ + {'track_name': 'A', 'artist_name': 'X'}, + ], profile_id=1) + + row = db.get_mirrored_playlist(pk) + assert row['name'] == 'Upstream Renamed' # upstream name keeps tracking + assert row['custom_name'] == 'My Alias' # alias preserved + assert effective_mirrored_name(row) == 'My Alias' + + +def test_set_custom_name_does_not_touch_other_fields(tmp_path): + db, pk = _mk(tmp_path) + before = db.get_mirrored_playlist(pk) + db.set_mirrored_playlist_custom_name(pk, 'Alias') + after = db.get_mirrored_playlist(pk) + assert after['name'] == before['name'] + assert after['source'] == before['source'] + assert after['source_playlist_id'] == before['source_playlist_id'] diff --git a/tests/test_navidrome_self_heal.py b/tests/test_navidrome_self_heal.py new file mode 100644 index 00000000..9f7fd4c4 --- /dev/null +++ b/tests/test_navidrome_self_heal.py @@ -0,0 +1,54 @@ +"""Navidrome connection self-heals after a transient ping failure (jimmydotcom). + +A failed ping nukes the creds in _setup_client; previously _connection_attempted +latched the client 'disconnected' until a manual Test. Now is_connected re-attempts +(throttled) so a blip recovers on its own.""" + +from __future__ import annotations + +from core.navidrome_client import NavidromeClient + + +def test_self_heals_after_transient_failure(monkeypatch): + c = NavidromeClient() + calls = {'n': 0} + + def fake_setup(): + calls['n'] += 1 + if calls['n'] == 1: # transient failure nukes creds + c.base_url = c.username = c.password = None + else: # blip passed → connects + c.base_url, c.username, c.password = 'http://nd:4533', 'u', 'p' + monkeypatch.setattr(c, '_setup_client', fake_setup) + + assert c.is_connected() is False # first attempt fails + assert calls['n'] == 1 + + assert c.is_connected() is False # immediate recheck: throttled + assert calls['n'] == 1 # did NOT re-ping (no storm) + + c._last_connect_attempt -= (c._RECONNECT_THROTTLE_S + 1) # throttle window elapses + assert c.is_connected() is True # re-attempts → recovers itself + assert calls['n'] == 2 # no manual reconnect was needed + + +def test_connected_client_does_not_reattempt(monkeypatch): + c = NavidromeClient() + c.base_url, c.username, c.password = 'http://nd', 'u', 'p' + c._connection_attempted = True + calls = {'n': 0} + monkeypatch.setattr(c, '_setup_client', lambda: calls.__setitem__('n', calls['n'] + 1)) + assert c.is_connected() is True + assert calls['n'] == 0 # already connected → never re-pings + + +def test_first_connect_attempts_once(monkeypatch): + c = NavidromeClient() + calls = {'n': 0} + + def fake_setup(): + calls['n'] += 1 + c.base_url, c.username, c.password = 'http://nd', 'u', 'p' + monkeypatch.setattr(c, '_setup_client', fake_setup) + assert c.is_connected() is True + assert calls['n'] == 1 diff --git a/tests/test_orphan_file_detector.py b/tests/test_orphan_file_detector.py index 02ba7150..2d691bb6 100644 --- a/tests/test_orphan_file_detector.py +++ b/tests/test_orphan_file_detector.py @@ -52,6 +52,64 @@ def _seed_library(db_path: Path) -> None: conn.close() +def test_mass_orphan_path_mismatch_creates_no_findings(tmp_path: Path) -> None: + """The "transferred to staging" footgun: when the DB's stored paths no longer + match the filesystem (remount / Docker volume change) EVERY file looks + orphaned. The detector must create NO findings then — otherwise a user + batch-applying "move to staging" relocates their whole library. Mirrors the + hard skip the stale-removal paths use. + """ + db_path = tmp_path / "library.sqlite" + _seed_library(db_path) # DB tracks live under /old/prefix/... — nothing on disk matches + + # Drop 30 untracked files (> the 20 absolute floor, and 100% > 50%). + music = tmp_path / "Some Artist" / "Some Album" + music.mkdir(parents=True) + for i in range(30): + (music / f"{i:02d} - Track {i}.mp3").write_bytes(b"unreadable tags; no DB match") + + findings = [] + context = JobContext( + db=_DB(db_path), + transfer_folder=str(tmp_path), + config_manager=None, + create_finding=lambda **kwargs: findings.append(kwargs) or True, + ) + + result = OrphanFileDetectorJob().scan(context) + + assert result.scanned == 30 + assert result.findings_created == 0 + assert findings == [] # hard skip — not even flagged as warnings + + +def test_small_orphan_set_still_surfaces(tmp_path: Path) -> None: + """Below the absolute floor, genuine orphans must still be reported — the + guard only suppresses an implausibly large flood, not normal stray files. + """ + db_path = tmp_path / "library.sqlite" + _seed_library(db_path) + + music = tmp_path / "Stray" / "Files" + music.mkdir(parents=True) + for i in range(3): # 3 orphans — under the 20-file floor + (music / f"{i:02d} - Stray {i}.mp3").write_bytes(b"no DB match") + + findings = [] + context = JobContext( + db=_DB(db_path), + transfer_folder=str(tmp_path), + config_manager=None, + create_finding=lambda **kwargs: findings.append(kwargs) or True, + ) + + result = OrphanFileDetectorJob().scan(context) + + assert result.scanned == 3 + assert result.findings_created == 3 + assert all(f['finding_type'] == 'orphan_file' for f in findings) + + def test_orphan_detector_accepts_picard_albumartist_folder_match(tmp_path: Path) -> None: """Picard paths use albumartist/album (year)/track - title. diff --git a/tests/test_playlist_materialize.py b/tests/test_playlist_materialize.py new file mode 100644 index 00000000..daf397e8 --- /dev/null +++ b/tests/test_playlist_materialize.py @@ -0,0 +1,143 @@ +"""Playlist materialization seam — a playlist folder is a derived view of links +into the real library. Locks down: symlink vs copy, the auto-fallback when +symlinks aren't supported, idempotency, stale-link pruning, collision handling, +and that nothing is ever written outside the playlists root.""" + +from __future__ import annotations + +import os +from pathlib import Path + +from core.playlists.materialize import ( + DEFAULT_MODE, + materialize_one, + normalize_mode, + playlist_dir_for, + rebuild_playlist_folder, +) + + +def _library(tmp_path: Path) -> list[str]: + a = tmp_path / "Music" / "Daft Punk" / "Discovery" / "One More Time.mp3" + b = tmp_path / "Music" / "Queen" / "A Night at the Opera" / "Bohemian Rhapsody.mp3" + for f in (a, b): + f.parent.mkdir(parents=True, exist_ok=True) + f.write_bytes(b"audio") + return [str(a), str(b)] + + +def test_normalize_mode(): + assert normalize_mode("symlink") == "symlink" + assert normalize_mode("COPY") == "copy" + assert normalize_mode("") == DEFAULT_MODE + assert normalize_mode(None) == DEFAULT_MODE + assert normalize_mode("nonsense") == DEFAULT_MODE + + +def test_symlink_mode_creates_relative_links(tmp_path: Path): + real = _library(tmp_path) + s = rebuild_playlist_folder(str(tmp_path / "Playlists"), "Road Trip", real, mode="symlink") + assert s.linked == 2 and s.copied == 0 and not s.fellback + link = Path(s.playlist_dir) / "One More Time.mp3" + assert link.is_symlink() + assert not os.path.isabs(os.readlink(link)) # relative for portability + assert link.resolve() == Path(real[0]).resolve() + assert link.read_bytes() == b"audio" + + +def test_symlink_mode_idempotent(tmp_path: Path): + real = _library(tmp_path) + root = str(tmp_path / "Playlists") + rebuild_playlist_folder(root, "Mix", real, mode="symlink") + s2 = rebuild_playlist_folder(root, "Mix", real, mode="symlink") + assert s2.unchanged == 2 and s2.linked == 0 and s2.removed_stale == 0 + + +def test_copy_mode_duplicates_real_files(tmp_path: Path): + real = _library(tmp_path) + s = rebuild_playlist_folder(str(tmp_path / "Playlists"), "USB", real, mode="copy") + assert s.copied == 2 and s.linked == 0 + f = Path(s.playlist_dir) / "Bohemian Rhapsody.mp3" + assert f.is_file() and not f.is_symlink() and f.read_bytes() == b"audio" + + +def test_copy_mode_idempotent(tmp_path: Path): + real = _library(tmp_path) + root = str(tmp_path / "Playlists") + rebuild_playlist_folder(root, "Mix", real, mode="copy") + s2 = rebuild_playlist_folder(root, "Mix", real, mode="copy") + assert s2.unchanged == 2 and s2.copied == 0 + + +def test_falls_back_to_copy_when_symlinks_unsupported(tmp_path: Path): + real = _library(tmp_path) + + def _no_symlinks(target, link): + raise OSError("symlinks not supported here") + + s = rebuild_playlist_folder(str(tmp_path / "Playlists"), "Car", real, + mode="symlink", symlink_fn=_no_symlinks) + assert s.fellback is True and s.copied == 2 and s.linked == 0 + f = Path(s.playlist_dir) / "One More Time.mp3" + assert f.is_file() and not f.is_symlink() and f.read_bytes() == b"audio" + + +def test_rebuild_prunes_entries_no_longer_in_playlist(tmp_path: Path): + real = _library(tmp_path) + root = str(tmp_path / "Playlists") + rebuild_playlist_folder(root, "Mix", real, mode="symlink") # 2 entries + s = rebuild_playlist_folder(root, "Mix", real[:1], mode="symlink") # drop one + assert s.removed_stale == 1 + pdir = Path(s.playlist_dir) + assert (pdir / "One More Time.mp3").exists() + assert not (pdir / "Bohemian Rhapsody.mp3").exists() + + +def test_prune_stale_can_be_disabled(tmp_path: Path): + real = _library(tmp_path) + root = str(tmp_path / "Playlists") + rebuild_playlist_folder(root, "Mix", real, mode="symlink") + s = rebuild_playlist_folder(root, "Mix", real[:1], mode="symlink", prune_stale=False) + assert s.removed_stale == 0 + assert (Path(s.playlist_dir) / "Bohemian Rhapsody.mp3").exists() + + +def test_switching_mode_replaces_links_with_copies(tmp_path: Path): + real = _library(tmp_path) + root = str(tmp_path / "Playlists") + rebuild_playlist_folder(root, "Mix", real, mode="symlink") + s = rebuild_playlist_folder(root, "Mix", real, mode="copy") + f = Path(s.playlist_dir) / "One More Time.mp3" + assert f.is_file() and not f.is_symlink() and s.copied == 2 + + +def test_basename_collision_is_disambiguated_not_overwritten(tmp_path: Path): + p1 = tmp_path / "Music" / "A" / "AlbumA" / "01 - Intro.mp3" + p2 = tmp_path / "Music" / "B" / "AlbumB" / "01 - Intro.mp3" + for f, data in ((p1, b"one"), (p2, b"two")): + f.parent.mkdir(parents=True, exist_ok=True) + f.write_bytes(data) + s = rebuild_playlist_folder(str(tmp_path / "Playlists"), "Dup", [str(p1), str(p2)], mode="copy") + pdir = Path(s.playlist_dir) + assert (pdir / "01 - Intro.mp3").read_bytes() == b"one" + assert (pdir / "01 - Intro (2).mp3").read_bytes() == b"two" # second kept, not lost + assert s.copied == 2 + + +def test_missing_source_is_counted_not_fatal(tmp_path: Path): + real = _library(tmp_path) + s = rebuild_playlist_folder(str(tmp_path / "Playlists"), "Mix", + real + [str(tmp_path / "gone.mp3")], mode="symlink") + assert s.linked == 2 and s.missing_source == 1 + + +def test_playlist_name_cannot_escape_root(tmp_path: Path): + root = tmp_path / "Playlists" + nasty = playlist_dir_for(str(root), "../../etc/evil") + assert os.path.abspath(nasty).startswith(os.path.abspath(str(root)) + os.sep) + + +def test_materialize_one_missing_source(tmp_path: Path): + dest = tmp_path / "Playlists" / "X" / "x.mp3" + assert materialize_one(str(tmp_path / "nope.mp3"), str(dest), "symlink") == "missing" + assert not dest.exists() diff --git a/tests/test_playlist_materialize_service.py b/tests/test_playlist_materialize_service.py new file mode 100644 index 00000000..fbb3332e --- /dev/null +++ b/tests/test_playlist_materialize_service.py @@ -0,0 +1,268 @@ +"""Playlist materialize SERVICE — builds the folder from a finished batch's own +payload (owned matched_file_path + downloaded final_file_path). Locks down: it +stitches owned + downloaded, ignores not-found/not-completed, de-dupes, gates on +the organize flag, and never depends on source IDs or a mirrored playlist.""" + +from __future__ import annotations + +from pathlib import Path + +from core.playlists.materialize_service import ( + collect_batch_real_paths, + materialize_playlist_from_batch, + rebuild_mirrored_playlist_if_organized, + rebuild_organized_playlists_from_db, + reconcile_batch_playlists, +) + + +class _Track: + """Mimics database.DatabaseTrack — what check_track_exists returns.""" + def __init__(self, file_path): + self.file_path = file_path + + +class _RebuildDB: + """One organized playlist (Mix) + one not (Off); check_track_exists matches + by NAME via `owned` (track_name -> file_path), so no source IDs involved.""" + def __init__(self, owned): + self.owned = owned + + def get_mirrored_playlists(self, profile_id=1): + return [ + {"id": 1, "name": "Mix", "source_playlist_id": "PL1", "organize_by_playlist": True}, + {"id": 2, "name": "Off", "source_playlist_id": "PL2", "organize_by_playlist": False}, + ] + + def get_mirrored_playlist_tracks(self, pid): + if pid == 1: + return [{"track_name": "A", "artist_name": "x"}, + {"track_name": "Gone", "artist_name": "y"}] # not owned + return [{"track_name": "B", "artist_name": "z"}] + + def get_mirrored_playlist(self, playlist_id): + for pl in self.get_mirrored_playlists(): + if pl["id"] == playlist_id: + return pl + return None + + def resolve_mirrored_playlist(self, ref, profile_id=1, *, default_source="spotify"): + for pl in self.get_mirrored_playlists(): + if str(ref) in (pl["source_playlist_id"], str(pl["id"]), pl["name"]): + return pl + return None + + def check_track_exists(self, title, artist, confidence_threshold=0.8, + server_source=None, album=None, candidate_tracks=None): + fp = self.owned.get(title) + return (_Track(fp), 1.0) if fp else (None, 0.0) + + +class _Cfg: + def __init__(self, root, mode="symlink"): + self._d = {"playlists.materialize_path": root, "playlists.materialize_mode": mode} + + def get(self, key, default=None): + return self._d.get(key, default) + + +def _mk(tmp_path: Path, *names) -> list[str]: + paths = [] + for n in names: + f = tmp_path / "Music" / n + f.parent.mkdir(parents=True, exist_ok=True) + f.write_bytes(b"audio") + paths.append(str(f)) + return paths + + +def test_collect_stitches_owned_and_downloaded(tmp_path: Path): + owned, downloaded = _mk(tmp_path, "Owned.mp3"), _mk(tmp_path, "Fresh.mp3") + batch = { + "analysis_results": [ + {"found": True, "matched_file_path": owned[0]}, + {"found": False, "matched_file_path": None}, # not owned → skip + ], + "queue": ["t1", "t2"], + } + tasks = { + "t1": {"status": "completed", "final_file_path": downloaded[0]}, + "t2": {"status": "failed", "final_file_path": None}, # not completed → skip + } + cfg = _Cfg(str(tmp_path / "Playlists")) + paths = collect_batch_real_paths(batch, tasks, config_manager=cfg) + assert paths == [owned[0], downloaded[0]] # owned first, then downloaded + + +def test_collect_dedupes(tmp_path: Path): + owned = _mk(tmp_path, "Same.mp3") + batch = { + "analysis_results": [{"found": True, "matched_file_path": owned[0]}], + "queue": ["t1"], + } + tasks = {"t1": {"status": "completed", "final_file_path": owned[0]}} # same file + cfg = _Cfg(str(tmp_path / "Playlists")) + assert collect_batch_real_paths(batch, tasks, config_manager=cfg) == [owned[0]] + + +def test_materialize_from_batch_all_owned(tmp_path: Path): + """The all-owned case (no downloads) — folder built entirely from analysis.""" + owned = _mk(tmp_path, "A.mp3", "B.mp3") + batch = { + "playlist_folder_mode": True, + "playlist_name": "Smack That", + "analysis_results": [ + {"found": True, "matched_file_path": owned[0]}, + {"found": True, "matched_file_path": owned[1]}, + ], + "queue": [], + } + cfg = _Cfg(str(tmp_path / "Playlists")) + summary = materialize_playlist_from_batch(batch, {}, cfg) + assert summary is not None and summary.linked == 2 + pdir = Path(summary.playlist_dir) + assert pdir == tmp_path / "Playlists" / "Smack That" + assert (pdir / "A.mp3").resolve() == Path(owned[0]).resolve() + assert (pdir / "B.mp3").resolve() == Path(owned[1]).resolve() + + +def test_materialize_from_batch_owned_plus_downloaded(tmp_path: Path): + owned, downloaded = _mk(tmp_path, "Owned.mp3"), _mk(tmp_path, "Fresh.mp3") + batch = { + "playlist_folder_mode": True, + "playlist_name": "Mix", + "analysis_results": [{"found": True, "matched_file_path": owned[0]}], + "queue": ["t1"], + } + tasks = {"t1": {"status": "completed", "final_file_path": downloaded[0]}} + cfg = _Cfg(str(tmp_path / "Playlists"), mode="copy") + summary = materialize_playlist_from_batch(batch, tasks, cfg) + assert summary.copied == 2 + pdir = Path(summary.playlist_dir) + assert (pdir / "Owned.mp3").is_file() and (pdir / "Fresh.mp3").is_file() + + +def test_materialize_skipped_when_not_organize(tmp_path: Path): + batch = {"playlist_folder_mode": False, "playlist_name": "X", "analysis_results": [], "queue": []} + assert materialize_playlist_from_batch(batch, {}, _Cfg(str(tmp_path / "Playlists"))) is None + assert not (tmp_path / "Playlists").exists() + + +def test_reconcile_organize_batch_rebuilds_from_library(tmp_path: Path): + """An organize batch → its playlist is rebuilt from the LIBRARY (membership × + check_track_exists), not from fragile per-task fields. Owned members link, + non-owned drop out.""" + a = _mk(tmp_path, "A.mp3")[0] # Mix membership = A, Gone; only A owned + db = _RebuildDB({"A": a}) + batch = {"playlist_folder_mode": True, "playlist_name": "Mix", + "source_playlist_ref": "PL1", "batch_source": "spotify", "queue": []} + cfg = _Cfg(str(tmp_path / "Playlists")) + results = reconcile_batch_playlists(db, batch, {}, cfg) + assert len(results) == 1 + name, s = results[0] + assert name == "Mix" and s.linked == 1 # A owned; Gone not in library → skipped + assert (tmp_path / "Playlists" / "Mix" / "A.mp3").exists() + + +def test_reconcile_batch_playlist_rebuilds_even_if_row_flag_off(tmp_path: Path): + """The batch's OWN playlist rebuilds because the per-download toggle (batch + playlist_folder_mode) is the intent — even when the saved organize_by_playlist + row flag is off (the common case: user flips the download-modal toggle, never + the saved preference). 'Off' (PL2) has organize_by_playlist=False on the row.""" + b = _mk(tmp_path, "B.mp3")[0] # Off's membership = B + db = _RebuildDB({"B": b}) + batch = {"playlist_folder_mode": True, "playlist_name": "Off", + "source_playlist_ref": "PL2", "batch_source": "spotify", "queue": []} + cfg = _Cfg(str(tmp_path / "Playlists")) + results = reconcile_batch_playlists(db, batch, {}, cfg) + assert len(results) == 1 and results[0][0] == "Off" + assert (tmp_path / "Playlists" / "Off" / "B.mp3").exists() + + +def test_reconcile_wishlist_track_rebuilds_its_playlist(tmp_path: Path): + """The wishlist gap: a wishlist batch (not organize) completes a track whose + provenance points to an organize playlist → that playlist gets rebuilt from the + library, regardless of which import path set which task field.""" + a = _mk(tmp_path, "A.mp3")[0] + db = _RebuildDB({"A": a}) + batch = {"playlist_folder_mode": False, "playlist_name": "wishlist", "queue": ["w1"]} + tasks = {"w1": {"status": "completed", + "track_info": {"source_info": {"playlist_id": "PL1", "source": "spotify"}}}} + cfg = _Cfg(str(tmp_path / "Playlists")) + results = reconcile_batch_playlists(db, batch, tasks, cfg) + assert len(results) == 1 and results[0][0] == "Mix" + assert (tmp_path / "Playlists" / "Mix" / "A.mp3").exists() # Mix rebuilt from the library + + +def test_reconcile_skips_non_organized_provenance(tmp_path: Path): + """A completed track pointing to a NON-organize playlist (Off / PL2) is ignored.""" + db = _RebuildDB({"B": _mk(tmp_path, "B.mp3")[0]}) + batch = {"playlist_folder_mode": False, "playlist_name": "wishlist", "queue": ["w1"]} + tasks = {"w1": {"status": "completed", + "track_info": {"source_info": {"playlist_id": "PL2", "source": "spotify"}}}} + assert reconcile_batch_playlists(db, batch, tasks, _Cfg(str(tmp_path / "Playlists"))) == [] + assert not (tmp_path / "Playlists" / "Off").exists() + + +def test_reconcile_noop_for_plain_batch(tmp_path: Path): + """A normal (non-organize, no provenance) batch → nothing happens.""" + db = _RebuildDB({}) + batch = {"playlist_folder_mode": False, "playlist_name": "album", "queue": ["a1"]} + tasks = {"a1": {"status": "completed", "track_info": {}}} # no source_info + assert reconcile_batch_playlists(db, batch, tasks, _Cfg(str(tmp_path / "Playlists"))) == [] + assert not (tmp_path / "Playlists").exists() + + +def test_mirror_cleanup_prunes_removed_track(tmp_path: Path): + """Mirror-update hook: after a track LEAVES the playlist, its symlink is pruned.""" + a = tmp_path / "Music" / "A.mp3" + gone = tmp_path / "Music" / "Gone.mp3" + for f in (a, gone): + f.parent.mkdir(parents=True, exist_ok=True) + f.write_bytes(b"audio") + + class _DB: + def get_mirrored_playlist(self, pid): + return {"id": 1, "name": "Mix", "organize_by_playlist": True} if pid == 1 else None + + def get_mirrored_playlist_tracks(self, pid): + return [{"track_name": "A", "artist_name": "x"}] # 'Gone' was removed upstream + + def check_track_exists(self, title, artist, confidence_threshold=0.8, + server_source=None, album=None, candidate_tracks=None): + fp = {"A": str(a), "Gone": str(gone)}.get(title) + return (_Track(fp), 1.0) if fp else (None, 0.0) + + from core.playlists.materialize import rebuild_playlist_folder + rebuild_playlist_folder(str(tmp_path / "Playlists"), "Mix", [str(a), str(gone)], "symlink") + assert (tmp_path / "Playlists" / "Mix" / "Gone.mp3").exists() # both present before + + summary = rebuild_mirrored_playlist_if_organized(_DB(), _Cfg(str(tmp_path / "Playlists")), 1, profile_id=1) + assert summary is not None + pdir = tmp_path / "Playlists" / "Mix" + assert (pdir / "A.mp3").exists() + assert not (pdir / "Gone.mp3").exists() # pruned on mirror update + + +def test_mirror_cleanup_skips_non_organized(tmp_path: Path): + db = _RebuildDB({}) + cfg = _Cfg(str(tmp_path / "Playlists")) + assert rebuild_mirrored_playlist_if_organized(db, cfg, 2, profile_id=1) is None # Off (organize=0) + assert rebuild_mirrored_playlist_if_organized(db, cfg, 999, profile_id=1) is None # unknown + assert rebuild_mirrored_playlist_if_organized(db, cfg, None, profile_id=1) is None + + +def test_rebuild_from_db_only_organized_and_owned(tmp_path: Path): + """The manual button: rebuild every organized playlist by re-matching with + check_track_exists (name), linking only owned tracks.""" + f = tmp_path / "Music" / "A.mp3" + f.parent.mkdir(parents=True, exist_ok=True) + f.write_bytes(b"audio") + db = _RebuildDB({"A": str(f)}) # 'Gone' + the 'Off' playlist's 'B' not owned + cfg = _Cfg(str(tmp_path / "Playlists")) + results = rebuild_organized_playlists_from_db(db, cfg, profile_id=1) + assert len(results) == 1 # only Mix (organize on) + name, s = results[0] + assert name == "Mix" and s.linked == 1 # only A owned; Gone skipped + assert (tmp_path / "Playlists" / "Mix" / "A.mp3").exists() + assert not (tmp_path / "Playlists" / "Off").exists() diff --git a/tests/test_replaygain_filler_job.py b/tests/test_replaygain_filler_job.py new file mode 100644 index 00000000..bce087be --- /dev/null +++ b/tests/test_replaygain_filler_job.py @@ -0,0 +1,82 @@ +"""ReplayGain Filler job (#437) — fills ReplayGain on library content that skipped +download post-processing (Lidarr / REST API / manual adds). Pure flag decision + +the apply handler's analyze→compute→write seam (ffmpeg mocked).""" + +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import patch + +from core.repair_jobs.replaygain_filler import needs_replaygain +from core.repair_worker import RepairWorker + + +# ── pure decision: does a track need ReplayGain? ──────────────────────────── +def test_needs_rg_when_no_tags(): + assert needs_replaygain(None) is True + + +def test_needs_rg_when_track_gain_missing(): + assert needs_replaygain({'track_gain': None, 'track_peak': None}) is True + + +def test_needs_rg_when_track_gain_blank(): + assert needs_replaygain({'track_gain': ' '}) is True + + +def test_no_rg_needed_when_gain_present(): + assert needs_replaygain({'track_gain': '-6.50 dB'}) is False + + +def test_zero_gain_counts_as_tagged(): + # A legitimate "+0.00 dB" is already analyzed — must NOT be re-flagged forever. + assert needs_replaygain({'track_gain': '+0.00 dB'}) is False + + +# ── apply handler: analyze → compute gain → write (ffmpeg mocked) ──────────── +def _worker(): + w = RepairWorker(database=SimpleNamespace()) + w._config_manager = None + return w + + +def test_apply_writes_rg_with_pipeline_gain_formula(tmp_path): + f = tmp_path / 'song.flac' + f.write_bytes(b'\x00' * 64) + written = {} + + def fake_write(path, gain, peak, *a, **k): + written.update(path=path, gain=gain, peak=peak) + return True + + with patch('core.replaygain.is_ffmpeg_available', return_value=True), \ + patch('core.replaygain.analyze_track', return_value=(-12.0, -1.5)), \ + patch('core.replaygain.write_replaygain_tags', side_effect=fake_write), \ + patch('core.replaygain.RG_REFERENCE_LUFS', -18.0): + res = _worker()._fix_missing_replaygain('track', '1', str(f), {'file_path': str(f)}) + + assert res['success'] is True and res['action'] == 'applied_replaygain' + # gain = reference - lufs = -18.0 - (-12.0) = -6.0 (same as the import pipeline) + assert written['gain'] == -6.0 + assert written['peak'] == -1.5 + assert written['path'] == str(f) + + +def test_apply_errors_without_ffmpeg(tmp_path): + f = tmp_path / 's.flac' + f.write_bytes(b'\x00' * 64) + with patch('core.replaygain.is_ffmpeg_available', return_value=False): + res = _worker()._fix_missing_replaygain('track', '1', str(f), {'file_path': str(f)}) + assert res['success'] is False and 'ffmpeg' in res['error'].lower() + + +def test_apply_errors_when_file_missing(): + res = _worker()._fix_missing_replaygain( + 'track', '1', '/no/such/file.flac', {'file_path': '/no/such/file.flac'}) + assert res['success'] is False + + +def test_job_is_registered_and_opt_in(): + from core.repair_jobs import get_all_jobs + j = get_all_jobs().get('replaygain_filler') + assert j is not None and j.default_enabled is False diff --git a/tests/test_resolve_mirrored_playlist.py b/tests/test_resolve_mirrored_playlist.py index 75d3feea..fa45b007 100644 --- a/tests/test_resolve_mirrored_playlist.py +++ b/tests/test_resolve_mirrored_playlist.py @@ -55,3 +55,30 @@ def test_empty_refs_return_none(tmp_path): assert db.resolve_mirrored_playlist(None) is None assert db.resolve_mirrored_playlist('') is None assert db.resolve_mirrored_playlist(' ') is None + + +def test_synthetic_mirrored_batch_ref_resolves_by_pk(tmp_path): + """A discovery/mirror batch carries a synthetic playlist_id like + youtube_mirrored_<pk> with batch source 'mirrored'. (source, source_playlist_id) + can't match it — it must resolve via the embedded PK. Regression for the + organize-by-playlist 'all found but no folder built' report.""" + db = MusicDatabase(str(tmp_path / "m.db")) + pk = db.mirror_playlist(source='youtube', source_playlist_id='abc123XYZ', + name='My Mirror', tracks=[], profile_id=1) + assert pk + for ref in (f'youtube_mirrored_{pk}', f'auto_mirror_{pk}', f'mirrored_{pk}'): + row = db.resolve_mirrored_playlist(ref, profile_id=1, default_source='mirrored') + assert row is not None and row['id'] == pk, ref + + +def test_extract_mirrored_pk_pure(): + from core.playlists.source_refs import extract_mirrored_pk + assert extract_mirrored_pk('youtube_mirrored_63') == 63 + assert extract_mirrored_pk('auto_mirror_7') == 7 + assert extract_mirrored_pk('mirrored_12') == 12 + assert extract_mirrored_pk('42') == 42 # bare PK + assert extract_mirrored_pk('908622995') == 908622995 # numeric upstream id (PK fallback only) + assert extract_mirrored_pk('37i9dQZF1DXcBWIGoYBM5M') is None # real spotify id + assert extract_mirrored_pk('youtube_mirrored_') is None # no digits + assert extract_mirrored_pk('') is None + assert extract_mirrored_pk(None) is None diff --git a/tests/test_soundcloud_client.py b/tests/test_soundcloud_client.py index cb258f87..70e5afb3 100644 --- a/tests/test_soundcloud_client.py +++ b/tests/test_soundcloud_client.py @@ -248,6 +248,99 @@ def test_search_handles_empty_entries(tmp_dl: Path) -> None: assert tracks == [] +# --------------------------------------------------------------------------- +# URL resolution (#865): paste a SoundCloud link, incl. unlisted/private +# --------------------------------------------------------------------------- + + +def test_is_soundcloud_url_accepts_real_links() -> None: + ok = [ + 'https://soundcloud.com/artist/track-name', + 'http://soundcloud.com/artist/sets/my-set', + 'https://soundcloud.com/artist/track/s-AbC123', # private share token + 'soundcloud.com/artist/track-name', # scheme-less + 'https://m.soundcloud.com/artist/track', + 'https://on.soundcloud.com/aBcDe', # short link + ] + for u in ok: + assert soundcloud_client.is_soundcloud_url(u) is True, u + + +def test_is_soundcloud_url_rejects_non_links() -> None: + no = [ + 'daft punk around the world', + 'best soundcloud tracks 2024', # mentions soundcloud, not a URL + 'https://youtube.com/watch?v=abc', + 'https://open.spotify.com/track/xyz', + '', None, 42, + ] + for u in no: + assert soundcloud_client.is_soundcloud_url(u) is False, repr(u) + + +def test_search_routes_a_url_to_resolve(tmp_dl: Path) -> None: + """A pasted SoundCloud URL must go through resolve_url, NOT scsearch.""" + client = SoundcloudClient(download_path=str(tmp_dl)) + sentinel = ([TrackResult(username='soundcloud', filename='1||u||n', size=0, + bitrate=128, duration=None, quality='mp3', + free_upload_slots=999, upload_speed=1, queue_length=0)], []) + # resolve_url is async → patch.object gives an AsyncMock, so return_value is + # what `await` yields. + with patch.object(client, 'resolve_url', return_value=sentinel) as rv, \ + patch.object(client, '_extract_search_entries') as scsearch: + tracks, albums = _run(client.search('https://soundcloud.com/x/private/s-tok')) + rv.assert_called_once() + scsearch.assert_not_called() + assert len(tracks) == 1 + + +def test_resolve_url_single_track_uses_permalink(tmp_dl: Path) -> None: + """Full extraction puts a transient stream URL in `url`; the stable permalink + is `webpage_url`. The filename must encode the permalink.""" + info = { + 'id': '999', + 'title': 'Artist Name - Secret Song', + 'uploader': 'artistname', + 'url': 'https://cf-media.sndcdn.com/transient-stream.mp3', # expires + 'webpage_url': 'https://soundcloud.com/artistname/secret-song/s-Tok3n', + 'duration': 200.0, + } + client = SoundcloudClient(download_path=str(tmp_dl)) + with patch.object(client, '_extract_url_info', return_value=info): + tracks, albums = _run(client.resolve_url('https://soundcloud.com/artistname/secret-song/s-Tok3n')) + assert albums == [] + assert len(tracks) == 1 + parts = tracks[0].filename.split('||') + assert parts[0] == '999' + assert parts[1] == 'https://soundcloud.com/artistname/secret-song/s-Tok3n' # permalink, not stream + assert tracks[0]._source_metadata['permalink_url'] == 'https://soundcloud.com/artistname/secret-song/s-Tok3n' + assert tracks[0].artist == 'Artist Name' + assert tracks[0].duration == 200000 + + +def test_resolve_url_set_yields_all_tracks(tmp_dl: Path) -> None: + info = { + 'entries': [ + {'id': '1', 'title': 'A - One', 'webpage_url': 'https://soundcloud.com/a/one', 'duration': 100}, + {'id': '2', 'title': 'A - Two', 'webpage_url': 'https://soundcloud.com/a/two', 'duration': 120}, + ], + } + client = SoundcloudClient(download_path=str(tmp_dl)) + with patch.object(client, '_extract_url_info', return_value=info): + tracks, _ = _run(client.resolve_url('https://soundcloud.com/a/sets/my-set')) + assert {t._source_metadata['track_id'] for t in tracks} == {'1', '2'} + assert all('soundcloud.com' in t.filename.split('||')[1] for t in tracks) + + +def test_resolve_url_empty_on_failure(tmp_dl: Path) -> None: + client = SoundcloudClient(download_path=str(tmp_dl)) + with patch.object(client, '_extract_url_info', side_effect=RuntimeError('blocked')): + assert _run(client.resolve_url('https://soundcloud.com/x/y')) == ([], []) + with patch.object(client, '_extract_url_info', return_value=None): + assert _run(client.resolve_url('https://soundcloud.com/x/y')) == ([], []) + assert _run(client.resolve_url('')) == ([], []) + + def test_search_handles_malformed_entries_individually(tmp_dl: Path) -> None: """One bad entry shouldn't poison the entire result set.""" fake_entries = [ diff --git a/tests/test_spotify_free_status.py b/tests/test_spotify_free_status.py new file mode 100644 index 00000000..d32b894f --- /dev/null +++ b/tests/test_spotify_free_status.py @@ -0,0 +1,126 @@ +"""Spotify-Free (no-auth) must read as a WORKING primary metadata source. + +A user who picks 'Spotify Free' (fallback_source='spotify' + metadata.spotify_free) +is officially unauthenticated, so is_spotify_authenticated() is False. The sidebar/ +dashboard status dot keys on get_primary_source_status()['connected'], and the +dashboard test button on run_service_test('spotify', ...). Both used to report +disconnected / "Deezer connection successful! (Spotify configured but not +authenticated)" even though Spotify metadata was actually flowing. + +Root cause (pinned by test_*_unauthed_free_seen_via_direct_client): get_client_for_source +('spotify') returns None unless officially authed, so the free-availability check in +get_primary_source_status could never fire — the client it probed was always None. +""" + +from __future__ import annotations + +import core.metadata.registry as registry +import core.connection_test as connection_test + + +class _FreeClient: + """No-auth Spotify: not officially authed, but free metadata IS available.""" + + def is_spotify_authenticated(self): + return False + + def is_spotify_metadata_available(self): + return True + + +class _NoMetaClient: + def is_spotify_authenticated(self): + return False + + def is_spotify_metadata_available(self): + return False + + +def _patch_registry(monkeypatch, *, free_selected, client): + cfg = { + "metadata.fallback_source": "spotify", + "metadata.spotify_free": free_selected, + } + monkeypatch.setattr(registry, "_get_config_value", lambda k, d=None: cfg.get(k, d)) + # get_client_for_source('spotify') returns None when unauthed; the direct fetch + # is what the fix relies on, so route both through the fake. + monkeypatch.setattr(registry, "get_spotify_client", lambda client_factory=None: client) + + +def test_unauthed_free_seen_via_direct_client(monkeypatch): + """REGRESSION: free selected + available but not officially authed → connected. + Before the fix this was False because the probed client was None.""" + _patch_registry(monkeypatch, free_selected=True, client=_FreeClient()) + status = registry.get_primary_source_status() + assert status["connected"] is True + assert status["source"] == "spotify_free" + + +def test_free_not_selected_unauthed_is_disconnected(monkeypatch): + """Free NOT chosen → an unauthenticated Spotify primary is genuinely down.""" + _patch_registry(monkeypatch, free_selected=False, client=_FreeClient()) + status = registry.get_primary_source_status() + assert status["connected"] is False + assert status["source"] == "spotify" + + +def test_free_selected_but_unavailable_is_disconnected(monkeypatch): + """Free chosen but the package/path can't serve → not connected (no false green).""" + _patch_registry(monkeypatch, free_selected=True, client=_NoMetaClient()) + status = registry.get_primary_source_status() + assert status["connected"] is False + + +# --- dashboard test button (run_service_test) --------------------------------- + + +class _FakeConfigManager: + def __init__(self, store): + self._store = store + + def get(self, key, default=None): + return self._store.get(key, default) + + def set(self, key, value): + self._store[key] = value + + +def _run_spotify_test(monkeypatch, *, metadata_available, fallback="deezer"): + fake_client = _FreeClient() if metadata_available else _NoMetaClient() + + class _Client: + def __init__(self): + self._d = fake_client + + def is_authenticated(self): + return True # free user passes the top-level auth gate + + def is_spotify_authenticated(self): + return self._d.is_spotify_authenticated() + + def is_spotify_metadata_available(self): + return self._d.is_spotify_metadata_available() + + monkeypatch.setattr(connection_test, "SpotifyClient", _Client) + monkeypatch.setattr( + connection_test, + "config_manager", + _FakeConfigManager({"spotify": {"client_id": "x", "client_secret": "y"}}), + ) + monkeypatch.setattr(connection_test, "_get_metadata_fallback_source", lambda: fallback) + monkeypatch.setattr(connection_test, "docker_resolve_url", lambda v: v, raising=False) + return connection_test.run_service_test("spotify", {}) + + +def test_test_button_reports_spotify_free(monkeypatch): + ok, msg = _run_spotify_test(monkeypatch, metadata_available=True) + assert ok is True + assert "Spotify (no-auth)" in msg + + +def test_test_button_falls_back_when_free_unavailable(monkeypatch): + """No free path → keep the honest Deezer-fallback message.""" + ok, msg = _run_spotify_test(monkeypatch, metadata_available=False, fallback="deezer") + assert ok is True + assert "Deezer connection successful" in msg + assert "Spotify (no-auth)" not in msg diff --git a/tests/test_stale_guard.py b/tests/test_stale_guard.py index 6259847e..68db9f0a 100644 --- a/tests/test_stale_guard.py +++ b/tests/test_stale_guard.py @@ -2,6 +2,7 @@ from __future__ import annotations +from core.library.stale_guard import is_implausible_orphan_flood as flood from core.library.stale_guard import is_implausible_stale_removal as g @@ -26,3 +27,29 @@ def test_edge_inputs(): assert g(0, 0) is False assert g(0, 100) is False # nothing missing assert g(5, 5) is True # min_total met, all missing + + +# ── orphan-flood guard: same shape, protects the "move to staging" path ────── + +def test_whole_library_flagged_orphan_is_blocked(): + # 4000/5000 files "orphaned" → a path mismatch, not real orphans. + assert flood(4000, 5000) is True + assert flood(21, 40) is True # just over both floors (>20 and >50%) + + +def test_a_handful_of_real_orphans_still_surface(): + assert flood(3, 4000) is False # a few stray files — report them + assert flood(20, 30) is False # at the absolute floor (not > 20) + assert flood(2000, 4000) is False # exactly 50% is NOT over the threshold + + +def test_orphan_flood_small_folders_never_blocked(): + # A 5-file folder that's all orphans is plausible (manual drop) — don't hide it. + assert flood(5, 5) is False + assert flood(20, 20) is False # below the absolute orphan floor + + +def test_orphan_flood_edge_inputs(): + assert flood(0, 0) is False + assert flood(0, 5000) is False # nothing orphaned + assert flood(5000, 0) is False # nonsense totals don't trip it diff --git a/tests/test_tidal_playlist_batch_chunking.py b/tests/test_tidal_playlist_batch_chunking.py new file mode 100644 index 00000000..3f097678 --- /dev/null +++ b/tests/test_tidal_playlist_batch_chunking.py @@ -0,0 +1,107 @@ +"""#867 regression: Tidal playlist track hydration must chunk to the API cap. + +Tidal's ``/tracks?filter[id]=...`` endpoint returns at most ``_COLLECTION_BATCH_SIZE`` +(20) tracks per request. ``get_playlist`` fetches a page of track-ID links and then +hydrates them via ``_get_tracks_batch``. If it sends a relationships page with more +than 20 IDs in a single hydration call, the surplus is silently dropped — a 59-track +playlist would surface as ~20. ``get_album_tracks`` already chunks; this pins the same +behavior for ``get_playlist`` so discovery sees every track. +""" + +from __future__ import annotations + +from core.tidal_client import TidalClient, Track + + +class _FakeResp: + status_code = 200 + + def raise_for_status(self): + return None + + def json(self): + return { + "data": { + "id": "PL1", + "attributes": {"name": "My Playlist", "accessType": "PRIVATE"}, + "relationships": {}, + } + } + + +class _FakeSession: + def get(self, *args, **kwargs): + return _FakeResp() + + +def _make_client(): + """Build a TidalClient without running its network/config __init__.""" + client = object.__new__(TidalClient) + client.base_url = "https://api.tidal.test" + client.session = _FakeSession() + return client + + +def test_get_playlist_chunks_oversized_relationships_page(monkeypatch): + client = _make_client() + cap = TidalClient._COLLECTION_BATCH_SIZE # 20 + n = 59 + ids = [str(i) for i in range(n)] + + monkeypatch.setattr(client, "_ensure_valid_token", lambda: True) + # One relationships page returns ALL 59 ID links at once, then no cursor. + monkeypatch.setattr( + client, + "_get_playlist_tracks_page", + lambda playlist_id, cursor=None: { + "data": [{"type": "tracks", "id": i} for i in ids], + "links": {"meta": {}}, # no nextCursor -> single page + }, + ) + + seen_chunk_sizes = [] + + def fake_batch(chunk_ids): + # Simulate the real filter[id] cap: never return more than the cap, so a + # single oversized call would lose the surplus (the bug being guarded). + seen_chunk_sizes.append(len(chunk_ids)) + capped = chunk_ids[:cap] + return [Track(id=i, name=f"t{i}", artists=["a"]) for i in capped] + + monkeypatch.setattr(client, "_get_tracks_batch", fake_batch) + + playlist = client.get_playlist("PL1") + + assert playlist is not None + # The whole point: all 59 hydrate, not just the first 20. + assert len(playlist.tracks) == n + # Every hydration call stayed within the API cap (so none truncated). + assert seen_chunk_sizes and max(seen_chunk_sizes) <= cap + + +def test_get_playlist_small_page_single_call(monkeypatch): + """A page at/under the cap still hydrates in one call (no behavior change).""" + client = _make_client() + ids = [str(i) for i in range(5)] + + monkeypatch.setattr(client, "_ensure_valid_token", lambda: True) + monkeypatch.setattr( + client, + "_get_playlist_tracks_page", + lambda playlist_id, cursor=None: { + "data": [{"type": "tracks", "id": i} for i in ids], + "links": {"meta": {}}, + }, + ) + + calls = [] + + def fake_batch(chunk_ids): + calls.append(list(chunk_ids)) + return [Track(id=i, name=f"t{i}", artists=["a"]) for i in chunk_ids] + + monkeypatch.setattr(client, "_get_tracks_batch", fake_batch) + + playlist = client.get_playlist("PL1") + assert len(playlist.tracks) == 5 + assert len(calls) == 1 # one chunk, one hydration call diff --git a/tests/test_tool_progress_events.py b/tests/test_tool_progress_events.py index d23dc765..d8424dc6 100644 --- a/tests/test_tool_progress_events.py +++ b/tests/test_tool_progress_events.py @@ -13,16 +13,15 @@ conftest is a different module instance. Use the ``shared_state`` fixture instea import pytest -# All 7 tool progress pollers +# Tool progress pollers TOOLS = [ - 'stream', 'quality-scanner', 'duplicate-cleaner', + 'stream', 'duplicate-cleaner', 'retag', 'db-update', 'metadata', 'logs', ] # Endpoint URLs keyed by tool name ENDPOINTS = { 'stream': '/api/stream/status', - 'quality-scanner': '/api/quality-scanner/status', 'duplicate-cleaner': '/api/duplicate-cleaner/status', 'retag': '/api/retag/status', 'db-update': '/api/database/update/status', @@ -63,27 +62,6 @@ class TestToolDataShape: assert 'error_message' in data assert isinstance(data['progress'], (int, float)) - def test_quality_scanner_shape(self, test_app, shared_state): - """Quality scanner has status, phase, progress, processed, total, quality_met.""" - app, socketio = test_app - client = socketio.test_client(app) - build = shared_state['build_quality_scanner_status'] - - socketio.emit('tool:quality-scanner', build()) - received = client.get_received() - events = [e for e in received if e['name'] == 'tool:quality-scanner'] - assert len(events) >= 1 - data = events[0]['args'][0] - - assert 'status' in data - assert 'phase' in data - assert 'progress' in data - assert 'processed' in data - assert 'total' in data - assert 'quality_met' in data - assert 'low_quality' in data - assert 'matched' in data - def test_duplicate_cleaner_shape(self, test_app, shared_state): """Duplicate cleaner has status, phase, progress, space_freed_mb.""" app, socketio = test_app @@ -255,11 +233,11 @@ class TestToolBackwardCompat: client2 = socketio.test_client(app) build = shared_state['build_tool_status'] - socketio.emit('tool:quality-scanner', build('quality-scanner')) + socketio.emit('tool:duplicate-cleaner', build('duplicate-cleaner')) for client in [client1, client2]: received = client.get_received() - events = [e for e in received if e['name'] == 'tool:quality-scanner'] + events = [e for e in received if e['name'] == 'tool:duplicate-cleaner'] assert len(events) >= 1 client1.disconnect() @@ -270,17 +248,15 @@ class TestToolBackwardCompat: app, socketio = test_app client = socketio.test_client(app) build = shared_state['build_tool_status'] - qs = shared_state['quality_scanner_state'] + dc = shared_state['duplicate_cleaner_state'] # Mutate state - qs['status'] = 'finished' - qs['progress'] = 100 - qs['processed'] = 100 + dc['status'] = 'finished' + dc['progress'] = 100 - socketio.emit('tool:quality-scanner', build('quality-scanner')) + socketio.emit('tool:duplicate-cleaner', build('duplicate-cleaner')) received = client.get_received() - events = [e for e in received if e['name'] == 'tool:quality-scanner'] + events = [e for e in received if e['name'] == 'tool:duplicate-cleaner'] data = events[-1]['args'][0] assert data['status'] == 'finished' assert data['progress'] == 100 - assert data['processed'] == 100 diff --git a/tests/test_watchlist_auto_download.py b/tests/test_watchlist_auto_download.py new file mode 100644 index 00000000..a75a867d --- /dev/null +++ b/tests/test_watchlist_auto_download.py @@ -0,0 +1,42 @@ +"""DB-level coverage for the per-artist watchlist auto_download ("follow only") +toggle — the column migrates in, defaults to auto-download, and round-trips +through get_watchlist_artists onto the WatchlistArtist dataclass.""" + +from __future__ import annotations + +from database.music_database import MusicDatabase + + +def _get(db, profile_id=1): + return {a.spotify_artist_id: a for a in db.get_watchlist_artists(profile_id=profile_id)} + + +def test_auto_download_defaults_true(tmp_path): + db = MusicDatabase(str(tmp_path / 'm.db')) + assert db.add_artist_to_watchlist('sp1', 'Artist One', profile_id=1) + artist = _get(db)['sp1'] + # New watchlist artists keep the existing behaviour: auto-download on. + assert artist.auto_download is True + + +def test_auto_download_persists_when_disabled(tmp_path): + db = MusicDatabase(str(tmp_path / 'm.db')) + db.add_artist_to_watchlist('sp1', 'Artist One', profile_id=1) + + # Flip it off the same way the config endpoint's UPDATE does. + with db._get_connection() as conn: + conn.execute( + "UPDATE watchlist_artists SET auto_download = 0 WHERE spotify_artist_id = ?", + ('sp1',), + ) + conn.commit() + + artist = _get(db)['sp1'] + assert artist.auto_download is False + + +def test_auto_download_column_exists_after_migration(tmp_path): + db = MusicDatabase(str(tmp_path / 'm.db')) + with db._get_connection() as conn: + cols = [c[1] for c in conn.execute("PRAGMA table_info(watchlist_artists)").fetchall()] + assert 'auto_download' in cols diff --git a/tests/test_watchlist_scanner_scan.py b/tests/test_watchlist_scanner_scan.py index f02eab45..9eda637f 100644 --- a/tests/test_watchlist_scanner_scan.py +++ b/tests/test_watchlist_scanner_scan.py @@ -446,6 +446,52 @@ def test_scan_watchlist_artists_scans_tracks_and_updates_state(monkeypatch): assert scan_state["recent_wishlist_additions"][0]["track_name"] == "Track One" +def test_follow_only_artist_discovers_but_skips_wishlist_add(monkeypatch): + """auto_download=False ("follow only"): the scan still finds the new track + (counts toward new_tracks_found + the scan summary) but must NOT call + add_track_to_wishlist — so nothing auto-downloads (corruption's request).""" + monkeypatch.setattr(watchlist_scanner_module, "DELAY_BETWEEN_ARTISTS", 0) + monkeypatch.setattr(watchlist_scanner_module, "DELAY_BETWEEN_ALBUMS", 0) + + artist = _build_artist() + artist.auto_download = False # follow only + album = types.SimpleNamespace(id="album-1", name="Album One") + album_data = { + "name": "Album One", + "images": [{"url": "https://example.com/album.jpg"}], + "tracks": {"items": [{ + "id": "track-1", "name": "Track One", "track_number": 1, + "disc_number": 1, "artists": [{"name": "Artist One"}], + }]}, + } + scanner = _build_scanner(album_data, [artist]) + scanner._database.has_fresh_similar_artists = lambda *a, **k: False + + add_calls = [] + monkeypatch.setattr(scanner, "_backfill_missing_ids", lambda *a, **k: None) + monkeypatch.setattr(scanner, "get_artist_image_url", lambda *a, **k: "https://example.com/artist.jpg") + monkeypatch.setattr(scanner, "get_artist_discography_for_watchlist", lambda *a, **k: [album]) + monkeypatch.setattr(scanner, "_get_lookback_period_setting", lambda: "30") + monkeypatch.setattr(scanner, "_get_rescan_cutoff", lambda: None) + monkeypatch.setattr(scanner, "_should_include_release", lambda *a, **k: True) + monkeypatch.setattr(scanner, "_should_include_track", lambda *a, **k: True) + monkeypatch.setattr(scanner, "is_track_missing_from_library", lambda *a, **k: True) + monkeypatch.setattr(scanner, "add_track_to_wishlist", lambda *a, **k: add_calls.append(a) or True) + monkeypatch.setattr(scanner, "update_artist_scan_timestamp", lambda *a, **k: True) + monkeypatch.setattr(scanner, "update_similar_artists", lambda *a, **k: True) + monkeypatch.setattr(scanner, "_backfill_similar_artists_fallback_ids", lambda *a, **k: 0) + + scan_state = {} + results = scanner.scan_watchlist_artists([artist], scan_state=scan_state) + + assert results[0].success is True + assert results[0].new_tracks_found == 1 # still discovered/surfaced + assert results[0].tracks_added_to_wishlist == 0 # but not auto-added + assert add_calls == [] # the one gated call never fired + assert scan_state["summary"]["new_tracks_found"] == 1 + assert scan_state["summary"]["tracks_added_to_wishlist"] == 0 + + def test_scan_watchlist_artists_skips_placeholder_tracklists(monkeypatch): monkeypatch.setattr(watchlist_scanner_module, "DELAY_BETWEEN_ARTISTS", 0) monkeypatch.setattr(watchlist_scanner_module, "DELAY_BETWEEN_ALBUMS", 0) diff --git a/tests/test_worker_artist_disambiguation.py b/tests/test_worker_artist_disambiguation.py new file mode 100644 index 00000000..17d7bc93 --- /dev/null +++ b/tests/test_worker_artist_disambiguation.py @@ -0,0 +1,186 @@ +"""Per-worker same-name artist disambiguation (#868). + +Each enrichment worker, when several same-name candidates clear the name gate, +must pick the one whose catalog overlaps the albums the library actually owns — +not whichever the source ranked first. Covers Spotify (also the Spotify-Free +path, same client surface), iTunes, Deezer, and MusicBrainz. +""" + +from __future__ import annotations + +import types + + +# A query-aware fake DB: owned-albums query → owned titles; the source_id_conflict +# query (SELECT name FROM artists ...) → no conflict. +class _Cur: + def __init__(self, rows): + self._rows = rows + + def fetchall(self): + return self._rows + + +class _Conn: + def __init__(self, owned): + self._owned = owned + + def __enter__(self): + return self + + def __exit__(self, *a): + return False + + def execute(self, sql, params=()): + if 'FROM albums' in sql: + return _Cur([(t,) for t in self._owned]) + return _Cur([]) # conflict check → none + + def cursor(self): + return self + + def commit(self): + pass + + def close(self): + pass + + +class _DB: + def __init__(self, owned): + self._owned = owned + + def _get_connection(self): + return _Conn(self._owned) + + +# The owned library: the RIGHT "Rone" made these. +OWNED = ['Tohu Bohu', 'Creatures', 'Mirapolis'] +WRONG = types.SimpleNamespace(id='wrong_rone', name='Rone') +RIGHT = types.SimpleNamespace(id='right_rone', name='Rone') +ALBUMS = { + 'wrong_rone': [{'title': 'Mixtape Vol 1'}, {'title': 'Random Single'}], + 'right_rone': [{'title': 'Tohu Bohu (Deluxe)'}, {'title': 'Creatures'}], +} + + +def test_spotify_picks_artist_overlapping_owned_catalog(): + from core.spotify_worker import SpotifyWorker + w = object.__new__(SpotifyWorker) + w.db = _DB(OWNED) + w.stats = {'matched': 0, 'not_found': 0, 'errors': 0} + w.client = types.SimpleNamespace( + search_artists=lambda name, limit=5: [WRONG, RIGHT], # wrong ranked first + get_artist_albums=lambda aid: ALBUMS.get(aid, []), + ) + captured = {} + w._get_existing_id = lambda *a: None + w._mark_status = lambda *a: None + w._name_similarity = lambda a, b: 1.0 # both "Rone" clear the gate + w._is_spotify_id = lambda i: True + w._update_artist = lambda artist_id, obj: captured.update(id=obj.id) + + w._process_artist({'id': 5, 'name': 'Rone'}) + assert captured.get('id') == 'right_rone' + assert w.stats['matched'] == 1 + + +def test_itunes_picks_artist_overlapping_owned_catalog(): + from core.itunes_worker import iTunesWorker + w = object.__new__(iTunesWorker) + w.db = _DB(OWNED) + w.stats = {'matched': 0, 'not_found': 0, 'errors': 0} + w.client = types.SimpleNamespace( + search_artists=lambda name, limit=5: [WRONG, RIGHT], + get_artist_albums=lambda aid: ALBUMS.get(aid, []), + ) + captured = {} + w._get_existing_id = lambda *a: None + w._mark_status = lambda *a: None + w._is_itunes_id = lambda i: True + w._update_artist = lambda artist_id, obj: captured.update(id=obj.id) + + w._process_artist({'id': 5, 'name': 'Rone'}) + assert captured.get('id') == 'right_rone' + assert w.stats['matched'] == 1 + + +def test_deezer_picks_artist_overlapping_owned_catalog(): + from core.deezer_worker import DeezerWorker + w = object.__new__(DeezerWorker) + w.db = _DB(OWNED) + w.stats = {'matched': 0, 'not_found': 0, 'errors': 0} + w.client = types.SimpleNamespace( + search_artists=lambda name, limit=5: [WRONG, RIGHT], + get_artist_albums_list=lambda aid: ALBUMS.get(aid, []), + get_artist_info=lambda aid: {'id': aid, 'name': 'Rone'}, + ) + captured = {} + w._get_existing_id = lambda *a: None + w._mark_status = lambda *a: None + w._update_artist = lambda artist_id, data: captured.update(id=data.get('id')) + + w._process_artist(5, 'Rone') + assert captured.get('id') == 'right_rone' + assert w.stats['matched'] == 1 + + +def _mb_service(owned_release_groups): + from core.musicbrainz_service import MusicBrainzService + s = object.__new__(MusicBrainzService) + s._check_cache = lambda *a, **k: None + s._save_to_cache = lambda *a, **k: None + s._calculate_similarity = lambda a, b: 1.0 + s.mb_client = types.SimpleNamespace( + search_artist=lambda name, limit=5: [ + {'id': 'wrong_mbid', 'name': 'Rone', 'score': 100}, # ranked first + {'id': 'right_mbid', 'name': 'Rone', 'score': 90}, + ], + get_artist=lambda mbid, includes=None: owned_release_groups.get(mbid), + ) + return s + + +def test_musicbrainz_picks_artist_overlapping_owned_catalog(): + rg = { + 'wrong_mbid': {'release-groups': [{'title': 'Other Stuff'}]}, + 'right_mbid': {'release-groups': [{'title': 'Tohu Bohu'}, {'title': 'Creatures'}]}, + } + result = _mb_service(rg).match_artist('Rone', owned_titles=OWNED) + assert result is not None + assert result['mbid'] == 'right_mbid' + + +def test_musicbrainz_without_owned_titles_keeps_legacy_behavior(): + """No owned titles → highest-confidence (name-order first) candidate, unchanged.""" + rg = {'wrong_mbid': {'release-groups': []}, 'right_mbid': {'release-groups': []}} + result = _mb_service(rg).match_artist('Rone') # no owned_titles + assert result['mbid'] == 'wrong_mbid' # the top-confidence pick + + +def test_musicbrainz_cache_hit_used_when_catalog_overlaps(): + """A cached mbid whose catalog overlaps the owned albums is trusted (no re-resolve).""" + rg = {'cached_mbid': {'release-groups': [{'title': 'Tohu Bohu'}, {'title': 'Creatures'}]}} + s = _mb_service(rg) + s._check_cache = lambda *a, **k: {'musicbrainz_id': 'cached_mbid', 'confidence': 95} + s.mb_client.search_artist = lambda *a, **k: (_ for _ in ()).throw( + AssertionError("must not re-search when the cached match fits the library")) + result = s.match_artist('Rone', owned_titles=OWNED) + assert result['mbid'] == 'cached_mbid' + assert result['cached'] is True + + +def test_musicbrainz_stale_cache_is_bypassed_and_re_resolved(): + """A cached mbid with ZERO owned-catalog overlap (wrong same-name artist) is + bypassed → fresh disambiguated resolve. This is what makes a re-match work for + MB despite the 90-day cache TTL (#868).""" + rg = { + 'cached_wrong': {'release-groups': [{'title': 'Unrelated'}]}, # the stale cache + 'wrong_mbid': {'release-groups': [{'title': 'Other Stuff'}]}, + 'right_mbid': {'release-groups': [{'title': 'Tohu Bohu'}, {'title': 'Creatures'}]}, + } + s = _mb_service(rg) + s._check_cache = lambda *a, **k: {'musicbrainz_id': 'cached_wrong', 'confidence': 95} + result = s.match_artist('Rone', owned_titles=OWNED) + assert result['mbid'] == 'right_mbid' # re-resolved + disambiguated + assert result.get('cached') is not True diff --git a/tests/test_ws_connect_gate.py b/tests/test_ws_connect_gate.py index d861410d..0876ac51 100644 --- a/tests/test_ws_connect_gate.py +++ b/tests/test_ws_connect_gate.py @@ -82,3 +82,26 @@ def test_socket_allowed_when_pin_verified(monkeypatch): s['launch_pin_verified'] = True sio = web_server.socketio.test_client(web_server.app, flask_test_client=flask_client) assert sio.is_connected() is True + + +# ── login mode (the "Sign in to SoulSync" path — #852 report was this one) ── +def _login_on(monkeypatch): + real = web_server.config_manager.get + monkeypatch.setattr(web_server.config_manager, 'get', + lambda k, d=None: True if k == 'security.require_login' else real(k, d)) + + +def test_socket_rejected_when_login_required_and_unauthenticated(monkeypatch): + _login_on(monkeypatch) + flask_client = web_server.app.test_client() # no login_authenticated + sio = web_server.socketio.test_client(web_server.app, flask_test_client=flask_client) + assert sio.is_connected() is False # login overlay can't be bypassed via WS + + +def test_socket_allowed_when_login_authenticated(monkeypatch): + _login_on(monkeypatch) + flask_client = web_server.app.test_client() + with flask_client.session_transaction() as s: + s['login_authenticated'] = True + sio = web_server.socketio.test_client(web_server.app, flask_test_client=flask_client) + assert sio.is_connected() is True diff --git a/tests/test_youtube_track_meta.py b/tests/test_youtube_track_meta.py new file mode 100644 index 00000000..82f9c83d --- /dev/null +++ b/tests/test_youtube_track_meta.py @@ -0,0 +1,94 @@ +"""Seam tests for YouTube playlist artist/title derivation (GitHub #863). + +Flat playlist extraction gives sparse entries; the parser used to take the +artist straight from `uploader`, which on a playlist is the OWNER — so every +track came out as "Wing It" / "Unknown Artist". `derive_artist_and_title` picks +the best available signal instead. These pin the precedence + the +"never use the playlist owner" guarantee. +""" + +from __future__ import annotations + +from core.youtube_track_meta import derive_artist_and_title + + +def test_music_artists_field_wins(): + artist, title = derive_artist_and_title( + {'title': 'Forgiven', 'artists': ['Within Temptation'], 'uploader': 'Wing It'}) + assert artist == 'Within Temptation' + assert title == 'Forgiven' + + +def test_artist_field_used_when_no_artists_list(): + artist, title = derive_artist_and_title( + {'title': 'Alive', 'artist': 'Empire of the Sun', 'uploader': 'Wing It'}) + assert artist == 'Empire of the Sun' + assert title == 'Alive' + + +def test_topic_channel_is_the_artist(): + artist, title = derive_artist_and_title( + {'title': 'Revolte', 'uploader': 'Paul Kalkbrenner - Topic'}) + assert artist == 'Paul Kalkbrenner' + assert title == 'Revolte' + + +def test_artist_title_split_from_title(): + # The exact #863 log case — title carries "Artist - Track", uploader is the + # playlist owner. + artist, title = derive_artist_and_title( + {'title': 'Paul Kalkbrenner - Revolte (Original Mix) [Bpitch]', 'uploader': 'Wing It'}) + assert artist == 'Paul Kalkbrenner' + # Splits on the FIRST separator; the remainder keeps the qualifiers for the + # title cleaner to strip downstream. + assert title == 'Revolte (Original Mix) [Bpitch]' + + +def test_no_signal_returns_empty_artist_not_playlist_owner(): + # The unrecoverable case: plain title, uploader is the owner. Must NOT label + # the track with the owner's channel (#863). + artist, title = derive_artist_and_title( + {'title': 'Forgiven', 'uploader': 'Wing It'}) + assert artist == '' + assert title == 'Forgiven' + + +def test_hyphenated_name_without_spaces_not_split(): + # "Jean-Michel Jarre" has no spaced dash → not an Artist-Title split. + artist, title = derive_artist_and_title({'title': 'Jean-Michel Jarre', 'uploader': 'Wing It'}) + assert artist == '' + assert title == 'Jean-Michel Jarre' + + +def test_en_dash_separator_splits(): + artist, title = derive_artist_and_title({'title': 'Koven – Worlds Apart'}) # en dash + assert artist == 'Koven' + assert title == 'Worlds Apart' + + +def test_topic_beats_title_split_but_cleaner_handles_prefix(): + # Topic channel present AND title repeats "Artist - Title": topic wins for the + # artist; the full title is returned for the downstream cleaner to de-prefix. + artist, title = derive_artist_and_title( + {'title': 'Paul Kalkbrenner - Revolte', 'uploader': 'Paul Kalkbrenner - Topic'}) + assert artist == 'Paul Kalkbrenner' + assert title == 'Paul Kalkbrenner - Revolte' + + +def test_missing_title_is_safe(): + artist, title = derive_artist_and_title({'uploader': 'Wing It'}) + assert artist == '' + assert title == 'Unknown Track' + + +def test_bad_input_is_safe(): + assert derive_artist_and_title(None) == ('', 'Unknown Track') + assert derive_artist_and_title("not a dict") == ('', 'Unknown Track') + + +def test_empty_artists_list_falls_through(): + # An empty/blank artists list must not win — fall through to the title split. + artist, title = derive_artist_and_title( + {'title': 'Koven - Worlds Apart', 'artists': ['', None]}) + assert artist == 'Koven' + assert title == 'Worlds Apart' diff --git a/web_server.py b/web_server.py index 200cea1a..0196fe9b 100644 --- a/web_server.py +++ b/web_server.py @@ -40,7 +40,7 @@ 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.1" +_SOULSYNC_BASE_VERSION = "2.7.2" def _build_version_string(): """Append short commit hash to version when available (e.g. 2.35+abc1234).""" @@ -973,6 +973,10 @@ db_update_state = { "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() @@ -986,21 +990,8 @@ def _set_db_update_automation_id(value): global _db_update_automation_id _db_update_automation_id = value -# Quality Scanner state -quality_scanner_state = { - "status": "idle", # idle, running, finished, error - "phase": "Ready to scan", - "progress": 0, - "processed": 0, - "total": 0, - "quality_met": 0, - "low_quality": 0, - "matched": 0, - "error_message": "", - "results": [], # List of low quality tracks with match status -} -quality_scanner_lock = threading.Lock() -quality_scanner_executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="QualityScanner") +# 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 = { @@ -1249,10 +1240,7 @@ def _register_automation_handlers(): duplicate_cleaner_lock=duplicate_cleaner_lock, duplicate_cleaner_executor=duplicate_cleaner_executor, run_duplicate_cleaner=_run_duplicate_cleaner, - get_quality_scanner_state=lambda: quality_scanner_state, - quality_scanner_lock=quality_scanner_lock, - quality_scanner_executor=quality_scanner_executor, - run_quality_scanner=_run_quality_scanner, + 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, @@ -1855,7 +1843,6 @@ def _shutdown_runtime_components(): for executor, name in [ (stream_executor, "stream executor"), (db_update_executor, "db update executor"), - (quality_scanner_executor, "quality scanner executor"), (duplicate_cleaner_executor, "duplicate cleaner executor"), (sync_executor, "sync executor"), (missing_download_executor, "missing download executor"), @@ -2695,6 +2682,15 @@ def generate_playlist_m3u(): 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('/\\') @@ -2723,43 +2719,41 @@ def generate_playlist_m3u(): s = _re.sub(r'\s*remaster(ed)?.*', '', s) return _re.sub(r'\s+', ' ', s).strip() - # Group tracks by primary artist to minimise DB queries + # 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 - artist_groups = defaultdict(list) - for idx, t in enumerate(tracks): - artist_groups[t.get('artist', '') or ''].append((idx, t)) + 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 artist, group in artist_groups.items(): - if not artist: - for idx, _ in group: - file_path_map[idx] = None + 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 - - 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 + candidates = lib_by_artist.get(_clean(artist)) + if not candidates: + file_path_map[idx] = None continue - - db_entries = [(_norm(t.title), _clean(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, s_clean = _norm(name), _clean(name) - matched = None - for db_n, db_c, db_t in db_entries: - if s_norm == db_n or s_clean == db_c: - matched = db_t - break - if max(SequenceMatcher(None, s_norm, db_n).ratio(), - SequenceMatcher(None, s_clean, db_c).ratio()) >= 0.7: - matched = db_t - break - file_path_map[idx] = matched.file_path if matched else None + 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 @@ -3120,11 +3114,22 @@ def handle_settings(): 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']: + 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) @@ -3807,6 +3812,30 @@ def get_mirrored_playlists_list(): 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.""" @@ -7319,10 +7348,22 @@ def manual_search_for_task(task_id): # 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 - if link: + # 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 @@ -8055,7 +8096,8 @@ def request_incremental_database_update(): with db_update_lock: db_update_state.update({ "status": "running", "phase": "Initializing...", - "progress": 0, "current_item": "", "processed": 0, "total": 0, "error_message": "" + "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) @@ -9316,6 +9358,59 @@ def get_album_tracks(album_id): logger.exception("Error fetching album tracks for album %s", album_id) return jsonify({"error": str(e)}), 500 +@app.route('/api/artist/<artist_id>/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/<artist_id>/download-discography', methods=['POST']) def download_discography(artist_id): """Add selected albums from an artist's discography to the wishlist. @@ -14634,12 +14729,63 @@ def clean_youtube_artist(artist_string): return artist_string +def _youtube_cookie_opts(): + """yt-dlp cookie options matching the rest of the app (Settings → YouTube). + Per-video extraction needs these to get past YouTube's bot checks.""" + opts = {} + try: + cb = config_manager.get('youtube.cookies_browser', '') + if cb: + opts['cookiesfrombrowser'] = (cb,) + except Exception: # noqa: S110 - cookie config is best-effort; resolve still works without it + pass + return opts + + +def _fetch_youtube_video_artist(video_id, cookie_opts): + """Recover a track's artist from its OWN video page (uploader/channel), which + flat playlist extraction no longer returns on current yt-dlp (#863). + + Returns a raw artist string or ''. Per-VIDEO uploader/channel is the track's + channel (the artist / "<Artist> - Topic"), NOT the playlist owner — so unlike + a flat playlist entry it's safe to trust here. ``ignoreerrors`` keeps an + age-gated / unavailable video from raising.""" + from core.youtube_track_meta import derive_artist_and_title + opts = {'quiet': True, 'no_warnings': True, 'skip_download': True, 'ignoreerrors': True} + opts.update(cookie_opts or {}) + try: + with yt_dlp.YoutubeDL(opts) as ydl: + info = ydl.extract_info(f"https://www.youtube.com/watch?v={video_id}", download=False) + except Exception as e: + logger.debug(f"[YT Parse] per-video meta fetch failed for {video_id}: {e}") + return '' + if not info: + return '' + # Reuse the precedence (music fields / Topic / title split); then fall back to + # the per-video uploader/channel, which here IS the artist. + artist, _title = derive_artist_and_title(info) + if artist: + return artist + return (info.get('uploader') or info.get('channel') or '').strip() + + +def _recover_youtube_artist_cleaned(video_id): + """Fetch + clean a YouTube track's artist from its video page (#863). + Used by the async discovery worker for tracks flat extraction left Unknown.""" + raw = _fetch_youtube_video_artist(video_id, _youtube_cookie_opts()) + if not raw: + return '' + cleaned = clean_youtube_artist(raw) + return cleaned if cleaned and cleaned != 'Unknown Artist' else '' + + def parse_youtube_playlist(url): """ Parse a YouTube Music playlist URL and extract track information using yt-dlp Uses flat playlist extraction to avoid rate limits and get all tracks Returns a list of track dictionaries compatible with our Track structure """ + from core.youtube_track_meta import derive_artist_and_title try: # Configure yt-dlp options for flat playlist extraction (avoids rate limits) ydl_opts = { @@ -14649,6 +14795,7 @@ def parse_youtube_playlist(url): 'skip_download': True, # Don't download, just extract IDs and basic info 'lazy_playlist': False, # Force full playlist resolution (prevents ~100 entry cap) } + ydl_opts.update(_youtube_cookie_opts()) tracks = [] @@ -14672,14 +14819,25 @@ def parse_youtube_playlist(url): # Extract basic information from flat extraction raw_title = entry.get('title', 'Unknown Track') - raw_uploader = entry.get('uploader', 'Unknown Artist') + raw_uploader = entry.get('uploader') or entry.get('channel') or '' duration = entry.get('duration', 0) video_id = entry.get('id', '') - - # Clean the track title and artist using our cleaning functions - cleaned_artist = clean_youtube_artist(raw_uploader) - cleaned_title = clean_youtube_track_title(raw_title, cleaned_artist) - + + # Derive the artist from the best available signal — music + # fields, a "- Topic" channel, or an "Artist - Title" split — + # instead of blindly using `uploader`, which on a playlist is the + # OWNER, not the track artist (#863: every track became "Wing It" + # / "Unknown Artist"). Returns ('' , title) when nothing reliable. + derived_artist, derived_title = derive_artist_and_title(entry) + + # Clean the track title and artist using our cleaning functions. + if derived_artist: + cleaned_artist = clean_youtube_artist(derived_artist) + cleaned_title = clean_youtube_track_title(derived_title, cleaned_artist) + else: + cleaned_artist = 'Unknown Artist' + cleaned_title = clean_youtube_track_title(derived_title, None) + # Create track object matching GUI structure track_data = { 'id': video_id, @@ -14687,12 +14845,20 @@ def parse_youtube_playlist(url): 'artists': [cleaned_artist], 'duration_ms': duration * 1000 if duration else 0, 'raw_title': raw_title, # Keep original for reference - 'raw_artist': raw_uploader, # Keep original for reference + 'raw_artist': derived_artist or raw_uploader, # Keep original for reference 'url': f"https://www.youtube.com/watch?v={video_id}" } tracks.append(track_data) - + + # NOTE: current yt-dlp flat extraction returns ONLY the title per entry + # (no uploader/artist), so tracks whose title isn't "Artist - Title" + # land here as "Unknown Artist". The per-video artist recovery does NOT + # run here — it would block this request for minutes on a big playlist + # and risk the 120s worker timeout. It runs in the async discovery + # worker instead (which already iterates every track with a progress + # bar). See run_youtube_discovery_worker / recover_youtube_artist (#863). + # Create playlist object matching GUI structure playlist_data = { 'id': playlist_id, @@ -14703,7 +14869,7 @@ def parse_youtube_playlist(url): 'source': 'youtube', 'image_url': playlist_info.get('thumbnail', '') or '', } - + logger.info(f"Successfully parsed YouTube playlist: {len(tracks)} tracks extracted") return playlist_data @@ -15626,7 +15792,8 @@ def _db_update_progress_callback(current_item, processed, total, percentage): "current_item": current_item, "processed": processed, "total": total, - "progress": percentage + "progress": percentage, + "last_progress_at": time.time(), # heartbeat for the stall watchdog }) _update_automation_progress(_db_update_automation_id, progress=percentage, processed=processed, total=total, @@ -15636,6 +15803,7 @@ def _db_update_phase_callback(phase): logger.info(f"[DB Phase] {phase}") with db_update_lock: db_update_state["phase"] = phase + db_update_state["last_progress_at"] = time.time() # heartbeat for the stall watchdog _update_automation_progress(_db_update_automation_id, phase=phase) def _db_update_artist_callback(artist_name, success, details, album_count, track_count): @@ -15756,6 +15924,44 @@ def _db_update_error_callback(error_message): # Add activity for database update error add_activity_item("", "Database Update Failed", error_message, "Now") + +def _check_db_update_stall(): + """Watchdog: flip a hung 'running' DB-update job to 'error' so the UI can + recover (#859). A worker that blocks indefinitely (media-server call with no + timeout, DB lock) never fires its finished/error callback, so the job would + otherwise sit at 'running' forever with a frozen progress bar. + + Idempotent — only acts on the running→stalled transition (after the flip, + status != 'running' so the pure check returns False and we don't re-fire). + Safe to call from the status endpoint and the 1s broadcast loop. Returns True + only on the transition.""" + from core.database_update_health import ( + DEFAULT_STALL_TIMEOUT_SECONDS, + is_db_update_stalled, + stalled_error_message, + ) + try: + timeout = config_manager.get('database.update_stall_timeout_seconds', + DEFAULT_STALL_TIMEOUT_SECONDS) + except Exception: + timeout = DEFAULT_STALL_TIMEOUT_SECONDS + now = time.time() + with db_update_lock: + if not is_db_update_stalled(db_update_state, now, timeout): + return False + msg = stalled_error_message(db_update_state, now) + db_update_state["status"] = "error" + db_update_state["error_message"] = msg + db_update_state["phase"] = "Stalled" + logger.error(f"[DB Update Watchdog] {msg}") + # The hung worker paused enrichment/maintenance workers and won't resume them + # itself — resume here so a stall doesn't leave them parked indefinitely. + try: + _resume_workers_after_scan() + except Exception as e: + logger.debug(f"[DB Update Watchdog] resume workers failed: {e}") + return True + _workers_paused_by_scan = set() # Track which workers WE paused (don't resume manually-paused ones) def _pause_workers_for_scan(): @@ -16622,7 +16828,10 @@ def start_database_update(): db_update_state.update({ "status": "running", "phase": f"{scan_type}: Initializing...", - "progress": 0, "current_item": "", "processed": 0, "total": 0, "error_message": "" + "progress": 0, "current_item": "", "processed": 0, "total": 0, "error_message": "", + # Seed the heartbeat now so a worker that hangs during init (before the + # first progress/phase callback) is still caught by the stall watchdog. + "last_progress_at": time.time(), }) # Add activity for database update start @@ -16640,6 +16849,7 @@ def start_database_update(): @app.route('/api/database/update/status', methods=['GET']) def get_database_update_status(): """Endpoint to poll for the current update status.""" + _check_db_update_stall() # self-heal a hung job before reporting (#859) with db_update_lock: # Debug: Log current state occasionally if db_update_state["status"] == "running": @@ -17412,28 +17622,7 @@ def _get_quality_tier_from_extension(file_path): return ('unknown', 999) -# Quality scanner worker logic lives in core/discovery/quality_scanner.py. -from core.discovery import quality_scanner as _discovery_quality_scanner - - -def _build_quality_scanner_deps(): - """Build the QualityScannerDeps bundle from web_server.py globals on each call.""" - return _discovery_quality_scanner.QualityScannerDeps( - quality_scanner_state=quality_scanner_state, - quality_scanner_lock=quality_scanner_lock, - QUALITY_TIERS=QUALITY_TIERS, - matching_engine=matching_engine, - automation_engine=automation_engine, - get_quality_tier_from_extension=_get_quality_tier_from_extension, - add_activity_item=add_activity_item, - ) - - -def _run_quality_scanner(scope='watchlist', profile_id=1): - return _discovery_quality_scanner.run_quality_scanner( - scope, profile_id, _build_quality_scanner_deps() - ) - +# (Quality scanning moved to the 'quality_upgrade' library-maintenance repair job.) from core.library.duplicate_cleaner import ( _run_duplicate_cleaner, @@ -17446,55 +17635,6 @@ _init_duplicate_cleaner( engine=automation_engine, ) -@app.route('/api/quality-scanner/start', methods=['POST']) -def start_quality_scan(): - """Start the quality scanner""" - with quality_scanner_lock: - if quality_scanner_state["status"] == "running": - return jsonify({"success": False, "error": "A scan is already in progress"}), 409 - - data = request.get_json() or {} - scope = data.get('scope', 'watchlist') # 'watchlist' or 'all' - - logger.info(f"[Quality Scanner API] Starting scan with scope: {scope}") - - # Reset state - quality_scanner_state["status"] = "running" - quality_scanner_state["phase"] = "Initializing..." - quality_scanner_state["progress"] = 0 - quality_scanner_state["processed"] = 0 - quality_scanner_state["total"] = 0 - quality_scanner_state["quality_met"] = 0 - quality_scanner_state["low_quality"] = 0 - quality_scanner_state["matched"] = 0 - quality_scanner_state["results"] = [] - quality_scanner_state["error_message"] = "" - - # Submit worker (capture profile_id before thread) - scan_profile_id = get_current_profile_id() - quality_scanner_executor.submit(_run_quality_scanner, scope, scan_profile_id) - - add_activity_item("", "Quality Scan Started", f"Scanning {scope} tracks", "Now") - - return jsonify({"success": True, "message": "Quality scan started"}) - -@app.route('/api/quality-scanner/status', methods=['GET']) -def get_quality_scanner_status(): - """Get current quality scanner status""" - with quality_scanner_lock: - return jsonify(quality_scanner_state) - -@app.route('/api/quality-scanner/stop', methods=['POST']) -def stop_quality_scan(): - """Stop the quality scanner (sets a stop flag)""" - with quality_scanner_lock: - if quality_scanner_state["status"] == "running": - quality_scanner_state["status"] = "finished" - quality_scanner_state["phase"] = "Scan stopped by user" - return jsonify({"success": True, "message": "Stop request sent"}) - else: - return jsonify({"success": False, "error": "No scan is currently running"}), 404 - @app.route('/api/duplicate-cleaner/start', methods=['POST']) def start_duplicate_cleaner(): """Start the duplicate cleaner""" @@ -21384,6 +21524,35 @@ def hifi_reorder_instances(): return jsonify({'success': False, 'error': str(e)}), 500 +@app.route('/api/hifi/instances/reset', methods=['POST']) +@admin_only +def hifi_reset_instances(): + """Restore any built-in default HiFi instances that were removed. + + Non-destructive (#Sokhi): keeps user-added instances and the existing + order/enabled state, and only re-adds the provided defaults that are + currently missing — so you can recover ones you removed by accident without + wiping the working instance you just found.""" + try: + from database.music_database import get_database + from core.hifi_client import DEFAULT_INSTANCES + db = get_database() + existing = {(i.get('url') or '').rstrip('/') for i in db.get_all_hifi_instances()} + priority = len(existing) + restored = [] + for url in DEFAULT_INSTANCES: + u = url.rstrip('/') + if u not in existing and db.add_hifi_instance(u, priority): + restored.append(u) + priority += 1 + if download_orchestrator: + download_orchestrator.reload_instances('hifi') + return jsonify({'success': True, 'restored': len(restored), 'urls': restored}) + except Exception as e: + logger.error(f"Error resetting HiFi instances: {e}") + return jsonify({'success': False, 'error': str(e)}), 500 + + @app.route('/api/hifi/instances/list', methods=['GET']) @admin_only def hifi_list_instances(): @@ -24499,6 +24668,7 @@ def _build_youtube_discovery_deps(): build_discovery_wing_it_stub=_build_discovery_wing_it_stub, get_database=get_database, add_activity_item=add_activity_item, + recover_youtube_artist=_recover_youtube_artist_cleaned, ) @@ -25509,6 +25679,15 @@ def create_profile(): from werkzeug.security import generate_password_hash pin_hash = generate_password_hash(pin, method='pbkdf2:sha256') + # No-gaps: while login mode is on, a new member must be born with a login + # password or they could never sign in. + password = (data.get('password') or '').strip() + from core.security.login_provisioning import create_needs_password + if create_needs_password(_require_login_enabled()) and not password: + return jsonify({'success': False, + 'error': 'Login mode is on — give this profile a login ' + 'password so they can sign in.'}), 400 + # Profile settings: home_page, allowed_pages, can_download home_page = data.get('home_page') or None allowed_pages = data.get('allowed_pages') # list or None @@ -25533,6 +25712,9 @@ def create_profile(): if profile_id is None: return jsonify({'success': False, 'error': 'Profile name already exists'}), 409 + if password: + database.set_profile_password(profile_id, password) + return jsonify({'success': True, 'profile_id': profile_id}) except Exception as e: return jsonify({'success': False, 'error': str(e)}), 500 @@ -25947,6 +26129,13 @@ def set_profile_password_endpoint(profile_id): return jsonify({'success': False, 'error': 'Unauthorized'}), 403 data = request.json or {} password = data.get('password', '') + # No-gaps: clearing a password while login mode is on would lock that + # profile out — refuse it (delete the profile instead if that's intended). + from core.security.login_provisioning import removing_password_strands + if not (password or '').strip() and removing_password_strands(_require_login_enabled()): + return jsonify({'success': False, + 'error': "Can't remove this password while login mode is on — " + "that profile couldn't sign in."}), 400 ok = database.set_profile_password(profile_id, password) return jsonify({'success': bool(ok), 'has_password': database.profile_has_password(profile_id)}) except Exception as e: @@ -26639,6 +26828,101 @@ def get_watchlist_artists(): logger.error(f"Error getting watchlist artists: {e}") return jsonify({"success": False, "error": str(e)}), 500 + +@app.route('/api/watchlist/export', methods=['GET']) +def export_watchlist(): + """Export the watchlist roster (name + source IDs, optionally external links) + as json / csv / txt. Returns the content for the export modal to preview + + download (X-Export-Count / X-Export-Ext headers carry the metadata).""" + try: + from core.exports.artist_export import build_artist_export, export_mime_and_ext + fmt = (request.args.get('format', 'json') or 'json').lower() + include_links = request.args.get('links', '') in ('1', 'true', 'yes') + database = get_database() + artists = [{ + 'artist_name': a.artist_name, + 'spotify_artist_id': a.spotify_artist_id, + 'itunes_artist_id': a.itunes_artist_id, + 'deezer_artist_id': getattr(a, 'deezer_artist_id', None), + 'discogs_artist_id': getattr(a, 'discogs_artist_id', None), + 'musicbrainz_artist_id': getattr(a, 'musicbrainz_artist_id', None), + 'amazon_artist_id': getattr(a, 'amazon_artist_id', None), + } for a in database.get_watchlist_artists(profile_id=get_current_profile_id())] + content = build_artist_export(artists, fmt=fmt, include_links=include_links) + mime, ext = export_mime_and_ext(fmt) + return Response(content, mimetype=mime, + headers={'X-Export-Count': str(len(artists)), 'X-Export-Ext': ext}) + except Exception as e: + logger.error(f"Watchlist export failed: {e}") + return jsonify({"success": False, "error": str(e)}), 500 + + +@app.route('/api/library/artists/export', methods=['GET']) +def export_library_artists(): + """Export the WHOLE library artist roster (name + every source id/url we have, + optional external links, optional owned album/track counts) as json/csv/txt.""" + try: + from core.exports.artist_export import build_artist_export, export_mime_and_ext + fmt = (request.args.get('format', 'json') or 'json').lower() + include_links = request.args.get('links', '') in ('1', 'true', 'yes') + include_contents = request.args.get('contents', '') in ('1', 'true', 'yes') + database = get_database() + conn = database._get_connection() + try: + cur = conn.cursor() + cur.execute(""" + SELECT id, name, spotify_artist_id, musicbrainz_id, deezer_id, + discogs_id, itunes_artist_id, tidal_id, qobuz_id, amazon_id, + lastfm_url, genius_url, soul_id + FROM artists ORDER BY name COLLATE NOCASE + """) + cols = [d[0] for d in cur.description] + rows = [dict(zip(cols, r, strict=False)) for r in cur.fetchall()] + + counts = {} + if include_contents: + for table, key in (('albums', 'album_count'), ('tracks', 'track_count')): + try: + for aid, n in cur.execute( + f"SELECT artist_id, COUNT(*) FROM {table} GROUP BY artist_id"): + counts.setdefault(str(aid), {})[key] = n + except Exception: # noqa: S110 — counts are best-effort + pass + finally: + conn.close() + + # Normalize onto the canonical *_artist_id keys the exporter expects. + artists = [] + for r in rows: + c = counts.get(str(r['id']), {}) + artists.append({ + 'name': r['name'], + 'spotify_artist_id': r['spotify_artist_id'], + 'musicbrainz_artist_id': r['musicbrainz_id'], + 'deezer_artist_id': r['deezer_id'], + 'discogs_artist_id': r['discogs_id'], + 'itunes_artist_id': r['itunes_artist_id'], + 'tidal_artist_id': r['tidal_id'], + 'qobuz_artist_id': r['qobuz_id'], + 'amazon_artist_id': r['amazon_id'], + 'lastfm_url': r['lastfm_url'], + 'genius_url': r['genius_url'], + 'soul_id': r['soul_id'], + 'album_count': c.get('album_count'), + 'track_count': c.get('track_count'), + }) + extra = ['lastfm_url', 'genius_url', 'soul_id'] + if include_contents: + extra += ['album_count', 'track_count'] + content = build_artist_export(artists, fmt=fmt, include_links=include_links, extra_fields=extra) + mime, ext = export_mime_and_ext(fmt) + return Response(content, mimetype=mime, + headers={'X-Export-Count': str(len(artists)), 'X-Export-Ext': ext}) + except Exception as e: + logger.error(f"Library artist export failed: {e}") + return jsonify({"success": False, "error": str(e)}), 500 + + @app.route('/api/watchlist/add', methods=['POST']) def add_to_watchlist(): """Add an artist to the watchlist""" @@ -27485,7 +27769,7 @@ def watchlist_artist_config(artist_id): artist_name, image_url, spotify_artist_id, itunes_artist_id, last_scan_timestamp, date_added, include_instrumentals, deezer_artist_id, lookback_days, discogs_artist_id, preferred_metadata_source, - amazon_artist_id, musicbrainz_artist_id + amazon_artist_id, musicbrainz_artist_id, auto_download FROM watchlist_artists WHERE spotify_artist_id = ? OR itunes_artist_id = ? OR deezer_artist_id = ? OR discogs_artist_id = ? OR amazon_artist_id = ? OR musicbrainz_artist_id = ? @@ -27614,6 +27898,8 @@ def watchlist_artist_config(artist_id): 'date_added': result[12], 'lookback_days': result[15] if len(result) > 15 else None, 'preferred_metadata_source': result[17] if len(result) > 17 else None, + # follow-only toggle (default True/auto-download when column absent) + 'auto_download': bool(result[20]) if len(result) > 20 and result[20] is not None else True, } from core.metadata.registry import get_primary_source @@ -27653,6 +27939,9 @@ def watchlist_artist_config(artist_id): # Validate — only accept known sources, empty string means clear override if preferred_metadata_source == '' or preferred_metadata_source not in ('spotify', 'deezer', 'itunes', 'discogs', 'musicbrainz'): preferred_metadata_source = None + # Follow-only toggle: default True so an older client that omits the + # field keeps auto-downloading. + auto_download = bool(data.get('auto_download', True)) # Validate at least one release type is selected if not (include_albums or include_eps or include_singles): @@ -27677,13 +27966,14 @@ def watchlist_artist_config(artist_id): SET include_albums = ?, include_eps = ?, include_singles = ?, include_live = ?, include_remixes = ?, include_acoustic = ?, include_compilations = ?, include_instrumentals = ?, lookback_days = ?, preferred_metadata_source = ?, + auto_download = ?, last_scan_timestamp = CASE WHEN ? THEN NULL ELSE last_scan_timestamp END, updated_at = CURRENT_TIMESTAMP WHERE spotify_artist_id = ? OR itunes_artist_id = ? OR deezer_artist_id = ? OR discogs_artist_id = ? OR musicbrainz_artist_id = ? """, (int(include_albums), int(include_eps), int(include_singles), int(include_live), int(include_remixes), int(include_acoustic), int(include_compilations), - int(include_instrumentals), lookback_days, preferred_metadata_source, lookback_changed, + int(include_instrumentals), lookback_days, preferred_metadata_source, int(auto_download), lookback_changed, artist_id, artist_id, artist_id, artist_id, artist_id)) conn.commit() @@ -27707,6 +27997,7 @@ def watchlist_artist_config(artist_id): 'include_acoustic': include_acoustic, 'include_compilations': include_compilations, 'include_instrumentals': include_instrumentals, + 'auto_download': auto_download, } }) @@ -33480,6 +33771,15 @@ def mirror_playlist_endpoint(): if playlist_id is None: return jsonify({"error": "Failed to mirror playlist"}), 500 + # Membership just changed — if organize-by-playlist, rebuild its folder + # (with prune) so a removed track's symlink is cleaned up now. Gated + + # non-fatal, so it can never break the mirror response. + try: + from core.playlists.materialize_service import rebuild_mirrored_playlist_if_organized + rebuild_mirrored_playlist_if_organized(database, config_manager, playlist_id, profile_id=profile_id) + except Exception as _mat_err: + logger.debug("[Playlist Folder] mirror-time cleanup skipped: %s", _mat_err) + try: if automation_engine: automation_engine.emit('mirrored_playlist_created', { @@ -33500,6 +33800,7 @@ def get_mirrored_playlists_endpoint(): """List all mirrored playlists for the active profile.""" try: from core.playlists.source_refs import describe_mirrored_source_ref + from core.playlists.naming import effective_mirrored_name database = get_database() profile_id = get_current_profile_id() playlists = database.get_mirrored_playlists(profile_id=profile_id) @@ -33518,6 +33819,9 @@ def get_mirrored_playlists_endpoint(): pl['source_ref_kind'] = source_ref.source_ref_kind pl['source_ref_status'] = source_ref.source_ref_status pl['source_ref_error'] = source_ref.source_ref_error + # The name the UI should show / sync uses: custom alias if set, else + # the upstream name. Single source of truth so card + sync agree. + pl['display_name'] = effective_mirrored_name(pl) pl['pipeline_state'] = _snapshot_playlist_pipeline_state(pl['id']) return jsonify(playlists) except Exception as e: @@ -33529,6 +33833,7 @@ def get_mirrored_playlist_endpoint(playlist_id): """Get a mirrored playlist with its tracks.""" try: from core.playlists.source_refs import describe_mirrored_source_ref + from core.playlists.naming import effective_mirrored_name database = get_database() playlist = database.get_mirrored_playlist(playlist_id) if not playlist: @@ -33538,6 +33843,7 @@ def get_mirrored_playlist_endpoint(playlist_id): playlist['source_ref_kind'] = source_ref.source_ref_kind playlist['source_ref_status'] = source_ref.source_ref_status playlist['source_ref_error'] = source_ref.source_ref_error + playlist['display_name'] = effective_mirrored_name(playlist) playlist['pipeline_state'] = _snapshot_playlist_pipeline_state(playlist_id) playlist['tracks'] = database.get_mirrored_playlist_tracks(playlist_id) return jsonify(playlist) @@ -33595,6 +33901,32 @@ def update_mirrored_playlist_source_ref_endpoint(playlist_id): return jsonify({"error": str(e)}), 500 +@app.route('/api/mirrored-playlists/<int:playlist_id>/custom-name', methods=['PATCH']) +def update_mirrored_playlist_custom_name_endpoint(playlist_id): + """Set or clear a user alias (custom display + sync name) for a mirrored + playlist. A blank/missing custom_name CLEARS the alias (falls back to the + upstream name). The upstream name keeps tracking on refresh either way.""" + try: + from core.playlists.naming import effective_mirrored_name + data = request.get_json() or {} + database = get_database() + playlist = database.get_mirrored_playlist(playlist_id) + if not playlist: + return jsonify({"error": "Playlist not found"}), 404 + + # `custom_name` may be '' / null to CLEAR the alias. + ok = database.set_mirrored_playlist_custom_name(playlist_id, data.get('custom_name')) + if not ok: + return jsonify({"error": "Failed to update name"}), 500 + + updated = database.get_mirrored_playlist(playlist_id) or {} + updated['display_name'] = effective_mirrored_name(updated) + return jsonify({"success": True, "playlist": updated}) + except Exception as e: + logger.error(f"Error updating mirrored playlist custom name: {e}") + return jsonify({"error": str(e)}), 500 + + @app.route('/api/mirrored-playlists/<int:playlist_id>/preferences', methods=['PATCH']) def update_mirrored_playlist_preferences_endpoint(playlist_id): """Update per-playlist download preferences (e.g. organize by playlist folder).""" @@ -36922,12 +37254,6 @@ def _emit_tool_progress_loop(): # (which skipped HTTP polling while the socket was up) never learned # its stream was ready. Each client polls /api/stream/status instead, # which resolves its own session from the cookie. - # Quality Scanner - try: - with quality_scanner_lock: - socketio.emit('tool:quality-scanner', dict(quality_scanner_state)) - except Exception as e: - logger.debug(f"Error emitting quality scanner status: {e}") # Duplicate Cleaner (add computed space_freed_mb) try: with duplicate_cleaner_lock: @@ -36938,6 +37264,7 @@ def _emit_tool_progress_loop(): logger.debug(f"Error emitting duplicate cleaner status: {e}") # DB Update try: + _check_db_update_stall() # self-heal a hung job, then broadcast (#859) with db_update_lock: socketio.emit('tool:db-update', dict(db_update_state)) except Exception as e: diff --git a/webui/index.html b/webui/index.html index 58a9e3a7..af40adfa 100644 --- a/webui/index.html +++ b/webui/index.html @@ -131,6 +131,7 @@ <span class="profile-color-swatch" data-color="#14b8a6" style="background:#14b8a6" title="Teal"></span> </div> <input type="password" id="new-profile-pin" class="profile-input" placeholder="PIN (optional)" maxlength="6"> + <input type="password" id="new-profile-password" class="profile-input" placeholder="Login password (required when login mode is on)" autocomplete="new-password"> <div class="profile-settings-section"> <label class="profile-settings-label">Home Page</label> <select id="new-profile-home-page" class="profile-input"> @@ -2088,6 +2089,7 @@ <button class="discog-filter" data-filter="matched" onclick="_serverEditorFilter(this, 'matched')">Matched</button> <button class="discog-filter" data-filter="missing" onclick="_serverEditorFilter(this, 'missing')">Missing</button> <button class="discog-filter" data-filter="extra" onclick="_serverEditorFilter(this, 'extra')">Extra</button> + <button class="discog-filter server-editor-export" id="server-editor-export-btn" style="margin-left:auto" onclick="exportServerPlaylistM3U()" title="Export this server playlist as an M3U file (for Music Assistant etc.)">📋 Export M3U</button> </div> <div class="server-compare-columns"> <div class="server-compare-col source" id="server-col-source"> @@ -2487,6 +2489,10 @@ <span class="stat-label">Artists</span> </span> </div> + <button class="library-watchlist-all-btn library-export-btn" id="library-export-btn" onclick="openArtistExportModal()" title="Export artists — pick watchlist or whole library, as JSON / CSV / text"> + <span class="watchlist-all-icon">⬇</span> + <span class="watchlist-all-text">Export</span> + </button> </div> <!-- Search and Filters --> @@ -4544,6 +4550,32 @@ </div> </div> + <div class="form-group"> + <label>Playlists Folder:</label> + <div class="path-input-group"> + <input type="text" id="playlists-materialize-path" placeholder="./Playlists" readonly> + <button class="browse-button locked" onclick="togglePathLock('playlists-materialize', this)">Unlock</button> + </div> + <div class="setting-help-text" style="margin-top: 6px;"> + Where <strong>Organize by playlist</strong> builds playlist folders — as shortcuts into your library, so songs are never stored twice. Keep this <strong>outside</strong> your music library folder so your media server doesn't scan the same tracks twice. + </div> + </div> + + <div class="form-group"> + <label>Playlist Folder Style:</label> + <select id="playlists-materialize-mode"> + <option value="symlink">Symlinks — no extra disk, point to your library</option> + <option value="copy">Copies — real duplicates (USB sticks / players that can't follow links)</option> + </select> + <div class="setting-help-text" style="margin-top: 6px;"> + Symlinks use almost no space. Copies are self-contained. Symlinks fall back to copies automatically where the filesystem can't link (Windows without privileges, some network shares, FAT/exFAT drives). + </div> + <button class="test-button" id="playlists-rebuild-btn" onclick="rebuildPlaylistFolders()" style="margin-top: 10px;"> + Rebuild playlist folders now + </button> + <div class="setting-help-text" id="playlists-rebuild-status" style="margin-top: 6px;"></div> + </div> + <div class="form-group"> <label>M3U Entry Base Path:</label> <div class="path-input-group"> @@ -4844,7 +4876,10 @@ <input type="url" id="hifi-new-instance" class="form-input" placeholder="https://example.com" style="flex:1;"> <button class="test-button" onclick="addHiFiInstance()">Add</button> </div> - <button class="test-button" onclick="checkHiFiInstances()" id="hifi-instances-check-btn" style="margin-bottom: 8px;">Check All Instances</button> + <div style="display:flex;gap:8px;margin-bottom:8px;flex-wrap:wrap;"> + <button class="test-button" onclick="checkHiFiInstances()" id="hifi-instances-check-btn">Check All Instances</button> + <button class="test-button" onclick="restoreDefaultHiFiInstances()" id="hifi-instances-restore-btn" title="Re-add any of the built-in default instances you've removed (keeps the ones you added)">Restore Defaults</button> + </div> <div id="hifi-instances-status-panel" style="display: none;"></div> </div> </div> @@ -5365,9 +5400,10 @@ <option value="qbittorrent">qBittorrent</option> <option value="transmission">Transmission</option> <option value="deluge">Deluge 2.x</option> + <option value="aria2">Aria2 (RPC)</option> </select> <div class="setting-help-text" id="torrent-client-help"> - qBittorrent: WebUI port (default 8080). Transmission: RPC port (default 9091). Deluge: WebUI port (default 8112). + qBittorrent: WebUI port (default 8080). Transmission: RPC port (default 9091). Deluge: WebUI port (default 8112). Aria2: RPC port (default 6800) — leave Username blank and put your <code>--rpc-secret</code> in the Password field. </div> </div> <div class="form-group"> @@ -5399,6 +5435,13 @@ Override where the torrent client writes downloads. This path is on the <strong>torrent client's</strong> machine, not SoulSync's. </div> </div> + <div class="form-group"> + <label>Completed Downloads Path — in SoulSync (optional):</label> + <input type="text" id="torrent-download-path" placeholder="leave blank for the shared-volume default"> + <div class="setting-help-text"> + Where <strong>SoulSync</strong> can read finished torrents — its own in-container path. Set this when your client saves to a category folder (e.g. a "Music" category at <code>/data/downloads/music</code>) that's mounted here at a different path. SoulSync finds the release by name under this folder. Blank = use the Soulseek download/transfer folders. + </div> + </div> <div class="form-group"> <label>Stalled torrent timeout (minutes):</label> <input type="number" id="torrent-stall-timeout" min="0" step="1" placeholder="10"> @@ -5479,6 +5522,13 @@ SoulSync tags every NZB with this category so it ends up in a predictable post-processing folder. </div> </div> + <div class="form-group"> + <label>Completed Downloads Path — in SoulSync (optional):</label> + <input type="text" id="usenet-download-path" placeholder="leave blank for the shared-volume default"> + <div class="setting-help-text"> + Where <strong>SoulSync</strong> can read finished NZB downloads — its own in-container path. Set this when SABnzbd/NZBGet writes that category to a folder (e.g. <code>/data/downloads/music</code>) mounted here at a different path. SoulSync finds the release by name under this folder. Blank = use the Soulseek download/transfer folders. + </div> + </div> <div class="form-group"> <label>Status:</label> <div class="form-actions" style="margin-top: 4px;"> @@ -5526,7 +5576,12 @@ <small class="settings-hint">Variables: $albumartist, $artist, $artistletter, $title, $track (01), $album, $albumtype, $year, $quality (filename only). Use ${var} to append text: ${albumtype}s → Singles</small> </div> - <div class="form-group"> + <!-- Playlist Path Template: hidden from the UI. Playlists now import + normally into Artist/Album and the Playlists/ folder is a symlink + view that mirrors the real library filename, so this template no + longer drives playlist-file naming. Kept in the DOM (display:none) + so settings load/save still round-trips the stored value. --> + <div class="form-group" style="display: none;"> <label>Playlist Path Template:</label> <input type="text" id="template-playlist-path" placeholder="$playlist/$artist - $title"> @@ -6776,49 +6831,6 @@ </div> </div> - <div class="tool-card" id="quality-scanner-card"> - <div class="tool-card-header"> - <h4 class="tool-card-title">Quality Scanner</h4> - <button class="tool-help-button" data-tool="quality-scanner" - title="Learn more about this tool">?</button> - </div> - <p class="tool-card-info">Scan library for tracks below quality preferences</p> - <div class="tool-card-stats"> - <div class="stat-item"> - <span class="stat-item-label">Processed:</span> - <span class="stat-item-value" id="quality-stat-processed">0</span> - </div> - <div class="stat-item"> - <span class="stat-item-label">Quality Met:</span> - <span class="stat-item-value" id="quality-stat-met">0</span> - </div> - <div class="stat-item"> - <span class="stat-item-label">Low Quality:</span> - <span class="stat-item-value" id="quality-stat-low">0</span> - </div> - <div class="stat-item"> - <span class="stat-item-label">Matched:</span> - <span class="stat-item-value" id="quality-stat-matched">0</span> - </div> - </div> - <div class="tool-card-controls"> - <select id="quality-scan-scope"> - <option value="watchlist">Watchlist Artists Only</option> - <option value="all">All Library Tracks</option> - </select> - <button id="quality-scan-button">Scan Library</button> - </div> - <div class="tool-card-progress-section"> - <p class="progress-phase-label" id="quality-phase-label">Ready to scan</p> - <div class="progress-bar-container"> - <div class="progress-bar-fill" id="quality-progress-bar" style="width: 0%;"> - </div> - </div> - <p class="progress-details-label" id="quality-progress-label">0 / 0 tracks scanned - (0.0%)</p> - </div> - </div> - <div class="tool-card" id="reconcile-ids-card"> <div class="tool-card-header"> <h4 class="tool-card-title">Import IDs from File Tags</h4> @@ -7705,6 +7717,24 @@ <div class="watchlist-artist-config-content"> <div class="watchlist-artist-config-body"> + <div class="config-section"> + <h3 class="config-section-title">Auto-Download</h3> + <p class="config-section-subtitle">When on, new releases are added to the wishlist and downloaded automatically. Turn off to <strong>follow only</strong> — still see new releases in scans, but pick what to download yourself.</p> + + <div class="config-options"> + <label class="config-option"> + <input type="checkbox" id="config-auto-download" checked> + <div class="config-option-content"> + <div class="config-option-icon">⬇️</div> + <div class="config-option-text"> + <span class="config-option-title">Auto-download new releases</span> + <span class="config-option-description">Off = follow only (discover but don't auto-add to wishlist)</span> + </div> + </div> + </label> + </div> + </div> + <div class="config-section"> <h3 class="config-section-title">Download Preferences</h3> <p class="config-section-subtitle">Select which types of releases to monitor for this artist</p> diff --git a/webui/static/api-monitor.js b/webui/static/api-monitor.js index 07641bd8..36e09242 100644 --- a/webui/static/api-monitor.js +++ b/webui/static/api-monitor.js @@ -2498,6 +2498,9 @@ async function openWatchlistArtistConfigModal(artistId, artistName) { } // Set checkbox states + // Default to true (auto-download) when absent (older configs). + const _autoDlToggle = document.getElementById('config-auto-download'); + if (_autoDlToggle) _autoDlToggle.checked = config.auto_download !== false; document.getElementById('config-include-albums').checked = config.include_albums; document.getElementById('config-include-eps').checked = config.include_eps; document.getElementById('config-include-singles').checked = config.include_singles; @@ -2985,6 +2988,8 @@ async function saveWatchlistArtistConfig(artistId) { const includeAcoustic = document.getElementById('config-include-acoustic').checked; const includeCompilations = document.getElementById('config-include-compilations').checked; const includeInstrumentals = document.getElementById('config-include-instrumentals').checked; + const autoDownloadEl = document.getElementById('config-auto-download'); + const autoDownload = autoDownloadEl ? autoDownloadEl.checked : true; const lookbackDaysVal = document.getElementById('config-lookback-days').value; const lookbackDays = lookbackDaysVal !== '' ? parseInt(lookbackDaysVal) : null; const activeSourceBtn = document.querySelector('#config-metadata-source-selector .config-msrc-btn.active'); @@ -3016,6 +3021,7 @@ async function saveWatchlistArtistConfig(artistId) { include_acoustic: includeAcoustic, include_compilations: includeCompilations, include_instrumentals: includeInstrumentals, + auto_download: autoDownload, lookback_days: lookbackDays, preferred_metadata_source: preferredMetadataSource, }) diff --git a/webui/static/auto-sync.js b/webui/static/auto-sync.js index fd243ed1..8a91d3ad 100644 --- a/webui/static/auto-sync.js +++ b/webui/static/auto-sync.js @@ -2014,6 +2014,39 @@ async function parseMirroredPipelineResponse(res, fallbackMessage) { return data; } +async function editMirroredCustomName(playlistId, originalName, currentCustom) { + // Custom alias: changes the name shown in SoulSync AND used when syncing to + // the media server, while staying tied to the original upstream playlist. + // Blank clears the alias (falls back to the original name). + const nextName = window.prompt( + `Rename "${originalName}"\n\nThis name is shown here and used when syncing. ` + + `Leave blank to use the original name.`, + currentCustom || '' + ); + if (nextName === null) return; // cancelled + const trimmed = nextName.trim(); + try { + const res = await fetch(`/api/mirrored-playlists/${playlistId}/custom-name`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ custom_name: trimmed }) + }); + const data = await res.json(); + if (!res.ok || data.error) { + throw new Error(data.error || 'Failed to update name'); + } + showToast(trimmed ? `Renamed to "${trimmed}"` : `Reverted to "${originalName}"`, 'success'); + loadMirroredPlaylists(); + const openModal = document.getElementById('mirrored-track-modal'); + if (openModal) { + closeMirroredModal(); + openMirroredPlaylistModal(playlistId); + } + } catch (err) { + showToast(`Error: ${err.message}`, 'error'); + } +} + async function editMirroredSourceRef(playlistId, name, source, currentRef) { const label = (source === 'spotify_public' || source === 'youtube') ? 'original playlist URL' diff --git a/webui/static/core.js b/webui/static/core.js index ca175627..a6b0f0f9 100644 --- a/webui/static/core.js +++ b/webui/static/core.js @@ -29,7 +29,6 @@ let isSortReversed = false; let searchAbortController = null; let dbStatsInterval = null; let dbUpdateStatusInterval = null; -let qualityScannerStatusInterval = null; let duplicateCleanerStatusInterval = null; let wishlistCountInterval = null; let wishlistCountdownInterval = null; // Countdown timer for wishlist overview modal @@ -487,7 +486,6 @@ function initializeWebSocket() { // 'tool:stream' is intentionally NOT wired: stream state is per-listener // (session cookie), so the global broadcast could only carry the DEFAULT // session's eternal "stopped" — the player polls /api/stream/status instead. - socket.on('tool:quality-scanner', (data) => { if (_qaToolBusy(data)) qaSignal('tools'); updateQualityScanProgressFromData(data); }); socket.on('tool:duplicate-cleaner', (data) => { if (_qaToolBusy(data)) qaSignal('tools'); updateDuplicateCleanProgressFromData(data); }); socket.on('tool:db-update', (data) => { if (_qaToolBusy(data)) qaSignal('tools'); updateDbProgressFromData(data); }); socket.on('tool:metadata', (data) => { if (_qaToolBusy(data)) qaSignal('tools'); updateMetadataStatusFromData(data); }); diff --git a/webui/static/enrichment.js b/webui/static/enrichment.js index 3ec070e0..3475b092 100644 --- a/webui/static/enrichment.js +++ b/webui/static/enrichment.js @@ -2735,6 +2735,7 @@ async function loadRepairFindings() { path_mismatch: 'Path Mismatch', metadata_gap: 'Missing Metadata', missing_cover_art: 'Missing Art', track_number_mismatch: 'Track Number', missing_lyrics: 'Missing Lyrics', expired_download: 'Expired', + missing_replaygain: 'No ReplayGain', empty_folder: 'Empty Folder', missing_lossy_copy: 'No Lossy Copy', library_retag: 'Re-tag' }; @@ -2745,6 +2746,8 @@ async function loadRepairFindings() { track_number_mismatch: 'Fix', missing_cover_art: 'Apply Art', missing_lyrics: 'Apply Lyrics', + missing_replaygain: 'Apply RG', + empty_folder: 'Delete Folder', expired_download: 'Delete', metadata_gap: 'Apply', duplicate_tracks: 'Keep Best', @@ -3138,6 +3141,16 @@ function _renderFindingDetail(f) { if (d.album_title) rows.push(['Album', d.album_title]); return _gridRows(rows); + case 'missing_replaygain': + if (d.track_title) rows.push(['Track', d.track_title]); + if (d.artist) rows.push(['Artist', d.artist]); + return _gridRows(rows); + + case 'empty_folder': + if (d.folder_path) rows.push(['Folder', d.folder_path, 'path']); + if (d.junk_files && d.junk_files.length) rows.push(['Junk files', d.junk_files.join(', ')]); + return _gridRows(rows); + case 'expired_download': if (d.title) rows.push(['Track', d.title]); if (d.artist) rows.push(['Artist', d.artist]); diff --git a/webui/static/helper.js b/webui/static/helper.js index 8e9b96c4..b1168b2d 100644 --- a/webui/static/helper.js +++ b/webui/static/helper.js @@ -328,16 +328,6 @@ const HELPER_CONTENT = { ], docsId: 'dashboard' }, - '#quality-scanner-card': { - title: 'Quality Scanner', - description: 'Analyzes audio files for quality integrity. Calculates bitrate density to detect transcodes (e.g., an MP3 re-encoded as FLAC). Scope options: Full Library, New Only, or Single Artist.', - tips: [ - '"Quality Met" = file quality matches its format claims', - '"Low Quality" = suspicious file flagged for review', - 'Matched count shows tracks with verified metadata' - ], - docsId: 'dashboard' - }, '#duplicate-cleaner-card': { title: 'Duplicate Cleaner', description: 'Scans your library for duplicate tracks by comparing title, artist, album, and file characteristics. Reviews duplicates before taking any action.', @@ -2358,7 +2348,6 @@ const HELPER_TOURS = { // Tools — in page order { page: 'dashboard', selector: '#db-updater-card', title: 'Database Updater', description: 'Syncs your media server\'s library into SoulSync\'s database. Three modes: Incremental (fast, new content only), Full Refresh (rebuilds everything), Deep Scan (finds and removes stale entries).' }, { page: 'dashboard', selector: '#metadata-updater-card', title: 'Metadata Enrichment', description: 'Background workers that enrich your library from 9 services — Spotify, MusicBrainz, Deezer, Last.fm, iTunes, AudioDB, Genius, Tidal, Qobuz. Runs automatically at the configured interval.' }, - { page: 'dashboard', selector: '#quality-scanner-card', title: 'Quality Scanner', description: 'Analyzes audio files for quality integrity. Calculates bitrate density to detect transcodes (e.g., an MP3 re-encoded as FLAC). Scan by Full Library, New Only, or Single Artist.' }, { page: 'dashboard', selector: '#duplicate-cleaner-card', title: 'Duplicate Cleaner', description: 'Finds and removes duplicate tracks by comparing title, artist, album, and audio characteristics. Always reviews before deleting.' }, { page: 'dashboard', selector: '#discovery-pool-card', title: 'Discovery Pool', description: 'Tracks from similar artists found during watchlist scans. Matched tracks feed the Discover page playlists and genre browser. Fix failed matches manually.' }, { page: 'dashboard', selector: '#retag-tool-card', title: 'Retag Tool', description: 'Queue of tracks needing metadata corrections. When enrichment detects better tags than what\'s in your files, they appear here for batch review.' }, @@ -3386,7 +3375,7 @@ function _guessPageFromSelector(selector) { 'import': ['import-page-'], 'settings': ['settings-', 'stg-tab', 'api-service', 'server-toggle', 'save-button', 'spotify-client', 'soulseek-url', 'quality-profile'], 'issues': ['issues-'], - 'dashboard': ['dashboard-', 'service-card', 'watchlist-button', 'wishlist-button', 'db-updater', 'metadata-updater', 'quality-scanner', 'duplicate-cleaner', 'discovery-pool-card', 'retag-tool', 'media-scan', 'backup-manager', 'metadata-cache'], + 'dashboard': ['dashboard-', 'service-card', 'watchlist-button', 'wishlist-button', 'db-updater', 'metadata-updater', 'duplicate-cleaner', 'discovery-pool-card', 'retag-tool', 'media-scan', 'backup-manager', 'metadata-cache'], }; const selectorLower = selector.toLowerCase(); @@ -3415,16 +3404,21 @@ function closeHelperSearch() { const WHATS_NEW = { // Convention: keep only the CURRENT release here, plus a single brief // "Earlier versions" summary entry. Don't accumulate old per-version blocks. - '2.7.1': [ - { date: 'June 2026 — 2.7.1 release' }, - { title: 'Download verification & review', desc: 'every download is fingerprint-checked (acoustid) against what you actually asked for, and the result sticks — a verified / unverified badge on the Downloads completed list, persisted to the db, a SOULSYNC_VERIFICATION file tag, and history. import + scan now share one verification core so they stop disagreeing about the same file.', page: 'active-downloads' }, - { title: 'Unverified review queue', desc: 'questionable downloads land in a review queue where you can listen, compare, approve, or delete them — with visible retry progress. approve/delete are admin-only.', page: 'active-downloads' }, - { title: 'Security: login bypass closed (#852)', desc: 'hiding the login/PIN overlay (safari "hide distracting items", devtools) could still stream live data over the websocket — the socket connection now enforces the same login/PIN gate the rest of the app does. every other surface was audited too; the socket was the one hole.', page: 'settings' }, - { title: 'AcoustID "Relocate" fix action (#704)', desc: 'when an acoustid scan flags a wrong song, retag alone left the file in the wrong artist/album folder. the new Relocate option retags it and moves it to staging so auto-import re-files it under the correct artist/album.', page: 'tools' }, - { title: 'Faster artist pages (#853)', desc: 'deezer / itunes / discogs now cache the artist→album list like spotify already did, so reopening an artist stops refetching the entire discography. thanks ramonskie!', page: 'library' }, - { title: 'Fixes', desc: 'listenbrainz weekly playlists can no longer wedge in an unrecoverable sync state — cancel now clears it so you can re-sync (#702); magnets stuck "downloading metadata" actually hit the stall timeout now, and dead torrents are cleaned out of qbittorrent instead of orphaned + re-grabbed as duplicates; a "/" or ":" in a title matches sources that use "_" (#851); "&" is available as an artist tag separator (#840); search auto-selects spotify when "spotify (no auth)" is the active source.', page: 'active-downloads' }, - { title: 'Contributor fixes', desc: 'opt-in import-folder artist override (#845), discogs master/release ID collision fetching the wrong album (#848), and the cover-art cache no longer hard-fails on first load (#850). thanks nick2000713 + RollingBase!' }, - { title: 'Earlier versions', desc: '2.7.0 brought multi-user for real — per-profile streaming accounts (My Accounts), auto-sync running as its owner, opt-in username/password login with recovery, reverse-proxy support, and admin quick-switch for active sources. before that, the 2.6.x cycle brought the blocklist, the download-retry overhaul, Download Origins, server-side launch-PIN enforcement, Spotify-no-auth metadata, Library Re-tag, and a large pile of import / library / watchlist fixes.' }, + '2.7.2': [ + { date: 'June 2026 — 2.7.2 release' }, + { title: 'Organize playlists into folders', desc: 'optionally mirror each playlist into its own folder on disk — symlink (no extra space) or copy — so external players (plex / jellyfin / music assistant) see your soulsync playlists as real folders. rebuilds itself after every sync, prunes removed tracks, and there\'s a manual rebuild button in settings.', page: 'settings' }, + { title: 'Export server playlists as M3U', desc: 'one-click "Export M3U" button in the Server Playlists compare/editor toolbar — writes a standard .m3u of the playlist\'s tracks and downloads it to your browser. handy for music assistant and friends.', page: 'sync' }, + { title: 'Follow-only watchlist', desc: 'each watchlist artist now has an "auto-download" toggle (on by default). turn it off to just follow an artist — scans still discover and surface new releases, they just don\'t get auto-added to the wishlist.', page: 'artists' }, + { title: 'Download from a SoundCloud link (#865)', desc: 'paste a soundcloud track url — including unlisted / private share links — into manual search and it resolves + downloads directly.', page: 'downloads' }, + { title: 'YouTube playlists keep the artist (#863)', desc: 'youtube / youtube-music playlists that used to import as "Unknown Artist" now recover the real artist from the track\'s music metadata, the "Artist - Title" pattern, or the uploading channel — and fall back to the matched artist when youtube gives nothing.', page: 'sync' }, + { title: 'Rename mirrored playlists', desc: 'a mirrored playlist now has a rename (✏️) button — set a custom name that changes how it shows in soulsync and how it syncs, survives upstream refreshes, and still tracks the same server playlist.', page: 'sync' }, + { title: 'New maintenance jobs', desc: 'ReplayGain Filler (#437) computes + writes missing replaygain tags across your library, and Empty Folder Cleaner sweeps out leftover empty directories. both under Tools → library maintenance.', page: 'tools' }, + { title: 'Export your watchlist + library', desc: 'one Export button with a scope selector dumps your watchlist roster and/or whole library roster to JSON / CSV / text.', page: 'artists' }, + { title: 'Custom completed-download path for Torrent/Usenet (#857)', desc: 'point soulsync at the in-container folder your torrent/usenet client drops finished files into (e.g. a category subfolder), so completed grabs are found instead of going missing.', page: 'settings' }, + { title: 'HiFi instances: restore + new working instance', desc: 'a "Restore Defaults" button re-adds any built-in instances you removed (keeps your own), the ✔/✖ controls have bigger tap targets, and a freshly-confirmed working instance is auto-pushed to everyone (thanks Sokhi).', page: 'settings' }, + { title: 'Artist DB Record inspector', desc: 'an artist\'s detail page can now show the raw "DB Record" — everything the database knows about that artist — for debugging metadata.', page: 'library' }, + { title: 'Fixes', desc: 'a hung database update self-heals now instead of wedging on "Starting..." forever (#859); Library Reorganize finally works on media-server libraries by falling back to tag mode (#862); spotify (no-auth) shows as connected and the dashboard test reports it correctly instead of claiming deezer; navidrome reconnects itself instead of latching disconnected; the orphan detector hard-bails on a mass-orphan flood; plus more #852 lock-screen hardening and login-password management in Manage Profiles.', page: 'settings' }, + { title: 'Earlier versions', desc: '2.7.1 added download verification & a review queue (acoustid fingerprint-checks every download), closed the websocket login-bypass (#852), and the acoustid Relocate fix. 2.7.0 brought multi-user for real — per-profile streaming accounts, opt-in login, reverse-proxy support. before that the 2.6.x cycle brought the blocklist, the download-retry overhaul, Download Origins, Spotify-no-auth metadata, and Library Re-tag.' }, ], }; @@ -3455,38 +3449,58 @@ const WHATS_NEW = { // usage_note?: 'optional hint shown at the bottom' } const VERSION_MODAL_SECTIONS = [ { - title: "Download verification & review", - description: "every download is fingerprint-checked (acoustid) against what you actually asked for, and the result sticks. import + scan now share one verification core, so the two stop disagreeing about the same file.", + title: "Organize playlists into folders", + description: "soulsync can now mirror each of your playlists into its own folder on disk, so external players (plex / jellyfin / music assistant) see them as real folders instead of just living inside soulsync.", features: [ - "verified / unverified badge on the Downloads completed list", - "status persisted to the db, a SOULSYNC_VERIFICATION file tag, and history", - "Unverified review queue: listen, compare, approve, or delete questionable downloads (approve/delete are admin-only)", - "visible retry progress while a questionable download re-fetches", + "symlink (no extra disk space) or copy — your choice", + "rebuilds itself after every sync and prunes tracks you removed", + "separate output root + a manual rebuild button in settings", ], - usage_note: "Downloads page → the Unverified filter", + usage_note: "Settings → Playlists → organize into folders", }, { - title: "Security fix — login bypass closed (#852)", - description: "hiding the login/PIN overlay (safari \"hide distracting items\", devtools, curl) could still stream live data over the websocket, because the gate was http-only. the socket connection now enforces the same login/PIN check the rest of the app does — every other surface was audited too; the socket was the one hole. covers both the launch PIN and the native login.", + title: "Better YouTube & SoundCloud imports", + description: "the two weak spots in playlist/link importing got fixed.", + features: [ + "#863 — youtube / youtube-music playlists that imported as \"Unknown Artist\" now recover the real artist from music metadata, the \"Artist - Title\" pattern, or the uploading channel (and fall back to the matched artist)", + "#865 — paste a soundcloud track link, including unlisted / private share urls, into manual search to download it directly", + ], + }, + { + title: "Export server playlists as M3U", + description: "a one-click \"Export M3U\" button now sits in the Server Playlists compare/editor toolbar — writes a standard .m3u of the playlist and downloads it to your browser. great for music assistant.", features: [], + usage_note: "Sync → Server Playlists → Export M3U", + }, + { + title: "Follow-only watchlist + more", + description: "a grab-bag of requested features.", + features: [ + "per-artist \"auto-download\" toggle: turn it off to just follow an artist — scans still surface new releases, they just don't auto-add to the wishlist", + "rename mirrored playlists (✏️): a custom name that changes the display + sync name, survives refreshes, still tracks the server playlist", + "export your watchlist and/or whole library roster to JSON / CSV / text", + "ReplayGain Filler (#437) and Empty Folder Cleaner library-maintenance jobs", + "custom in-container completed-download path for Torrent / Usenet sources (#857)", + "HiFi instances: Restore Defaults button, bigger tap targets, and a new confirmed-working instance pushed to everyone", + "Artist detail \"DB Record\" inspector for debugging metadata", + ], }, { title: "Fixes this release", - description: "a stack of issue fixes on top of 2.7.0.", + description: "a stack of issue fixes on top of 2.7.1.", features: [ - "#704 — new acoustid Relocate action: retags a wrong song AND moves it to staging so auto-import re-files it under the correct artist/album", - "#702 — listenbrainz weekly playlists can no longer wedge in an unrecoverable sync state; cancel clears it so you can re-sync", - "torrents stuck \"downloading metadata\" actually hit the stall timeout now, and dead torrents are cleaned out of qbittorrent instead of orphaned + re-grabbed as duplicates", - "#853 — artist pages load way faster on reopen: deezer / itunes / discogs now cache the artist→album list instead of refetching the whole discography (thanks ramonskie!)", - "#851 — a \"/\" or \":\" in a title now matches sources that use \"_\"", - "#840 — \"&\" is available as an artist tag separator (musicbrainz/picard style)", - "search auto-selects spotify when \"spotify (no auth)\" is the active metadata source", - "#845 / #848 / #850 — opt-in import-folder artist override, discogs wrong-album collision, cover-art cache first-load failure (thanks nick2000713 + RollingBase!)", + "#859 — a hung database update self-heals instead of wedging on \"Starting...\" forever", + "#862 — Library Reorganize now works on media-server libraries (falls back to tag mode when there are no source IDs)", + "spotify (no-auth) shows as connected and the dashboard test reports it correctly, instead of claiming a deezer fallback", + "navidrome reconnects itself instead of latching \"disconnected\"", + "the orphan detector hard-bails on a mass-orphan flood instead of plowing ahead", + "more #852 lock-screen hardening + login-password management in Manage Profiles", + "Aria2 added to the torrent client list", ], }, { - title: "Earlier in 2.7.0", - description: "the release just before this one made multi-user real: per-profile streaming accounts (My Accounts — connect your own spotify / tidal / listenbrainz), auto-sync running as its owner profile, an opt-in username/password login with forgot-password recovery, reverse-proxy + auth-proxy support, admin quick-switch for active sources, and a round of fixes (#835–#843). before that, the 2.6.x cycle brought the blocklist, the download-retry overhaul, Download Origins, server-side launch-PIN enforcement, Spotify-no-auth metadata, and Library Re-tag.", + title: "Earlier in 2.7.1 / 2.7.0", + description: "2.7.1 added download verification & an unverified review queue (acoustid fingerprint-checks every download against what you asked for), closed the websocket login-bypass (#852), and added the acoustid Relocate fix action. 2.7.0 made multi-user real: per-profile streaming accounts (My Accounts), auto-sync running as its owner, opt-in username/password login with recovery, and reverse-proxy support. before that, the 2.6.x cycle brought the blocklist, the download-retry overhaul, Download Origins, Spotify-no-auth metadata, and Library Re-tag.", features: [], }, ]; diff --git a/webui/static/init.js b/webui/static/init.js index e6ea4816..d339cb18 100644 --- a/webui/static/init.js +++ b/webui/static/init.js @@ -404,6 +404,10 @@ async function initProfileSystem() { function showLoginScreen() { const overlay = document.getElementById('login-overlay'); if (!overlay) return; + // Hide the entire app while locked, so removing the overlay (Safari "Hide + // Distracting Items", devtools) reveals nothing — not even the empty chrome. + // initApp() reveals it again on a successful sign-in (#852). + document.body.classList.add('app-locked'); overlay.style.display = 'flex'; const u = document.getElementById('login-username'); if (u) setTimeout(() => u.focus(), 50); @@ -507,6 +511,8 @@ async function submitRecoveryReset() { function showLaunchPinScreen() { const overlay = document.getElementById('launch-pin-overlay'); if (!overlay) return; + // Hide the whole app while locked — bypassing the overlay reveals nothing (#852). + document.body.classList.add('app-locked'); overlay.style.display = 'flex'; const input = document.getElementById('launch-pin-input'); @@ -1788,6 +1794,7 @@ function initProfileManagement() { const name = document.getElementById('new-profile-name').value.trim(); const avatarUrl = document.getElementById('new-profile-avatar-url').value.trim(); const pin = document.getElementById('new-profile-pin').value; + const loginPassword = (document.getElementById('new-profile-password') || {}).value || ''; if (!name) return; // Collect profile settings @@ -1804,6 +1811,7 @@ function initProfileManagement() { name, avatar_color: selectedColor, avatar_url: avatarUrl || undefined, pin: pin || undefined, + password: loginPassword || undefined, home_page: homePage, allowed_pages: allowedPages, can_download: canDl @@ -1814,6 +1822,7 @@ function initProfileManagement() { document.getElementById('new-profile-name').value = ''; document.getElementById('new-profile-avatar-url').value = ''; document.getElementById('new-profile-pin').value = ''; + if (document.getElementById('new-profile-password')) document.getElementById('new-profile-password').value = ''; document.getElementById('new-profile-home-page').value = ''; pageCheckboxes.forEach(cb => cb.checked = true); document.getElementById('new-profile-can-download').checked = true; @@ -1864,6 +1873,22 @@ async function loadProfileManageList() { const data = await res.json(); const profiles = data.profiles || []; + // Login-mode aware: when it's on, surface which members can't sign in yet + // (no login password) so the lock button's purpose is obvious. + let loginMode = false; + try { loginMode = !!(await (await fetch('/api/profiles/current')).json()).login_mode; } catch (e) { /* ignore */ } + + // Banner when login mode is on (explains the password requirement up front). + const banner = document.getElementById('profile-manage-login-banner'); + if (banner) banner.remove(); + if (loginMode) { + const b = document.createElement('div'); + b.id = 'profile-manage-login-banner'; + b.className = 'profile-manage-login-banner'; + b.textContent = '🔐 Login mode is on — every member needs a login password to sign in. Use the lock button to set one.'; + list.parentNode.insertBefore(b, list); + } + list.innerHTML = ''; profiles.forEach(p => { const item = document.createElement('div'); @@ -1887,6 +1912,12 @@ async function loadProfileManageList() { if (p.is_admin) pills.push({ text: 'Admin', cls: 'profile-role-pill--admin' }); if (p.can_download === false) pills.push({ text: 'No Downloads', cls: '' }); if (p.allowed_pages) pills.push({ text: `${p.allowed_pages.length} pages`, cls: '' }); + // Login-password status (only meaningful while login mode is on). + if (loginMode && !p.is_admin) { + pills.push(p.has_password + ? { text: '🔒 Login ready', cls: 'profile-role-pill--ok' } + : { text: '⚠ No login password', cls: 'profile-role-pill--warn' }); + } if (pills.length) { const roleDiv = document.createElement('div'); roleDiv.className = 'role'; @@ -1918,6 +1949,20 @@ async function loadProfileManageList() { actions.appendChild(editBtn); if (!p.is_admin) { + // Set/change the LOGIN password (separate from the quick-switch PIN; + // used when "Require login" is on). A member with no password can't + // sign in and can't self-bootstrap one, so the admin sets it here. + const pwBtn = document.createElement('button'); + // Pulse the button when login's on and this member can't sign in yet. + const needsPw = loginMode && !p.has_password; + pwBtn.className = 'profile-password-btn' + (p.has_password ? ' has-password' : '') + (needsPw ? ' needs-password' : ''); + pwBtn.dataset.id = p.id; + pwBtn.dataset.name = p.name; + pwBtn.dataset.hasPassword = p.has_password ? '1' : '0'; + pwBtn.title = p.has_password ? 'Change login password' : 'Set login password (for Require Login mode)'; + pwBtn.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="16" r="1"/><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>'; + actions.appendChild(pwBtn); + const delBtn = document.createElement('button'); delBtn.className = 'profile-delete-btn'; delBtn.dataset.id = p.id; @@ -1942,6 +1987,11 @@ async function loadProfileManageList() { }; }); + // Bind set-login-password buttons + list.querySelectorAll('.profile-password-btn').forEach(btn => { + btn.onclick = () => showProfilePasswordForm(btn.dataset.id, btn.dataset.name, btn.dataset.hasPassword === '1'); + }); + // Bind delete buttons list.querySelectorAll('.profile-delete-btn').forEach(btn => { btn.onclick = async () => { @@ -1962,6 +2012,100 @@ async function loadProfileManageList() { checkAdminPinRequired(); } +function showProfilePasswordForm(profileId, name, hasPassword) { + const list = document.getElementById('profile-manage-list'); + // One inline form at a time — drop any edit/password form already open. + ['profile-password-form', 'profile-edit-form'].forEach(id => { + const el = document.getElementById(id); if (el) el.remove(); + }); + + const form = document.createElement('div'); + form.id = 'profile-password-form'; + form.className = 'profile-edit-form'; + + const title = document.createElement('div'); + title.style.cssText = 'font-weight:600;margin-bottom:4px;'; + title.textContent = 'Login password — ' + name; // textContent = XSS-safe + form.appendChild(title); + + const hint = document.createElement('div'); + hint.style.cssText = 'font-size:0.8em;color:rgba(255,255,255,0.5);margin-bottom:8px;line-height:1.4;'; + hint.textContent = 'Used when "Require login" is on (separate from the quick-switch PIN). ' + + (hasPassword ? 'This profile has a password set.' + : "This profile has no password yet — it can't sign in until you set one."); + form.appendChild(hint); + + const pw = document.createElement('input'); + pw.type = 'password'; pw.className = 'profile-input'; + pw.placeholder = 'New password'; pw.autocomplete = 'new-password'; + const confirm = document.createElement('input'); + confirm.type = 'password'; confirm.className = 'profile-input'; + confirm.placeholder = 'Confirm password'; confirm.autocomplete = 'new-password'; + form.appendChild(pw); form.appendChild(confirm); + + const msg = document.createElement('div'); + msg.style.cssText = 'font-size:0.8em;margin:6px 0;display:none;'; + form.appendChild(msg); + const showMsg = (t, ok) => { + msg.textContent = t; msg.style.color = ok ? '#10b981' : '#ef4444'; msg.style.display = 'block'; + }; + + const post = async (password, okMsg, okType) => { + const res = await fetch('/api/profiles/' + encodeURIComponent(profileId) + '/set-password', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ password }), + }); + const data = await res.json(); + if (data.success) { + form.remove(); + loadProfileManageList(); + if (typeof showToast === 'function') showToast(okMsg, okType); + return true; + } + showMsg(data.error || 'Failed to update password', false); + return false; + }; + + const btnRow = document.createElement('div'); + btnRow.style.cssText = 'display:flex;gap:8px;margin-top:8px;flex-wrap:wrap;'; + + const saveBtn = document.createElement('button'); + saveBtn.className = 'btn btn--primary'; + saveBtn.textContent = 'Save password'; + saveBtn.onclick = async () => { + const p1 = pw.value, p2 = confirm.value; + if (!p1 || !p1.trim()) { showMsg('Enter a password', false); return; } + if (p1.length < 4) { showMsg('Use at least 4 characters', false); return; } + if (p1 !== p2) { showMsg("Passwords don't match", false); return; } + saveBtn.disabled = true; + try { if (!await post(p1, 'Login password set for ' + name, 'success')) saveBtn.disabled = false; } + catch (e) { showMsg('Connection error', false); saveBtn.disabled = false; } + }; + btnRow.appendChild(saveBtn); + + if (hasPassword) { + const clearBtn = document.createElement('button'); + clearBtn.className = 'btn'; + clearBtn.textContent = 'Remove password'; + clearBtn.onclick = async () => { + clearBtn.disabled = true; + try { if (!await post('', 'Login password removed', 'info')) clearBtn.disabled = false; } + catch (e) { showMsg('Connection error', false); clearBtn.disabled = false; } + }; + btnRow.appendChild(clearBtn); + } + + const cancelBtn = document.createElement('button'); + cancelBtn.className = 'btn'; + cancelBtn.textContent = 'Cancel'; + cancelBtn.onclick = () => form.remove(); + btnRow.appendChild(cancelBtn); + + form.appendChild(btnRow); + list.appendChild(form); + pw.focus(); +} + function showProfileEditForm(profileId, currentName, currentColor, currentAvatarUrl, profileSettings = {}) { const list = document.getElementById('profile-manage-list'); // Remove any existing edit form @@ -2322,6 +2466,10 @@ async function _continueAppInit() { } function initApp() { + // Unlocked / authenticated — reveal the app (the lock screens hide it via + // body.app-locked so a bypassed overlay shows nothing). Do this FIRST so + // component init below measures real layout, not a display:none container. + document.body.classList.remove('app-locked'); // Initialize components initializeNavigation(); initializeMobileNavigation(); diff --git a/webui/static/library.js b/webui/static/library.js index 4eaf6c03..cd88e264 100644 --- a/webui/static/library.js +++ b/webui/static/library.js @@ -1226,6 +1226,9 @@ function populateArtistDetailPage(data) { // Update hero section with image, name, and stats updateArtistHeroSection(artist, discography); + // "DB Record" inspector button (library artists only) + setupArtistRecordButton(artist); + // Update genres (if element exists) updateArtistGenres(artist.genres); @@ -8788,3 +8791,352 @@ async function _mlmDeleteMatch(id) { } // ================================= + + +// ════════════════════════════════════════════════════════════════════════════ +// Artist "DB Record" inspector — everything the database knows about an artist. +// A small glowing button at the bottom-right of the hero opens a programmer-style +// modal: a copyable field table + syntax-highlighted raw JSON, with copy-all and +// save-as-JSON. Library artists only (source artists have no DB row). +// ════════════════════════════════════════════════════════════════════════════ + +let _artistRecordData = null; // last-fetched { artist_id, counts, record } + +function setupArtistRecordButton(artist) { + const hero = document.getElementById('artist-hero-section'); + if (!hero) return; + let btn = document.getElementById('artist-db-record-btn'); + + const isLibrary = !!(artist && artist.id && document.body.dataset.artistSource === 'library'); + if (!isLibrary) { if (btn) btn.style.display = 'none'; return; } + + if (!btn) { + btn = document.createElement('button'); + btn.id = 'artist-db-record-btn'; + btn.className = 'artist-db-record-btn'; + btn.type = 'button'; + btn.title = 'Inspect everything the database knows about this artist'; + btn.innerHTML = + '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' + + '<ellipse cx="12" cy="5" rx="8" ry="3"></ellipse>' + + '<path d="M4 5v6c0 1.66 3.58 3 8 3s8-1.34 8-3V5"></path>' + + '<path d="M4 11v6c0 1.66 3.58 3 8 3s8-1.34 8-3v-6"></path>' + + '</svg><span>DB Record</span>'; + hero.appendChild(btn); + } + btn.style.display = ''; + btn.onclick = () => openArtistRecordModal(artist.id, artist.name || 'Artist'); +} + +async function openArtistRecordModal(artistId, artistName) { + // Clean any prior instance + const existing = document.getElementById('artist-record-overlay'); + if (existing) existing.remove(); + + const overlay = document.createElement('div'); + overlay.id = 'artist-record-overlay'; + overlay.className = 'arec-overlay'; + overlay.innerHTML = + '<div class="arec-card" role="dialog" aria-label="Artist database record">' + + '<div class="arec-header">' + + '<div class="arec-title-wrap">' + + '<div class="arec-title"><span class="arec-dot"></span>Artist DB Record</div>' + + '<div class="arec-sub" id="arec-sub">' + _arecEsc(artistName) + '</div>' + + '</div>' + + '<button class="arec-close" id="arec-close" title="Close (Esc)">×</button>' + + '</div>' + + '<div class="arec-toolbar">' + + '<div class="arec-tabs">' + + '<button class="arec-tab active" data-tab="fields">Fields</button>' + + '<button class="arec-tab" data-tab="json">JSON</button>' + + '</div>' + + '<input id="arec-filter" class="arec-filter" type="text" placeholder="filter fields…" autocomplete="off" spellcheck="false">' + + '<div class="arec-actions">' + + '<button class="arec-btn" id="arec-copy"><svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>Copy JSON</button>' + + '<button class="arec-btn" id="arec-download"><svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="7 10 12 15 17 10"></polyline><line x1="12" y1="15" x2="12" y2="3"></line></svg>Save .json</button>' + + '</div>' + + '</div>' + + '<div class="arec-body" id="arec-body">' + + '<div class="arec-loading">Loading record…</div>' + + '</div>' + + '<div class="arec-footer" id="arec-footer"></div>' + + '</div>'; + document.body.appendChild(overlay); + requestAnimationFrame(() => overlay.classList.add('visible')); + + const close = () => { + overlay.classList.remove('visible'); + document.removeEventListener('keydown', onKey); + setTimeout(() => overlay.remove(), 220); + }; + const onKey = (e) => { if (e.key === 'Escape') close(); }; + document.addEventListener('keydown', onKey); + overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); }); + overlay.querySelector('#arec-close').onclick = close; + + // Fetch the record + let payload; + try { + const res = await fetch(`/api/artist/${encodeURIComponent(artistId)}/record`); + payload = await res.json(); + if (!payload || !payload.success) throw new Error((payload && payload.error) || 'Request failed'); + } catch (err) { + document.getElementById('arec-body').innerHTML = + '<div class="arec-error">Could not load record: ' + _arecEsc(err.message || String(err)) + '</div>'; + return; + } + + _artistRecordData = payload; + const record = payload.record || {}; + const counts = payload.counts || {}; + + // Footer stat line + const fieldCount = Object.keys(record).length; + const matched = Object.entries(record).filter(([k, v]) => /match_status$/.test(k) && v === 'matched').length; + document.getElementById('arec-footer').innerHTML = + '<span><b>' + fieldCount + '</b> fields</span>' + + '<span><b>' + (counts.albums != null ? counts.albums : '–') + '</b> albums</span>' + + '<span><b>' + (counts.tracks != null ? counts.tracks : '–') + '</b> tracks</span>' + + '<span><b>' + matched + '</b> sources matched</span>' + + '<span class="arec-id">id ' + _arecEsc(String(payload.artist_id)) + '</span>'; + + _arecRenderFields(record); + + // Toolbar wiring + overlay.querySelectorAll('.arec-tab').forEach(tab => { + tab.onclick = () => { + overlay.querySelectorAll('.arec-tab').forEach(t => t.classList.remove('active')); + tab.classList.add('active'); + const filterEl = document.getElementById('arec-filter'); + if (tab.dataset.tab === 'json') { _arecRenderJson(record); filterEl.style.visibility = 'hidden'; } + else { _arecRenderFields(record); filterEl.style.visibility = ''; _arecApplyFilter(filterEl.value); } + }; + }); + document.getElementById('arec-filter').addEventListener('input', (e) => _arecApplyFilter(e.target.value)); + document.getElementById('arec-copy').onclick = () => + _arecCopy(JSON.stringify(record, null, 2), 'Full record copied as JSON'); + document.getElementById('arec-download').onclick = () => _arecDownload(record, artistName); +} + +function _arecRenderFields(record) { + const body = document.getElementById('arec-body'); + if (!body) return; + const rows = Object.entries(record).map(([key, val]) => { + const isEmpty = val === null || val === undefined || val === ''; + let display, copyVal; + if (isEmpty) { display = '<span class="arec-null">null</span>'; copyVal = ''; } + else if (typeof val === 'object') { + copyVal = JSON.stringify(val); + display = '<span class="arec-json">' + _arecEsc(JSON.stringify(val)) + '</span>'; + } else { + copyVal = String(val); + display = _arecEsc(String(val)); + } + return '<div class="arec-row' + (isEmpty ? ' is-empty' : '') + '" data-field="' + _arecEscAttr(key.toLowerCase()) + + ' ' + _arecEscAttr(copyVal.toLowerCase()) + '">' + + '<span class="arec-key">' + _arecEsc(key) + '</span>' + + '<span class="arec-val">' + display + '</span>' + + '<button class="arec-rowcopy" title="Copy value" data-copy="' + _arecEscAttr(copyVal) + '">⧉</button>' + + '</div>'; + }).join(''); + body.innerHTML = '<div class="arec-fields">' + rows + '</div>'; + body.querySelectorAll('.arec-rowcopy').forEach(b => { + b.onclick = () => _arecCopy(b.getAttribute('data-copy'), 'Value copied'); + }); +} + +function _arecRenderJson(record) { + const body = document.getElementById('arec-body'); + if (!body) return; + body.innerHTML = '<pre class="arec-code">' + _jsonSyntaxHighlight(record) + '</pre>'; +} + +function _arecApplyFilter(q) { + q = (q || '').trim().toLowerCase(); + document.querySelectorAll('#arec-body .arec-row').forEach(row => { + row.style.display = (!q || row.dataset.field.includes(q)) ? '' : 'none'; + }); +} + +function _jsonSyntaxHighlight(obj) { + let json = JSON.stringify(obj, null, 2); + json = json.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); + return json.replace(/("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false)\b|\bnull\b|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?)/g, (m) => { + let cls = 'tok-num'; + if (/^"/.test(m)) cls = /:$/.test(m) ? 'tok-key' : 'tok-str'; + else if (/true|false/.test(m)) cls = 'tok-bool'; + else if (/null/.test(m)) cls = 'tok-null'; + return '<span class="' + cls + '">' + m + '</span>'; + }); +} + +function _arecCopy(text, label) { + text = text == null ? '' : String(text); + const done = () => (typeof showToast === 'function') && showToast(label || 'Copied', 'success'); + if (navigator.clipboard && window.isSecureContext) { + navigator.clipboard.writeText(text).then(done).catch(() => _arecCopyFallback(text, done)); + } else { _arecCopyFallback(text, done); } +} + +function _arecCopyFallback(text, done) { + const ta = document.createElement('textarea'); + ta.value = text; + ta.style.cssText = 'position:fixed;left:-9999px'; + document.body.appendChild(ta); + ta.select(); + try { document.execCommand('copy'); } catch (e) { /* ignore */ } + document.body.removeChild(ta); + done(); +} + +function _arecDownload(record, artistName) { + const safe = String(artistName || 'artist').replace(/[^a-z0-9._-]+/gi, '_').slice(0, 60) || 'artist'; + const blob = new Blob([JSON.stringify(record, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = safe + '_db_record.json'; + document.body.appendChild(a); + a.click(); + a.remove(); + setTimeout(() => URL.revokeObjectURL(url), 1000); + if (typeof showToast === 'function') showToast('Saved ' + a.download, 'success'); +} + +function _arecEsc(s) { + return String(s == null ? '' : s) + .replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); +} +function _arecEscAttr(s) { + return _arecEsc(s).replace(/"/g, '"'); +} + + +// ════════════════════════════════════════════════════════════════════════════ +// Watchlist export — bulk export the watchlist roster to JSON / CSV / text, with +// optional external discography links. Reuses the DB-record modal aesthetic + +// helpers (_jsonSyntaxHighlight / _arecCopy / _arecEsc). #export-request +// ════════════════════════════════════════════════════════════════════════════ +async function openArtistExportModal(initialScope) { + // One export modal for both rosters — pick Watchlist or Library inside. + let scope = initialScope || 'watchlist'; + const epOf = (s) => s === 'library' ? '/api/library/artists/export' : '/api/watchlist/export'; + const fileOf = (s) => s === 'library' ? 'library_artists' : 'watchlist'; + + const existing = document.getElementById('wl-export-overlay'); + if (existing) existing.remove(); + + const overlay = document.createElement('div'); + overlay.id = 'wl-export-overlay'; + overlay.className = 'arec-overlay'; + overlay.innerHTML = + '<div class="arec-card" role="dialog" aria-label="Export artists">' + + '<div class="arec-header">' + + '<div class="arec-title-wrap">' + + '<div class="arec-title"><span class="arec-dot"></span>Export Artists</div>' + + '<div class="arec-tabs" id="wlx-scope" style="margin-top:7px;">' + + '<button class="arec-tab' + (scope === 'watchlist' ? ' active' : '') + '" data-scope="watchlist">Watchlist</button>' + + '<button class="arec-tab' + (scope === 'library' ? ' active' : '') + '" data-scope="library">Library</button>' + + '</div>' + + '</div>' + + '<button class="arec-close" id="wlx-close" title="Close (Esc)">×</button>' + + '</div>' + + '<div class="arec-toolbar">' + + '<div class="arec-tabs" id="wlx-format">' + + '<button class="arec-tab active" data-fmt="json">JSON</button>' + + '<button class="arec-tab" data-fmt="csv">CSV</button>' + + '<button class="arec-tab" data-fmt="txt">Text</button>' + + '</div>' + + '<label class="wlx-opt"><input type="checkbox" id="wlx-links"> external links</label>' + + '<label class="wlx-opt" id="wlx-contents-wrap" style="display:none;"><input type="checkbox" id="wlx-contents"> library counts</label>' + + '<div class="arec-actions">' + + '<button class="arec-btn" id="wlx-copy">Copy</button>' + + '<button class="arec-btn" id="wlx-download">Download</button>' + + '</div>' + + '</div>' + + '<div class="arec-body" id="wlx-body"><div class="arec-loading">Building export…</div></div>' + + '<div class="arec-footer" id="wlx-footer"></div>' + + '</div>'; + document.body.appendChild(overlay); + requestAnimationFrame(() => overlay.classList.add('visible')); + + let fmt = 'json', links = false, contents = false, content = ''; + + const applyScopeUI = () => { + // "library counts" only applies to the library roster. + document.getElementById('wlx-contents-wrap').style.display = (scope === 'library') ? '' : 'none'; + if (scope !== 'library') { + contents = false; + const cb = document.getElementById('wlx-contents'); + if (cb) cb.checked = false; + } + }; + applyScopeUI(); + + const close = () => { + overlay.classList.remove('visible'); + document.removeEventListener('keydown', onKey); + setTimeout(() => overlay.remove(), 220); + }; + const onKey = (e) => { if (e.key === 'Escape') close(); }; + document.addEventListener('keydown', onKey); + overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); }); + overlay.querySelector('#wlx-close').onclick = close; + + const refresh = async () => { + const body = document.getElementById('wlx-body'); + body.innerHTML = '<div class="arec-loading">Building export…</div>'; + try { + const res = await fetch(epOf(scope) + '?format=' + fmt + '&links=' + (links ? '1' : '0') + + (scope === 'library' && contents ? '&contents=1' : '')); + content = await res.text(); + const count = res.headers.get('X-Export-Count') || '?'; + document.getElementById('wlx-footer').innerHTML = + '<span><b>' + count + '</b> ' + (scope === 'library' ? 'library' : 'watchlist') + ' artists</span>' + + '<span class="arec-id">' + fmt.toUpperCase() + '</span>'; + if (fmt === 'json') { + let parsed; try { parsed = JSON.parse(content || '[]'); } catch (e) { parsed = []; } + body.innerHTML = '<pre class="arec-code">' + _jsonSyntaxHighlight(parsed) + '</pre>'; + } else { + body.innerHTML = '<pre class="arec-code">' + _arecEsc(content || '(empty)') + '</pre>'; + } + } catch (err) { + body.innerHTML = '<div class="arec-error">Export failed: ' + _arecEsc(err.message || String(err)) + '</div>'; + } + }; + + overlay.querySelectorAll('#wlx-scope .arec-tab').forEach(t => { + t.onclick = () => { + if (t.dataset.scope === scope) return; + overlay.querySelectorAll('#wlx-scope .arec-tab').forEach(x => x.classList.remove('active')); + t.classList.add('active'); + scope = t.dataset.scope; + applyScopeUI(); + refresh(); + }; + }); + overlay.querySelectorAll('#wlx-format .arec-tab').forEach(t => { + t.onclick = () => { + overlay.querySelectorAll('#wlx-format .arec-tab').forEach(x => x.classList.remove('active')); + t.classList.add('active'); + fmt = t.dataset.fmt; + refresh(); + }; + }); + document.getElementById('wlx-links').addEventListener('change', (e) => { links = e.target.checked; refresh(); }); + document.getElementById('wlx-contents').addEventListener('change', (e) => { contents = e.target.checked; refresh(); }); + document.getElementById('wlx-copy').onclick = () => _arecCopy(content, 'Export copied'); + document.getElementById('wlx-download').onclick = () => { + const ext = fmt; + const mime = fmt === 'json' ? 'application/json' : (fmt === 'csv' ? 'text/csv' : 'text/plain'); + const blob = new Blob([content || ''], { type: mime }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; a.download = fileOf(scope) + '_export.' + ext; + document.body.appendChild(a); a.click(); a.remove(); + setTimeout(() => URL.revokeObjectURL(url), 1000); + if (typeof showToast === 'function') showToast('Saved ' + fileOf(scope) + '_export.' + ext, 'success'); + }; + + refresh(); +} diff --git a/webui/static/pages-extra.js b/webui/static/pages-extra.js index 08790589..4e2633a9 100644 --- a/webui/static/pages-extra.js +++ b/webui/static/pages-extra.js @@ -1648,6 +1648,70 @@ function _serverEditorRefresh() { _openServerCompareView(_serverEditorState.playlistId, _serverEditorState.playlistName, _serverEditorState.mirroredPlaylist); } +/** + * Export the currently-open server playlist as an M3U file. Takes the tracks + * physically present ON the server (matched + extra) and reuses the shared M3U + * writer, which resolves each to its real library file path (+ the configured + * entry_base_path prefix) so media servers like Music Assistant can read it. + * force:true bypasses the auto-save "m3u_export.enabled" gate — this is a manual + * on-demand export. + */ +async function exportServerPlaylistM3U() { + const st = _serverEditorState; + const btn = document.getElementById('server-editor-export-btn'); + const tracks = (st && Array.isArray(st.tracks) ? st.tracks : []) + .filter(t => t.server_track) + .map(t => ({ + name: t.server_track.title, + artist: t.server_track.artist || '', + duration_ms: t.server_track.duration || 0, + })); + if (!tracks.length) { + showToast('No server tracks to export', 'warning'); + return; + } + const orig = btn ? btn.textContent : ''; + if (btn) { btn.disabled = true; btn.textContent = '⏳ Exporting…'; } + try { + const res = await fetch('/api/generate-playlist-m3u', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + playlist_name: st.playlistName || 'Playlist', + tracks, + context_type: 'playlist', + save_to_disk: true, + force: true, + }), + }); + const data = await res.json(); + if (!res.ok || data.success === false) { + throw new Error(data.error || 'Export failed'); + } + // Download the .m3u to the browser (same as the other Export-as-M3U + // buttons) — force=true also saved it server-side for media servers. + const name = (st.playlistName || 'Playlist').replace(/[/\\?%*:|"<>]/g, '-'); + const blob = new Blob([data.m3u_content || ''], { type: 'audio/x-mpegurl;charset=utf-8' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = `${name}.m3u`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + // `found` = server tracks resolved to a real library file path; any not in + // SoulSync's library are skipped (can't write a path for them). + const found = data.stats && data.stats.found != null ? data.stats.found : tracks.length; + const note = found < tracks.length ? ` (${found}/${tracks.length} in library)` : ` (${found} tracks)`; + showToast(`Exported M3U: ${st.playlistName}${note}`, 'success'); + } catch (e) { + showToast(`M3U export failed: ${e.message}`, 'error'); + } finally { + if (btn) { btn.disabled = false; btn.textContent = orig || '📋 Export M3U'; } + } +} + function serverEditorBack() { const container = document.getElementById('server-playlist-container'); const editor = document.getElementById('server-editor'); @@ -1916,11 +1980,28 @@ setInterval(() => { }, 30000); async function loadDashboardSyncHistory() { + // Don't poll the auth-gated sync-history endpoint while the app is locked — + // it would 401 every 30s cycle (the result is discarded anyway). Resumes + // automatically on unlock (init.js removes 'app-locked'). + if (document.body.classList.contains('app-locked')) return; const container = document.getElementById('sync-history-cards'); if (!container) return; try { const response = await fetch('/api/sync/history?limit=10'); + if (response.status === 401) { + // Session lapsed (e.g. the server restarted) while this tab still + // believed it was unlocked, so the guard above couldn't fire. Surface + // the correct unlock screen — both add 'app-locked', which stops the + // poll until the user re-authenticates (same as a fresh page load). + const info = await response.json().catch(() => ({})); + if (info.login_required && typeof showLoginScreen === 'function') { + showLoginScreen(); + } else if (typeof showLaunchPinScreen === 'function') { + showLaunchPinScreen(); + } + return; + } if (!response.ok) return; const data = await response.json(); diff --git a/webui/static/settings.js b/webui/static/settings.js index 6e699dd5..af4bb3f7 100644 --- a/webui/static/settings.js +++ b/webui/static/settings.js @@ -1129,6 +1129,8 @@ async function loadSettingsData() { document.getElementById('transfer-path').value = settings.soulseek?.transfer_path || './Transfer'; document.getElementById('staging-path').value = settings.import?.staging_path || './Staging'; document.getElementById('music-videos-path').value = settings.library?.music_videos_path || './MusicVideos'; + document.getElementById('playlists-materialize-path').value = settings.playlists?.materialize_path || './Playlists'; + document.getElementById('playlists-materialize-mode').value = settings.playlists?.materialize_mode || 'symlink'; // Populate Download Source settings document.getElementById('download-source-mode').value = settings.download_source?.mode || 'soulseek'; @@ -1177,6 +1179,8 @@ async function loadSettingsData() { _tcStall.value = (secs === undefined || secs === null) ? 10 : Math.round(Number(secs) / 60); } if (_tcStallAct) _tcStallAct.value = settings.download_source?.torrent_stall_action || 'abandon'; + const _tcDlPath = document.getElementById('torrent-download-path'); + if (_tcDlPath) _tcDlPath.value = settings.download_source?.torrent_download_path || ''; const _ucType = document.getElementById('usenet-client-type'); const _ucUrl = document.getElementById('usenet-client-url'); const _ucKey = document.getElementById('usenet-client-api-key'); @@ -1189,6 +1193,8 @@ async function loadSettingsData() { if (_ucUser) _ucUser.value = settings.usenet_client?.username || ''; if (_ucPass) _ucPass.value = settings.usenet_client?.password || ''; if (_ucCat) _ucCat.value = settings.usenet_client?.category || 'soulsync'; + const _ucDlPath = document.getElementById('usenet-download-path'); + if (_ucDlPath) _ucDlPath.value = settings.download_source?.usenet_download_path || ''; if (typeof updateUsenetClientUI === 'function') updateUsenetClientUI(); // Sync ARL to connections tab field + bidirectional listeners const _connArl = document.getElementById('deezer-connection-arl'); @@ -2999,6 +3005,10 @@ async function saveSettings(quiet = false) { return (Number.isFinite(m) && m >= 0 ? m : 10) * 60; })(), torrent_stall_action: document.getElementById('torrent-stall-action')?.value || 'abandon', + // In-container path(s) where SoulSync reads finished torrent/usenet + // downloads (#857). Rendered in the torrent/usenet client sections. + torrent_download_path: document.getElementById('torrent-download-path')?.value || '', + usenet_download_path: document.getElementById('usenet-download-path')?.value || '', }, tidal_download: { quality: document.getElementById('tidal-download-quality').value || 'lossless', @@ -3133,6 +3143,10 @@ async function saveSettings(quiet = false) { folder_artist_override: document.getElementById('import-folder-artist-override')?.checked === true, staging_path: document.getElementById('staging-path').value || './Staging' }, + playlists: { + materialize_path: document.getElementById('playlists-materialize-path').value || './Playlists', + materialize_mode: document.getElementById('playlists-materialize-mode').value || 'symlink' + }, lossy_copy: { enabled: document.getElementById('lossy-copy-enabled').checked, codec: document.getElementById('lossy-copy-codec').value, @@ -4206,6 +4220,30 @@ async function addHiFiInstance() { } } +async function restoreDefaultHiFiInstances() { + const btn = document.getElementById('hifi-instances-restore-btn'); + const orig = btn ? btn.textContent : ''; + if (btn) { btn.disabled = true; btn.textContent = 'Restoring…'; } + try { + const resp = await fetch('/api/hifi/instances/reset', { method: 'POST' }); + const data = await resp.json(); + if (data.success) { + loadHiFiInstances(); + const n = data.restored || 0; + if (typeof showToast === 'function') { + showToast(n ? `Restored ${n} default instance${n === 1 ? '' : 's'}` + : 'All default instances are already present', 'success'); + } + } else { + alert(data.error || 'Failed to restore defaults'); + } + } catch (e) { + alert(`Error: ${e.message}`); + } finally { + if (btn) { btn.disabled = false; btn.textContent = orig || 'Restore Defaults'; } + } +} + async function removeHiFiInstance(url) { try { const resp = await fetch(`/api/hifi/instances?url=${encodeURIComponent(url)}`, { @@ -4628,6 +4666,7 @@ const PATH_INPUT_IDS = { transfer: 'transfer-path', staging: 'staging-path', 'music-videos': 'music-videos-path', + 'playlists-materialize': 'playlists-materialize-path', 'm3u-entry-base': 'm3u-entry-base-path' }; @@ -4647,5 +4686,44 @@ function togglePathLock(pathType, btn) { } } +// Manually (re)build every "organize by playlist" folder from current library +// ownership — mirrors the automatic rebuild that runs after a playlist download. +async function rebuildPlaylistFolders() { + const btn = document.getElementById('playlists-rebuild-btn'); + const status = document.getElementById('playlists-rebuild-status'); + if (!btn) return; + const original = btn.textContent; + btn.disabled = true; + btn.textContent = 'Rebuilding…'; + if (status) { status.style.color = ''; status.textContent = 'Rebuilding playlist folders…'; } + try { + const res = await fetch('/api/playlists/materialize/rebuild', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: '{}' + }); + const data = await res.json(); + if (!res.ok || !data.success) throw new Error(data.error || 'Rebuild failed'); + const n = data.count || 0; + let linked = 0, copied = 0, removed = 0; + (data.results || []).forEach(r => { + linked += r.linked || 0; copied += r.copied || 0; removed += r.removed_stale || 0; + }); + if (status) { + status.style.color = '#4caf50'; + status.textContent = (n === 0) + ? 'No "organize by playlist" playlists to rebuild yet.' + : `Rebuilt ${n} playlist folder${n === 1 ? '' : 's'} — ${linked} linked, ${copied} copied, ${removed} stale removed.`; + } + if (typeof showToast === 'function') showToast('Playlist folders rebuilt', 'success'); + } catch (e) { + if (status) { status.style.color = '#f44336'; status.textContent = 'Rebuild failed: ' + (e.message || e); } + if (typeof showToast === 'function') showToast('Playlist rebuild failed', 'error'); + } finally { + btn.disabled = false; + btn.textContent = original; + } +} + // =============================== diff --git a/webui/static/shared-helpers.js b/webui/static/shared-helpers.js index 620fec26..9c2866aa 100644 --- a/webui/static/shared-helpers.js +++ b/webui/static/shared-helpers.js @@ -1007,9 +1007,11 @@ function normalizePlaylistOrganizeRef(playlistRef, source = 'spotify') { } function downloadMissingModalOrganizeCheckboxHtml(playlistId) { + const safeId = String(playlistId).replace(/'/g, "\\'"); return ` <label class="force-download-toggle"> - <input type="checkbox" id="playlist-folder-mode-${playlistId}" class="playlist-folder-mode-sync"> + <input type="checkbox" id="playlist-folder-mode-${playlistId}" class="playlist-folder-mode-sync" + onchange="onPlaylistOrganizePreferenceChange('${safeId}', this.checked, playlistOrganizeSourceForRef('${safeId}'))"> <span>Organize by Playlist (Downloads/Playlist/Artist - Track.ext)</span> </label>`; } diff --git a/webui/static/stats-automations.js b/webui/static/stats-automations.js index 68e1adea..395ba528 100644 --- a/webui/static/stats-automations.js +++ b/webui/static/stats-automations.js @@ -509,7 +509,8 @@ function renderMirroredCard(p, container) { card.innerHTML = ` <div class="source-icon ${_escAttr(p.source)}">${srcIcon}</div> <div class="mirrored-card-info"> - <div class="card-name">${_esc(p.name)}</div> + <div class="card-name">${_esc(p.display_name || p.name)}</div> + ${p.custom_name ? `<div class="card-original-name" title="Original (upstream) playlist name — still tracked">↳ ${_esc(p.name)}</div>` : ''} <div class="card-meta"> <span class="source-badge ${_escAttr(p.source)}">${_esc(p.source)}</span> <span>${p.track_count} tracks</span> @@ -520,6 +521,7 @@ function renderMirroredCard(p, container) { </div> ${disc > 0 ? `<button class="mirrored-card-clear" onclick="event.stopPropagation(); clearMirroredDiscovery(${p.id}, '${_escJs(p.name)}')" title="Clear discovery data">↺</button>` : ''} <button class="mirrored-card-pipeline" onclick="event.stopPropagation(); runMirroredPlaylistPipeline(${p.id}, '${_escJs(p.name)}')" title="Refresh, discover, sync, and queue missing tracks">Auto-Sync</button> + <button class="mirrored-card-rename" onclick="event.stopPropagation(); editMirroredCustomName(${p.id}, '${_escJs(p.name)}', '${_escJs(p.custom_name || '')}')" title="Rename (changes the name shown here and used when syncing)">✏️</button> <button class="mirrored-card-link" onclick="event.stopPropagation(); editMirroredSourceRef(${p.id}, '${_escJs(p.name)}', '${_escJs(p.source)}', '${_escJs(sourceRef)}')" title="Edit original playlist link">🔗</button> <button class="mirrored-card-delete" onclick="event.stopPropagation(); deleteMirroredPlaylist(${p.id}, '${_escJs(p.name)}')" title="Delete mirror">✕</button> `; diff --git a/webui/static/style.css b/webui/static/style.css index 754d32c7..d3a2d09c 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -14460,7 +14460,8 @@ body.helper-mode-active #dashboard-activity-feed:hover { .mirrored-card-delete, .mirrored-card-clear, -.mirrored-card-link { +.mirrored-card-link, +.mirrored-card-rename { background: rgba(255, 255, 255, 0.06); border: 1px solid rgba(255, 255, 255, 0.08); color: #555; @@ -14481,6 +14482,7 @@ body.helper-mode-active #dashboard-activity-feed:hover { .mirrored-playlist-card:hover .mirrored-card-delete, .mirrored-playlist-card:hover .mirrored-card-clear, .mirrored-playlist-card:hover .mirrored-card-link, +.mirrored-playlist-card:hover .mirrored-card-rename, .mirrored-playlist-card:hover .mirrored-card-pipeline { opacity: 1; } @@ -14506,6 +14508,13 @@ body.helper-mode-active #dashboard-activity-feed:hover { transform: scale(1.1); } +.mirrored-card-rename:hover { + color: #4ade80; + background: rgba(74, 222, 128, 0.15); + border-color: rgba(74, 222, 128, 0.3); + transform: scale(1.1); +} + .discovery-ratio { font-size: 0.8em; color: rgba(255, 255, 255, 0.4); @@ -62628,8 +62637,19 @@ body[data-artist-source="source"] #artist-detail-page #library-artist-enhance-bt .hifi-instance-toggle { flex-shrink: 0; cursor: pointer; - font-size: 0.9em; - transition: color 0.15s; + font-size: 0.95em; + transition: color 0.15s, background 0.15s; + /* Bigger tap target — the bare glyph was hard to hit/register (#Sokhi). */ + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 30px; + height: 30px; + border-radius: 6px; + user-select: none; +} +.hifi-instance-toggle:hover { + background: rgba(255, 255, 255, 0.07); } .hifi-instance-toggle.on { color: #4caf50; @@ -62647,11 +62667,20 @@ body[data-artist-source="source"] #artist-detail-page #library-artist-enhance-bt flex-shrink: 0; color: #f44336; cursor: pointer; - font-size: 0.9em; - transition: color 0.15s; + font-size: 0.95em; + transition: color 0.15s, background 0.15s; + /* Bigger tap target — the bare ✖ was hard to hit/register (#Sokhi). */ + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 30px; + height: 30px; + border-radius: 6px; + user-select: none; } .hifi-instance-remove:hover { color: #ef5350; + background: rgba(244, 67, 54, 0.15); } /* ========================================================================== @@ -67977,3 +68006,279 @@ body.em-scroll-lock { overflow: hidden; } font-weight: 500; margin-bottom: 6px; } + + +/* ════════════════════════════════════════════════════════════════════════ + Artist "DB Record" inspector — button + programmer-style modal + ════════════════════════════════════════════════════════════════════════ */ +#artist-hero-section { position: relative; } + +.artist-db-record-btn { + position: absolute; + bottom: 14px; + right: 16px; + z-index: 6; + display: inline-flex; + align-items: center; + gap: 7px; + padding: 7px 13px; + font: 600 11.5px/1 'JetBrains Mono', ui-monospace, 'SF Mono', Menlo, Consolas, monospace; + letter-spacing: 0.04em; + color: #cdd6f4; + background: linear-gradient(135deg, rgba(20,24,34,0.72), rgba(14,17,23,0.82)); + border: 1px solid rgba(122,162,247,0.35); + border-radius: 999px; + cursor: pointer; + backdrop-filter: blur(8px); + box-shadow: 0 4px 18px rgba(0,0,0,0.35), inset 0 0 0 1px rgba(255,255,255,0.03), + 0 0 0 0 rgba(122,162,247,0.0); + opacity: 0.82; + transition: transform .2s cubic-bezier(.4,0,.2,1), box-shadow .25s ease, + opacity .2s ease, border-color .25s ease, color .2s ease; +} +.artist-db-record-btn svg { color: #7aa2f7; transition: color .2s ease, transform .3s ease; } +.artist-db-record-btn:hover { + opacity: 1; + transform: translateY(-2px); + color: #fff; + border-color: rgba(122,162,247,0.7); + box-shadow: 0 8px 26px rgba(0,0,0,0.45), inset 0 0 0 1px rgba(255,255,255,0.05), + 0 0 18px rgba(122,162,247,0.35); +} +.artist-db-record-btn:hover svg { color: #9ece6a; transform: rotate(-6deg) scale(1.08); } +.artist-db-record-btn:active { transform: translateY(0); } + +/* ── Overlay + card ── */ +.arec-overlay { + position: fixed; inset: 0; z-index: 10050; + display: flex; align-items: center; justify-content: center; + padding: 24px; + background: rgba(8,10,14,0.7); + backdrop-filter: blur(10px); + opacity: 0; transition: opacity .22s ease; +} +.arec-overlay.visible { opacity: 1; } + +.arec-card { + width: min(760px, 96vw); + max-height: min(82vh, 820px); + display: flex; flex-direction: column; + background: linear-gradient(180deg, #11151c, #0d1017); + border: 1px solid rgba(122,162,247,0.22); + border-radius: 16px; + box-shadow: 0 30px 80px rgba(0,0,0,0.6), 0 0 0 1px rgba(255,255,255,0.02), + 0 0 60px rgba(122,162,247,0.08); + overflow: hidden; + transform: translateY(10px) scale(0.985); + transition: transform .26s cubic-bezier(.34,1.3,.5,1); + font-family: ui-sans-serif, system-ui, sans-serif; +} +.arec-overlay.visible .arec-card { transform: translateY(0) scale(1); } + +.arec-header { + display: flex; align-items: center; justify-content: space-between; + padding: 14px 16px 12px; + border-bottom: 1px solid rgba(255,255,255,0.06); + background: linear-gradient(180deg, rgba(122,162,247,0.06), transparent); +} +.arec-title { + display: flex; align-items: center; gap: 8px; + font: 700 14px/1 'JetBrains Mono', ui-monospace, Menlo, monospace; + color: #e6ebff; letter-spacing: 0.02em; +} +.arec-dot { + width: 9px; height: 9px; border-radius: 50%; + background: #9ece6a; box-shadow: 0 0 10px #9ece6a; + animation: arecPulse 2.4s ease-in-out infinite; +} +@keyframes arecPulse { 0%,100% { opacity: 1; } 50% { opacity: 0.4; } } +.arec-sub { + margin-top: 4px; font-size: 12px; color: #8b93b0; + font-family: ui-monospace, Menlo, monospace; +} +.arec-close { + background: none; border: none; color: #8b93b0; font-size: 24px; + line-height: 1; cursor: pointer; padding: 0 4px; transition: color .15s ease, transform .15s ease; +} +.arec-close:hover { color: #ff6b6b; transform: scale(1.12); } + +/* ── Toolbar ── */ +.arec-toolbar { + display: flex; align-items: center; gap: 10px; + padding: 10px 14px; + border-bottom: 1px solid rgba(255,255,255,0.06); + flex-wrap: wrap; +} +.arec-tabs { display: inline-flex; background: rgba(255,255,255,0.04); border-radius: 8px; padding: 2px; } +.arec-tab { + border: none; background: none; cursor: pointer; + padding: 5px 12px; border-radius: 6px; + font: 600 11.5px/1 'JetBrains Mono', ui-monospace, monospace; + color: #8b93b0; transition: all .15s ease; +} +.arec-tab.active { background: rgba(122,162,247,0.22); color: #cdd6f4; } +.arec-tab:not(.active):hover { color: #cdd6f4; } +.arec-filter { + flex: 1; min-width: 120px; + padding: 7px 11px; + background: rgba(0,0,0,0.35); + border: 1px solid rgba(255,255,255,0.08); + border-radius: 8px; + color: #cdd6f4; + font: 12px/1 ui-monospace, Menlo, monospace; + outline: none; transition: border-color .15s ease, box-shadow .15s ease; +} +.arec-filter:focus { border-color: rgba(122,162,247,0.5); box-shadow: 0 0 0 3px rgba(122,162,247,0.12); } +.arec-actions { display: inline-flex; gap: 8px; } +.arec-btn { + display: inline-flex; align-items: center; gap: 6px; + padding: 7px 12px; + background: rgba(122,162,247,0.1); + border: 1px solid rgba(122,162,247,0.25); + border-radius: 8px; + color: #c0caf5; cursor: pointer; + font: 600 11.5px/1 'JetBrains Mono', ui-monospace, monospace; + transition: all .15s ease; +} +.arec-btn:hover { background: rgba(122,162,247,0.2); border-color: rgba(122,162,247,0.55); color: #fff; } +.arec-btn svg { opacity: 0.85; } + +/* ── Body ── */ +.arec-body { + flex: 1; overflow: auto; padding: 8px 6px 8px 14px; + font-family: ui-monospace, 'JetBrains Mono', Menlo, Consolas, monospace; +} +.arec-loading, .arec-error { padding: 28px; text-align: center; color: #8b93b0; font-size: 13px; } +.arec-error { color: #ff8585; } + +.arec-fields { display: flex; flex-direction: column; } +.arec-row { + display: grid; grid-template-columns: minmax(140px, 210px) 1fr auto; + gap: 10px; align-items: start; + padding: 6px 8px; border-radius: 7px; + border-bottom: 1px solid rgba(255,255,255,0.035); + transition: background .12s ease; +} +.arec-row:hover { background: rgba(122,162,247,0.07); } +.arec-row.is-empty { opacity: 0.5; } +.arec-key { color: #7aa2f7; font-size: 12px; font-weight: 600; word-break: break-all; } +.arec-val { color: #c8d0e8; font-size: 12px; word-break: break-word; white-space: pre-wrap; } +.arec-val .arec-json { color: #9ece6a; } +.arec-val .arec-null, .arec-null { color: #565f89; font-style: italic; } +.arec-rowcopy { + background: none; border: none; cursor: pointer; + color: #565f89; font-size: 13px; padding: 1px 5px; border-radius: 5px; + opacity: 0; transition: opacity .12s ease, color .12s ease, background .12s ease; +} +.arec-row:hover .arec-rowcopy { opacity: 1; } +.arec-rowcopy:hover { color: #9ece6a; background: rgba(255,255,255,0.06); } + +.arec-code { + margin: 0; padding: 6px 8px; font-size: 12px; line-height: 1.55; + color: #c0caf5; white-space: pre; tab-size: 2; +} +.arec-code .tok-key { color: #7aa2f7; } +.arec-code .tok-str { color: #9ece6a; } +.arec-code .tok-num { color: #ff9e64; } +.arec-code .tok-bool { color: #bb9af7; } +.arec-code .tok-null { color: #565f89; font-style: italic; } + +/* ── Footer ── */ +.arec-footer { + display: flex; align-items: center; gap: 16px; flex-wrap: wrap; + padding: 10px 16px; + border-top: 1px solid rgba(255,255,255,0.06); + background: rgba(0,0,0,0.25); + font: 11px/1 'JetBrains Mono', ui-monospace, monospace; + color: #8b93b0; +} +.arec-footer b { color: #cdd6f4; } +.arec-footer .arec-id { margin-left: auto; color: #565f89; } + +/* scrollbar */ +.arec-body::-webkit-scrollbar { width: 10px; } +.arec-body::-webkit-scrollbar-thumb { background: rgba(122,162,247,0.25); border-radius: 8px; border: 2px solid transparent; background-clip: padding-box; } +.arec-body::-webkit-scrollbar-thumb:hover { background: rgba(122,162,247,0.45); background-clip: padding-box; } + +@media (max-width: 640px) { + .artist-db-record-btn span { display: none; } + .artist-db-record-btn { padding: 8px; } + .arec-row { grid-template-columns: 1fr; gap: 2px; } + .arec-rowcopy { opacity: 1; justify-self: end; margin-top: -22px; } +} + + +/* #852: while the launch-PIN / login lock screen is up, hide EVERYTHING in the + body except the lock overlays themselves — so removing the overlay (Safari + "Hide Distracting Items", devtools) reveals a blank page, not the empty chrome + or any of the on-demand modals/sidebars. The hide-element trick can only ADD + hiding, never undo this rule. initApp() drops body.app-locked once authenticated. */ +body.app-locked > *:not(#launch-pin-overlay):not(#login-overlay):not(script):not(style):not(noscript) { + display: none !important; +} + + +/* Login-password clarity in Manage Profiles (#login-mode) */ +.profile-manage-login-banner { + margin: 0 0 12px; + padding: 9px 12px; + border-radius: 10px; + font-size: 12px; + line-height: 1.4; + color: #fcd9a8; + background: rgba(245, 158, 11, 0.10); + border: 1px solid rgba(245, 158, 11, 0.30); +} +.profile-role-pill--warn { + background: rgba(239, 68, 68, 0.16); + color: #fca5a5; + border-color: rgba(239, 68, 68, 0.40); + text-transform: none; +} +.profile-role-pill--ok { + background: rgba(16, 185, 129, 0.14); + color: #6ee7b7; + border-color: rgba(16, 185, 129, 0.30); + text-transform: none; +} +/* The set-login-password button (lock icon) + an attention pulse when a member + can't sign in yet (login on, no password). */ +.profile-manage-item .profile-password-btn { + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.07); + color: #9ca3af; + border-radius: 8px; + padding: 6px; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + transition: all .15s ease; +} +.profile-manage-item .profile-password-btn svg { width: 15px; height: 15px; } +.profile-manage-item .profile-password-btn:hover { + background: rgba(99, 102, 241, 0.18); + border-color: rgba(99, 102, 241, 0.4); + color: #c7d2fe; +} +.profile-manage-item .profile-password-btn.has-password { color: #6ee7b7; } +.profile-manage-item .profile-password-btn.needs-password { + color: #fca5a5; + border-color: rgba(239, 68, 68, 0.45); + background: rgba(239, 68, 68, 0.12); + animation: arecPwPulse 1.8s ease-in-out infinite; +} +@keyframes arecPwPulse { + 0%, 100% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.0); } + 50% { box-shadow: 0 0 0 4px rgba(239, 68, 68, 0.18); } +} + + +/* Watchlist export modal — reuses the .arec-* card; just the option label + button */ +.wlx-opt { + display: inline-flex; align-items: center; gap: 6px; + font: 12px/1 'JetBrains Mono', ui-monospace, monospace; + color: #8b93b0; cursor: pointer; user-select: none; +} +.wlx-opt input { accent-color: #7aa2f7; } +.watchlist-export-btn .watchlist-all-icon { font-weight: 700; } diff --git a/webui/static/sync-services.js b/webui/static/sync-services.js index f375bdd3..c2eb1399 100644 --- a/webui/static/sync-services.js +++ b/webui/static/sync-services.js @@ -150,42 +150,19 @@ async function handleTidalCardClick(playlistId) { console.log(`🎵 [Card Click] Tidal card clicked: ${playlistId}, Phase: ${state.phase}`); if (state.phase === 'fresh') { - // Fetch tracks if not yet loaded (metadata-only listing doesn't include them) - if (!state.playlist.tracks || state.playlist.tracks.length === 0) { - console.log(`🎵 Fetching tracks for Tidal playlist: ${state.playlist.name}`); - showLoadingOverlay(`Loading ${state.playlist.name}...`); - try { - const resp = await fetch(`/api/tidal/playlist/${playlistId}`); - if (resp.ok) { - const fullData = await resp.json(); - if (fullData.tracks && fullData.tracks.length > 0) { - // Convert to Track-like objects for the discovery modal - state.playlist.tracks = fullData.tracks.map(t => ({ - id: t.id, name: t.name, artists: t.artists || [], - album: t.album || '', duration_ms: t.duration_ms || 0, - track_number: t.track_number || 0 - })); - // Update card count - const countEl = document.querySelector(`#tidal-card-${playlistId} .playlist-card-track-count`); - if (countEl) countEl.textContent = `${state.playlist.tracks.length} tracks`; - } - } - } catch (e) { - console.error(`Failed to fetch Tidal playlist tracks: ${e}`); - hideLoadingOverlay(); - } + // Open the modal IMMEDIATELY — don't block on a slow Tidal track fetch. + // The old flow awaited /api/tidal/playlist/<id> (which paginates with a + // 1s sleep per page + rate-limit throttle, ~10s for a big playlist) + // before the modal appeared, then the backend re-fetched the same + // playlist when discovery started. Now the backend discovery fetch is + // the single source of truth and the modal builds its rows from those + // results as they stream in (#867), so we open right away. If the + // background loader already cached the track list, the rows seed + // instantly; otherwise the discovery poll fills them in. (#867 UX) + if (!Array.isArray(state.playlist.tracks)) { + state.playlist.tracks = []; } - if (!state.playlist.tracks || state.playlist.tracks.length === 0) { - hideLoadingOverlay(); - showToast('Could not load tracks for this playlist', 'error'); - return; - } - - hideLoadingOverlay(); - console.log(`🎵 Ready with ${state.playlist.tracks.length} Tidal tracks for discovery`); - - // Open discovery modal - phase will be updated when discovery actually starts openTidalDiscoveryModal(playlistId, state.playlist); } else if (state.phase === 'discovering' || state.phase === 'discovered' || state.phase === 'syncing' || state.phase === 'sync_complete') { @@ -563,6 +540,23 @@ async function openTidalDiscoveryModal(playlistId, playlistData) { // Only start discovery if not already discovered AND not currently discovering if (!isAlreadyDiscovered && !isCurrentlyDiscovering) { + // Open the modal FIRST so it appears instantly. The discovery-start POST + // below fetches the ENTIRE playlist server-side before it responds (Tidal + // sleeps 1s per page → ~10s for a large playlist). Previously the modal + // was only opened AFTER this await, so the user stared at nothing for those + // ~10s. Now the modal shows immediately with a loading note and tracks + // stream in once discovery starts. We return early so the shared open at + // the bottom isn't reached for this path. (#867 UX) + // + // Open in the 'discovering' phase (not 'fresh') because discovery is + // auto-starting: that renders the non-interactive "Discovering matches…" + // footer instead of clickable Start Discovery / Wing It buttons, which + // must NOT be clickable while the table is still empty/loading. (#867 UX) + youtubePlaylistStates[fakeUrlHash].phase = 'discovering'; + openYouTubeDiscoveryModal(fakeUrlHash); + const _descEl = document.querySelector(`#youtube-discovery-modal-${fakeUrlHash} .modal-description`); + if (_descEl) _descEl.textContent = 'Loading playlist from Tidal…'; + // Start Tidal discovery process automatically (like sync.py) try { console.log(`🔍 Starting Tidal discovery for: ${playlistData.name}`); @@ -576,10 +570,12 @@ async function openTidalDiscoveryModal(playlistId, playlistData) { if (result.error) { console.error('❌ Error starting Tidal discovery:', result.error); showToast(`Error starting discovery: ${result.error}`, 'error'); + if (_descEl) _descEl.textContent = 'Could not start discovery.'; return; } console.log('✅ Tidal discovery started, beginning polling...'); + if (_descEl) _descEl.textContent = 'Discovering tracks…'; // Update phase to discovering now that backend discovery is actually started tidalPlaylistStates[playlistId].phase = 'discovering'; @@ -594,7 +590,9 @@ async function openTidalDiscoveryModal(playlistId, playlistData) { } catch (error) { console.error('❌ Error starting Tidal discovery:', error); showToast(`Error starting discovery: ${error.message}`, 'error'); + if (_descEl) _descEl.textContent = 'Could not start discovery.'; } + return; } else if (isCurrentlyDiscovering) { // Resume polling if discovery is already in progress (like YouTube) console.log(`🔄 Resuming Tidal discovery polling for: ${playlistData.name}`); @@ -4063,7 +4061,11 @@ async function handleDbUpdateButtonClick() { } try { - button.disabled = true; + // Leave the button ENABLED while "Starting..." so it doubles as a + // cancel affordance — a wedged start (#859) must stay clickable. A + // second click reads "Starting..." and falls through to the stop + // branch below, so there's no double-start risk. + button.disabled = false; button.textContent = 'Starting...'; const response = await fetch('/api/database/update', { method: 'POST', @@ -4072,14 +4074,19 @@ async function handleDbUpdateButtonClick() { // scan takes precedence server-side, so send only its flag. body: JSON.stringify(isDeepScan ? { deep_scan: true } : { full_refresh: isFullRefresh }) }); + const data = await response.json().catch(() => ({})); - if (response.ok) { + // Check BOTH the HTTP status and the body's success flag — a 200 with + // success:false must not be mistaken for a started job (#859). + if (response.ok && data.success !== false) { showToast('Database update started!', 'success'); // Start polling immediately to get live status checkAndUpdateDbProgress(); + // Socket-independent safety net: recovers the card from + // "Starting..." even if the WebSocket goes quiet/half-open (#859). + armDbUpdateSafetyPoll(); } else { - const errorData = await response.json(); - showToast(`Error: ${errorData.error}`, 'error'); + showToast(`Error: ${data.error || 'Failed to start update.'}`, 'error'); button.disabled = false; button.textContent = 'Update Database'; } @@ -10039,7 +10046,14 @@ function generateTableRowsFromState(state, urlHash) { return discoveryResults.map((result, index) => { // Handle different field names based on platform const trackName = result.lb_track || result.yt_track || result.track_name || '-'; - const artistName = result.lb_artist || result.yt_artist || result.artist_name || '-'; + // YouTube flat extraction often can't give a per-track artist, so the + // source-side artist comes back "Unknown Artist" even when discovery + // matched the song. Fall back to the matched (Spotify/iTunes) artist so + // the column shows the real artist instead of "Unknown" (#863). + let artistName = result.lb_artist || result.yt_artist || result.artist_name || ''; + if (!artistName || artistName === 'Unknown Artist') { + artistName = result.spotify_artist || artistName || '-'; + } return ` <tr id="discovery-row-${urlHash}-${result.index}"> @@ -10170,8 +10184,30 @@ function updateYouTubeDiscoveryModal(urlHash, status) { // Update table rows status.results.forEach(result => { - const row = document.getElementById(`discovery-row-${urlHash}-${result.index}`); - if (!row) return; + let row = document.getElementById(`discovery-row-${urlHash}-${result.index}`); + if (!row) { + // #867: the initial rows are pre-rendered from a separately-fetched + // track list (state.playlist.tracks) that can be SHORTER than the + // backend's authoritative discovery results — e.g. a Tidal playlist + // whose discovery fetched 59 tracks while the modal's own track fetch + // returned a rate-limited partial (~21). The old `if (!row) return` + // silently dropped every result past the pre-rendered rows. Create + // the missing row instead so the authoritative results drive the + // list and no discovered track disappears. The existing cell-fill + // logic below then populates it like any other row. + const trackName = result.yt_track || result.lb_track || result.track_name || '-'; + row = document.createElement('tr'); + row.id = `discovery-row-${urlHash}-${result.index}`; + row.innerHTML = + `<td class="yt-track">${trackName}</td>` + + '<td class="yt-artist"></td>' + + '<td class="discovery-status"></td>' + + '<td class="spotify-track">-</td>' + + '<td class="spotify-artist">-</td>' + + '<td class="spotify-album">-</td>' + + '<td class="discovery-actions">-</td>'; + tableBody.appendChild(row); + } const statusCell = row.querySelector('.discovery-status'); const spotifyTrackCell = row.querySelector('.spotify-track'); @@ -10182,6 +10218,19 @@ function updateYouTubeDiscoveryModal(urlHash, status) { statusCell.textContent = result.status; statusCell.className = `discovery-status ${result.status_class}`; + // Fill the source-side artist live too — YouTube flat extraction often + // leaves it "Unknown Artist", so fall back to the matched artist once + // discovery resolves the track (#863). Without this, the per-row poll + // updates only the Spotify cells and the left column stays "Unknown". + const ytArtistCell = row.querySelector('.yt-artist'); + if (ytArtistCell) { + let ytArtist = result.yt_artist || result.artist_name || ''; + if (!ytArtist || ytArtist === 'Unknown Artist') { + ytArtist = result.spotify_artist || ytArtist || '-'; + } + ytArtistCell.textContent = ytArtist; + } + spotifyTrackCell.textContent = result.spotify_track || '-'; spotifyArtistCell.textContent = result.spotify_artist || '-'; spotifyAlbumCell.textContent = result.spotify_album || '-'; diff --git a/webui/static/wishlist-tools.js b/webui/static/wishlist-tools.js index e47fd5d6..b59ab783 100644 --- a/webui/static/wishlist-tools.js +++ b/webui/static/wishlist-tools.js @@ -2811,145 +2811,8 @@ function stopDbUpdatePolling() { } } -// =================================================================== -// QUALITY SCANNER TOOL -// =================================================================== - -async function handleQualityScanButtonClick() { - const button = document.getElementById('quality-scan-button'); - const currentAction = button.textContent; - - if (currentAction === 'Scan Library') { - const scopeSelect = document.getElementById('quality-scan-scope'); - const scope = scopeSelect.value; - - try { - button.disabled = true; - button.textContent = 'Starting...'; - const response = await fetch('/api/quality-scanner/start', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ scope: scope }) - }); - - if (response.ok) { - showToast('Quality scan started!', 'success'); - // Start polling immediately to get live status - checkAndUpdateQualityScanProgress(); - } else { - const errorData = await response.json(); - showToast(`Error: ${errorData.error}`, 'error'); - button.disabled = false; - button.textContent = 'Scan Library'; - } - } catch (error) { - showToast('Failed to start quality scan.', 'error'); - button.disabled = false; - button.textContent = 'Scan Library'; - } - - } else { // "Stop Scan" - try { - const response = await fetch('/api/quality-scanner/stop', { method: 'POST' }); - if (response.ok) { - showToast('Stop request sent.', 'info'); - } else { - showToast('Failed to send stop request.', 'error'); - } - } catch (error) { - showToast('Error sending stop request.', 'error'); - } - } -} - -async function checkAndUpdateQualityScanProgress() { - if (socketConnected) return; // WebSocket handles this - try { - const response = await fetch('/api/quality-scanner/status', { - signal: AbortSignal.timeout(10000) // 10 second timeout - }); - if (!response.ok) return; - - const state = await response.json(); - console.debug('🔍 Quality Scanner Status:', state.status, `${state.processed}/${state.total}`, `${state.progress.toFixed(1)}%`); - updateQualityScanProgressUI(state); - - // Start polling only if not already polling and status is running - if (state.status === 'running' && !qualityScannerStatusInterval) { - console.log('🔄 Starting quality scanner polling (1 second interval)'); - qualityScannerStatusInterval = setInterval(checkAndUpdateQualityScanProgress, 1000); - } - - } catch (error) { - console.warn('Could not fetch quality scanner status:', error); - // Don't stop polling on network errors - keep trying - } -} - -function updateQualityScanProgressFromData(data) { - const prev = _lastToolStatus['quality-scanner']; - _lastToolStatus['quality-scanner'] = data.status; - if (prev !== undefined && data.status === prev && data.status !== 'running') return; - updateQualityScanProgressUI(data); -} - -function updateQualityScanProgressUI(state) { - const button = document.getElementById('quality-scan-button'); - const phaseLabel = document.getElementById('quality-phase-label'); - const progressLabel = document.getElementById('quality-progress-label'); - const progressBar = document.getElementById('quality-progress-bar'); - const scopeSelect = document.getElementById('quality-scan-scope'); - - // Stats - const processedStat = document.getElementById('quality-stat-processed'); - const metStat = document.getElementById('quality-stat-met'); - const lowStat = document.getElementById('quality-stat-low'); - const matchedStat = document.getElementById('quality-stat-matched'); - - if (!button || !phaseLabel || !progressLabel || !progressBar || !scopeSelect) return; - - // Update stats - if (processedStat) processedStat.textContent = state.processed || 0; - if (metStat) metStat.textContent = state.quality_met || 0; - if (lowStat) lowStat.textContent = state.low_quality || 0; - if (matchedStat) matchedStat.textContent = state.matched || 0; - - if (state.status === 'running') { - button.textContent = 'Stop Scan'; - button.disabled = false; - scopeSelect.disabled = true; - - phaseLabel.textContent = state.phase || 'Scanning...'; - progressLabel.textContent = `${state.processed} / ${state.total} tracks scanned (${state.progress.toFixed(1)}%)`; - progressBar.style.width = `${state.progress}%`; - } else { // idle, finished, or error - stopQualityScannerPolling(); - button.textContent = 'Scan Library'; - button.disabled = false; - scopeSelect.disabled = false; - - if (state.status === 'error') { - phaseLabel.textContent = `Error: ${state.error_message}`; - progressBar.style.backgroundColor = '#ff4444'; // Red for error - } else { - phaseLabel.textContent = state.phase || 'Ready to scan'; - progressBar.style.backgroundColor = 'rgb(var(--accent-rgb))'; // Green for normal - } - - if (state.status === 'finished') { - // Show completion toast with results - showToast(`Scan complete! ${state.matched} tracks added to wishlist`, 'success'); - } - } -} - -function stopQualityScannerPolling() { - if (qualityScannerStatusInterval) { - console.log('⏹️ Stopping quality scanner polling'); - clearInterval(qualityScannerStatusInterval); - qualityScannerStatusInterval = null; - } -} +// (Quality Scanner tool removed — quality scanning is now the 'Quality Upgrade +// Finder' job in Library Maintenance / Tools → repair jobs.) // =================================================================== // IMPORT IDS FROM FILE TAGS (reconcile embedded provider IDs) @@ -5630,43 +5493,6 @@ const TOOL_HELP_CONTENT = { <p>Available for <strong>Plex</strong> and <strong>Jellyfin</strong> media servers. Each enrichment worker only runs if its service is authenticated.</p> ` }, - 'quality-scanner': { - title: 'Quality Scanner', - content: ` - <h4>What does this tool do?</h4> - <p>The Quality Scanner identifies tracks in your library that don't meet your preferred quality settings and automatically matches them to Spotify to add to your wishlist for re-downloading.</p> - - <h4>Scan Scope</h4> - <ul> - <li><strong>Watchlist Artists Only:</strong> Only scans tracks from artists you're watching. Faster and more focused.</li> - <li><strong>All Library Tracks:</strong> Scans your entire music library. Comprehensive but takes longer.</li> - </ul> - - <h4>How it works</h4> - <ol> - <li>Scans tracks and checks file format against your quality preferences</li> - <li>Identifies tracks below your quality threshold (e.g., MP3 when you prefer FLAC)</li> - <li>Uses fuzzy matching to find the track on Spotify (70% confidence minimum)</li> - <li>Automatically adds matched tracks to your wishlist for re-download</li> - </ol> - - <h4>Quality Tiers</h4> - <ul> - <li><strong>Tier 1 (Best):</strong> FLAC, WAV, ALAC, AIFF - Lossless formats</li> - <li><strong>Tier 2:</strong> OPUS, OGG - High quality lossy</li> - <li><strong>Tier 3:</strong> M4A, AAC - Standard lossy</li> - <li><strong>Tier 4:</strong> MP3, WMA - Lower quality lossy</li> - </ul> - - <h4>Stats Explained</h4> - <ul> - <li><strong>Processed:</strong> Total tracks scanned so far</li> - <li><strong>Quality Met:</strong> Tracks that meet your quality standards</li> - <li><strong>Low Quality:</strong> Tracks below your quality threshold</li> - <li><strong>Matched:</strong> Low quality tracks successfully matched to Spotify and added to wishlist</li> - </ul> - ` - }, 'duplicate-cleaner': { title: 'Duplicate Cleaner', content: ` @@ -7566,11 +7392,6 @@ async function initializeToolsPage() { metadataButton._toolsWired = true; } - const qualityScanButton = document.getElementById('quality-scan-button'); - if (qualityScanButton && !qualityScanButton._toolsWired) { - qualityScanButton.addEventListener('click', handleQualityScanButtonClick); - qualityScanButton._toolsWired = true; - } const duplicateCleanButton = document.getElementById('duplicate-clean-button'); if (duplicateCleanButton && !duplicateCleanButton._toolsWired) { @@ -7615,7 +7436,6 @@ async function initializeToolsPage() { // Check for ongoing operations await checkAndUpdateDbProgress(); - await checkAndUpdateQualityScanProgress(); await checkAndUpdateDuplicateCleanProgress(); // Initialize library maintenance section @@ -8280,6 +8100,30 @@ function updateDbProgressFromData(data) { updateDbProgressUI(data); } +// Socket-independent safety net for the DB-updater card. The 1s WebSocket +// broadcast normally drives the card, but if the socket goes quiet/half-open the +// card can wedge on "Starting..." with a frozen bar and no recovery (#859). This +// polls /status directly, applies the same idempotent UI update, and stops itself +// once the job is no longer running. +let _dbUpdateSafetyPoll = null; +function armDbUpdateSafetyPoll() { + if (_dbUpdateSafetyPoll) { clearInterval(_dbUpdateSafetyPoll); _dbUpdateSafetyPoll = null; } + const tick = async () => { + try { + const r = await fetch('/api/database/update/status'); + if (!r.ok) return; + const state = await r.json(); + updateDbProgressUI(state); + if (state.status !== 'running') { + clearInterval(_dbUpdateSafetyPoll); + _dbUpdateSafetyPoll = null; + } + } catch (e) { /* transient — keep the safety net armed */ } + }; + tick(); // immediate: flip off "Starting..." as soon as the server confirms state + _dbUpdateSafetyPoll = setInterval(tick, 5000); +} + function updateDbProgressUI(state) { const button = document.getElementById('db-update-button'); const phaseLabel = document.getElementById('db-phase-label'); @@ -8311,6 +8155,17 @@ function updateDbProgressUI(state) { progressBar.style.backgroundColor = 'rgb(var(--accent-rgb))'; // Green for normal } + // Reset the bar/label so a finished/idle/error card doesn't keep showing a + // frozen partial bar from the previous run (e.g. "2/3 artists 66.7%" left + // over after completion) — the #859 confusion. Finished → full bar; any + // other terminal state → cleared bar. The phase label carries the summary. + if (state.status === 'finished') { + progressBar.style.width = '100%'; + } else { + progressBar.style.width = '0%'; + } + progressLabel.textContent = ''; + if (state.status === 'finished' || state.status === 'error') { // Final stats refresh after completion/error setTimeout(fetchAndUpdateDbStats, 500);