diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 9ffbb51a..a216db03 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.2)' + description: 'Version tag (e.g. 2.7.3)' required: true - default: '2.7.2' + default: '2.7.3' jobs: build-and-push: diff --git a/config/config.example.json b/config/config.example.json index 0543822c..e6adf55c 100644 --- a/config/config.example.json +++ b/config/config.example.json @@ -44,7 +44,8 @@ }, "metadata_enhancement": { "enabled": true, - "embed_album_art": true + "embed_album_art": true, + "single_to_album": false }, "file_organization": { "enabled": true, diff --git a/config/settings.py b/config/settings.py index a31ed972..71e5428e 100644 --- a/config/settings.py +++ b/config/settings.py @@ -662,7 +662,12 @@ class ConfigManager: # source whose art is smaller is skipped so the next source is # tried — stops a low-res Cover Art Archive upload from winning. # 0 disables the size gate. - "min_art_size": 1000 + "min_art_size": 1000, + # When a track matches a SINGLE release, look up the parent ALBUM + # that contains it and tag it as that album, so it groups with its + # album-mates and gets the album cover (not the single's). Off by + # default — it's an extra per-import metadata lookup. + "single_to_album": False }, "musicbrainz": { "embed_tags": True @@ -882,6 +887,20 @@ class ConfigManager: config[keys[-1]] = value self._save_config() + def resolve_secret(self, key: str, posted: Any) -> str: + """Resolve a secret value coming back from the settings UI. + + The UI renders a saved-but-untouched secret as the REDACTED_SENTINEL (shown + masked); empty or that sentinel means "use the stored value", while a real + string is a genuine new secret. A connection-test endpoint should test the + EFFECTIVE secret, not the mask — otherwise testing a saved-but-untouched + token sends the sentinel and the source rejects it (#870).""" + if isinstance(posted, str): + posted = posted.strip() + if not posted or posted == self.REDACTED_SENTINEL: + return self.get(key, '') or '' + return posted + def get_spotify_config(self) -> Dict[str, str]: return self.get('spotify', {}) diff --git a/core/auto_import_worker.py b/core/auto_import_worker.py index 8bfbea34..15776fa4 100644 --- a/core/auto_import_worker.py +++ b/core/auto_import_worker.py @@ -660,8 +660,13 @@ class AutoImportWorker: auto_process = self._config_manager.get('auto_import.auto_process', True) try: - # Phase 3: Identify - identification = self._identify_folder(candidate) + # Phase 3: Identify. + # Re-identify (#889): if the user designated this exact file's release in + # the Re-identify modal, a hint short-circuits the guessing — we match + # straight against the chosen album. No hint → byte-identical to before. + rematch_hint, identification = self._resolve_rematch_hint(candidate) + if identification is None: + identification = self._identify_folder(candidate) if not identification: self._record_result(candidate, 'needs_identification', 0.0, error_message='Could not identify album from tags, folder name, or fingerprint') @@ -690,7 +695,10 @@ class AutoImportWorker: high_conf_matches = [m for m in match_result.get('matches', []) if m['confidence'] >= 0.8] has_strong_individual_matches = len(high_conf_matches) > 0 - if (confidence >= threshold or has_strong_individual_matches) and auto_process: + # A re-identify is an explicit user choice — let it auto-process like a + # strong match (still gated on the global auto_process preference). + if (confidence >= threshold or has_strong_individual_matches + or rematch_hint is not None) and auto_process: # Phase 5: Auto-process — insert an in-progress row # so the UI sees the import the moment it starts, # then update it with the final status when done. @@ -709,6 +717,13 @@ class AutoImportWorker: confidence = max(confidence, effective_conf) if success: self._bump_stat('auto_processed') + # Re-identify (#889): only NOW that the new home exists do we + # consume the hint and (if replace was chosen) delete the old + # row + file — so a failed import never loses the original. Pass + # the landing paths so we never delete a file the re-import landed + # at the SAME place (picking the release it's already in). + if rematch_hint is not None: + self._finalize_rematch_hint(rematch_hint, getattr(candidate, '_reid_final_paths', None)) else: self._bump_stat('failed') @@ -1003,6 +1018,75 @@ class AutoImportWorker: except Exception: return False + # ── Re-identify hints (#889) ── + + def _resolve_rematch_hint(self, candidate: 'FolderCandidate'): + """If this staged file carries a user-designated re-identify hint, return + ``(hint, identification)`` so matching skips the guessing tiers; otherwise + ``(None, None)`` and the caller falls back to normal identification. + + Fail-safe: ANY error (no table, DB hiccup) returns ``(None, None)`` so a + re-identify problem can never break ordinary auto-import. Only single-file + candidates are eligible — a re-identify always stages exactly one track.""" + try: + files = candidate.audio_files or [] + if len(files) != 1: + return None, None + from core.imports.rematch_hints import ( + build_identification_from_hint, + find_hint_for_file, + quick_file_signature, + ) + file_path = files[0] + sig = quick_file_signature(file_path) + conn = self.database._get_connection() + try: + cursor = conn.cursor() + hint = find_hint_for_file(cursor, file_path, sig) + finally: + conn.close() + if hint is None: + return None, None + logger.info("[Auto-Import] Re-identify hint for %s → %s '%s' (%s)", + candidate.name, hint.album_type or 'release', + hint.album_name or '?', hint.source) + return hint, build_identification_from_hint(hint) + except Exception as e: + logger.debug("[Auto-Import] rematch-hint lookup skipped: %s", e) + return None, None + + def _finalize_rematch_hint(self, hint, new_paths=None) -> None: + """Post-success: delete the replaced library row + file (if the user chose + replace) and consume the hint so it's single-use. ``new_paths`` are where the + re-import landed — passed through so the same-home guard never deletes a file + the import wrote at the old location. Best-effort — a cleanup failure is + logged, never raised, since the re-import already succeeded.""" + try: + from core.imports.rematch_hints import consume_hint, delete_replaced_track + + def _resolve_old(stored): + # The old row's path is a STORED path (Docker/media-server view) — map + # it to a file this process can actually unlink, same as everywhere else. + try: + from core.library.path_resolver import resolve_library_file_path + return resolve_library_file_path(stored, config_manager=getattr(self, '_config_manager', None)) + except Exception: + return None + + conn = self.database._get_connection() + try: + cursor = conn.cursor() + removed = delete_replaced_track(cursor, hint.replace_track_id, + resolve_fn=_resolve_old, new_paths=new_paths) + consume_hint(cursor, hint.id) + conn.commit() + finally: + conn.close() + if removed: + logger.info("[Auto-Import] Re-identify replaced old track — removed %s", removed) + except Exception as e: + logger.warning("[Auto-Import] rematch-hint finalize failed (import still OK): %s", e) + # ── Identification ── def _identify_folder(self, candidate: FolderCandidate) -> Optional[Dict]: @@ -1431,8 +1515,11 @@ class AutoImportWorker: def _match_tracks(self, candidate: FolderCandidate, identification: Dict) -> Optional[Dict]: """Match staging files to the identified album's tracklist.""" - # Singles: no album tracklist to match against — the file IS the match - if candidate.is_single or identification.get('is_single'): + # Singles: no album tracklist to match against — the file IS the match. + # force_album_match (set by a re-identify hint) overrides this: even a lone + # staged file is matched INTO the chosen album, so it inherits the album's + # year / track number / art instead of the bare singles stub (#889). + if not identification.get('force_album_match') and (candidate.is_single or identification.get('is_single')): conf = identification.get('identification_confidence', 0.7) track_data = { 'name': identification.get('track_name', identification.get('album_name', '')), @@ -1600,6 +1687,7 @@ class AutoImportWorker: processed = 0 errors = [] + reid_final_paths = [] # #889: where the pipeline landed each file (same-home guard) all_matches = list(match_result.get('matches', [])) # Album total duration — sum of every matched track's duration. @@ -1780,6 +1868,11 @@ class AutoImportWorker: self._process_callback(context_key, context, file_path) processed += 1 + # Capture where the pipeline actually landed the file (#889 same-home + # guard) — the pipeline writes it back into the mutable context. + _landed = context.get('_final_processed_path') + if _landed: + reid_final_paths.append(_landed) logger.info(f"[Auto-Import] Processed: {track_number}. {track_name}") except Exception as e: @@ -1803,6 +1896,13 @@ class AutoImportWorker: except Exception as e: logger.debug("automation emit failed: %s", e) + # Stash landing paths on the candidate so _finalize_rematch_hint can avoid + # deleting a file the re-import landed at the SAME place (#889). + try: + candidate._reid_final_paths = reid_final_paths + except Exception as e: + logger.debug("could not stash reid final paths: %s", e) + return processed > 0 # ── Database ── 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/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/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 5a1e424b..4d2a2bdf 100644 --- a/core/discovery/quality_scanner.py +++ b/core/discovery/quality_scanner.py @@ -1,43 +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'). - - Probe REAL audio quality (bit depth / sample rate / bitrate) and check it - against the user's v3 ranked targets via quality_meets_profile — the SAME - strict core the download import guard uses (fallback ignored). Extension - tier is only a fallback label when the file can't be probed. - - Skip tracks that satisfy a target (quality_met). - - 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 @@ -63,24 +43,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 - # Probe real audio quality (mutagen-read bit depth / sample rate / bitrate) - # so the scan checks against the SAME ranked-target core as the download - # quality guard — not just the file extension. Injected for testability. - probe_audio_quality: Callable = None - # Resolve a DB-stored (often RELATIVE, e.g. "Artist/Album/Track.flac") path - # to an absolute on-disk file by checking the transfer/download/library - # dirs. Without this the probe opens a relative path and fails. - resolve_library_file_path: Callable = None def _extract_lookup_value(value: Any, *names: str, default: Any = None) -> Any: @@ -315,400 +277,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() - - # Load the user's v3 ranked targets — the SAME quality definition the - # download import guard uses. A track is "low quality" when its REAL - # measured quality (bit depth + sample rate + bitrate) satisfies none of - # the targets. Strict: fallback is ignored (it's a download-time - # concession, not a definition of what counts as good enough), so the - # scanner surfaces every genuinely-upgradeable track. - from core.quality.selection import targets_from_profile, quality_meets_profile - quality_profile = db.get_quality_profile() - profile_targets, _fallback_enabled = targets_from_profile(quality_profile) - - logger.info( - "[Quality Scanner] Profile targets (strict): %s", - [t.label for t in profile_targets] or "(none — no constraint)", - ) - - # 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") - - # Count tracks whose file couldn't be probed — surfaces a systematic - # path/access problem (e.g. the library mount differs in this container). - probe_failed_count = 0 - - # 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 - - # Probe the REAL audio quality (bit depth / sample rate / bitrate) - # and check it against the ranked targets — same core as the - # download guard. The DB stores paths RELATIVE to the library root - # (e.g. "Artist/Album/Track.flac"), so resolve to an absolute - # on-disk path first or mutagen can't open the file. - resolved_path = None - if deps.resolve_library_file_path: - try: - resolved_path = deps.resolve_library_file_path(file_path) - except Exception: - resolved_path = None - if not resolved_path: - try: - from core.imports.paths import docker_resolve_path - resolved_path = docker_resolve_path(file_path) if file_path else file_path - except Exception: - resolved_path = file_path - - aq = deps.probe_audio_quality(resolved_path) if deps.probe_audio_quality else None - if aq is not None: - tier_name = aq.label() - else: - # Probe failed — the file couldn't be read at resolved_path. - # Bit-depth/sample-rate can't be assessed without reading the - # file, so this track can't be judged. Count + log it so a - # systematic path problem is visible instead of silently - # passing the whole library. - probe_failed_count += 1 - if probe_failed_count <= 5: - logger.warning( - "[Quality Scanner] Could not probe audio quality (file not " - "readable?): db_path=%r resolved=%r", file_path, resolved_path, - ) - tier_name = deps.get_quality_tier_from_extension(file_path)[0] - - # 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 it meets the profile (strict — satisfies a real target). - if quality_meets_profile(aq, profile_targets): - # 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 - - if probe_failed_count: - logger.warning( - "[Quality Scanner] %d/%d tracks could not be probed (file unreadable) — " - "their quality could NOT be verified and they were left unflagged. If this " - "is most of the library, the stored file paths don't resolve to readable " - "files in this container.", probe_failed_count, total_tracks, - ) - - # 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/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/imports/album_grouping.py b/core/imports/album_grouping.py new file mode 100644 index 00000000..be43c108 --- /dev/null +++ b/core/imports/album_grouping.py @@ -0,0 +1,98 @@ +"""Canonical album grouping for the SoulSync standalone import. + +SoulSync grouped imported tracks into albums by the album NAME string +(``_stable_soulsync_id("artist::album_name")``). That splits one release into +several album rows whenever the name string drifts between imports (case, +punctuation, ``(Deluxe Edition)`` suffixes, source-A-vs-B spelling), and every +downstream tool (Library Re-tag, Cover-Art Filler) then dresses each split row +in its own cover — so songs that belong to one album end up with different art +(Sokhi). + +This module is the pure, seam-testable heart of "group by canonical id, not +name": when an imported track carries a metadata-source RELEASE id, prefer +matching an existing album row by that id over the fragile name string, so the +SAME release always lands in ONE album row regardless of how its name was typed. + +Scope (deliberate): this unifies differently-named imports of the SAME release. +It does NOT merge a track that genuinely matched a SINGLE release (a different +release id) into its parent album — that needs single->album resolution upstream +and is a separate change. New imports only; existing rows are left untouched. + +Pure SQL-over-a-cursor; no app singletons, so it tests against an in-memory DB. +""" + +from __future__ import annotations + +from typing import Any, Optional + +from utils.logging_config import get_logger + +logger = get_logger("imports.album_grouping") + +# Album source-id columns this grouping may key on. An allowlist (not arbitrary +# interpolation) — the column name IS spliced into SQL, so it must be a known, +# trusted identifier. Mirrors get_library_source_id_columns()' 'album' values. +ALLOWED_ALBUM_SOURCE_COLS = frozenset({ + "spotify_album_id", + "itunes_album_id", + "deezer_id", + "soul_id", + "discogs_id", + "musicbrainz_release_id", +}) + + +def find_existing_soulsync_album_id( + cursor: Any, + *, + name_key_id: str, + artist_id: str, + album_name: str, + album_source_col: Optional[str] = None, + album_source_id: Optional[str] = None, +) -> Optional[str]: + """Resolve the existing ``soulsync`` album row a track should join, or None + (caller inserts a new row keyed by ``name_key_id``). + + Match precedence: + 1. ``name_key_id`` — the exact prior stable-name-hash id (unchanged + behaviour: a re-import with the identical name hits its own row). + 2. ``album_source_col == album_source_id`` — CANONICAL grouping: an + existing row already carrying THIS release's source id, so a + differently-named import of the same release unifies instead of + splitting. Only when the column is allow-listed and the id is non-empty. + 3. ``(title, artist_id)`` — the legacy name match (kept so nothing that + grouped before stops grouping now). + """ + cursor.execute( + "SELECT id FROM albums WHERE id = ? AND server_source = 'soulsync'", + (name_key_id,), + ) + row = cursor.fetchone() + if row: + return row[0] + + if album_source_col in ALLOWED_ALBUM_SOURCE_COLS and album_source_id: + try: + cursor.execute( + f"SELECT id FROM albums WHERE {album_source_col} = ? " + "AND server_source = 'soulsync' LIMIT 1", + (album_source_id,), + ) + row = cursor.fetchone() + if row: + return row[0] + except Exception as exc: + # That source has no dedicated album column on this DB (e.g. Deezer + # doesn't split per-entity id columns) — fall through to the name + # match rather than break the import. Mirrors the guarded source-id + # UPDATE the caller already does on insert. + logger.debug("album source-id lookup skipped (%s): %s", album_source_col, exc) + + cursor.execute( + "SELECT id FROM albums WHERE title COLLATE NOCASE = ? AND artist_id = ? " + "AND server_source = 'soulsync' LIMIT 1", + (album_name, artist_id), + ) + row = cursor.fetchone() + return row[0] if row else None diff --git a/core/imports/album_matching.py b/core/imports/album_matching.py index f29d6b56..fce847ad 100644 --- a/core/imports/album_matching.py +++ b/core/imports/album_matching.py @@ -156,8 +156,11 @@ def score_file_against_track( score = 0.0 # Title similarity (TITLE_WEIGHT). Falls back to filename stem when - # the file has no title tag. + # the file has no title tag — strip a leading track-number prefix off that + # stem (#890) so "01 - Sun It Rises" scores against "Sun It Rises". title = file_tags.get('title') or os.path.splitext(os.path.basename(file_path))[0] + from core.imports.paths import strip_leading_track_number + title = strip_leading_track_number(title) track_name = track.get('name', '') score += similarity(title, track_name) * TITLE_WEIGHT diff --git a/core/imports/context.py b/core/imports/context.py index f718f014..9ed2a909 100644 --- a/core/imports/context.py +++ b/core/imports/context.py @@ -8,6 +8,10 @@ from __future__ import annotations from typing import Any, Dict, Optional +from utils.logging_config import get_logger + +logger = get_logger("imports.context") + def _as_dict(value: Any) -> Dict[str, Any]: return value if isinstance(value, dict) else {} @@ -179,7 +183,12 @@ def get_import_clean_title( if not title: track_info = get_import_track_info(context) title = _first_value(track_info, "name", "title", default="") - return str(title or default) + title = str(title or default) + # #890: strip a leading track-number prefix that leaked from a filename stem + # (e.g. "01 - Sun It Rises" → "Sun It Rises") so it matches the canonical title. + # Conservative — clean source titles ("7 Rings" etc.) pass through untouched. + from core.imports.paths import strip_leading_track_number + return strip_leading_track_number(title) def get_import_clean_album( @@ -319,7 +328,11 @@ def build_import_album_info( (album_info or {}).get("track_number") or track_info.get("track_number") or original_search.get("track_number") - or 1 + # "Track 01" bug: default to 0 (the codebase's "unknown" sentinel, + # same as total_tracks below), NOT 1. A fabricated 1 looks + # authoritative and blocks the pipeline's downstream recovery + # (embedded file tag / resolve chain); 0 lets it fall through. + or 0 ) disc_number = ( (album_info or {}).get("disc_number") @@ -436,4 +449,86 @@ def detect_album_info_web(context, artist_context=None): force_album=True, ) - return None + # Last resort: the track matched a SINGLE with no usable album context — + # look up the parent ALBUM that actually contains it (gated, fail-safe). + return _resolve_single_to_parent_album(context, artist_context) + + +def _resolve_single_to_parent_album(context, artist_context): + """A single-matched track -> a promoted album_info for its parent album, or + None. GATED by ``metadata_enhancement.single_to_album`` (default OFF — it's a + per-import metadata lookup, so it's opt-in). Fail-safe: any miss/error returns + None so the track stays exactly as it was matched (never worse than today).""" + try: + from core.metadata.common import get_config_manager + if not get_config_manager().get("metadata_enhancement.single_to_album", False): + return None + except Exception: + return None + + try: + source = get_import_source(context) + track_info = get_import_track_info(context) + original_search = get_import_original_search(context) + track_title = (track_info.get("name") or original_search.get("title") or "").strip() + artist_name = (extract_artist_name(artist_context) + or get_import_clean_artist(context, default="")).strip() + if not source or not track_title or not artist_name: + return None + artist_id = str(get_import_source_ids(context).get("artist_id") or "") + + from core.metadata.album_tracks import ( + get_artist_albums_for_source, + get_artist_album_tracks, + ) + from core.imports.single_to_album import resolve_single_to_album + + def _acc(o, *ks): + for k in ks: + v = o.get(k) if isinstance(o, dict) else getattr(o, k, None) + if v: + return v + return None + + def fetch_candidates(): + albums = get_artist_albums_for_source( + source, artist_id, artist_name=artist_name, + album_type="album", limit=20) or [] + return [{"name": _acc(a, "name", "title"), + "album_type": _acc(a, "album_type") or "album", + "id": _acc(a, "id", "album_id")} for a in albums] + + def fetch_tracks(alb): + payload = get_artist_album_tracks( + str(alb.get("id") or ""), artist_name=artist_name, + album_name=alb.get("name") or "") or {} + return [(_acc(t, "title", "name", "track_name") or "") + for t in (payload.get("tracks") or [])] + + album = resolve_single_to_album( + track_title, + fetch_album_candidates=fetch_candidates, + fetch_album_tracks=fetch_tracks) + if not album or not album.get("name"): + return None + logger.info("single->album: re-homed '%s' onto parent album '%s'", + track_title, album["name"]) + promoted = build_import_album_info( + context, + album_info={ + "album_name": album["name"], + "track_number": track_info.get("track_number"), + "disc_number": track_info.get("disc_number"), + "album_image_url": "", + "confidence": 0.5, + }, + force_album=True, + ) + # build_import_album_info resolves album_name via get_import_clean_album, + # which prefers original_search.album (the SINGLE's name); override it + # with the resolved parent album so grouping + tags use the album. + promoted["album_name"] = album["name"] + return promoted + except Exception as e: + logger.debug("single->album resolution failed: %s", e) + return None diff --git a/core/imports/paths.py b/core/imports/paths.py index 69de9cc7..f65c6765 100644 --- a/core/imports/paths.py +++ b/core/imports/paths.py @@ -158,6 +158,33 @@ def clean_track_title(track_title: str, artist_name: str) -> str: return cleaned if cleaned else original +# A leading track-number prefix is EITHER a zero-padded number (01, 04, 099 — no +# real song title starts with one), OR a plain number followed by a real separator +# AND a space ("3 - ", "12. "). Deliberately NOT a bare "number space word", so it +# leaves "7 Rings", "99 Luftballons", "50 Ways to Leave Your Lover" and +# "1-800-273-8255" untouched. +_TRACK_NUM_PREFIX_RE = re.compile(r"^\s*(?:0\d{1,2}[\s._)\-]*|\d{1,3}\s*[._)\-]\s+)(?=\S)") + + +def strip_leading_track_number(title: str) -> str: + """Conservatively remove a leading track-number prefix from a track title. + + Fixes #890 — files named ``01 - Sun It Rises.flac`` whose stem leaks into the + title as ``01 - Sun It Rises``, which then never matches the canonical + ``Sun It Rises`` (false "missing"). Only strips an unambiguous track-number + prefix; a coincidental leading number that's part of the title is preserved, and + it never reduces a title to empty or a bare number.""" + s = (title or "").strip() + if not s: + return title or "" + stripped = _TRACK_NUM_PREFIX_RE.sub("", s, count=1).strip() + # Keep the original if stripping left nothing real — empty, a bare number, or + # only punctuation (e.g. "01 - " → "-"). A real title has a letter/digit. + if stripped.isdigit() or not re.search(r"[^\W_]", stripped): + return s + return stripped + + def get_album_type_display(raw_type, track_count) -> str: diff --git a/core/imports/pipeline.py b/core/imports/pipeline.py index d21e826e..17dcd30a 100644 --- a/core/imports/pipeline.py +++ b/core/imports/pipeline.py @@ -693,9 +693,17 @@ def post_process_matched_download(context_key, context, file_path, runtime, meta # See ``core/imports/track_number.py`` for the resolution # chain — pure function, unit-tested in isolation, single # place to fix the rule. - from core.imports.track_number import resolve_track_number + from core.imports.track_number import resolve_track_number, read_embedded_track_number track_info_for_resolve = context.get('track_info') if isinstance(context, dict) else None - track_number = resolve_track_number(album_info, track_info_for_resolve, file_path) + # "Track 01" bug: a single Deezer track is matched via an endpoint + # that omits track_position, so the context never carried the real + # number. The downloaded file itself does (deemix/source wrote it), + # so read it as a source between metadata and the filename guess. + embedded_track_number = read_embedded_track_number(file_path) + track_number = resolve_track_number( + album_info, track_info_for_resolve, file_path, + embedded_track_number=embedded_track_number, + ) logger.debug( "Final track_number processing: source=%s album_info=%s resolved=%s", album_info.get('source', 'unknown'), diff --git a/core/imports/quarantine.py b/core/imports/quarantine.py index 9e49121c..879814c5 100644 --- a/core/imports/quarantine.py +++ b/core/imports/quarantine.py @@ -153,6 +153,83 @@ def get_quarantined_source_keys(quarantine_dir: str) -> set: return keys +def quarantine_group_key( + expected_artist: Any, expected_track: Any, context: Any = None +) -> Optional[str]: + """Grouping key for "the same intended download target". + + #876: when several sources are downloaded for one wishlist/queue + track they each fail verification and land in quarantine as separate + entries. They are *alternatives for the same song*, so they should + group together — and once the user accepts one, the rest are + redundant failed attempts at a song they now own. + + The key identifies the *intended* target — what SoulSync was trying to + fetch — NOT the downloaded file's own tags. That matters: the file's + metadata is frequently *wrong* (that's why it failed acoustid / + integrity), whereas the target is fixed and identical across every + alternative for one song (they're all Soulseek uploads of the *same* + source track), so grouping by it is an exact relationship, not a fuzzy + metadata guess. + + Prefers a stable target-track id from the sidecar `context.track_info` + when present — isrc, then source id, then uri — since those are exact + and constant across siblings. Falls back to the normalized + artist|track name only for legacy/thin sidecars that carry no context. + Keys are kind-prefixed so an id-based key never collides with a + name-based one. + + Returns ``None`` when nothing identifies the target (no usable id and + both name fields empty). Callers treat a ``None`` key as "its own + singleton group" — ungroupable entries must never collapse together. + """ + ti = {} + if isinstance(context, dict): + maybe_ti = context.get("track_info") + if isinstance(maybe_ti, dict): + ti = maybe_ti + isrc = str(ti.get("isrc") or "").strip().lower() + if isrc: + return f"isrc:{isrc}" + tid = str(ti.get("id") or "").strip() + if tid: + return f"id:{tid}" + uri = str(ti.get("uri") or "").strip() + if uri: + return f"uri:{uri}" + artist = " ".join(str(expected_artist or "").split()).lower() + track = " ".join(str(expected_track or "").split()).lower() + if not artist and not track: + return None + return f"nm:{artist}|{track}" + + +def find_quarantine_siblings(quarantine_dir: str, entry_id: str) -> List[str]: + """Other entry ids that share ``entry_id``'s intended-target group key. + + Returns the ids of every *other* quarantine entry whose + `expected_artist`/`expected_track` normalize to the same key as + ``entry_id`` (see :func:`quarantine_group_key`). Excludes ``entry_id`` + itself. Returns ``[]`` when the entry is missing, has an ungroupable + (``None``) key, or has no siblings. Never raises. + """ + if not entry_id: + return [] + entries = list_quarantine_entries(quarantine_dir) + target_key = None + for e in entries: + if e.get("id") == entry_id: + target_key = e.get("group_key") + break + if target_key is None: + return [] + return [ + e["id"] + for e in entries + if e.get("id") != entry_id and e.get("group_key") == target_key + ] + + def list_quarantine_entries(quarantine_dir: str) -> List[Dict[str, Any]]: """Enumerate quarantined files paired with their sidecars. @@ -213,6 +290,11 @@ def list_quarantine_entries(quarantine_dir: str) -> List[Dict[str, Any]]: "reason": sidecar.get("quarantine_reason", "Unknown reason"), "expected_track": sidecar.get("expected_track", ""), "expected_artist": sidecar.get("expected_artist", ""), + "group_key": quarantine_group_key( + sidecar.get("expected_artist", ""), + sidecar.get("expected_track", ""), + ctx, + ), "timestamp": sidecar.get("timestamp", ""), "size_bytes": size_bytes, "has_full_context": isinstance(sidecar.get("context"), dict), diff --git a/core/imports/rematch_apply.py b/core/imports/rematch_apply.py new file mode 100644 index 00000000..33bcb8c6 --- /dev/null +++ b/core/imports/rematch_apply.py @@ -0,0 +1,92 @@ +"""#889 Phase 4/5: apply a re-identify — stage the library file + write the hint. + +When the user confirms a release in the Re-identify modal, we: + 1. COPY (never move) the track's library file into the auto-import staging folder, + so the original is untouched until the re-import succeeds, + 2. fingerprint the staged copy (rename-proof binding), and + 3. write a single-use hint carrying the chosen release's IDs (+ ``replace_track_id`` + when 'replace original' is ticked). + +The auto-import worker then picks the staged file up, finds the hint, and re-imports +it against the user-chosen release (Phase 2). The pieces here are split so the +naming + hint construction are pure/unit-tested and the actual copy is injectable. +""" + +from __future__ import annotations + +import os +import shutil +from typing import Any, Callable, Dict, Optional + +from core.imports.paths import sanitize_filename +from core.imports.rematch_hints import RematchHint, quick_file_signature + + +def staged_destination(staging_dir: str, real_path: str, library_track_id: Any) -> str: + """Where the staged copy lands: a single loose file in the staging ROOT (so the + worker treats it as a single-track candidate), named to keep the extension and + be unique + traceable to the track it re-identifies. The filename is cosmetic — + matching is driven by the hint, not the name.""" + base = os.path.basename(real_path) + stem, ext = os.path.splitext(base) + safe_stem = sanitize_filename(stem).strip() or "track" + name = f"{safe_stem} [reid-{library_track_id}]{ext}" + return os.path.join(staging_dir, name) + + +def stage_file_for_reidentify( + real_path: str, + staging_dir: str, + library_track_id: Any, + *, + copy_fn: Callable[[str, str], object] = shutil.copy2, + signature_fn: Callable[[str], Optional[str]] = quick_file_signature, +) -> Dict[str, Any]: + """Copy the library file into staging and fingerprint the copy. Returns + ``{staged_path, content_hash}``. Raises ``FileNotFoundError`` if the source is + gone (caller surfaces a clear error rather than writing a dangling hint).""" + if not real_path or not os.path.isfile(real_path): + raise FileNotFoundError(real_path or "(empty path)") + os.makedirs(staging_dir, exist_ok=True) + dest = staged_destination(staging_dir, real_path, library_track_id) + copy_fn(real_path, dest) + return {"staged_path": dest, "content_hash": signature_fn(dest)} + + +def build_reidentify_hint( + library_track_id: Any, + hint_fields: Dict[str, Any], + staged_path: str, + content_hash: Optional[str], + *, + replace: bool, +) -> RematchHint: + """Pure: assemble the RematchHint from the resolved release fields + staging + info. ``replace_track_id`` is the library row to delete on success, but only + when 'replace original' was ticked. ``exempt_dedup`` is always True — a + re-identify is explicit and must bypass dedup-skip.""" + return RematchHint( + staged_path=staged_path, + content_hash=content_hash, + source=hint_fields.get("source") or "", + isrc=hint_fields.get("isrc"), + track_id=hint_fields.get("track_id"), + album_id=hint_fields.get("album_id"), + artist_id=hint_fields.get("artist_id"), + track_title=hint_fields.get("track_title"), + album_name=hint_fields.get("album_name"), + artist_name=hint_fields.get("artist_name"), + album_type=hint_fields.get("album_type"), + track_number=hint_fields.get("track_number"), + disc_number=hint_fields.get("disc_number"), + replace_track_id=(int(library_track_id) if replace and str(library_track_id).isdigit() else + (library_track_id if replace else None)), + exempt_dedup=True, + ) + + +__all__ = [ + "staged_destination", + "stage_file_for_reidentify", + "build_reidentify_hint", +] diff --git a/core/imports/rematch_hints.py b/core/imports/rematch_hints.py new file mode 100644 index 00000000..8e5d8e4c --- /dev/null +++ b/core/imports/rematch_hints.py @@ -0,0 +1,334 @@ +"""Re-identify hints (#889) — a single-use, user-designated answer to "which +release does this already-imported track belong to". + +Flow: the user clicks *Re-identify* on a library track, searches a source, and +picks the exact release (single / EP / album) it should live under. We write a +**hint** here and stage the file for auto-import. The import flow then reads the +hint at the very TOP of matching — before any fuzzy tier — builds the match from +these exact IDs, and consumes the row. So the original ambiguity that mis-filed +the track (which release?) is gone: the user already answered it. + +Two safety properties live in the hint, not the import code: + +- ``replace_track_id`` — the library row to delete AFTER the re-import lands (so a + re-identify *replaces* rather than *duplicates*). Cleanup is deferred to success + so a failed import can never lose the file. +- ``exempt_dedup`` — always set: a re-identify is an explicit user action and must + not be silently dropped by the quality dedup-skip (which would otherwise see the + incoming file as a duplicate of the very row we're replacing). + +This module is pure DB mechanics over an injected ``cursor`` (sqlite3-style, +``?`` params) — no connection management, no app state — so the create / find / +consume seam is unit-tested against an in-memory DB with no live metadata client. +The binding is keyed on the staged path, with ``content_hash`` as a rename-proof +fallback in case the staging watcher normalizes the filename on ingest. +""" + +from __future__ import annotations + +import os +from dataclasses import dataclass +from typing import Any, Callable, Optional + +# Columns in INSERT/SELECT order — single source of truth so the dataclass, the +# write, and the read can't drift apart. +_FIELDS = ( + "staged_path", + "content_hash", + "source", + "isrc", + "track_id", + "album_id", + "artist_id", + "track_title", + "album_name", + "artist_name", + "album_type", + "track_number", + "disc_number", + "replace_track_id", + "exempt_dedup", +) + + +@dataclass +class RematchHint: + """One user-designated re-identify answer. ``id``/``status`` are set by the DB.""" + staged_path: str + source: str + content_hash: Optional[str] = None + isrc: Optional[str] = None + track_id: Optional[str] = None + album_id: Optional[str] = None + artist_id: Optional[str] = None + track_title: Optional[str] = None + album_name: Optional[str] = None + artist_name: Optional[str] = None + album_type: Optional[str] = None + track_number: Optional[int] = None + disc_number: Optional[int] = None + replace_track_id: Optional[int] = None + exempt_dedup: bool = True + id: Optional[int] = None + status: str = "pending" + + def _values(self) -> tuple: + return ( + self.staged_path, + self.content_hash, + self.source, + self.isrc, + self.track_id, + self.album_id, + self.artist_id, + self.track_title, + self.album_name, + self.artist_name, + self.album_type, + self.track_number, + self.disc_number, + self.replace_track_id, + 1 if self.exempt_dedup else 0, + ) + + +def _row_to_hint(row: Any) -> RematchHint: + """Map a sqlite3.Row (or any mapping/sequence-by-name) to a RematchHint.""" + def g(key, default=None): + try: + return row[key] + except (KeyError, IndexError, TypeError): + return default + return RematchHint( + id=g("id"), + staged_path=g("staged_path") or "", + content_hash=g("content_hash"), + source=g("source") or "", + isrc=g("isrc"), + track_id=g("track_id"), + album_id=g("album_id"), + artist_id=g("artist_id"), + track_title=g("track_title"), + album_name=g("album_name"), + artist_name=g("artist_name"), + album_type=g("album_type"), + track_number=g("track_number"), + disc_number=g("disc_number"), + replace_track_id=g("replace_track_id"), + exempt_dedup=bool(g("exempt_dedup", 1)), + status=g("status") or "pending", + ) + + +def create_hint(cursor: Any, hint: RematchHint) -> int: + """Insert a pending hint; return its new id. Caller owns commit.""" + placeholders = ", ".join("?" for _ in _FIELDS) + cursor.execute( + f"INSERT INTO rematch_hints ({', '.join(_FIELDS)}) VALUES ({placeholders})", + hint._values(), + ) + new_id = cursor.lastrowid + hint.id = new_id + return new_id + + +def find_hint_for_file( + cursor: Any, + staged_path: str, + content_hash: Optional[str] = None, +) -> Optional[RematchHint]: + """Return the newest PENDING hint for a staged file, or ``None``. + + Matched by exact ``staged_path`` first; if that misses and a ``content_hash`` + is given, fall back to it (covers a staging watcher that renamed the file on + ingest). Only ``status='pending'`` rows are returned, so a consumed hint is + never reused.""" + if staged_path: + cursor.execute( + "SELECT * FROM rematch_hints WHERE staged_path = ? AND status = 'pending' " + "ORDER BY id DESC LIMIT 1", + (staged_path,), + ) + row = cursor.fetchone() + if row is not None: + return _row_to_hint(row) + # Try by basename too — the watcher may move the file into a different dir. + base = os.path.basename(staged_path) + if base and base != staged_path: + cursor.execute( + "SELECT * FROM rematch_hints WHERE staged_path LIKE ? AND status = 'pending' " + "ORDER BY id DESC LIMIT 1", + ("%/" + base,), + ) + row = cursor.fetchone() + if row is not None: + return _row_to_hint(row) + + if content_hash: + cursor.execute( + "SELECT * FROM rematch_hints WHERE content_hash = ? AND status = 'pending' " + "ORDER BY id DESC LIMIT 1", + (content_hash,), + ) + row = cursor.fetchone() + if row is not None: + return _row_to_hint(row) + + return None + + +def consume_hint(cursor: Any, hint_id: int) -> None: + """Mark a hint consumed (single-use). Caller owns commit.""" + cursor.execute( + "UPDATE rematch_hints SET status = 'consumed', consumed_at = CURRENT_TIMESTAMP " + "WHERE id = ?", + (hint_id,), + ) + + +def list_pending_hints(cursor: Any) -> list: + """All pending hints (newest first) — for a 'pending re-identify' view and + orphan recovery when a staged file never imports.""" + cursor.execute("SELECT * FROM rematch_hints WHERE status = 'pending' ORDER BY id DESC") + return [_row_to_hint(r) for r in cursor.fetchall()] + + +def build_identification_from_hint(hint: RematchHint) -> dict: + """Turn a hint into the ``identification`` dict the auto-import matcher expects, + so a re-identify SKIPS the guessing tiers entirely and matches straight against + the user-chosen release. Mirrors the shape `_identify_folder` returns (album_id + / source / track_number drive the album fetch + file→track match).""" + return { + "album_id": hint.album_id or None, + "album_name": hint.album_name or hint.track_title or "", + "artist_name": hint.artist_name or "", + "artist_id": hint.artist_id or "", + "track_name": hint.track_title or "", + "track_id": hint.track_id or "", + "image_url": "", + "release_date": "", + "track_number": hint.track_number or 1, + "total_tracks": 1, + "source": hint.source, + "method": "rematch_hint", + "identification_confidence": 1.0, + # is_single reflects the CHOSEN release, but force_album_match makes the + # matcher FETCH that release (even for a lone staged file) instead of taking + # the singles fast-path — so the re-imported track gets the real album + # metadata: year, the correct in-album track number, and the album art. + "is_single": (str(hint.album_type or "").lower() == "single"), + "force_album_match": True, + "album_type": hint.album_type, + } + + +def _canonical(path: Optional[str]) -> str: + """Canonical form of a path for same-file comparison (symlinks + case + sep).""" + if not path: + return "" + try: + return os.path.normcase(os.path.realpath(path)) + except OSError: + return os.path.normcase(os.path.normpath(path)) + + +def delete_replaced_track( + cursor: Any, + replace_track_id: Any, + *, + unlink=os.remove, + resolve_fn: Optional[Callable[[str], Optional[str]]] = None, + new_paths: Optional[list] = None, +) -> Optional[str]: + """Remove the OLD library row a re-identify replaces, and its file. + + Called only AFTER the re-import has landed the track at its new home, so the + original is never lost on failure. Safe by construction: + + * **Same-home guard (CRITICAL):** if the re-import landed at the SAME file as the + old one (``new_paths`` — the paths the import actually wrote), this is a no-op: + we DON'T delete the row or the file, because that file IS the re-imported track. + This is what stops "re-identify to the release it's already in" from deleting + the file (the import reuses the same row, so deleting it would orphan the file). + * the file is unlinked only if it still exists and **no other track row references + it** (guards against yanking a file a different row legitimately points to). + + Returns the path it removed, or ``None`` if there was nothing to do. ``unlink`` is + injectable for tests. ``resolve_fn`` maps the STORED DB path to the file's actual + on-disk location (the stored path may be a Docker/media-server view this process + can't read literally — without it we'd delete the row but orphan the file).""" + if not replace_track_id: + return None + cursor.execute("SELECT file_path FROM tracks WHERE id = ?", (replace_track_id,)) + row = cursor.fetchone() + if row is None: + return None + old_path = (row["file_path"] if not isinstance(row, (tuple, list)) else row[0]) or "" + if not old_path: + cursor.execute("DELETE FROM tracks WHERE id = ?", (replace_track_id,)) + return None + + # Resolve the old stored path to its real on-disk location up front. + real_path = old_path + if resolve_fn is not None: + try: + real_path = resolve_fn(old_path) or old_path + except Exception: + real_path = old_path + + # Same-home guard: if the re-import wrote to this very file, do NOTHING — the row + # is the re-imported track's row and the file is its file. Deleting either would + # be data loss (the "picked the same release" bug). + if new_paths: + landed = {_canonical(p) for p in new_paths if p} + if _canonical(real_path) in landed or _canonical(old_path) in landed: + return None + + cursor.execute("DELETE FROM tracks WHERE id = ?", (replace_track_id,)) + # Only unlink if no surviving row still points at this file (rows store the + # stored path, so compare against the stored path, not the resolved one). + cursor.execute("SELECT 1 FROM tracks WHERE file_path = ? LIMIT 1", (old_path,)) + if cursor.fetchone() is not None: + return None + try: + if os.path.exists(real_path): # real_path resolved above + unlink(real_path) + return real_path + except OSError: + pass + return None + + +def quick_file_signature(path: str, *, chunk: int = 65536) -> Optional[str]: + """A cheap, rename-proof content fingerprint: size + first/last chunk, hashed. + + Audio files are large, so a full hash is wasteful when we only need to re-bind + a hint to *this* file after a possible rename. Size + head + tail is plenty to + distinguish staged files in practice. Returns ``None`` if the file can't be + read (caller falls back to path-only binding).""" + import hashlib + + try: + size = os.path.getsize(path) + h = hashlib.sha256() + h.update(str(size).encode()) + with open(path, "rb") as f: + h.update(f.read(chunk)) + if size > chunk: + f.seek(max(0, size - chunk)) + h.update(f.read(chunk)) + return h.hexdigest() + except OSError: + return None + + +__all__ = [ + "RematchHint", + "create_hint", + "find_hint_for_file", + "consume_hint", + "list_pending_hints", + "build_identification_from_hint", + "delete_replaced_track", + "quick_file_signature", +] diff --git a/core/imports/rematch_search.py b/core/imports/rematch_search.py new file mode 100644 index 00000000..da32c373 --- /dev/null +++ b/core/imports/rematch_search.py @@ -0,0 +1,247 @@ +"""#889 Phase 3: search a metadata source for the releases a track appears on. + +The Re-identify modal lets the user search ANY configured source (tabs, defaulting +to the active one) and shows the SAME song across its different collections — +single / EP / album — so they can pick which release the track should be filed +under. Two steps, deliberately split: + + * ``search_release_candidates(source, query)`` — lightweight DISPLAY rows from the + normal typed ``search_tracks`` (title, artist, release name, type badge, year, + track count, art, ISRC, track_id). No album_id needed to draw the list. + * ``resolve_hint_fields(source, track_id)`` — runs ONCE, on the row the user + picks: ``get_track_details`` yields the album_id / isrc / track#/disc the hint + needs. We don't pay that lookup for every search result, only the chosen one. + +Pure normalization + injected client factory, so the search/normalize/resolve seam +is unit-tested with a fake client and no network. +""" + +from __future__ import annotations + +from typing import Any, Callable, Dict, List, Optional + + +def _get(obj: Any, key: str, default=None): + """Read ``key`` from either an object (attr) or a mapping (item).""" + if obj is None: + return default + if isinstance(obj, dict): + return obj.get(key, default) + return getattr(obj, key, default) + + +def _year(release_date: Any) -> Optional[str]: + s = str(release_date or "").strip() + return s[:4] if len(s) >= 4 and s[:4].isdigit() else None + + +def infer_release_type(album_type: Any, total_tracks: Any) -> str: + """Normalize a source's release type to one of album / ep / single / compilation. + + Sources disagree: Spotify has no 'EP' — EPs come back as ``album_type='single'`` + with several tracks; MusicBrainz/Deezer label EPs properly. So when a 'single' + carries more than a handful of tracks, call it an EP for the badge. The actual + filing is unaffected — that's driven by the real album_id, not this label.""" + t = str(album_type or "").strip().lower() + try: + n = int(total_tracks) if total_tracks is not None else 0 + except (TypeError, ValueError): + n = 0 + if t in ("compilation", "comp"): + return "compilation" + if t == "ep": + return "ep" + if t == "album": + return "album" # an explicit album stays an album; only 'single' gets promoted to EP + if t == "single": + # 1–3 tracks → single; 4+ → almost always an EP in practice. + return "ep" if n >= 4 else "single" + # Unknown type: infer purely from track count. + if n >= 7: + return "album" + if n >= 4: + return "ep" + if n >= 1: + return "single" + return t or "album" + + +def normalize_search_result(result: Any, source: str) -> Optional[Dict[str, Any]]: + """One typed search Track (or raw dict) → a display row, or ``None`` if it has + no usable id/title. ``album`` is just a name at search time; album_id is + resolved later for the picked row only.""" + track_id = _get(result, "id") or _get(result, "track_id") + title = _get(result, "name") or _get(result, "title") + if not track_id or not title: + return None + + artists = _get(result, "artists") + if isinstance(artists, list): + artist_name = ", ".join(str(_get(a, "name", a) if not isinstance(a, str) else a) for a in artists) + else: + artist_name = str(artists or _get(result, "artist") or "") + + album = _get(result, "album") + album_name = album if isinstance(album, str) else (_get(album, "name") or "") + raw_type = _get(result, "album_type") + total = _get(result, "total_tracks") + ext = _get(result, "external_ids") or {} + isrc = _get(result, "isrc") or (ext.get("isrc") if isinstance(ext, dict) else None) + + return { + "source": source, + "track_id": str(track_id), + "track_title": str(title), + "artist_name": artist_name, + "album_name": str(album_name or ""), + "album_type": infer_release_type(raw_type, total), + "raw_album_type": str(raw_type or ""), + "total_tracks": int(total) if isinstance(total, int) else None, + "year": _year(_get(result, "release_date")), + "image_url": _get(result, "image_url") or "", + "isrc": isrc or None, + } + + +def search_release_candidates( + source: str, + query: str, + *, + limit: int = 25, + client_factory: Optional[Callable[[str], Any]] = None, +) -> List[Dict[str, Any]]: + """Search ``source`` for tracks matching ``query`` → normalized display rows. + + Returns ``[]`` (never raises) when the source has no client or errors — the UI + just shows an empty tab. Rows keep duplicate releases; the UI groups them.""" + query = (query or "").strip() + if not query: + return [] + factory = client_factory or _default_client_factory + try: + client = factory(source) + except Exception: + client = None + if client is None or not hasattr(client, "search_tracks"): + return [] + try: + results = client.search_tracks(query, limit=limit) + except TypeError: + results = client.search_tracks(query) # clients with no limit kwarg + except Exception: + return [] + + rows: List[Dict[str, Any]] = [] + for r in results or []: + row = normalize_search_result(r, source) + if row is not None: + rows.append(row) + return rows + + +def resolve_hint_fields( + source: str, + track_id: str, + *, + client_factory: Optional[Callable[[str], Any]] = None, +) -> Optional[Dict[str, Any]]: + """Resolve the picked track to the fields a hint needs (album_id critically, + plus isrc / track# / disc# / album name+type). One lookup for one chosen row. + Returns ``None`` if it can't be resolved (caller surfaces an error).""" + factory = client_factory or _default_client_factory + try: + client = factory(source) + except Exception: + client = None + if client is None or not hasattr(client, "get_track_details"): + return None + try: + details = client.get_track_details(track_id) + except Exception: + return None + if not details: + return None + + album = _get(details, "album") or {} + album_id = _get(album, "id") if not isinstance(album, str) else None + album_name = _get(album, "name") if not isinstance(album, str) else album + album_type = _get(album, "album_type") or _get(details, "album_type") + total = _get(album, "total_tracks") or _get(details, "total_tracks") + + artists = _get(details, "artists") or [] + artist_id = None + artist_name = "" + if isinstance(artists, list) and artists: + artist_id = _get(artists[0], "id") + artist_name = ", ".join(str(_get(a, "name", a) if not isinstance(a, str) else a) for a in artists) + + ext = _get(details, "external_ids") or {} + isrc = _get(details, "isrc") or (ext.get("isrc") if isinstance(ext, dict) else None) + + if not album_id: + return None # without an album_id the import can't fetch the tracklist + + return { + "source": source, + "track_id": str(track_id), + "album_id": str(album_id), + "artist_id": str(artist_id) if artist_id else None, + "track_title": _get(details, "name") or _get(details, "title") or "", + "album_name": str(album_name or ""), + "artist_name": artist_name, + "album_type": infer_release_type(album_type, total), + "track_number": _get(details, "track_number"), + "disc_number": _get(details, "disc_number") or 1, + "isrc": isrc or None, + } + + +def _default_client_factory(source: str): + from core.metadata.registry import get_client_for_source + return get_client_for_source(source) + + +def available_sources() -> List[Dict[str, Any]]: + """The source tabs for the modal: every metadata source with a live client, + the primary one flagged ``active`` so the UI selects it by default.""" + from core.metadata.registry import ( + METADATA_SOURCE_PRIORITY, + get_client_for_source, + get_primary_source, + ) + + try: + primary = get_primary_source() + except Exception: + primary = None + + out: List[Dict[str, Any]] = [] + seen = set() + for src in METADATA_SOURCE_PRIORITY: + if src in seen: + continue + seen.add(src) + try: + client = get_client_for_source(src) + except Exception: + client = None + if client is None or not hasattr(client, "search_tracks"): + continue + out.append({ + "source": src, + "label": src.replace("_", " ").title(), + "active": src == primary, + }) + # Guarantee the primary is selectable + first even if priority ordering missed it. + if primary and not any(s["active"] for s in out): + out.insert(0, {"source": primary, "label": primary.replace("_", " ").title(), "active": True}) + return out + + +__all__ = [ + "infer_release_type", + "normalize_search_result", + "search_release_candidates", + "resolve_hint_fields", + "available_sources", +] diff --git a/core/imports/side_effects.py b/core/imports/side_effects.py index 0898fdcb..f2e21efa 100644 --- a/core/imports/side_effects.py +++ b/core/imports/side_effects.py @@ -569,19 +569,22 @@ def record_soulsync_library_entry(context: Dict[str, Any], artist_context: Dict[ # ── Album row: same insert-or-fill-empty-fields shape ── album_source_col = source_columns.get("album") - cursor.execute( - "SELECT id FROM albums WHERE id = ? AND server_source = 'soulsync'", - (album_id,), + # Group by CANONICAL release id when we have one (not just the name + # string), so differently-named imports of the SAME release land in + # one album row instead of splitting — which left the repair jobs + # dressing each split row in its own cover art (Sokhi). Precedence: + # name-hash id -> source release id -> (title, artist). Falls back to + # the legacy name match, so nothing that grouped before stops now. + from core.imports.album_grouping import find_existing_soulsync_album_id + existing_album_id = find_existing_soulsync_album_id( + cursor, name_key_id=album_id, artist_id=artist_id, album_name=album_name, + album_source_col=album_source_col, album_source_id=album_source_id, ) - row = cursor.fetchone() - if not row: - cursor.execute( - "SELECT id FROM albums WHERE title COLLATE NOCASE = ? AND artist_id = ? AND server_source = 'soulsync' LIMIT 1", - (album_name, artist_id), - ) - row = cursor.fetchone() - if row: - album_id = row[0] + if existing_album_id is not None: + album_id = existing_album_id + row = (album_id,) + else: + row = None if row: _fill_empty_columns( diff --git a/core/imports/single_to_album.py b/core/imports/single_to_album.py new file mode 100644 index 00000000..e6fc5f2d --- /dev/null +++ b/core/imports/single_to_album.py @@ -0,0 +1,124 @@ +"""Single -> parent-album resolution. + +When a track is matched to a SINGLE release (album_type 'single', the single's +name usually equal to the track title), it carries the single's name + the +single's source album id. The canonical grouping in +[core/imports/album_grouping.py] then files it under a different album row than +its album-mates, and the album-grouped repair jobs dress that row in the +single's art — songs of one album end up with different covers (Sokhi). + +This module re-homes such a track onto the ALBUM it actually belongs to, so it +carries the album's name/id and groups with the rest of the album. + +Design: the SELECTION is a pure, conservative function (no I/O), and the lookup +loop takes INJECTED fetchers, so both are unit-testable without a live metadata +client. CONSERVATIVE by intent — it only re-homes a track when a real +``album``-type release's tracklist *contains that exact track*. It never +promotes a genuine standalone single and never guesses, because a wrong +promotion would mis-home a real single onto an album (the inverse bug). +""" + +from __future__ import annotations + +import re +from typing import Any, Callable, Dict, List, Optional + +_WS = re.compile(r"\s+") +# Trailing version qualifiers that differ between a single and its album cut but +# don't change track identity (kept conservative — only the obvious ones). +_QUALIFIER = re.compile( + r"\s*[\(\[]\s*(album version|single version|radio edit|remaster(ed)?( \d{4})?)\s*[\)\]]\s*$", + re.IGNORECASE, +) + + +def _norm(s: Any) -> str: + """Lowercase, strip a trailing '(Album Version)'-style qualifier, collapse + whitespace — so 'Song' matches 'Song (Album Version)'.""" + t = str(s or "").strip().lower() + t = _QUALIFIER.sub("", t) + return _WS.sub(" ", t).strip() + + +def _get(obj: Any, *keys: str, default=None): + for k in keys: + if isinstance(obj, dict): + if obj.get(k) is not None: + return obj.get(k) + else: + v = getattr(obj, k, None) + if v is not None: + return v + return default + + +def select_parent_album(track_title: str, candidate_albums: List[Dict[str, Any]]) -> Optional[Dict[str, Any]]: + """Pick the parent ALBUM for ``track_title`` from normalized candidates, or + None. Each candidate is ``{name, album_type, tracks: [title, ...], ...}``. + + Conservative rules — a candidate qualifies ONLY when: + * it is an ``album`` release (never single / ep / compilation), and + * its name is not just the track title (that IS the single), and + * its tracklist contains the track by exact normalized title. + Returns the FIRST qualifying candidate (caller passes them in priority + order, so the result is deterministic). + """ + tgt = _norm(track_title) + if not tgt: + return None + for alb in candidate_albums or []: + if str(_get(alb, "album_type", default="album")).lower() != "album": + continue + if _norm(_get(alb, "name", "title", default="")) == tgt: + continue + tracks = _get(alb, "tracks", default=[]) or [] + if any(_norm(t) == tgt for t in tracks): + return alb + return None + + +def resolve_single_to_album( + track_title: str, + *, + fetch_album_candidates: Callable[[], List[Dict[str, Any]]], + fetch_album_tracks: Callable[[Dict[str, Any]], List[str]], + max_albums: int = 8, +) -> Optional[Dict[str, Any]]: + """Find the parent album for a single-matched track. I/O is INJECTED so this + is testable without a live client: + * ``fetch_album_candidates()`` -> the artist's ALBUM-type releases (dicts + with name/album_type/id/source), in priority order. + * ``fetch_album_tracks(album)`` -> that album's track titles. + Probes at most ``max_albums`` albums, lazily (stops at the first that + contains the track). Fail-safe: any error / no confident match -> None + (the track stays as it was matched). Returns the normalized winning album + ``{name, album_type, album_id, source, tracks}`` or None. + """ + if not _norm(track_title): + return None + try: + albums = fetch_album_candidates() or [] + except Exception: + return None + + probed = 0 + for alb in albums: + if str(_get(alb, "album_type", default="album")).lower() != "album": + continue + if probed >= max_albums: + break + probed += 1 + try: + tracks = fetch_album_tracks(alb) or [] + except Exception: + continue + normalized = { + "name": _get(alb, "name", "title", default=""), + "album_type": "album", + "album_id": _get(alb, "id", "album_id"), + "source": _get(alb, "source"), + "tracks": list(tracks), + } + if select_parent_album(track_title, [normalized]): + return normalized + return None diff --git a/core/imports/track_number.py b/core/imports/track_number.py index 43c98029..63b53b03 100644 --- a/core/imports/track_number.py +++ b/core/imports/track_number.py @@ -65,17 +65,64 @@ def _coerce_spotify_data(track_info: Any) -> dict: return {} +def read_embedded_track_number(file_path: str) -> Optional[int]: + """Read the track position from a downloaded audio file's own tags. + + Streaming sources (Deezer/deemix, Qobuz, Tidal) and most Soulseek + uploads write the correct album position into the file itself. That + tag is authoritative for the *source's* idea of the track's place on + its album — more reliable than a filename guess — so the resolver + consults it before falling back to the filename / default-1 floor. + + Issue #874-adjacent / "Track 01" bug: a single Deezer track is matched + via Deezer's ``/search/track`` endpoint, which omits ``track_position`` + (core/deezer_client.py), so the metadata context never carried the + real number — but the downloaded file *does* (deemix wrote it). This + recovers it with no network call. + + Returns a positive int, or None when the file has no usable + tracknumber tag / can't be read. Never raises. Handles the common + ``"2/15"`` (number/total) form by taking the leading number. + """ + if not file_path: + return None + try: + from mutagen import File as MutagenFile + audio = MutagenFile(file_path, easy=True) + if audio is None: + return None + raw = audio.get('tracknumber') + if isinstance(raw, list): + raw = raw[0] if raw else None + if raw is None: + return None + # "2/15" -> "2"; bare "2" -> "2". + text = str(raw).split('/', 1)[0].strip() + return _coerce_positive(text) + except Exception: + return None + + def resolve_track_number( album_info: Any, track_info: Any, file_path: str, + embedded_track_number: Any = None, ) -> Optional[int]: """Walk the resolution chain and return the first valid positive int found, or None when every source is missing / unusable. - Caller is responsible for the final default-1 floor — leaving - that out of this function so tests can pin "everything missing + Order: album_info -> track_info -> nested spotify_data -> filename -> + ``embedded_track_number`` (the source-written file tag, when the caller + supplies it). Caller is responsible for the final default-1 floor — + leaving that out of this function so tests can pin "everything missing returns None" separate from the floor behaviour. + + ``embedded_track_number`` is passed in (not read here) so this stays a + pure function — the file I/O lives in :func:`read_embedded_track_number`. + It is consulted **last**, only when every other source came up empty, so + it can never override a value the pre-fix resolver already produced — it + only fills the gap that would otherwise hit the default-1 floor. """ album_info = album_info if isinstance(album_info, dict) else {} track_info = track_info if isinstance(track_info, dict) else {} @@ -96,10 +143,19 @@ def resolve_track_number( # default-1 floor is the single source of that fallback — # otherwise this resolver would silently fill 1 and the # downstream floor logic would have no effect. - if not file_path: - return None - try: - from_filename = extract_explicit_track_number(file_path) - except Exception: - from_filename = None - return _coerce_positive(from_filename) + if file_path: + try: + from_filename = extract_explicit_track_number(file_path) + except Exception: + from_filename = None + ff = _coerce_positive(from_filename) + if ff is not None: + return ff + + # Embedded source-written file tag is consulted LAST — only when every + # other source (metadata + the ripped-album "NN - Title" filename) came + # up empty. This is deliberate: it can ONLY fill the gap that would + # otherwise hit the caller's default-1 floor, so it never overrides a + # value the pre-fix resolver would have used. A correctly-named file + # with a stale/wrong embedded tag is therefore never regressed. + return _coerce_positive(embedded_track_number) 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/residual_files.py b/core/library/residual_files.py new file mode 100644 index 00000000..5a15831d --- /dev/null +++ b/core/library/residual_files.py @@ -0,0 +1,53 @@ +"""What counts as a *residual* file — a leftover with no value once the audio it +accompanied is gone: OS junk, cover/scan images, and lyric/metadata sidecars. + +Single source of truth shared by: + * the **Reorganize** cleanup, which strips these from a source dir after every + track has moved out (so the empty-dir pruner can take the folder), and + * the **Empty Folder Cleaner** job, which can optionally treat a folder holding + ONLY residual files as removable (#891). + +Defining "disposable" in one place keeps the two features agreeing on what a "dead +folder" is. Pure predicates — no filesystem access — so they're unit-tested in +isolation. The whitelist is deliberately conservative: anything NOT recognized here +(a booklet ``.pdf``, a video, a ``.txt`` note) is treated as real content and kept. +""" + +from __future__ import annotations + +import os + +# OS / tooling junk. +JUNK_FILES = {'.ds_store', 'thumbs.db', 'desktop.ini', '.directory', 'album.nfo~'} +# Cover art + booklet scans. +IMAGE_EXTS = {'.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.tiff', '.tif'} +# Lyric / metadata / playlist sidecars that are worthless without their audio. +SIDECAR_EXTS = {'.lrc', '.nfo', '.cue', '.m3u', '.m3u8'} + + +def _ext(name: str) -> str: + return os.path.splitext(name or '')[1].lower() + + +def is_junk(name: str) -> bool: + return (name or '').lower() in JUNK_FILES + + +def is_image(name: str) -> bool: + return _ext(name) in IMAGE_EXTS + + +def is_sidecar(name: str) -> bool: + return _ext(name) in SIDECAR_EXTS + + +def is_disposable(name: str) -> bool: + """True if this file is junk, a cover/scan image, or a lyric/metadata sidecar — + i.e. safe to delete from a folder that has no audio left.""" + return is_junk(name) or is_image(name) or is_sidecar(name) + + +__all__ = [ + 'JUNK_FILES', 'IMAGE_EXTS', 'SIDECAR_EXTS', + 'is_junk', 'is_image', 'is_sidecar', 'is_disposable', +] diff --git a/core/library_reorganize.py b/core/library_reorganize.py index e2991682..a5c9e4cd 100644 --- a/core/library_reorganize.py +++ b/core/library_reorganize.py @@ -1847,14 +1847,8 @@ def _prune_empty_album_dirs(artist_dir: str) -> None: # Sidecars that live alongside ONE audio file (same filename stem). _TRACK_SIDECAR_EXTS = ('.lrc', '.nfo', '.txt', '.cue', '.json') -# Sidecars that live at the ALBUM level (one per directory). -_ALBUM_SIDECARS = ( - 'cover.jpg', 'cover.jpeg', 'cover.png', - 'folder.jpg', 'folder.png', - 'front.jpg', 'front.png', - 'album.jpg', 'album.png', - 'artwork.jpg', 'artwork.png', -) +# Album-level leftovers (cover images, .lrc, etc.) are classified by the shared +# `core.library.residual_files.is_disposable` predicate — see `_delete_album_sidecars`. # Audio extensions used to decide whether a source directory still has # tracks the user might care about (i.e. a per-track failure left audio @@ -1954,16 +1948,30 @@ def _delete_track_sidecars(audio_path: str) -> None: def _delete_album_sidecars(src_dir: str) -> None: - """Delete album-level sidecars (cover.jpg, folder.jpg, etc.) from - `src_dir`. Used during end-of-run cleanup when no audio files remain - in the directory. Best-effort — individual failures are debug-logged.""" - for name in _ALBUM_SIDECARS: - sidecar = os.path.join(src_dir, name) - if os.path.isfile(sidecar): + """Delete album-level *residual* files from ``src_dir`` — any cover/scan image, + lyric/metadata sidecar (.lrc/.nfo/.cue/.m3u), or OS junk. Called during + end-of-run cleanup ONLY when no audio remains in the directory, so everything + here is leftover from the album that just moved (#891 — previously this only + removed a fixed list of cover names, so ``back.jpg`` / ``disc.jpg`` / ``.webp`` + survived and kept the folder un-prunable). + + Uses the shared ``is_disposable`` predicate so it agrees with the Empty Folder + Cleaner on what's a dead leftover; anything unrecognized (a booklet ``.pdf``, a + video) is deliberately LEFT. Best-effort — individual failures are debug-logged.""" + from core.library.residual_files import is_disposable + try: + entries = os.listdir(src_dir) + except OSError: + return + for name in entries: + if not is_disposable(name): + continue + full = os.path.join(src_dir, name) + if os.path.isfile(full): try: - os.remove(sidecar) + os.remove(full) except OSError as e: - logger.debug(f"[Reorganize] Couldn't remove album sidecar {sidecar}: {e}") + logger.debug(f"[Reorganize] Couldn't remove residual file {full}: {e}") def _has_remaining_audio(directory: str) -> bool: diff --git a/core/metadata/enrichment.py b/core/metadata/enrichment.py index fdd34042..e3642419 100644 --- a/core/metadata/enrichment.py +++ b/core/metadata/enrichment.py @@ -242,18 +242,25 @@ def enhance_file_metadata(file_path: str, context: dict, artist: dict, album_inf logger.warning("[Metadata Debug] Artist: %s", artist.get("name", "MISSING") if artist else "None") logger.warning("[Metadata Debug] Album info: %s", album_info.get("album_name", "MISSING") if album_info else "None") logger.error("[Metadata Debug] Traceback:\n%s", traceback.format_exc()) - # We cleared the file's art early; if the rewrite then crashed - # before re-embedding, the on-disk file (already saved cleared at - # the start) would be left art-less. Best-effort: put the original - # art back and persist it so a mid-enrichment crash never destroys - # the cover (#764). Guarded so a failure here can't mask the - # original error. + # The file was saved with tags CLEARED up front (so stale tags never + # linger), then the failure-prone enrichment ran. By the time most + # failures hit — the external source-id embed / cover-art fetch — the + # core tags (album/artist/title/track from the matched context) are + # already on the in-memory object but NOT yet on disk; the on-disk + # file is still the cleared one. Persist the in-memory tags now (and + # restore the original art too, #764) so a mid-enrichment crash leaves + # a correctly-tagged file instead of an UNTAGGED one (Sokhi: tracks + # landing in Rockbox's 'untagged' bucket after a 'processing failed'). + # + # Previously this save was gated on there being original art to + # restore, so an art-less file lost its tags entirely on any crash. + # Guarded so a failure here can't mask the original error. try: - if audio_file is not None and art_snapshot and restore_embedded_art( - audio_file, symbols, art_snapshot - ): + if audio_file is not None: + if art_snapshot: + restore_embedded_art(audio_file, symbols, art_snapshot) save_audio_file(audio_file, symbols) - logger.info("Restored original cover art after enrichment error.") + logger.info("Persisted core tags (and restored art) after enrichment error.") except Exception as restore_exc: - logger.debug("Art restore after error failed: %s", restore_exc) + logger.debug("Tag/art persist after error failed: %s", restore_exc) return False 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/repair_jobs/__init__.py b/core/repair_jobs/__init__.py index 8700723c..477eab1d 100644 --- a/core/repair_jobs/__init__.py +++ b/core/repair_jobs/__init__.py @@ -51,6 +51,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 index 0f8f16e3..fe1f5d02 100644 --- a/core/repair_jobs/empty_folder_cleaner.py +++ b/core/repair_jobs/empty_folder_cleaner.py @@ -22,37 +22,36 @@ from __future__ import annotations import os from typing import Iterable, List +from core.library.residual_files import JUNK_FILES, is_disposable, is_junk # noqa: F401 — JUNK_FILES/is_junk re-exported 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: + *, ignore_junk: bool = True, ignore_disposable: bool = False) -> 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). + OS-junk files, or (when ``ignore_disposable`` — #891) only *residual* files: + junk + cover/scan images + lyric/metadata sidecars. ``ignore_disposable`` is the + broader opt-in that clears the cover.jpg-only folders a reorganize leaves behind. + ``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) + if ignore_disposable: + return all(is_disposable(f) for f in files) + if ignore_junk: + return all(is_junk(f) for f in files) + return False @register_job @@ -65,15 +64,18 @@ class EmptyFolderCleanerJob(RepairJob): '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.' + 're-checking it is still empty). Folders that contain any real file — an ' + 'audio track, a booklet, anything not recognized as a leftover — 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.\n\n' + 'Enable "Also remove image/sidecar-only folders" to clear the cover.jpg / ' + '.lrc leftovers a Library Reorganize leaves behind — folders whose only ' + 'remaining files are cover/scan images or lyric/metadata sidecars.' ) icon = 'repair-icon-folder' default_enabled = False default_interval_hours = 168 # weekly — empties accrue slowly - default_settings = {'remove_junk_files': True} + default_settings = {'remove_junk_files': True, 'remove_residual_files': False} auto_fix = False def scan(self, context: JobContext) -> JobResult: @@ -86,10 +88,15 @@ class EmptyFolderCleanerJob(RepairJob): root = os.path.realpath(root) ignore_junk = True + ignore_disposable = False try: if context.config_manager: ignore_junk = bool(context.config_manager.get( 'repair.jobs.empty_folder_cleaner.remove_junk_files', True)) + # #891: also clear folders left holding only images / .lrc / sidecars + # (what a reorganize leaves behind). Opt-in — default off. + ignore_disposable = bool(context.config_manager.get( + 'repair.jobs.empty_folder_cleaner.remove_residual_files', False)) except Exception: # noqa: S110 — setting read is best-effort; defaults to True pass @@ -108,17 +115,29 @@ class EmptyFolderCleanerJob(RepairJob): 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): + if not dir_is_removable(filenames, surviving, + ignore_junk=ignore_junk, ignore_disposable=ignore_disposable): result.skipped += 1 continue flagged.add(dirpath) junk = [f for f in filenames if is_junk(f)] + # Files that will be swept along with the folder (junk always; images/ + # sidecars only when the residual option is on). + purgeable = [f for f in filenames + if is_junk(f) or (ignore_disposable and is_disposable(f))] + residual = [f for f in purgeable if not 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: + if residual: + extra = f' (only {len(residual)} leftover image/sidecar file(s))' + elif junk: + extra = f' (only {len(junk)} junk file(s))' + else: + extra = '' inserted = context.create_finding( job_id=self.job_id, finding_type='empty_folder', @@ -127,13 +146,13 @@ class EmptyFolderCleanerJob(RepairJob): 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.'), + description=(f'"{rel}" holds no music' + extra + ' — safe to remove.'), details={ 'folder_path': dirpath, 'junk_files': junk, + 'purgeable_files': purgeable, 'remove_junk': ignore_junk, + 'remove_disposable': ignore_disposable, }) if inserted: result.findings_created += 1 @@ -158,12 +177,17 @@ class EmptyFolderCleanerJob(RepairJob): def remove_empty_folder(folder_path: str, *, junk_files: List[str], remove_junk: bool, - root: str, listdir, isdir, islink, remove_file, rmdir) -> dict: + root: str, listdir, isdir, islink, remove_file, rmdir, + remove_disposable: bool = False) -> 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. + removable, then deletes any purgeable leftovers + 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. + With ``remove_disposable`` (#891) the re-check also treats cover images and + lyric/metadata sidecars as removable, and sweeps them before rmdir. Returns + ``{'removed': bool, 'error': str|None}``. Refuses to touch the root, a symlink, a + non-dir, or a folder that gained REAL content (audio, a booklet, anything not + recognized as residual) since the scan. """ if not folder_path or not isdir(folder_path): return {'removed': False, 'error': 'Folder no longer exists'} @@ -172,19 +196,21 @@ def remove_empty_folder(folder_path: str, *, junk_files: List[str], remove_junk: 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.) + def _purgeable(e: str) -> bool: + return (remove_junk and is_junk(e)) or (remove_disposable and is_disposable(e)) + + # Re-check at apply time: only purgeable leftovers 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))] + real_entries = [e for e in entries if not _purgeable(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 + for e in entries: + if _purgeable(e): + try: + remove_file(os.path.join(folder_path, e)) + except Exception: # noqa: S110 — leftover best-effort; rmdir below fails loudly if blocked + pass try: rmdir(folder_path) except Exception as e: 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_worker.py b/core/repair_worker.py index 4a0bdd9a..84c67532 100644 --- a/core/repair_worker.py +++ b/core/repair_worker.py @@ -17,7 +17,7 @@ import threading import time import uuid from difflib import SequenceMatcher -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from typing import Any, Dict, List, Optional, Tuple from core.metadata_service import ( @@ -585,13 +585,29 @@ class RepairWorker: logger.info("Repair worker thread finished") + @staticmethod + def _hours_since(finished_at_iso: str, now_utc: datetime) -> float: + """Hours between a stored ``finished_at`` and ``now_utc``, both in UTC. + + ``finished_at`` is written by SQLite's CURRENT_TIMESTAMP, which is ALWAYS + UTC (and naive). #885: the scheduler compared it against ``datetime.now()`` + (naive LOCAL), so the local↔UTC offset leaked into the elapsed time. For a + zone AHEAD of UTC (Australia/Sydney = +11) every job looked ~11h stale and + fired every poll; behind UTC (the Americas) it just waited too long. Parse + the naive timestamp AS UTC and subtract a UTC ``now`` so scheduling is + timezone-independent.""" + dt = datetime.fromisoformat(finished_at_iso) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return (now_utc - dt).total_seconds() / 3600 + def _pick_next_job(self) -> Optional[str]: """Pick the next job to run based on staleness priority. Returns job_id of the stalest job whose interval has elapsed, or None if nothing is due. """ - now = datetime.now() + now = datetime.now(timezone.utc) best_job_id = None best_staleness = -1 @@ -613,8 +629,7 @@ class RepairWorker: continue try: - last_finished = datetime.fromisoformat(last_run['finished_at']) - elapsed_hours = (now - last_finished).total_seconds() / 3600 + elapsed_hours = self._hours_since(last_run['finished_at'], now) if elapsed_hours < interval_hours: continue # Not due yet @@ -982,6 +997,7 @@ class RepairWorker: 'quality_upgrade': self._fix_quality_upgrade, '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: @@ -1008,6 +1024,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 @@ -1522,6 +1568,7 @@ class RepairWorker: resolved, junk_files=details.get('junk_files') or [], remove_junk=bool(details.get('remove_junk', True)), + remove_disposable=bool(details.get('remove_disposable', False)), root=self.transfer_folder, listdir=os.listdir, isdir=os.path.isdir, islink=os.path.islink, remove_file=os.remove, rmdir=os.rmdir, diff --git a/core/soulseek_client.py b/core/soulseek_client.py index e513a957..a5062585 100644 --- a/core/soulseek_client.py +++ b/core/soulseek_client.py @@ -427,8 +427,11 @@ class SoulseekClient(DownloadSourcePlugin): if f'.{file_ext}' not in audio_extensions: continue - quality = file_ext if file_ext in ['flac', 'mp3', 'ogg', 'aac', 'wma'] else 'unknown' - + # .m4a is the usual AAC container — bucket it as 'aac' (the + # quality filter treats AAC as an opt-in tier; off by default). + quality = 'aac' if file_ext == 'm4a' else ( + file_ext if file_ext in ['flac', 'mp3', 'ogg', 'aac', 'wma'] else 'unknown') + # Create TrackResult # Convert duration from seconds to milliseconds (slskd returns seconds, Spotify uses ms) raw_duration = file_data.get('length') @@ -1151,7 +1154,9 @@ class SoulseekClient(DownloadSourcePlugin): ext = Path(filename).suffix.lower() if ext not in audio_extensions: continue - quality = ext.lstrip('.') if ext.lstrip('.') in ['flac', 'mp3', 'ogg', 'aac', 'wma'] else 'unknown' + _qext = ext.lstrip('.') + quality = 'aac' if _qext == 'm4a' else ( + _qext if _qext in ['flac', 'mp3', 'ogg', 'aac', 'wma'] else 'unknown') raw_duration = file_data.get('length') duration_ms = raw_duration * 1000 if raw_duration else None slskd_attrs = {a['type']: a['value'] for a in file_data.get('attributes', [])} @@ -1965,6 +1970,7 @@ class SoulseekClient(DownloadSourcePlugin): 'mp3_320': (1, 50), 'mp3_256': (1, 40), 'mp3_192': (1, 30), + 'aac': (1, 50), 'other': (0, 500), } diff --git a/core/spotify_worker.py b/core/spotify_worker.py index ffc6b07d..4693cd40 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, ) @@ -44,6 +47,12 @@ class SpotifyWorker: # Current item being processed (for UI tooltip) self.current_item = None + # Whether the worker is serving via the no-creds Spotify Free source as of + # its last loop iteration. Cached from the loop's _free_active() probe so + # get_stats() can report it without an auth API call (#887: a no-auth user + # whose enrichment runs on Free was shown "Not Authenticated"). + self._serving_via_free = False + # Statistics self.stats = { 'matched': 0, @@ -133,8 +142,14 @@ class SpotifyWorker: # real-API daily budget (the worker set _budget_exhausted_use_free — # a cheap attribute read). Lets the UI show "Running (Spotify Free)" # instead of a misleading "rate limited" / "daily limit reached". + # #887: the loop's cached _free_active() result is the comprehensive + # signal — it's True for a no-auth user enriching via Spotify Free by + # default (prefer-free is on unless disabled), not just the rate-limit + # / budget bridges. The extra terms stay as a fallback for the brief + # window before the loop's first iteration sets the cache. using_free = bool( - (rate_limited and self.client.is_spotify_metadata_available()) + getattr(self, '_serving_via_free', False) + or (rate_limited and self.client.is_spotify_metadata_available()) or getattr(self.client, '_budget_exhausted_use_free', False) ) except Exception: @@ -261,6 +276,9 @@ class SpotifyWorker: free_serving = self.client._free_active() except Exception: free_serving = False + # Cache for get_stats() so the dashboard status reflects that the + # worker IS enriching via Free even with no official auth (#887). + self._serving_via_free = free_serving # Daily budget guard — pause ONLY when the budget is spent AND we # can't serve via free (no free available). Otherwise free took over. @@ -563,16 +581,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/text/title_match.py b/core/text/title_match.py index 85e1c10b..8d1d1080 100644 --- a/core/text/title_match.py +++ b/core/text/title_match.py @@ -193,17 +193,44 @@ def numeric_tokens_differ(title_a: str, title_b: str) -> bool: string similarity ('Vol.4' vs 'Vol.4.5' = 0.97) and token-subset checks both wave these through, which hung volume 4.5's cover art on volume 4 (Sokhi). Shared digits on both sides ('1989' vs '1989 (Deluxe)') are - fine.""" + fine. + + Tokenises on non-word runs but KEEPS word characters of every script, so a + digit glued to a non-latin word stays its own digit-bearing token. Stripping + to [a-z0-9] turned CJK into spaces, collapsing 'サウンドトラック2' to a bare + '2' that a shared number elsewhere ('第2期' = season 2) already covered — so + 'Soundtrack' and 'Soundtrack2' both reduced to {'2'} and matched, hanging the + wrong cover (Sokhi again).""" def _digit_tokens(text: str) -> frozenset: - tokens = re.sub(r"[^a-z0-9]+", " ", (text or "").casefold()).split() + # \W is Unicode-aware for str: CJK/kana count as word chars, so a digit + # stays attached to its word instead of collapsing to a bare '2'. + tokens = re.sub(r"\W+", " ", (text or "").casefold()).split() return frozenset(t for t in tokens if any(c.isdigit() for c in t)) return _digit_tokens(title_a) != _digit_tokens(title_b) +def base_title_before_dash(title: str) -> str: + """The base title before Spotify's ' - ' version separator. + + Spotify renders versions as 'Calma - Remix' / 'Song - Radio Edit' / + 'Track - Remastered 2019'. Libraries (and the files people actually have) + very often store just the base — 'Calma' — so a literal search for + 'Calma - Remix' finds nothing and the OR-fuzzy fallback then floods on the + common qualifier word ('remix' matches every remix). This returns the base + ('Calma') for a base-title search fallback. Splits on the FIRST ' - ' (the + spaced hyphen is Spotify's separator; a bare hyphen inside a word is left + alone). Returns the title unchanged when there's no separator.""" + if not title: + return title + idx = title.find(' - ') + return title[:idx].strip() if idx > 0 else title + + __all__ = [ "titles_plausibly_same", "strip_redundant_context_qualifiers", "strip_subtitle_qualifiers", "numeric_tokens_differ", + "base_title_before_dash", ] diff --git a/core/tidal_client.py b/core/tidal_client.py index a18ab04f..4b542d3d 100644 --- a/core/tidal_client.py +++ b/core/tidal_client.py @@ -1655,6 +1655,8 @@ class TidalClient: ids: List[str] = [] next_path: Optional[str] = None + consecutive_429 = 0 + MAX_PAGE_RETRIES = 4 while True: if next_path: @@ -1687,6 +1689,28 @@ class TidalClient: logger.warning(f"Tidal collection page request failed: {e}") break + # Rate limited mid-walk → retry the SAME cursor page with backoff + # rather than silently truncating the collection. Without this a 429 + # on page ~5 capped a 513-track favorites list at ~98 (issue #880); + # the regular-playlist paginator already retries 429 the same way. + if resp.status_code == 429: + consecutive_429 += 1 + if consecutive_429 <= MAX_PAGE_RETRIES: + backoff = 5.0 * consecutive_429 # 5s, 10s, 15s, 20s + logger.warning( + f"Tidal collection {expected_type} rate limited (429) — " + f"retry {consecutive_429}/{MAX_PAGE_RETRIES} in {backoff}s " + f"({len(ids)} fetched so far)" + ) + time.sleep(backoff) + continue # next_path/cursor unchanged → re-request same page + logger.error( + f"Tidal collection {expected_type} still rate limited after " + f"{MAX_PAGE_RETRIES} retries — returning {len(ids)} IDs " + f"(PARTIAL: the collection may be larger)" + ) + break + if resp.status_code != 200: # 401/403 = scope/permission issue. Token predates the # `collection.read` scope expansion or the user revoked @@ -1704,6 +1728,8 @@ class TidalClient: ) break + consecutive_429 = 0 # a good page → reset the retry budget + try: data = resp.json() except ValueError as e: diff --git a/core/usenet_clients/nzbget.py b/core/usenet_clients/nzbget.py index d58074ff..b8c30dad 100644 --- a/core/usenet_clients/nzbget.py +++ b/core/usenet_clients/nzbget.py @@ -198,7 +198,15 @@ class NZBGetAdapter: size=size_bytes, downloaded=downloaded_bytes, download_speed=speed, - save_path=group.get('DestDir'), + # A QUEUED group's DestDir is the in-progress intermediate dir + # (NZBGet names it '.#' and empties/renames it once + # the move completes). Never offer it as a final save_path — finalize + # only from the HISTORY entry (real FinalDir/DestDir). Otherwise a + # PP_FINISHED group (which maps to 'completed') would finalize on the + # incomplete '....#2141' dir, which is then gone -> "No audio files + # found in /…/incomplete/….#2141" (Swigs). Mirrors the SAB adapter + # ignoring its incomplete_path. + save_path=None, category=group.get('Category'), ) @@ -217,7 +225,15 @@ class NZBGetAdapter: size=size_bytes, downloaded=size_bytes if not is_failed else 0, download_speed=0, - save_path=entry.get('DestDir'), + # Prefer FinalDir — the location after a post-processing script (or + # NZBGet's own move) relocated the files; DestDir can still point at + # the intermediate '….#NZBID' dir. Swigs: files landed in + # /data/soulseek/… (FinalDir) while DestDir stayed /…/incomplete/….#2141. + # Fall back to DestDir when FinalDir is empty (no PP move). Empty/ + # whitespace -> None so the plugin waits for a real path. + save_path=(str(entry.get('FinalDir') or '').strip() + or str(entry.get('DestDir') or '').strip() + or None), category=entry.get('Category'), error=status_field if is_failed else None, ) diff --git a/core/wishlist/ignore.py b/core/wishlist/ignore.py new file mode 100644 index 00000000..beced2c4 --- /dev/null +++ b/core/wishlist/ignore.py @@ -0,0 +1,162 @@ +"""Wishlist ignore-list — a TTL'd skip-gate for the wishlist (#874). + +When a user removes a track from the wishlist or cancels an in-flight +wishlist download, SoulSync would otherwise re-add it on the next +automatic cycle (watchlist scan, failed-track capture, or the cancel +handler's own re-add), so the same release downloads → fails/cancels → +re-queues forever. The ignore list records the user's "stop +auto-grabbing this" intent, and the wishlist *add* path checks it, +skipping automatic re-adds until the entry ages out. + +It is deliberately softer than the blocklist: + - it **expires** after ``IGNORE_TTL_DAYS`` so the track is re-attempted + again later rather than banned forever, and + - it **never blocks a manual force-download** — only the automatic + re-queue. (Manual downloads don't go through ``add_to_wishlist`` at + all, and an explicit manual *add* both bypasses the gate and clears + any existing ignore for the track.) + +This module is pure decision logic — no database handle and no clock of +its own; the caller passes ``now``. The SQL lives in ``MusicDatabase`` +as a thin wrapper around these helpers, which keeps the TTL / id-matching +rules unit-testable without a database. +""" + +from __future__ import annotations + +from datetime import datetime, timedelta +from typing import Any, Dict, Iterable, List, Optional, Set, Tuple + +IGNORE_TTL_DAYS = 30 + +# Recognised reasons (free-text tolerated; these are the canonical two). +REASON_REMOVED = "removed" +REASON_CANCELLED = "cancelled" + + +def normalize_ignore_id(track_id: Any) -> str: + """Canonical key for a wishlist track id. + + The wishlist stores some ids as a composite ``::`` + (when ``wishlist.allow_duplicate_tracks`` is on). The add-path gate + keys on the bare track id (``spotify_track_data['id']``), so we strip + the ``::album`` suffix here so an ignore recorded from a composite-id + wishlist row still matches the bare-id add attempt, and vice-versa. + Returns ``''`` for falsy/blank input. + """ + s = str(track_id or "").strip() + if not s: + return "" + return s.split("::", 1)[0] + + +def _parse_ts(value: Any) -> Optional[datetime]: + """Best-effort parse of a sqlite TIMESTAMP / ISO string / datetime.""" + if isinstance(value, datetime): + return value + if not value: + return None + s = str(value).strip() + for fmt in ( + "%Y-%m-%d %H:%M:%S", + "%Y-%m-%dT%H:%M:%S", + "%Y-%m-%d %H:%M:%S.%f", + "%Y-%m-%dT%H:%M:%S.%f", + ): + try: + return datetime.strptime(s, fmt) + except ValueError: + continue + try: + return datetime.fromisoformat(s) + except ValueError: + return None + + +def is_expired(created_at: Any, now: datetime, ttl_days: int = IGNORE_TTL_DAYS) -> bool: + """True when an ignore entry created at ``created_at`` has aged past TTL. + + Fail-SAFE in the gate's favour: an unparseable/blank timestamp is + treated as **expired** (returns True) so a corrupt row can never wedge + a track out of the wishlist permanently — the worst case is the + ignore silently lapses and the track becomes eligible again. + """ + created = _parse_ts(created_at) + if created is None: + return True + return now >= created + timedelta(days=ttl_days) + + +def active_ignored_ids( + rows: Iterable[Dict[str, Any]], now: datetime, ttl_days: int = IGNORE_TTL_DAYS +) -> Set[str]: + """Set of normalized track ids whose ignore entry is still within TTL.""" + out: Set[str] = set() + for row in rows or []: + tid = normalize_ignore_id(row.get("track_id")) + if tid and not is_expired(row.get("created_at"), now, ttl_days): + out.add(tid) + return out + + +def is_ignored( + rows: Iterable[Dict[str, Any]], track_id: Any, now: datetime, ttl_days: int = IGNORE_TTL_DAYS +) -> bool: + """Whether ``track_id`` matches an in-TTL entry among ``rows``.""" + key = normalize_ignore_id(track_id) + if not key: + return False + return key in active_ignored_ids(rows, now, ttl_days) + + +def extract_display(data: Any) -> Tuple[str, str]: + """Pull a (track_name, artist_name) pair from a Spotify-shaped dict. + + Tolerates ``artists`` as a list of dicts or bare strings, and missing + fields. Used to give ignore-list rows a human label for the UI. + Returns ``('', '')`` when nothing usable is present. + """ + if not isinstance(data, dict): + return "", "" + name = str(data.get("name") or "").strip() + artist = "" + artists = data.get("artists") or [] + if isinstance(artists, list) and artists: + first = artists[0] + if isinstance(first, dict): + artist = str(first.get("name") or "").strip() + else: + artist = str(first or "").strip() + return name, artist + + +def ignore_wishlist_track( + database: Any, + profile_id: int, + track_id: Any, + reason: str, + spotify_data: Any = None, +) -> bool: + """Record an ignore entry for a wishlist track. Best-effort; never raises. + + Copies the track's display name/artist from ``spotify_data`` when + provided (callers should capture it BEFORE removing the wishlist row, + since the row may be gone afterwards); otherwise leaves them blank. + Returns True when an entry was written. + """ + key = normalize_ignore_id(track_id) + if not key or database is None: + return False + name, artist = extract_display(spotify_data or {}) + try: + return bool( + database.add_to_wishlist_ignore( + key, + track_name=name, + artist_name=artist, + reason=reason or REASON_REMOVED, + profile_id=profile_id, + ) + ) + except Exception: + return False diff --git a/core/wishlist/routes.py b/core/wishlist/routes.py index c05a626c..a467676c 100644 --- a/core/wishlist/routes.py +++ b/core/wishlist/routes.py @@ -310,12 +310,30 @@ def remove_track_from_wishlist( if not spotify_track_id: return {"success": False, "error": "No spotify_track_id provided"}, 400 - success = get_wishlist_service().remove_track_from_wishlist( + service = get_wishlist_service() + _db = getattr(service, "database", None) + # #874: capture the track's display info BEFORE removal (the row is + # gone afterwards) so the ignore-list entry carries a human label. + _ignore_data = None + try: + if _db is not None: + _ignore_data = _db.get_wishlist_spotify_data( + spotify_track_id, profile_id=runtime.profile_id) + except Exception: + _ignore_data = None + + success = service.remove_track_from_wishlist( spotify_track_id, profile_id=runtime.profile_id, ) if success: + # #874: a user-initiated remove means "stop auto-requeuing this". + # Record a TTL'd ignore so the watchlist/auto-processor doesn't + # re-add it. Best-effort — never fails the remove. + from core.wishlist.ignore import ignore_wishlist_track, REASON_REMOVED + ignore_wishlist_track(_db, runtime.profile_id, + spotify_track_id, REASON_REMOVED, spotify_data=_ignore_data) runtime.logger.info("Successfully removed track from wishlist: %s", spotify_track_id) return {"success": True, "message": "Track removed from wishlist"}, 200 @@ -358,13 +376,20 @@ def remove_album_from_wishlist( if matched: spotify_track_id = track.get("track_id") or track.get("spotify_track_id") or track.get("id") if spotify_track_id: - tracks_to_remove.append(spotify_track_id) + # Keep the loaded spotify_data alongside the id so the #874 + # ignore entry can be labelled without a second DB read. + tracks_to_remove.append((spotify_track_id, spotify_data)) + from core.wishlist.ignore import ignore_wishlist_track, REASON_REMOVED + _db = getattr(wishlist_service, "database", None) removed_count = 0 album_remove_pid = runtime.profile_id - for spotify_track_id in tracks_to_remove: + for spotify_track_id, track_spotify_data in tracks_to_remove: if wishlist_service.remove_track_from_wishlist(spotify_track_id, profile_id=album_remove_pid): removed_count += 1 + # #874: user removed the whole album → ignore each track. + ignore_wishlist_track(_db, album_remove_pid, + spotify_track_id, REASON_REMOVED, spotify_data=track_spotify_data) if removed_count > 0: runtime.logger.info("Successfully removed %s tracks from album %s", removed_count, album_id) @@ -390,11 +415,22 @@ def remove_batch_from_wishlist( if not spotify_track_ids or not isinstance(spotify_track_ids, list): return {"success": False, "error": "Missing or invalid spotify_track_ids"}, 400 + from core.wishlist.ignore import ignore_wishlist_track, REASON_REMOVED + service = get_wishlist_service() + _db = getattr(service, "database", None) removed = 0 pid = runtime.profile_id for track_id in spotify_track_ids: - if get_wishlist_service().remove_track_from_wishlist(track_id, profile_id=pid): + # Capture label before the row is deleted (#874). + _data = None + try: + if _db is not None: + _data = _db.get_wishlist_spotify_data(track_id, profile_id=pid) + except Exception: + _data = None + if service.remove_track_from_wishlist(track_id, profile_id=pid): removed += 1 + ignore_wishlist_track(_db, pid, track_id, REASON_REMOVED, spotify_data=_data) runtime.logger.info("Batch removed %s track(s) from wishlist", removed) return { diff --git a/core/wishlist/service.py b/core/wishlist/service.py index 6dc8c859..df3be19a 100644 --- a/core/wishlist/service.py +++ b/core/wishlist/service.py @@ -214,7 +214,11 @@ class WishlistService: "preview_url": track_data.get("preview_url") if isinstance(track_data, dict) else None, "external_urls": track_data.get("external_urls", {}) if isinstance(track_data, dict) else {}, "popularity": track_data.get("popularity", 0) if isinstance(track_data, dict) else 0, - "track_number": track_data.get("track_number", 1) if isinstance(track_data, dict) else 1, + # "Track 01" bug: 0 = "unknown position", NOT a fabricated 1. + # A fake 1 looks authoritative and blocks the import + # pipeline's track-number recovery; 0 lets it recover the + # real position (file tag / source lookup) before the floor. + "track_number": track_data.get("track_number", 0) if isinstance(track_data, dict) else 0, "disc_number": track_data.get("disc_number", 1) if isinstance(track_data, dict) else 1, } 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/database/music_database.py b/database/music_database.py index 52591be0..07315108 100644 --- a/database/music_database.py +++ b/database/music_database.py @@ -344,7 +344,29 @@ class MusicDatabase: source_info TEXT -- JSON of source context (playlist name, album info, etc.) ) """) - + + # Wishlist ignore-list (#874): a TTL'd skip-gate. When a user + # removes a track from the wishlist or cancels an in-flight + # wishlist download, the track is recorded here so the automatic + # re-add paths (watchlist scan, failed-track capture, cancel + # re-add) skip it until the entry ages out (see core.wishlist. + # ignore.IGNORE_TTL_DAYS). Softer than `blocklist`: it expires + # and never blocks a manual force-download. Keyed on the bare + # track id; unique per (profile, track) so re-ignoring refreshes. + cursor.execute(""" + CREATE TABLE IF NOT EXISTS wishlist_ignore ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + profile_id INTEGER NOT NULL DEFAULT 1, + track_id TEXT NOT NULL, + track_name TEXT DEFAULT '', + artist_name TEXT DEFAULT '', + reason TEXT DEFAULT 'removed', -- 'removed' | 'cancelled' + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(profile_id, track_id) + ) + """) + cursor.execute("CREATE INDEX IF NOT EXISTS idx_wishlist_ignore_profile ON wishlist_ignore (profile_id, track_id)") + # Watchlist table for storing artists to monitor for new releases cursor.execute(""" CREATE TABLE IF NOT EXISTS watchlist_artists ( @@ -729,6 +751,41 @@ class MusicDatabase: cursor.execute("CREATE INDEX IF NOT EXISTS idx_aih_status ON auto_import_history (status)") cursor.execute("CREATE INDEX IF NOT EXISTS idx_aih_folder_hash ON auto_import_history (folder_hash)") + # Re-identify hints (#889) — a user-designated, single-use answer to "which + # release does this track belong to". Written when the user picks a release in + # the Re-identify modal and the file is staged for auto-import; the import flow + # reads the hint at the TOP of matching (keyed by staged path, content_hash as a + # rename-proof fallback), expedites the match to these exact IDs, then consumes + # the row. `replace_track_id` (when set) is the library row to delete AFTER the + # re-import lands; `exempt_dedup` is always 1 because a re-identify is an explicit + # user action that must not be silently dropped by the quality dedup-skip. + cursor.execute(""" + CREATE TABLE IF NOT EXISTS rematch_hints ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + staged_path TEXT NOT NULL, + content_hash TEXT, + source TEXT NOT NULL, + isrc TEXT, + track_id TEXT, + album_id TEXT, + artist_id TEXT, + track_title TEXT, + album_name TEXT, + artist_name TEXT, + album_type TEXT, + track_number INTEGER, + disc_number INTEGER, + replace_track_id INTEGER, + exempt_dedup INTEGER NOT NULL DEFAULT 1, + status TEXT NOT NULL DEFAULT 'pending', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + consumed_at TIMESTAMP + ) + """) + cursor.execute("CREATE INDEX IF NOT EXISTS idx_rmh_staged_path ON rematch_hints (staged_path)") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_rmh_content_hash ON rematch_hints (content_hash)") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_rmh_status ON rematch_hints (status)") + # Sync history table — tracks the last 100 sync operations with cached context for re-trigger cursor.execute(""" CREATE TABLE IF NOT EXISTS sync_history ( @@ -6888,11 +6945,26 @@ class MusicDatabase: # STRATEGY 1: Try basic SQL LIKE search first (fastest) basic_results = self._search_tracks_basic(cursor, title, artist, limit, server_source, rank_artist) - + if basic_results: logger.debug(f"Basic search found {len(basic_results)} results") return basic_results + # STRATEGY 1b: Spotify renders versions as "Title - Qualifier" + # ("Calma - Remix") but libraries usually store just the base + # ("Calma"), so the literal search misses. Retry on the base title + # BEFORE the OR-fuzzy fallback (which would flood on the common + # qualifier word — every "... remix" matches "remix"). #: Calma - Remix + if title: + from core.text.title_match import base_title_before_dash + base_title = base_title_before_dash(title) + if base_title and base_title != title: + base_results = self._search_tracks_basic( + cursor, base_title, artist, limit, server_source, rank_artist) + if base_results: + logger.debug("Base-title search matched '%s' via '%s'", title, base_title) + return base_results + # STRATEGY 2: Broader fuzzy search - splits into individual words with OR matching fuzzy_results = self._search_tracks_fuzzy_fallback(cursor, title, artist, limit, server_source) if fuzzy_results: @@ -6921,6 +6993,17 @@ class MusicDatabase: if basic_rows: return [dict(r) for r in basic_rows] + # Base-title fallback for Spotify "Title - Qualifier" forms (see + # search_tracks STRATEGY 1b) before the OR-fuzzy flood. + if title: + from core.text.title_match import base_title_before_dash + base_title = base_title_before_dash(title) + if base_title and base_title != title: + base_rows = self._search_tracks_basic_rows( + cursor, base_title, artist, limit, server_source) + if base_rows: + return [dict(r) for r in base_rows] + fuzzy_rows = self._search_tracks_fuzzy_rows(cursor, title, artist, limit, server_source) return [dict(r) for r in fuzzy_rows] except Exception as e: @@ -8632,10 +8715,40 @@ class MusicDatabase: ], # Keep qualities dict for backwards compat with any old code paths still reading it "qualities": { - "flac": {"enabled": True, "min_kbps": 500, "max_kbps": 10000, "priority": 1, "bit_depth": "any"}, - "mp3_320": {"enabled": True, "min_kbps": 280, "max_kbps": 500, "priority": 2}, - "mp3_256": {"enabled": True, "min_kbps": 200, "max_kbps": 400, "priority": 3}, - "mp3_192": {"enabled": False, "min_kbps": 150, "max_kbps": 300, "priority": 4}, + "flac": { + "enabled": True, + "min_kbps": 500, + "max_kbps": 10000, + "priority": 1, + "bit_depth": "any" + }, + "mp3_320": { + "enabled": True, + "min_kbps": 280, + "max_kbps": 500, + "priority": 2 + }, + "mp3_256": { + "enabled": True, + "min_kbps": 200, + "max_kbps": 400, + "priority": 3 + }, + "mp3_192": { + "enabled": False, + "min_kbps": 150, + "max_kbps": 300, + "priority": 4 + }, + # AAC (incl. .m4a): opt-in, OFF by default. Priority 1.5 sits it + # above MP3 but below FLAC (AAC is more efficient than MP3); the + # min_kbps gate keeps junk-bitrate AAC from beating a good MP3. + "aac": { + "enabled": False, + "min_kbps": 128, + "max_kbps": 400, + "priority": 1.5 + } }, } @@ -8764,6 +8877,21 @@ class MusicDatabase: _blocked[0], _blocked[1]) return False + # Ignore-list guard (#874): a user who removed or cancelled this + # track asked us to stop AUTO-requeuing it — every automatic + # re-add funnels through here, so one check covers them all. + # A *manual* add is explicit user intent → bypass the gate AND + # clear any stale ignore so it sticks. Fail-open: any error here + # must never block a legitimate wishlist add. + try: + if source_type == 'manual': + self.remove_from_wishlist_ignore(track_id, profile_id=profile_id) + elif self.is_track_ignored(track_id, profile_id=profile_id): + logger.info("Skipping wishlist add — track is on the ignore-list (#874): %s", track_id) + return False + except Exception as _ignore_exc: + logger.debug("Wishlist ignore-list check skipped (fail-open): %s", _ignore_exc) + from core.library import manual_library_match as _mlm if _mlm.get_match_for_track(self, profile_id, spotify_track_data): logger.info( @@ -8908,7 +9036,147 @@ class MusicDatabase: except Exception as e: logger.error(f"Error removing track from wishlist: {e}") return False - + + # ── Wishlist ignore-list (#874) ────────────────────────────────────── + # A TTL'd skip-gate consulted by add_to_wishlist so user-removed / + # user-cancelled tracks are not auto-re-queued. All methods fail-open + # (an error here must never block a legitimate wishlist add). + + def add_to_wishlist_ignore(self, track_id: str, track_name: str = "", + artist_name: str = "", reason: str = "removed", + profile_id: int = 1) -> bool: + """Record (or refresh) an ignore entry for a wishlist track id. + + Keyed on the bare track id; UNIQUE(profile_id, track_id) means a + repeat ignore replaces the row and so refreshes its TTL clock. + """ + from core.wishlist.ignore import normalize_ignore_id + key = normalize_ignore_id(track_id) + if not key: + return False + try: + with self._get_connection() as conn: + cursor = conn.cursor() + cursor.execute(""" + INSERT OR REPLACE INTO wishlist_ignore + (profile_id, track_id, track_name, artist_name, reason, created_at) + VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP) + """, (profile_id, key, track_name or "", artist_name or "", reason or "removed")) + conn.commit() + logger.info("Added track to wishlist ignore-list (%s): '%s' [%s]", + reason or "removed", track_name or key, key) + return True + except Exception as e: + logger.error("Error adding to wishlist ignore-list: %s", e) + return False + + def is_track_ignored(self, track_id: str, profile_id: int = 1, + ttl_days: Optional[int] = None) -> bool: + """Whether ``track_id`` has a non-expired ignore entry. Fail-open False.""" + from core.wishlist.ignore import normalize_ignore_id, is_expired, IGNORE_TTL_DAYS + ttl = IGNORE_TTL_DAYS if ttl_days is None else ttl_days + key = normalize_ignore_id(track_id) + if not key: + return False + try: + with self._get_connection() as conn: + cursor = conn.cursor() + cursor.execute( + "SELECT created_at FROM wishlist_ignore WHERE profile_id = ? AND track_id = ?", + (profile_id, key)) + row = cursor.fetchone() + if not row: + return False + return not is_expired(row["created_at"], datetime.now(), ttl) + except Exception as e: + logger.debug("is_track_ignored failed open: %s", e) + return False + + def remove_from_wishlist_ignore(self, track_id: str, profile_id: int = 1) -> bool: + """Un-ignore a track (manual override / UI action). Returns True if a row went.""" + from core.wishlist.ignore import normalize_ignore_id + key = normalize_ignore_id(track_id) + if not key: + return False + try: + with self._get_connection() as conn: + cursor = conn.cursor() + cursor.execute( + "DELETE FROM wishlist_ignore WHERE profile_id = ? AND track_id = ?", + (profile_id, key)) + conn.commit() + return cursor.rowcount > 0 + except Exception as e: + logger.error("Error removing from wishlist ignore-list: %s", e) + return False + + def get_wishlist_ignore(self, profile_id: int = 1, + ttl_days: Optional[int] = None) -> List[Dict[str, Any]]: + """Active (non-expired) ignore entries, newest first; purges lapsed rows.""" + from core.wishlist.ignore import is_expired, IGNORE_TTL_DAYS + ttl = IGNORE_TTL_DAYS if ttl_days is None else ttl_days + try: + with self._get_connection() as conn: + cursor = conn.cursor() + cursor.execute( + "SELECT track_id, track_name, artist_name, reason, created_at " + "FROM wishlist_ignore WHERE profile_id = ? ORDER BY created_at DESC", + (profile_id,)) + rows = cursor.fetchall() + now = datetime.now() + active, expired_ids = [], [] + for r in rows: + if is_expired(r["created_at"], now, ttl): + expired_ids.append(r["track_id"]) + else: + active.append({ + "track_id": r["track_id"], + "track_name": r["track_name"] or "", + "artist_name": r["artist_name"] or "", + "reason": r["reason"] or "removed", + "created_at": r["created_at"], + }) + # Opportunistic housekeeping so the table can't grow unbounded. + if expired_ids: + cursor.executemany( + "DELETE FROM wishlist_ignore WHERE profile_id = ? AND track_id = ?", + [(profile_id, tid) for tid in expired_ids]) + conn.commit() + return active + except Exception as e: + logger.error("Error reading wishlist ignore-list: %s", e) + return [] + + def clear_wishlist_ignore(self, profile_id: int = 1) -> int: + """Drop every ignore entry for a profile. Returns rows removed.""" + try: + with self._get_connection() as conn: + cursor = conn.cursor() + cursor.execute("DELETE FROM wishlist_ignore WHERE profile_id = ?", (profile_id,)) + conn.commit() + return cursor.rowcount + except Exception as e: + logger.error("Error clearing wishlist ignore-list: %s", e) + return 0 + + def get_wishlist_spotify_data(self, track_id: str, profile_id: int = 1) -> Dict[str, Any]: + """Parsed ``spotify_data`` for a wishlist row, or {}. Used to label an + ignore entry with the track's name/artist before the row is removed.""" + try: + with self._get_connection() as conn: + cursor = conn.cursor() + cursor.execute( + "SELECT spotify_data FROM wishlist_tracks WHERE spotify_track_id = ? AND profile_id = ?", + (track_id, profile_id)) + row = cursor.fetchone() + if not row or not row["spotify_data"]: + return {} + data = json.loads(row["spotify_data"]) + return data if isinstance(data, dict) else {} + except Exception as e: + logger.debug("get_wishlist_spotify_data failed: %s", e) + return {} + def get_wishlist_tracks(self, limit: Optional[int] = None, profile_id: int = 1, offset: int = 0, category: Optional[str] = None) -> List[Dict[str, Any]]: """Get tracks in the wishlist for the given profile, ordered by date added diff --git a/pr_description.md b/pr_description.md index 6b9f791d..365c2dfa 100644 --- a/pr_description.md +++ b/pr_description.md @@ -1,116 +1,37 @@ -# SoulSync 2.6.4 — Merge `dev` → `main` +# soulsync 2.7.4 — `dev` → `main` -Patch release on top of 2.6.3. Headline: - -- **#721 — Usenet album bundles stuck on "downloading release" when SAB History flips before storage lands.** Reported by @IamGroot60 against 2.6.3, validated on the `fix/usenet-bundle-save-path-handoff` branch, merged via PR #723. - -Everything from 2.6.3 also rolls in unchanged (was bumped on dev but never tagged / published to main / docker, so this is the first time these changes reach users). +patch release on top of 2.7.3. headline is **re-identify** — re-file an already-imported track under the right release without re-downloading it. --- -## 2.6.4 — the patch +## what's new -### #721 — Usenet album bundle stuck on "downloading release" when SAB History flips before `storage` lands -Follow-up to the 2.6.3 queue→history handoff fix (#706). 2.6.3 covered the gap where SAB removes a job from the queue before adding it to history. **2.6.4** covers a second-stage gap: SAB flips `status` to `Completed` in History a few seconds **before** its post-processing writes the final `storage` field. +### re-identify a track (#889) +filed a track under the wrong release (single vs ep vs album)? there's now a ⇄ button in the library Enhanced view that lets you fix it. search any configured source (tabs, defaults to your active one), see the same song across its single / ep / album with type badges, pick the right one, and soulsync re-files the file you already have under that release — correct year, in-album track number, and art. opt to replace the original entry or keep both. -Pre-fix: `poll_album_download` saw the first `Completed` read with `save_path=None` and bailed. The bundle plugin marked the batch failed, but the UI froze on the last `downloading progress=0.61` emit because the terminal `failed` emit never registered (renderer holds the last-known progress). +built additively over 5 phases (hint store → import seam → multi-source search → modal → button), all riding the existing import pipeline so a no-hint import is byte-identical to before. and it can't lose your file: replace deletes the old entry only *after* the re-import lands, and never if you pick the release it's already in. -- **`poll_album_download`**: separate transient counter for "completed but no save_path." Tolerates up to `transient_miss_threshold` (default 5) consecutive reads in that state — gives SAB ~10s to land the path. When it arrives, return normally. When it doesn't, fail loudly with an explicit error pointing at the missing field. -- **Sticky save_path**: earlier `downloading` reads with a non-empty `save_path` (qBit / Transmission set this from the start) remain cached. So torrent flows aren't affected by the retry path. -- **SAB adapter (`_parse_history_slot`)**: widened the save_path fallback chain — `storage` → `path` → `download_path` → `dirname`. Covers SAB version differences (older builds populated `path`) and forks that expose `download_path` or `dirname`. Whitespace-only values skipped. `incomplete_path` intentionally NOT in the chain — it'd bypass the retry window and point at the in-progress staging dir. -- **Diagnostic**: loud debug log when none of the known fields land, dumping the slot keys so we can grow `_HISTORY_SAVE_PATH_KEYS` if a fork ships a novel field name. +### cleaner libraries & imports +- **#890** — track titles no longer keep the "01 - " prefix from the filename when there's no embedded title tag (which made the real track read as a false "missing"). stripped conservatively so "7 Rings" / "1-800-273-8255" / "1979" are left alone. +- **#891** — a Library Reorganize now sweeps the leftover cover.jpg / .lrc / sidecars from the old folder so it actually empties, plus an opt-in "Remove Residual Files" toggle on the Empty Folder Cleaner for the image-only folders you already have. +- **Sokhi's batch** — same-album songs group under one canonical release id (no more split discographies / mixed cover art); a single can match its parent album; a mid-enrichment crash on an art-less file no longer leaves it untagged; and a sequel digit glued to a CJK title no longer matches the wrong album. -**9 new tests**: -- `test_album_bundle.py` (3): late-save_path arrival recovers; threshold-exhausted fails cleanly; sticky save_path keeps torrent flows working. -- `test_usenet_client_adapters.py` (6): each fallback field tier, whitespace-only skip, all-empty returns None, `incomplete_path` ignored. +### quality & sources +- **#886** — AAC (.m4a) as an opt-in soulseek quality tier, ranked above mp3 / below flac. off by default; existing profiles unchanged until you enable it. +- **#887** — enrichment on Spotify Free now reads "Running (Spotify Free)" instead of wrongly showing "Not Authenticated". +- **#884** — NZBGet imports from the finished location, not the incomplete "….#NZBID" folder. +- **#885** — setting the timezone to Australia/Sydney no longer makes the cache-maintenance job loop every 5 seconds. -132 album-bundle + usenet tests pass. Strictly additive — zero impact on users whose SAB returns `storage` on the first Completed read. +### polish +- the artist-detail header no longer bleeds the blurred artist photo behind it. --- -## Everything else from 2.6.3 (carried forward) +## tests +strictly additive across the board — every new behavior is opt-in or gated so default flows are unchanged. ~100 new tests this cycle (re-identify seam, title-strip danger cases, the shared residual-file classifier, aac tier, tz scheduler, spotify-free status). full imports / matching / reorganize / auto-import suites green, ruff clean. -### Fixes - -**#715 — Soulseek album downloads stuck on "failed" after slskd finished the release.** -`core/soulseek_client._resolve_downloaded_album_file` probed 3 hard-coded candidate paths. On the common slskd config `directories.downloads.username = true`, files land at `//` — none of the 3 candidates carried a username segment, so every file looked locally missing and the bundle poll silently spun for ~30 minutes before marking the batch failed. - -- Lifted the per-track flow's recursive walk-by-basename helper into `core/downloads/file_finder.py` (`find_completed_audio_file`). Bundle resolver now delegates to it. Default-slskd users see zero behavior change (3-candidate fast path preserved). -- Bundle poll detects "slskd reports Completed but local file can't be resolved past a 45s grace window" → exits early with explicit log line pointing at the likely `soulseek.download_path` mismatch. -- Misleading `"(0 tracks, quality=)"` log on the preflight-reuse path fixed. -- **17 new tests** pin every slskd layout (flat, username-prefixed, full-tree-preserved, deep nested, dedup-suffix, quarantine-skip, YouTube/Tidal encoded, transfer-dir fallback, fuzzy variants). - -**Auto-Sync ListenBrainz pipelines stuck on `Refreshing:` for 5+ minutes.** -Refresh path ran `_maybe_discover` inline AND Phase 2 ran the same matching engine via `run_playlist_discovery_worker`. LB tracks discovered twice; refresh-side run blocked with zero progress emission. Also: LB manager only exposed `update_all_playlists` (refreshing one playlist re-pulled all 12+ cached playlists). Also: LB adapter had a silent `except Exception: pass` masking real API failures. - -- Pipeline sets `skip_discovery=True` on refresh config; Phase 2 handles discovery with proper progress emits. -- New `LBManager.refresh_playlist(mbid)` targeted refresh. -- LB adapter logs exceptions with traceback at warning level + returns `None`. -- **12 new tests**. - -**Wishlist: harden Spotify backfill — poisoned `tn=1` can't mask a lean album.** -Spotify-API backfill that hydrates `release_date` / `total_tracks` was coupled to the "track_number missing" branch, so a poisoned default-1 track_number short-circuited it. Lifted to `core/downloads/track_metadata_backfill.py` with split concerns — track-number resolution keeps its precedence chain; album hydration runs whenever `release_date` / `total_tracks` missing, independent of track_number. Single API call still serves both. Also `core/wishlist/routes.py:_build_track_data` no longer defaults `track_number=1` / `disc_number=1` / `total_tracks=1` / `release_date=''`. **24 new tests**. - -**Wishlist: fix three regressions causing all imports to land as track 01.** -Track→dict conversion in payload helpers dropped everything except `album.name`; Deezer-sourced discovery matches saved without `track_number`/`disc_number`; import pipeline only consulted `album_info.track_number` before falling to the filename. Track_number resolution chain lifted into `core/imports/track_number.py:resolve_track_number` with 18 unit tests. - -**Wishlist: only engage album-bundle when several tracks from the same album are missing.** -New `core/wishlist/album_grouping.py`. Bundle path only engages when an album has ≥2 missing tracks; single-track items take the cheaper per-track path. Configurable via `wishlist.album_bundle_min_tracks`. - -**Wishlist: distinguish Queued from Analyzing batches in the UI.** - -**Album-bundle staging: clean Soulseek copies + sweep orphans at startup.** -Cleanup gate extended to include `soulseek` (was torrent/usenet only). New `sweep_orphan_album_bundle_staging` runs once at server boot. **12 new tests**. - -**Usenet album poll: tolerate SAB queue→history handoff (#706).** - -**Discogs: strip artist disambiguation suffixes everywhere (#634).** - -**Library: Enhanced / Standard view toggle persists per browser.** - -**Fix popup: manual matches survive Playlist Pipeline runs.** - -**Fix popup: artist + track fields no longer surface unrelated covers.** - -### UX overhauls - -**Dashboard enrichment panel — equalizer-bar redesign.** 11 speedometer tiles → 11 vertical VU-meter equalizer bars in one symmetric flex row. Brand-logo avatar disc above each bar (Spotify/Apple Music/Deezer/Last.fm/Genius/MusicBrainz/AudioDB/Tidal/Qobuz/Discogs/Amazon with initial-letter CDN-fail fallback); peak-flash on cpm step-up; rolling counter; glass-surface reflection puddle. Last.fm circle-clipped; Tidal/Qobuz/Discogs/Amazon inverted to white silhouettes. - -**Auto-Sync manager — full visual overhaul.** Selector-based override layer (zero JS/HTML changes). Every surface inside the modal restyled to match the dashboard's glassy / accent-radial aesthetic. - -**Auto-Sync — weekly board cards now match the hourly board.** Same Run-now button, unschedule X, next-run countdown, health badge. Weekly cards now draggable between day columns. - -**Auto-Sync sidebar — brand logo on each source-group header.** - -**Sync page tabs — brand-logo chips with active label pill.** 14 tabs collapsed from cramped labeled pills to circular brand-logo chips; active tab swells into a pill with its label inline. `Link` variants (Spotify Link / Deezer Link / iTunes Link) carry a small chain-link badge bottom-right. - -### Architectural lifts - -**Unified Playlist Sources layer.** `PlaylistSource` ABC + registry in `core/playlists/sources/`. Refresh handler dropped from ~190 lines of if/elif to ~80 lines. ListenBrainz / Last.fm / SoulSync Discovery are now Sync-page tabs. - -**Auto-Sync schedule types — weekday + time.** New Weekly Board tab on the Auto-Sync manager. - -**iTunes / Apple Music link import.** New iTunes Link tab on the Sync page. - ---- - -## Test plan - -- [x] 132 album-bundle + usenet tests pass (the new #721 path) -- [x] 488 downloads tests pass (full suite) -- [x] ~90 new unit tests across the cycle, including 9 new for #721 -- [x] Smoke: dashboard equalizer renders w/ brand logos, peak-flash on cpm increase -- [x] Smoke: Auto-Sync manager renders glass overhaul, hourly + weekly cards both have action rows -- [x] Smoke: Sync page tab strip renders as logo chips; active expands; Link variants show chain-link badge -- [ ] Live: @IamGroot60 to re-test Forty Licks usenet bundle on dev (build with the #721 fix applied) -- [ ] Live: Soulseek album download on a username-subdir slskd config completes cleanly (#715, user-validated post-merge) -- [ ] Live: bundle staging dir cleaned on completion (user-validated post-merge) - ---- - -## Post-merge checklist - -- [ ] Tag `v2.6.4` on `main` -- [ ] Trigger `docker-publish.yml` with `version_tag: 2.6.4` to push `boulderbadgedad/soulsync:2.6.4` + `ghcr.io/nezreka/soulsync:2.6.4` (default already updated) -- [ ] Discord release announcement (auto-fired by the workflow) -- [ ] Reply on #721 with the 2.6.4 release link +## post-merge +- [ ] tag `v2.7.4` on `main` +- [ ] docker-publish with `version_tag: 2.7.4` +- [ ] discord announce (auto-fired by the workflow) +- [ ] reply on #889 / #890 / #891 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 0841fec8..00000000 --- a/tests/discovery/test_discovery_quality_scanner.py +++ /dev/null @@ -1,531 +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, - probe_fn=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=[]) - - # Quality is now decided by probing the real file against the v3 targets. - # Derive a stand-in AudioQuality from the legacy tier param so existing - # tests keep their intent: 'lossless' → a FLAC that meets the default - # flac-enabled profile; anything else → an MP3 that doesn't. - from core.quality.model import AudioQuality - - def _default_probe(_fp): - if quality_tier_result[0] == 'lossless': - return AudioQuality('flac', bit_depth=16, sample_rate=44100) - return AudioQuality('mp3', bitrate=128) - - 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, - probe_audio_quality=probe_fn or _default_probe, - resolve_library_file_path=lambda fp: fp, # identity — tests use direct paths - ) - 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_scanner_resolves_relative_path_before_probing(mock_db_and_wishlist): - """DB stores RELATIVE paths (Artist/Album/Track.flac); the scanner must - resolve them to absolute via resolve_library_file_path before probing, - otherwise mutagen can't open the file and the whole library passes.""" - from core.quality.model import AudioQuality - - db, _ = mock_db_and_wishlist - db._watchlist_artists = [_WatchlistArtist('A')] - db._tracks = [_track_row(file_path='Artist/Album/Track.flac')] - state = {} - probed = [] - - def _probe(fp): - probed.append(fp) - return AudioQuality('flac', bit_depth=16, sample_rate=44100) - - deps = _build_deps(state=state, probe_fn=_probe) - deps.resolve_library_file_path = lambda fp: '/music/' + fp - - qs.run_quality_scanner('watchlist', 1, deps) - - assert probed == ['/music/Artist/Album/Track.flac'] - - -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 = {} - from core.quality.model import AudioQuality - - # Probe is called once per track; use it to stop after the first. - 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 state_lock: - state['status'] = 'stopping' - return AudioQuality('flac', bit_depth=16, sample_rate=44100) # meets profile - - state_lock = threading.Lock() - deps = _build_deps(state=state, quality_tier_result=('lossless', 1), probe_fn=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_soulseek_aac_quality.py b/tests/downloads/test_soulseek_aac_quality.py new file mode 100644 index 00000000..1dd9fe2f --- /dev/null +++ b/tests/downloads/test_soulseek_aac_quality.py @@ -0,0 +1,106 @@ +"""#886: AAC as an opt-in Soulseek quality tier. + +The whole point is "purely additive": with AAC OFF (the default, and every +profile that predates this), an AAC candidate must behave EXACTLY as before — +it lands in the 'other' bucket, which the waterfall never returns, so it's +dropped. Only a profile that explicitly enables AAC makes it a selectable tier, +ranked above MP3 and below FLAC. + +filter_results_by_quality_preference reads db.get_quality_profile() and walks the +buckets; we stub the db + the quarantine sweep so it runs offline. +""" + +from __future__ import annotations + +import types +from pathlib import Path +from unittest.mock import patch + +import pytest + +from core.soulseek_client import SoulseekClient +from core.download_plugins.types import TrackResult + + +def _client(): + c = SoulseekClient.__new__(SoulseekClient) + c.base_url = 'http://localhost:5030' + c.api_key = 'k' + c.download_path = Path('./test_downloads') + return c + + +def _cand(quality, size_mb, bitrate=None): + return TrackResult( + username='peer', filename=f'A/B/01 - Song.{quality}', + size=int(size_mb * 1024 * 1024), bitrate=bitrate, duration=None, + quality=quality, free_upload_slots=1, upload_speed=1_000_000, + queue_length=0, artist='A', title='Song', album='B', track_number=1) + + +def _q(enabled_flac=True, enabled_mp3=True, aac=None): + qualities = { + 'flac': {'enabled': enabled_flac, 'min_kbps': 500, 'max_kbps': 10000, 'priority': 1, 'bit_depth': 'any'}, + 'mp3_320': {'enabled': enabled_mp3, 'min_kbps': 280, 'max_kbps': 500, 'priority': 2}, + } + if aac is not None: # None => omit the tier entirely (pre-existing profile) + qualities['aac'] = {'enabled': aac, 'min_kbps': 128, 'max_kbps': 400, 'priority': 1.5} + return {'preset': 'custom', 'qualities': qualities, 'fallback_enabled': True} + + +def _filter(candidates, profile): + c = _client() + fake_db = types.SimpleNamespace(get_quality_profile=lambda: profile) + with patch('database.music_database.MusicDatabase', return_value=fake_db), \ + patch.object(SoulseekClient, '_drop_quarantined_sources', lambda self, r: r): + return c.filter_results_by_quality_preference(candidates) + + +# ── additive proof: AAC off == today (dropped) ──────────────────────────────── +def test_aac_dropped_when_tier_absent_pre_existing_profile(): + # A profile saved before this feature has no 'aac' key at all. + out = _filter([_cand('aac', 5)], _q(aac=None)) + assert out == [] # AAC went to 'other' -> never returned, exactly as before + + +def test_aac_dropped_when_tier_present_but_disabled(): + out = _filter([_cand('aac', 5)], _q(aac=False)) + assert out == [] + + +def test_flac_mp3_selection_unchanged_when_aac_absent(): + # The headline no-regression guard: a normal FLAC/MP3 mix is unaffected. + flac, mp3 = _cand('flac', 30), _cand('mp3', 5, bitrate=320) + out = _filter([mp3, flac], _q(aac=None)) + assert out and out[0].quality == 'flac' # FLAC still wins, as before + + +# ── opt-in: AAC on makes it a real tier ─────────────────────────────────────── +def test_aac_selected_when_enabled(): + out = _filter([_cand('aac', 5)], _q(aac=True)) + assert len(out) == 1 and out[0].quality == 'aac' + + +def test_flac_beats_aac_when_both_present(): + flac, aac = _cand('flac', 30), _cand('aac', 5) + out = _filter([aac, flac], _q(aac=True)) + assert out[0].quality == 'flac' # priority 1 < 1.5 + + +def test_aac_beats_mp3_when_both_present(): + mp3, aac = _cand('mp3', 5, bitrate=320), _cand('aac', 5) + out = _filter([mp3, aac], _q(aac=True)) + assert out[0].quality == 'aac' # priority 1.5 < 2 + + +def test_default_and_presets_ship_aac_disabled_above_mp3(): + from database.music_database import MusicDatabase + db = MusicDatabase.__new__(MusicDatabase) # no DB init + profiles = [db._get_default_quality_profile()] + profiles += [db.get_quality_preset(p) for p in ('audiophile', 'balanced', 'space_saver')] + for prof in profiles: + aac = prof['qualities']['aac'] + assert aac['enabled'] is False # opt-in everywhere + # above MP3: lower priority number than the best MP3 tier present + mp3_prios = [v['priority'] for k, v in prof['qualities'].items() if k.startswith('mp3')] + assert aac['priority'] < min(mp3_prios) 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/imports/test_album_grouping.py b/tests/imports/test_album_grouping.py new file mode 100644 index 00000000..01656cc9 --- /dev/null +++ b/tests/imports/test_album_grouping.py @@ -0,0 +1,138 @@ +"""Seam tests for canonical album grouping (Sokhi: split album rows -> mixed +cover art). Drives find_existing_soulsync_album_id against a real in-memory +SQLite albums table — no app singletons, no I/O. +""" + +from __future__ import annotations + +import sqlite3 + +import pytest + +from core.imports.album_grouping import ( + find_existing_soulsync_album_id, + ALLOWED_ALBUM_SOURCE_COLS, +) + + +@pytest.fixture() +def cur(): + conn = sqlite3.connect(":memory:") + conn.execute( + """CREATE TABLE albums ( + id TEXT PRIMARY KEY, + artist_id TEXT, + title TEXT, + server_source TEXT, + spotify_album_id TEXT, + itunes_album_id TEXT, + deezer_id TEXT, + soul_id TEXT, + discogs_id TEXT, + musicbrainz_release_id TEXT + )""" + ) + yield conn.cursor() + conn.close() + + +def _add(cur, *, id, title, artist_id="art1", server_source="soulsync", **source_ids): + cols = ["id", "artist_id", "title", "server_source"] + list(source_ids) + vals = [id, artist_id, title, server_source] + list(source_ids.values()) + cur.execute( + f"INSERT INTO albums ({', '.join(cols)}) VALUES ({', '.join(['?'] * len(cols))})", + vals, + ) + + +def test_empty_db_returns_none(cur): + assert find_existing_soulsync_album_id( + cur, name_key_id="nk", artist_id="art1", album_name="Parachutes", + album_source_col="spotify_album_id", album_source_id="SP1") is None + + +def test_exact_name_hash_id_wins_first(cur): + _add(cur, id="nk", title="Parachutes") + assert find_existing_soulsync_album_id( + cur, name_key_id="nk", artist_id="art1", album_name="Parachutes") == "nk" + + +def test_canonical_source_id_unifies_differently_named_imports(cur): + # Existing row for release SP1 named "Parachutes". A second import of the + # SAME release id but a drifted name must JOIN it, not split. + _add(cur, id="existing", title="Parachutes", spotify_album_id="SP1") + got = find_existing_soulsync_album_id( + cur, name_key_id="different_hash", artist_id="art1", + album_name="Parachutes (Deluxe Edition)", + album_source_col="spotify_album_id", album_source_id="SP1") + assert got == "existing" + + +def test_different_release_id_stays_separate(cur): + # The single-vs-album case: a genuinely different release id must NOT merge + # (documents the known limit — single->album resolution is a separate step). + _add(cur, id="album_row", title="Parachutes", spotify_album_id="SP_ALBUM") + got = find_existing_soulsync_album_id( + cur, name_key_id="single_hash", artist_id="art1", album_name="Yellow", + album_source_col="spotify_album_id", album_source_id="SP_SINGLE") + assert got is None + + +def test_legacy_name_match_still_groups_without_a_source_id(cur): + _add(cur, id="byname", title="Parachutes") + got = find_existing_soulsync_album_id( + cur, name_key_id="other_hash", artist_id="art1", album_name="parachutes", + album_source_col=None, album_source_id=None) + assert got == "byname" # case-insensitive title + artist + + +def test_source_id_match_is_scoped_to_soulsync_rows(cur): + _add(cur, id="plexrow", title="Parachutes", server_source="plex", spotify_album_id="SP1") + got = find_existing_soulsync_album_id( + cur, name_key_id="nk", artist_id="art1", album_name="X", + album_source_col="spotify_album_id", album_source_id="SP1") + assert got is None # the matching row belongs to Plex, not soulsync + + +def test_non_allowlisted_column_is_ignored(cur): + # A column not on the allowlist must never be spliced into SQL. + assert "title" not in ALLOWED_ALBUM_SOURCE_COLS + _add(cur, id="row", title="Parachutes") + got = find_existing_soulsync_album_id( + cur, name_key_id="nk", artist_id="art1", album_name="nope", + album_source_col="title", album_source_id="Parachutes") + assert got is None # 'title' ignored as a source col; name 'nope' doesn't match + + +def test_empty_source_id_skips_canonical_match(cur): + _add(cur, id="row", title="Parachutes", spotify_album_id="") + got = find_existing_soulsync_album_id( + cur, name_key_id="nk", artist_id="art1", album_name="Other", + album_source_col="spotify_album_id", album_source_id="") + assert got is None + + +def test_missing_album_column_falls_through_not_raises(cur): + # Some sources (Deezer) don't have a dedicated album id column on the albums + # table; an allow-listed-but-absent column must NOT raise (it broke the whole + # import once) — it falls through to the name match. + cur.execute("CREATE TABLE albums_min (id TEXT, artist_id TEXT, title TEXT, server_source TEXT)") + cur.execute("INSERT INTO albums_min VALUES ('byname','art1','DZ Album','soulsync')") + # Point the helper at a table missing deezer_id by aliasing via a fresh cursor. + conn2 = sqlite3.connect(":memory:") + conn2.execute("CREATE TABLE albums (id TEXT, artist_id TEXT, title TEXT, server_source TEXT)") + conn2.execute("INSERT INTO albums VALUES ('byname','art1','DZ Album','soulsync')") + c2 = conn2.cursor() + got = find_existing_soulsync_album_id( + c2, name_key_id="nk", artist_id="art1", album_name="DZ Album", + album_source_col="deezer_id", album_source_id="67890") + conn2.close() + assert got == "byname" # deezer_id column absent -> fell through to name match + + +def test_musicbrainz_release_id_grouping(cur): + _add(cur, id="mbrow", title="Album", musicbrainz_release_id="mb-123") + got = find_existing_soulsync_album_id( + cur, name_key_id="nk2", artist_id="art1", album_name="Album (Remaster)", + album_source_col="musicbrainz_release_id", album_source_id="mb-123") + assert got == "mbrow" diff --git a/tests/imports/test_quarantine_management.py b/tests/imports/test_quarantine_management.py index 3615ca6a..7234c497 100644 --- a/tests/imports/test_quarantine_management.py +++ b/tests/imports/test_quarantine_management.py @@ -5,9 +5,11 @@ from core.imports.quarantine import ( approve_quarantine_entry, delete_quarantine_entry, entry_id_from_quarantined_filename, + find_quarantine_siblings, get_quarantine_entry_stream_info, get_quarantined_source_keys, list_quarantine_entries, + quarantine_group_key, recover_to_staging, serialize_quarantine_context, ) @@ -71,18 +73,20 @@ def test_serialize_round_trips_through_json(): # list_quarantine_entries # ────────────────────────────────────────────────────────────────────── -def _write_entry(quarantine_dir, entry_id, original_name, *, with_context=False, trigger="integrity", reason="boom", file_bytes=b"X" * 100): +def _write_entry(quarantine_dir, entry_id, original_name, *, with_context=False, trigger="integrity", reason="boom", file_bytes=b"X" * 100, expected_track="Track", expected_artist="Artist", context=None): qfile = quarantine_dir / f"{entry_id}_{original_name}.quarantined" qfile.write_bytes(file_bytes) sidecar = { "original_filename": original_name, "quarantine_reason": reason, - "expected_track": "Track", - "expected_artist": "Artist", + "expected_track": expected_track, + "expected_artist": expected_artist, "timestamp": "2026-05-14T12:00:00", "trigger": trigger, } - if with_context: + if context is not None: + sidecar["context"] = context + elif with_context: sidecar["context"] = {"track_info": {"name": "Track"}, "context_key": entry_id} sidecar_path = quarantine_dir / f"{entry_id}_{os.path.splitext(original_name)[0]}.json" sidecar_path.write_text(json.dumps(sidecar)) @@ -454,3 +458,95 @@ def test_move_with_retry_returns_false_on_missing_source(tmp_path): # attempts=1 keeps the test fast (no retry sleeps) assert _move_with_retry(str(tmp_path / "nope.flac"), str(tmp_path / "dst.flac"), attempts=1, delay=0) is False + + +# ────────────────────────────────────────────────────────────────────── +# #876: grouping alternatives for one song — quarantine_group_key / +# find_quarantine_siblings, and the group_key field on list entries. +# ────────────────────────────────────────────────────────────────────── + +def test_group_key_prefers_isrc_over_everything(): + ctx = {"track_info": {"isrc": "USRC12345678", "id": "spid", "uri": "spotify:track:x"}} + assert quarantine_group_key("Artist", "Track", ctx) == "isrc:usrc12345678" + + +def test_group_key_falls_back_to_source_id_then_uri(): + assert quarantine_group_key("A", "T", {"track_info": {"id": "abc123"}}) == "id:abc123" + assert quarantine_group_key("A", "T", {"track_info": {"uri": "spotify:track:z"}}) == "uri:spotify:track:z" + + +def test_group_key_falls_back_to_normalized_name_without_context(): + # Trivial case/whitespace differences still collapse to one key. + k1 = quarantine_group_key("Kendrick Lamar", "DNA.") + k2 = quarantine_group_key("kendrick lamar", "dna.") + assert k1 == k2 == "nm:kendrick lamar|dna." + + +def test_group_key_none_when_nothing_identifies_target(): + assert quarantine_group_key("", "", {}) is None + assert quarantine_group_key("", "", None) is None + + +def test_list_entries_carry_group_key(tmp_path): + _write_entry(tmp_path, "20260514_120000", "a.flac", + context={"track_info": {"isrc": "USABC1234567"}}) + entries = list_quarantine_entries(str(tmp_path)) + assert entries[0]["group_key"] == "isrc:usabc1234567" + + +def test_find_siblings_returns_same_target_attempts(tmp_path): + # Two failed source attempts at the SAME target track (same isrc) + an + # unrelated entry. Siblings of #2 = {#1}, never the unrelated one. + same = {"track_info": {"isrc": "USAAA0000001"}} + other = {"track_info": {"isrc": "USZZZ9999999"}} + q1, _ = _write_entry(tmp_path, "20260514_120000", "src1.flac", context=same) + q2, _ = _write_entry(tmp_path, "20260514_120001", "src2.flac", context=same) + _write_entry(tmp_path, "20260514_120002", "diff.flac", context=other) + + id1 = entry_id_from_quarantined_filename(q1.name) + id2 = entry_id_from_quarantined_filename(q2.name) + assert find_quarantine_siblings(str(tmp_path), id2) == [id1] + + +def test_find_siblings_groups_by_intended_target_not_file_tags(tmp_path): + # Same intended target (isrc) even though the bad files differ — that's + # the whole point: the file metadata is wrong, the target is constant. + same = {"track_info": {"isrc": "USAAA0000001", "name": "Whatever"}} + q1, _ = _write_entry(tmp_path, "20260514_120000", "garbage_wrong_song.flac", context=same) + q2, _ = _write_entry(tmp_path, "20260514_120001", "another_bad_rip.flac", context=same) + id1 = entry_id_from_quarantined_filename(q1.name) + id2 = entry_id_from_quarantined_filename(q2.name) + assert find_quarantine_siblings(str(tmp_path), id1) == [id2] + + +def test_find_siblings_empty_for_ungroupable_entry(tmp_path): + # No id and blank expected fields -> None key -> never grouped. + q1, _ = _write_entry(tmp_path, "20260514_120000", "orphan.flac", + expected_track="", expected_artist="") + id1 = entry_id_from_quarantined_filename(q1.name) + assert find_quarantine_siblings(str(tmp_path), id1) == [] + + +def test_find_siblings_empty_for_missing_entry(tmp_path): + _write_entry(tmp_path, "20260514_120000", "a.flac") + assert find_quarantine_siblings(str(tmp_path), "does_not_exist") == [] + + +def test_siblings_must_be_captured_before_accepted_entry_leaves_quarantine(tmp_path): + # Regression for the approve-endpoint ordering: approving RESTORES (moves) + # the accepted entry out of quarantine, after which an id-based sibling + # lookup for that id can't resolve its group_key and returns []. The + # endpoint therefore captures siblings BEFORE approving. This pins that + # invariant: lookup-before == sibling found, lookup-after == empty. + same = {"track_info": {"isrc": "USAAA0000001"}} + q1, _ = _write_entry(tmp_path, "20260514_120000", "a.flac", context=same) + q2, _ = _write_entry(tmp_path, "20260514_120001", "b.flac", context=same) + id1 = entry_id_from_quarantined_filename(q1.name) + id2 = entry_id_from_quarantined_filename(q2.name) + + captured = find_quarantine_siblings(str(tmp_path), id1) # while id1 present + assert captured == [id2] + + delete_quarantine_entry(str(tmp_path), id1) # simulate approve restoring it + + assert find_quarantine_siblings(str(tmp_path), id1) == [] # too late now diff --git a/tests/imports/test_rematch_apply.py b/tests/imports/test_rematch_apply.py new file mode 100644 index 00000000..20c3fd3e --- /dev/null +++ b/tests/imports/test_rematch_apply.py @@ -0,0 +1,71 @@ +"""#889 Phase 4/5: apply a re-identify — stage the file (copy, not move) + build +the hint. Locks down: the original is never touched, the staged name is unique + +keeps the extension, the hint carries the chosen release, and replace_track_id is +set ONLY when 'replace' is ticked. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from core.imports.rematch_apply import ( + build_reidentify_hint, + stage_file_for_reidentify, + staged_destination, +) + +_FIELDS = { + "source": "spotify", "track_id": "trk_1", "album_id": "alb_album1", + "artist_id": "art_1", "track_title": "Song", "album_name": "Album1", + "artist_name": "Artist", "album_type": "album", "track_number": 5, + "disc_number": 1, "isrc": "US1234567890", +} + + +def test_staged_destination_keeps_ext_and_is_traceable(): + dest = staged_destination("/staging", "/lib/EP1/05 - Song.flac", 42) + assert dest.endswith(".flac") + assert "[reid-42]" in dest # traceable to the track + unique per track + assert dest.startswith("/staging/") # loose file in staging root → single candidate + + +def test_stage_copies_not_moves(tmp_path: Path): + src = tmp_path / "lib" / "EP1" / "05 - Song.flac" + src.parent.mkdir(parents=True) + src.write_bytes(b"audio-bytes") + staging = tmp_path / "Staging" + + out = stage_file_for_reidentify(str(src), str(staging), 42, + signature_fn=lambda p: "sig123") + staged = Path(out["staged_path"]) + assert staged.is_file() and staged.read_bytes() == b"audio-bytes" + assert src.is_file() # ORIGINAL untouched (copy, never move) + assert out["content_hash"] == "sig123" + + +def test_stage_missing_source_raises(tmp_path: Path): + with pytest.raises(FileNotFoundError): + stage_file_for_reidentify(str(tmp_path / "gone.flac"), str(tmp_path / "S"), 1) + + +def test_build_hint_sets_replace_when_ticked(): + h = build_reidentify_hint(42, _FIELDS, "/staging/x.flac", "sig", replace=True) + assert h.replace_track_id == 42 + assert h.album_id == "alb_album1" and h.source == "spotify" + assert h.track_number == 5 and h.isrc == "US1234567890" + assert h.exempt_dedup is True + assert h.staged_path == "/staging/x.flac" and h.content_hash == "sig" + + +def test_build_hint_no_replace_when_unticked(): + h = build_reidentify_hint(42, _FIELDS, "/staging/x.flac", "sig", replace=False) + assert h.replace_track_id is None # keep original → no deletion + assert h.exempt_dedup is True # still bypasses dedup-skip (explicit action) + + +def test_build_hint_handles_non_numeric_track_id(): + # Jellyfin-style GUID track ids must still round-trip as replace target. + h = build_reidentify_hint("abc-guid", _FIELDS, "/s/x.flac", None, replace=True) + assert h.replace_track_id == "abc-guid" diff --git a/tests/imports/test_rematch_hints.py b/tests/imports/test_rematch_hints.py new file mode 100644 index 00000000..6e1d08de --- /dev/null +++ b/tests/imports/test_rematch_hints.py @@ -0,0 +1,155 @@ +"""#889 Phase 1: the re-identify hint store — create / find / consume. + +The hint is the single-use, user-designated "which release" answer the import +flow reads at the top of matching. These lock down: a hint round-trips, it's +found by staged path, found by content_hash when the path missed (rename-proof), +found by basename when the dir changed, consumed exactly once, and that a +consumed hint is never handed back. +""" + +from __future__ import annotations + +import sqlite3 + +import pytest + +from core.imports.rematch_hints import ( + RematchHint, + consume_hint, + create_hint, + find_hint_for_file, + list_pending_hints, + quick_file_signature, +) + +# The slice of the real schema this module touches (kept in sync with +# database/music_database.py's rematch_hints CREATE). +_SCHEMA = """ +CREATE TABLE rematch_hints ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + staged_path TEXT NOT NULL, + content_hash TEXT, + source TEXT NOT NULL, + isrc TEXT, + track_id TEXT, + album_id TEXT, + artist_id TEXT, + track_title TEXT, + album_name TEXT, + artist_name TEXT, + album_type TEXT, + track_number INTEGER, + disc_number INTEGER, + replace_track_id INTEGER, + exempt_dedup INTEGER NOT NULL DEFAULT 1, + status TEXT NOT NULL DEFAULT 'pending', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + consumed_at TIMESTAMP +) +""" + + +@pytest.fixture +def cur(): + conn = sqlite3.connect(":memory:") + conn.row_factory = sqlite3.Row + conn.executescript(_SCHEMA) + yield conn.cursor() + conn.close() + + +def _hint(**kw): + base = dict( + staged_path="/staging/Song.flac", + source="spotify", + isrc="USABC1234567", + track_id="trk_1", + album_id="alb_album1", + artist_id="art_1", + track_title="Song", + album_name="Album1", + artist_name="Artist", + album_type="album", + track_number=5, + disc_number=1, + replace_track_id=42, + ) + base.update(kw) + return RematchHint(**base) + + +def test_create_and_find_by_path_roundtrips(cur): + new_id = create_hint(cur, _hint()) + assert new_id > 0 + got = find_hint_for_file(cur, "/staging/Song.flac") + assert got is not None + assert got.id == new_id + assert got.album_id == "alb_album1" and got.album_type == "album" + assert got.isrc == "USABC1234567" + assert got.track_number == 5 and got.disc_number == 1 + assert got.replace_track_id == 42 + assert got.exempt_dedup is True # always set for a user-designated re-identify + assert got.status == "pending" + + +def test_find_by_content_hash_when_path_missed(cur): + create_hint(cur, _hint(content_hash="deadbeef")) + # Watcher renamed/moved the file → path lookup misses, hash rescues it. + assert find_hint_for_file(cur, "/totally/different.flac") is None + got = find_hint_for_file(cur, "/totally/different.flac", content_hash="deadbeef") + assert got is not None and got.album_name == "Album1" + + +def test_find_by_basename_when_dir_changed(cur): + create_hint(cur, _hint(staged_path="/staging/in/Song.flac")) + # Same filename, different directory (watcher moved it deeper). + got = find_hint_for_file(cur, "/staging/processing/Song.flac") + assert got is not None and got.track_id == "trk_1" + + +def test_consume_is_single_use(cur): + new_id = create_hint(cur, _hint()) + assert find_hint_for_file(cur, "/staging/Song.flac") is not None + consume_hint(cur, new_id) + # Consumed → never handed back, by path or by hash. + assert find_hint_for_file(cur, "/staging/Song.flac") is None + assert find_hint_for_file(cur, "/x", content_hash=None) is None + + +def test_list_pending_excludes_consumed(cur): + a = create_hint(cur, _hint(staged_path="/staging/A.flac")) + create_hint(cur, _hint(staged_path="/staging/B.flac")) + assert len(list_pending_hints(cur)) == 2 + consume_hint(cur, a) + pend = list_pending_hints(cur) + assert len(pend) == 1 and pend[0].staged_path == "/staging/B.flac" + + +def test_newest_pending_wins_on_duplicate_path(cur): + create_hint(cur, _hint(album_id="alb_old")) + create_hint(cur, _hint(album_id="alb_new")) # user re-picked for the same file + got = find_hint_for_file(cur, "/staging/Song.flac") + assert got.album_id == "alb_new" + + +def test_exempt_dedup_false_roundtrips(cur): + create_hint(cur, _hint(staged_path="/staging/Keep.flac", exempt_dedup=False)) + got = find_hint_for_file(cur, "/staging/Keep.flac") + assert got.exempt_dedup is False + + +# ── content fingerprint ─────────────────────────────────────────────────────── +def test_quick_file_signature_stable_and_distinct(tmp_path): + a = tmp_path / "a.bin" + b = tmp_path / "b.bin" + a.write_bytes(b"hello world" * 1000) + b.write_bytes(b"goodbye moon" * 1000) + sig_a1 = quick_file_signature(str(a)) + sig_a2 = quick_file_signature(str(a)) + sig_b = quick_file_signature(str(b)) + assert sig_a1 and sig_a1 == sig_a2 # stable + assert sig_a1 != sig_b # distinct content → distinct sig + + +def test_quick_file_signature_missing_file_is_none(): + assert quick_file_signature("/no/such/file.flac") is None diff --git a/tests/imports/test_rematch_hints_seam.py b/tests/imports/test_rematch_hints_seam.py new file mode 100644 index 00000000..5917cbdc --- /dev/null +++ b/tests/imports/test_rematch_hints_seam.py @@ -0,0 +1,217 @@ +"""#889 Phase 2: the import seam — a hint short-circuits identification, and the +old library row is replaced only after the re-import succeeds. + +Two layers: + * pure helpers (build_identification_from_hint, delete_replaced_track) — exact + mapping + safe replacement against an in-memory DB, injectable unlink. + * the worker seam (_resolve_rematch_hint / _finalize_rematch_hint) — proves the + NO-HINT path is untouched, the hint path returns a ready identification, the + lookup is fail-safe, and finalize consumes + replaces. +""" + +from __future__ import annotations + +import sqlite3 +import types + +import pytest + +from core.auto_import_worker import AutoImportWorker, FolderCandidate +from core.imports.rematch_hints import ( + RematchHint, + build_identification_from_hint, + consume_hint, + create_hint, + delete_replaced_track, + find_hint_for_file, +) + +_SCHEMA = """ +CREATE TABLE rematch_hints ( + id INTEGER PRIMARY KEY AUTOINCREMENT, staged_path TEXT NOT NULL, content_hash TEXT, + source TEXT NOT NULL, isrc TEXT, track_id TEXT, album_id TEXT, artist_id TEXT, + track_title TEXT, album_name TEXT, artist_name TEXT, album_type TEXT, + track_number INTEGER, disc_number INTEGER, replace_track_id INTEGER, + exempt_dedup INTEGER NOT NULL DEFAULT 1, status TEXT NOT NULL DEFAULT 'pending', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, consumed_at TIMESTAMP +); +CREATE TABLE tracks ( + id INTEGER PRIMARY KEY, album_id INTEGER, artist_id INTEGER, title TEXT, + track_number INTEGER, file_path TEXT +); +""" + + +@pytest.fixture +def conn(): + c = sqlite3.connect(":memory:") + c.row_factory = sqlite3.Row + c.executescript(_SCHEMA) + yield c + c.close() + + +def _hint(**kw): + base = dict(staged_path="/staging/Song.flac", source="spotify", album_id="alb_album1", + artist_id="art_1", track_id="trk_1", track_title="Song", album_name="Album1", + artist_name="Artist", album_type="album", track_number=5, disc_number=1) + base.update(kw) + return RematchHint(**base) + + +# ── pure: identification mapping ────────────────────────────────────────────── +def test_build_identification_maps_hint_fields(): + ident = build_identification_from_hint(_hint()) + assert ident["album_id"] == "alb_album1" + assert ident["source"] == "spotify" + assert ident["album_name"] == "Album1" + assert ident["artist_id"] == "art_1" + assert ident["track_number"] == 5 + assert ident["method"] == "rematch_hint" + assert ident["identification_confidence"] == 1.0 + # album_type 'album' → not a single, and force_album_match makes the matcher + # fetch the real album (year/track#/art) instead of the singles stub. + assert ident["is_single"] is False + assert ident["force_album_match"] is True + + +def test_build_identification_single_release_still_forces_album_fetch(): + # Even a chosen SINGLE release is fetched (it has a year too); is_single flags + # the type, force_album_match drives the album path regardless. + ident = build_identification_from_hint(_hint(album_type="single")) + assert ident["is_single"] is True + assert ident["force_album_match"] is True + + +# ── pure: safe replacement ──────────────────────────────────────────────────── +def test_delete_replaced_track_removes_row_and_file(conn): + cur = conn.cursor() + cur.execute("INSERT INTO tracks (id, file_path) VALUES (7, '/lib/EP1/05 - Song.flac')") + removed = [] + out = delete_replaced_track(cur, 7, unlink=lambda p: removed.append(p)) + assert out == "/lib/EP1/05 - Song.flac" + assert removed == ["/lib/EP1/05 - Song.flac"] # file removed (we faked existence below) + cur.execute("SELECT 1 FROM tracks WHERE id = 7") + assert cur.fetchone() is None # row gone + + +def test_delete_replaced_track_keeps_file_if_another_row_points_at_it(conn): + cur = conn.cursor() + cur.execute("INSERT INTO tracks (id, file_path) VALUES (7, '/lib/shared.flac')") + cur.execute("INSERT INTO tracks (id, file_path) VALUES (8, '/lib/shared.flac')") + removed = [] + out = delete_replaced_track(cur, 7, unlink=lambda p: removed.append(p)) + assert out is None and removed == [] # row 8 still references it → no unlink + cur.execute("SELECT 1 FROM tracks WHERE id = 7") + assert cur.fetchone() is None # but row 7 still deleted + + +def test_delete_replaced_track_noops_on_missing_id(conn): + cur = conn.cursor() + assert delete_replaced_track(cur, None) is None + assert delete_replaced_track(cur, 999) is None # no such row + + +def test_delete_replaced_track_same_home_is_noop(conn): + # THE data-loss bug: re-identify to the release it's already in → the import + # reuses the same file/row, so deleting it would orphan the file. Guard: no-op. + cur = conn.cursor() + cur.execute("INSERT INTO tracks (id, file_path) VALUES (7, '/lib/Album1/05 - Song.flac')") + removed = [] + out = delete_replaced_track(cur, 7, unlink=lambda p: removed.append(p), + new_paths=['/lib/Album1/05 - Song.flac']) + assert out is None and removed == [] # NOTHING unlinked + cur.execute("SELECT 1 FROM tracks WHERE id = 7") + assert cur.fetchone() is not None # row PRESERVED (it's the re-imported track) + + +def test_delete_replaced_track_different_home_still_deletes(conn): + # Genuinely re-homed (new path differs) → old row + file removed as intended. + cur = conn.cursor() + cur.execute("INSERT INTO tracks (id, file_path) VALUES (7, '/lib/EP1/05 - Song.flac')") + removed = [] + out = delete_replaced_track(cur, 7, unlink=lambda p: removed.append(p), + new_paths=['/lib/Album1/05 - Song.flac']) + assert out == '/lib/EP1/05 - Song.flac' + assert removed == ['/lib/EP1/05 - Song.flac'] + cur.execute("SELECT 1 FROM tracks WHERE id = 7") + assert cur.fetchone() is None + + +def test_delete_replaced_track_resolves_path_before_unlink(conn): + # The stored path is a server/Docker view this process can't read literally; + # resolve_fn maps it to the real file so we unlink the RIGHT path (not orphan it). + cur = conn.cursor() + cur.execute("INSERT INTO tracks (id, file_path) VALUES (7, '/mnt/serverview/Song.flac')") + removed = [] + out = delete_replaced_track(cur, 7, unlink=lambda p: removed.append(p), + resolve_fn=lambda stored: '/real/local/Song.flac') + assert out == '/real/local/Song.flac' + assert removed == ['/real/local/Song.flac'] # unlinked the RESOLVED path + + +# patch os.path.exists so the unlink branch is reachable without real files +@pytest.fixture(autouse=True) +def _exists(monkeypatch): + monkeypatch.setattr("core.imports.rematch_hints.os.path.exists", lambda p: True) + + +# ── worker seam ─────────────────────────────────────────────────────────────── +def _worker(conn): + # Production hands out a FRESH connection per call (the worker closes it); + # here we share one in-memory DB, so proxy close() to a no-op. + w = AutoImportWorker.__new__(AutoImportWorker) + proxy = types.SimpleNamespace(cursor=conn.cursor, commit=conn.commit, close=lambda: None) + w.database = types.SimpleNamespace(_get_connection=lambda: proxy) + return w + + +def test_resolve_returns_none_when_no_hint(conn): + w = _worker(conn) + cand = FolderCandidate(path="/staging", name="Song", audio_files=["/staging/Song.flac"]) + assert w._resolve_rematch_hint(cand) == (None, None) # untouched → normal identify + + +def test_resolve_returns_identification_when_hinted(conn, monkeypatch): + # don't hash a real file + monkeypatch.setattr("core.imports.rematch_hints.quick_file_signature", lambda p: None) + create_hint(conn.cursor(), _hint(staged_path="/staging/Song.flac")) + conn.commit() + w = _worker(conn) + cand = FolderCandidate(path="/staging", name="Song", audio_files=["/staging/Song.flac"]) + hint, ident = w._resolve_rematch_hint(cand) + assert hint is not None and hint.album_id == "alb_album1" + assert ident["album_id"] == "alb_album1" and ident["method"] == "rematch_hint" + + +def test_resolve_ignores_multi_file_candidates(conn): + create_hint(conn.cursor(), _hint(staged_path="/staging/Song.flac")) + conn.commit() + w = _worker(conn) + cand = FolderCandidate(path="/staging", name="Album", + audio_files=["/staging/Song.flac", "/staging/Other.flac"]) + assert w._resolve_rematch_hint(cand) == (None, None) # re-identify is single-track only + + +def test_resolve_is_failsafe_on_db_error(): + w = AutoImportWorker.__new__(AutoImportWorker) + def _boom(): + raise RuntimeError("db down") + w.database = types.SimpleNamespace(_get_connection=_boom) + cand = FolderCandidate(path="/staging", name="Song", audio_files=["/staging/Song.flac"]) + assert w._resolve_rematch_hint(cand) == (None, None) # error never breaks auto-import + + +def test_finalize_consumes_and_replaces(conn, monkeypatch): + monkeypatch.setattr("core.imports.rematch_hints.quick_file_signature", lambda p: None) + cur = conn.cursor() + cur.execute("INSERT INTO tracks (id, file_path) VALUES (42, '/lib/EP1/05 - Song.flac')") + hid = create_hint(cur, _hint(replace_track_id=42)) + conn.commit() + w = _worker(conn) + hint = find_hint_for_file(conn.cursor(), "/staging/Song.flac") + w._finalize_rematch_hint(hint) + # old row deleted, hint consumed + cur.execute("SELECT 1 FROM tracks WHERE id = 42") + assert cur.fetchone() is None + assert find_hint_for_file(conn.cursor(), "/staging/Song.flac") is None # consumed diff --git a/tests/imports/test_rematch_search.py b/tests/imports/test_rematch_search.py new file mode 100644 index 00000000..dd962bbe --- /dev/null +++ b/tests/imports/test_rematch_search.py @@ -0,0 +1,121 @@ +"""#889 Phase 3: re-identify search — normalize results across sources, infer the +release-type badge, and resolve the picked row's album_id. + +Locks down: same song surfaces as multiple rows (single/EP/album), the EP +inference from a multi-track 'single', graceful empty on a dead source, and that +resolve_hint_fields pulls album_id (and refuses a result without one). +""" + +from __future__ import annotations + +import types + +from core.imports.rematch_search import ( + available_sources, + infer_release_type, + normalize_search_result, + resolve_hint_fields, + search_release_candidates, +) + + +# typed-Track-ish object (mirrors core.metadata.types.Track attrs the modal reads) +def _track(tid, title, album, album_type, total, isrc=None, year="2020"): + return types.SimpleNamespace( + id=tid, name=title, artists=["Artist"], album=album, + album_type=album_type, total_tracks=total, release_date=year + "-01-01", + image_url="http://img/" + tid, isrc=isrc, external_ids={}, + ) + + +# ── release-type inference ──────────────────────────────────────────────────── +def test_infer_album_stays_album(): + assert infer_release_type("album", 12) == "album" + + +def test_infer_single_one_track_is_single(): + assert infer_release_type("single", 1) == "single" + + +def test_infer_multitrack_single_promoted_to_ep(): + # Spotify labels EPs as album_type='single' — promote on track count. + assert infer_release_type("single", 5) == "ep" + + +def test_infer_compilation(): + assert infer_release_type("compilation", 40) == "compilation" + + +def test_infer_unknown_falls_back_to_count(): + assert infer_release_type(None, 10) == "album" + assert infer_release_type("", 4) == "ep" + assert infer_release_type(None, 1) == "single" + + +# ── normalization ───────────────────────────────────────────────────────────── +def test_normalize_builds_display_row(): + row = normalize_search_result(_track("t1", "Song", "Album1", "album", 12, isrc="US1234567890"), "spotify") + assert row["track_id"] == "t1" + assert row["album_name"] == "Album1" and row["album_type"] == "album" + assert row["artist_name"] == "Artist" + assert row["year"] == "2020" and row["isrc"] == "US1234567890" + + +def test_normalize_skips_result_without_id_or_title(): + assert normalize_search_result(types.SimpleNamespace(id="", name="X"), "spotify") is None + assert normalize_search_result(types.SimpleNamespace(id="t", name=""), "spotify") is None + + +def test_same_song_multiple_collections(): + """The headline case: one song, three releases, three distinct rows + badges.""" + results = [ + _track("t_alb", "Song", "Album1", "album", 12), + _track("t_ep", "Song", "EP1", "single", 5), # multi-track single → EP + _track("t_sgl", "Song", "Song (Single)", "single", 1), + ] + client = types.SimpleNamespace(search_tracks=lambda q, limit=25: results) + rows = search_release_candidates("spotify", "Song", client_factory=lambda s: client) + badges = {r["album_name"]: r["album_type"] for r in rows} + assert badges == {"Album1": "album", "EP1": "ep", "Song (Single)": "single"} + + +def test_search_empty_on_missing_client(): + assert search_release_candidates("spotify", "x", client_factory=lambda s: None) == [] + + +def test_search_empty_on_blank_query(): + called = [] + search_release_candidates("spotify", " ", client_factory=lambda s: called.append(1)) + assert called == [] # never even fetches a client for an empty query + + +def test_search_swallows_client_error(): + def boom(q, limit=25): + raise RuntimeError("rate limited") + client = types.SimpleNamespace(search_tracks=boom) + assert search_release_candidates("spotify", "x", client_factory=lambda s: client) == [] + + +# ── resolve on select ───────────────────────────────────────────────────────── +def test_resolve_pulls_album_id_and_fields(): + details = { + "name": "Song", "track_number": 5, "disc_number": 1, "isrc": "US1234567890", + "album": {"id": "alb_album1", "name": "Album1", "album_type": "album", "total_tracks": 12}, + "artists": [{"id": "art_1", "name": "Artist"}], + } + client = types.SimpleNamespace(get_track_details=lambda tid: details) + out = resolve_hint_fields("spotify", "t_alb", client_factory=lambda s: client) + assert out["album_id"] == "alb_album1" + assert out["artist_id"] == "art_1" + assert out["track_number"] == 5 and out["disc_number"] == 1 + assert out["album_type"] == "album" and out["isrc"] == "US1234567890" + + +def test_resolve_refuses_result_without_album_id(): + details = {"name": "Song", "album": {"name": "NoId Album"}} # no album id + client = types.SimpleNamespace(get_track_details=lambda tid: details) + assert resolve_hint_fields("spotify", "t", client_factory=lambda s: client) is None + + +def test_resolve_none_on_missing_client(): + assert resolve_hint_fields("spotify", "t", client_factory=lambda s: None) is None diff --git a/tests/imports/test_single_to_album.py b/tests/imports/test_single_to_album.py new file mode 100644 index 00000000..ad41f26a --- /dev/null +++ b/tests/imports/test_single_to_album.py @@ -0,0 +1,206 @@ +"""Seam tests for single -> parent-album resolution (Sokhi: single-matched track +splits from its album -> mixed cover art). The selector is pure; the resolver +takes injected fetchers, so neither needs a live metadata client. +""" + +from __future__ import annotations + +import pytest + +from core.imports.single_to_album import ( + select_parent_album, + resolve_single_to_album, +) +from core.imports.context import detect_album_info_web + + +# ── pure selector ───────────────────────────────────────────────────────────── +def _alb(name, tracks, album_type="album", **extra): + return {"name": name, "album_type": album_type, "tracks": tracks, **extra} + + +def test_picks_album_that_contains_the_track(): + got = select_parent_album("Yellow", [ + _alb("Parachutes", ["Don't Panic", "Shiver", "Yellow", "Trouble"]), + ]) + assert got and got["name"] == "Parachutes" + + +def test_returns_none_when_no_album_contains_the_track(): + assert select_parent_album("Yellow", [ + _alb("Some Other Album", ["Track A", "Track B"]), + ]) is None + + +def test_never_promotes_onto_a_single_release(): + # The single's own release (album_type 'single', name == track) must be ignored. + assert select_parent_album("Yellow", [ + _alb("Yellow", ["Yellow"], album_type="single"), + ]) is None + + +def test_ignores_ep_and_compilation_types(): + assert select_parent_album("Yellow", [ + _alb("Yellow EP", ["Yellow", "Yellow (Live)"], album_type="ep"), + _alb("Greatest Hits", ["Yellow", "Clocks"], album_type="compilation"), + ]) is None + + +def test_skips_album_named_exactly_like_the_track(): + # An 'album' whose name IS the track title is the single dressed as an album; + # don't treat it as the parent. + assert select_parent_album("Yellow", [ + _alb("Yellow", ["Yellow"]), + ]) is None + + +def test_matches_through_album_version_qualifier(): + got = select_parent_album("Yellow", [ + _alb("Parachutes", ["Shiver", "Yellow (Album Version)"]), + ]) + assert got and got["name"] == "Parachutes" + + +def test_first_qualifying_candidate_wins_deterministically(): + got = select_parent_album("Yellow", [ + _alb("Parachutes", ["Yellow"]), + _alb("Parachutes (Deluxe)", ["Yellow", "Bonus"]), + ]) + assert got["name"] == "Parachutes" # input order = priority + + +def test_empty_title_returns_none(): + assert select_parent_album("", [_alb("Parachutes", ["Yellow"])]) is None + + +# ── injected-I/O resolver ───────────────────────────────────────────────────── +def test_resolver_finds_parent_album_lazily(): + calls = {"tracks": 0} + albums = [ + {"name": "Single Yellow", "album_type": "single", "id": "s1"}, # skipped (not album) + {"name": "Wrong Album", "album_type": "album", "id": "a1"}, + {"name": "Parachutes", "album_type": "album", "id": "a2"}, + ] + + def fetch_tracks(alb): + calls["tracks"] += 1 + return {"a1": ["Other"], "a2": ["Yellow", "Shiver"]}.get(alb["id"], []) + + got = resolve_single_to_album( + "Yellow", + fetch_album_candidates=lambda: albums, + fetch_album_tracks=fetch_tracks, + ) + assert got and got["name"] == "Parachutes" and got["album_id"] == "a2" + assert calls["tracks"] == 2 # probed a1 then a2, stopped; never probed the single + + +def test_resolver_returns_none_when_nothing_contains_track(): + got = resolve_single_to_album( + "Yellow", + fetch_album_candidates=lambda: [{"name": "X", "album_type": "album", "id": "a1"}], + fetch_album_tracks=lambda alb: ["Nope"], + ) + assert got is None + + +def test_resolver_is_failsafe_on_candidate_fetch_error(): + def boom(): + raise RuntimeError("api down") + assert resolve_single_to_album( + "Yellow", fetch_album_candidates=boom, fetch_album_tracks=lambda a: []) is None + + +def test_resolver_is_failsafe_on_track_fetch_error(): + def boom(alb): + raise RuntimeError("api down") + got = resolve_single_to_album( + "Yellow", + fetch_album_candidates=lambda: [{"name": "Parachutes", "album_type": "album", "id": "a1"}], + fetch_album_tracks=boom) + assert got is None + + +def test_resolver_caps_albums_probed(): + albums = [{"name": f"A{i}", "album_type": "album", "id": str(i)} for i in range(20)] + probed = {"n": 0} + + def fetch_tracks(alb): + probed["n"] += 1 + return ["nope"] + + resolve_single_to_album( + "Yellow", + fetch_album_candidates=lambda: albums, + fetch_album_tracks=fetch_tracks, + max_albums=5) + assert probed["n"] == 5 # never probes more than the cap + + +# ── gated wiring through detect_album_info_web (config gate + client shapes) ─── +class _Cfg: + def __init__(self, on): + self._on = on + + def get(self, key, default=None): + if key == "metadata_enhancement.single_to_album": + return self._on + return default + + +_SINGLE_CTX = { + "source": "spotify", + "artist": {"id": "art1", "name": "Coldplay"}, + # album_type unset + name == track + total_tracks 1 -> is_album False, and the + # existing best-effort skips (album name == track), so the glue is reached. + "album": {"id": "s1", "name": "Yellow", "total_tracks": 1}, + "track_info": {"id": "t1", "name": "Yellow", "track_number": 7}, + "original_search_result": {"title": "Yellow", "album": "Yellow"}, +} + + +def _patch_clients(monkeypatch, albums, tracks_by_id): + monkeypatch.setattr("core.metadata.album_tracks.get_artist_albums_for_source", + lambda *a, **k: albums) + monkeypatch.setattr("core.metadata.album_tracks.get_artist_album_tracks", + lambda album_id, **k: {"tracks": tracks_by_id.get(album_id, [])}) + + +def test_glue_promotes_single_to_parent_album_when_enabled(monkeypatch): + monkeypatch.setattr("core.metadata.common.get_config_manager", lambda: _Cfg(True)) + _patch_clients( + monkeypatch, + albums=[{"name": "Yellow", "album_type": "single", "id": "s1"}, + {"name": "Parachutes", "album_type": "album", "id": "a2"}], + tracks_by_id={"a2": [{"title": "Shiver"}, {"title": "Yellow"}]}, + ) + out = detect_album_info_web(dict(_SINGLE_CTX), {"id": "art1", "name": "Coldplay"}) + assert out and out["is_album"] is True + assert out["album_name"] == "Parachutes" + assert out["track_number"] == 7 # preserved + + +def test_glue_disabled_by_default_returns_none(monkeypatch): + monkeypatch.setattr("core.metadata.common.get_config_manager", lambda: _Cfg(False)) + # Even with clients that WOULD match, the flag off => no promotion. + _patch_clients(monkeypatch, + albums=[{"name": "Parachutes", "album_type": "album", "id": "a2"}], + tracks_by_id={"a2": [{"title": "Yellow"}]}) + assert detect_album_info_web(dict(_SINGLE_CTX), {"id": "art1", "name": "Coldplay"}) is None + + +def test_glue_no_match_returns_none(monkeypatch): + monkeypatch.setattr("core.metadata.common.get_config_manager", lambda: _Cfg(True)) + _patch_clients(monkeypatch, + albums=[{"name": "Other Album", "album_type": "album", "id": "a9"}], + tracks_by_id={"a9": [{"title": "Different Song"}]}) + assert detect_album_info_web(dict(_SINGLE_CTX), {"id": "art1", "name": "Coldplay"}) is None + + +def test_glue_failsafe_when_client_raises(monkeypatch): + monkeypatch.setattr("core.metadata.common.get_config_manager", lambda: _Cfg(True)) + + def boom(*a, **k): + raise RuntimeError("spotify down") + monkeypatch.setattr("core.metadata.album_tracks.get_artist_albums_for_source", boom) + assert detect_album_info_web(dict(_SINGLE_CTX), {"id": "art1", "name": "Coldplay"}) is None diff --git a/tests/imports/test_track_number_resolver.py b/tests/imports/test_track_number_resolver.py index 088a4bcb..381d5f86 100644 --- a/tests/imports/test_track_number_resolver.py +++ b/tests/imports/test_track_number_resolver.py @@ -14,7 +14,7 @@ from __future__ import annotations from unittest.mock import patch -from core.imports.track_number import resolve_track_number +from core.imports.track_number import read_embedded_track_number, resolve_track_number # --------------------------------------------------------------------------- @@ -226,3 +226,113 @@ def test_non_dict_track_info_treated_as_empty(): """Defensive — non-dict track_info won't crash the resolver.""" result = resolve_track_number({}, 'not a dict', '/dir/file.flac') assert result is None + + +# --------------------------------------------------------------------------- +# "Track 01" bug (Deezer single tracks): embedded file-tag source. +# --------------------------------------------------------------------------- + + +def test_embedded_tag_used_when_metadata_missing(): + """The Deezer single-track case: context carried no position (search + endpoint omits track_position), but the downloaded file did. With the + metadata-context de-poisoned to 0/None, the embedded tag is consulted + BEFORE the filename guess.""" + result = resolve_track_number( + album_info={'track_number': 0}, # de-poisoned "unknown" + track_info={'track_number': 0}, + file_path='/dl/Lenny Kravitz - Fly Away.flac', # no number prefix + embedded_track_number=2, + ) + assert result == 2 + + +def test_real_metadata_still_beats_embedded_tag(): + """No regression for album downloads: an authoritative album_info + position wins over the file tag (they normally agree, but the + context is the source of truth when present).""" + result = resolve_track_number( + album_info={'track_number': 5}, + track_info={}, + file_path='/dl/file.flac', + embedded_track_number=2, + ) + assert result == 5 + + +def test_filename_beats_embedded_tag_no_regression(): + """SAFETY: embedded tag is consulted LAST, so a correctly-named ripped + file ('05 - Song') with a stale/wrong embedded tag is NOT regressed — + the filename still wins, exactly as before the fix.""" + result = resolve_track_number( + album_info={}, + track_info={}, + file_path='/dl/09 - Mislabelled.flac', + embedded_track_number=2, # wrong/stale tag must NOT win + ) + assert result == 9 + + +def test_embedded_only_fills_the_floor_gap(): + """When metadata AND filename are both empty (the Deezer case), the + embedded tag fills what would otherwise be the blind default-1 floor.""" + result = resolve_track_number( + album_info={}, track_info={}, + file_path='/dl/Lenny Kravitz - Fly Away.flac', # no number prefix + embedded_track_number=2, + ) + assert result == 2 + + +def test_resolver_without_embedded_arg_is_backwards_compatible(): + """The new parameter is optional — existing 3-arg callers unaffected.""" + assert resolve_track_number({}, {'track_number': 4}, '/x.flac') == 4 + + +# read_embedded_track_number — mutagen-backed file tag reader. + +def _fake_mutagen(tag_value): + """Patch target factory: returns a callable standing in for + ``mutagen.File`` that yields an object whose .get('tracknumber') + returns tag_value (mimicking easy=True list-valued tags).""" + class _Audio(dict): + pass + def _factory(path, easy=False): + a = _Audio() + if tag_value is not None: + a['tracknumber'] = tag_value + return a + return _factory + + +def test_read_embedded_handles_number_slash_total(): + with patch('mutagen.File', _fake_mutagen(['2/15'])): + assert read_embedded_track_number('/x.flac') == 2 + + +def test_read_embedded_handles_bare_number(): + with patch('mutagen.File', _fake_mutagen(['7'])): + assert read_embedded_track_number('/x.flac') == 7 + + +def test_read_embedded_none_when_tag_absent(): + with patch('mutagen.File', _fake_mutagen(None)): + assert read_embedded_track_number('/x.flac') is None + + +def test_read_embedded_none_when_file_unreadable(): + def _factory(path, easy=False): + return None + with patch('mutagen.File', _factory): + assert read_embedded_track_number('/x.flac') is None + + +def test_read_embedded_never_raises(): + def _boom(path, easy=False): + raise RuntimeError('corrupt') + with patch('mutagen.File', _boom): + assert read_embedded_track_number('/x.flac') is None + + +def test_read_embedded_empty_path_returns_none(): + assert read_embedded_track_number('') is None diff --git a/tests/imports/test_track_number_strip.py b/tests/imports/test_track_number_strip.py new file mode 100644 index 00000000..ece899f5 --- /dev/null +++ b/tests/imports/test_track_number_strip.py @@ -0,0 +1,77 @@ +"""#890: a leading track number leaking from a filename stem into the title +("01 - Sun It Rises") makes the track never match the canonical "Sun It Rises", +so it reads as a false "missing". strip_leading_track_number removes the prefix — +conservatively, so titles that merely START with a number are left alone. +""" + +from __future__ import annotations + +import pytest + +from core.imports.context import get_import_clean_title +from core.imports.paths import strip_leading_track_number + + +# ── the bug: track-number prefixes get stripped ─────────────────────────────── +@pytest.mark.parametrize("dirty,clean", [ + ("01 - Sun It Rises", "Sun It Rises"), # the screenshot + ("04 - Tiger Mountain Peasant Song", "Tiger Mountain Peasant Song"), + ("05 - Quiet Houses", "Quiet Houses"), + ("07 - Heard Them Stirring", "Heard Them Stirring"), + ("01 Sun It Rises", "Sun It Rises"), # zero-padded, no separator + ("3 - Title", "Title"), # plain number + separator + space + ("12. Some Song", "Some Song"), # dot separator + ("10 - Track Ten", "Track Ten"), + ("09) Closing Time", "Closing Time"), # paren separator + (" 02 - Spaced Out ", "Spaced Out"), # messy whitespace +]) +def test_strips_track_number_prefix(dirty, clean): + assert strip_leading_track_number(dirty) == clean + + +# ── the guard: titles that legitimately start with a number are UNTOUCHED ────── +@pytest.mark.parametrize("title", [ + "7 Rings", + "99 Luftballons", + "50 Ways to Leave Your Lover", + "1-800-273-8255", # number-with-dashes is part of the title + "1979", + "9 to 5", + "4 Minutes", + "8 Mile", + "21 Guns", + "24 Hour Party People", # no separator → not a track number + "0 to 100", + "Sun It Rises", # no leading number at all +]) +def test_preserves_real_titles(title): + assert strip_leading_track_number(title) == title + + +# ── degenerate inputs ───────────────────────────────────────────────────────── +def test_never_reduces_to_empty_or_bare_number(): + assert strip_leading_track_number("01") == "01" # bare number → keep + assert strip_leading_track_number("01 - ") == "01 -" # nothing left → keep original (trimmed) + assert strip_leading_track_number("") == "" + assert strip_leading_track_number(None) == "" + + +def test_only_strips_one_prefix(): + # A title that legitimately follows the number keeps its own leading number. + assert strip_leading_track_number("01 - 24 Hour Party People") == "24 Hour Party People" + + +# ── the chokepoint: every import path resolves its title through here ────────── +def test_get_import_clean_title_strips_filename_leak(): + # original_search['title'] came from the filename stem (no embedded tag). + ctx = {"original_search_result": {"title": "01 - Sun It Rises"}} + assert get_import_clean_title(ctx) == "Sun It Rises" + + +def test_get_import_clean_title_leaves_clean_source_title(): + ctx = {"original_search_result": {"title": "7 Rings"}} + assert get_import_clean_title(ctx) == "7 Rings" + + +def test_get_import_clean_title_default_untouched(): + assert get_import_clean_title({}, default="Unknown Track") == "Unknown Track" diff --git a/tests/library/test_residual_files.py b/tests/library/test_residual_files.py new file mode 100644 index 00000000..3c030d4e --- /dev/null +++ b/tests/library/test_residual_files.py @@ -0,0 +1,51 @@ +"""#891: the shared 'residual file' classifier — junk + cover/scan images + +lyric/metadata sidecars — used by both the Reorganize cleanup and the Empty +Folder Cleaner, plus the reorganize sweep that uses it. +""" + +from __future__ import annotations + +from pathlib import Path + +from core.library.residual_files import ( + is_disposable, + is_image, + is_junk, + is_sidecar, +) + + +def test_images_classified(): + for n in ('cover.jpg', 'Cover.JPEG', 'folder.png', 'back.webp', 'scan.tiff', 'art.gif'): + assert is_image(n) and is_disposable(n) + + +def test_sidecars_classified(): + for n in ('lyrics.lrc', 'album.nfo', 'disc.cue', 'playlist.m3u', 'x.m3u8'): + assert is_sidecar(n) and is_disposable(n) + + +def test_junk_classified(): + assert is_junk('.DS_Store') and is_disposable('Thumbs.db') + + +def test_real_content_not_disposable(): + # Audio + anything unrecognized (booklet, video, a note) is real content. + for n in ('song.flac', 'track.mp3', 'booklet.pdf', 'movie.mkv', 'readme.txt', 'data.json'): + assert not is_disposable(n), n + + +# ── the reorganize sweep that uses the predicate ────────────────────────────── +def test_delete_album_sidecars_sweeps_all_residual_keeps_real(tmp_path: Path): + from core.library_reorganize import _delete_album_sidecars + + d = tmp_path / 'Old Album' + d.mkdir() + for n in ('cover.jpg', 'back.jpg', 'disc.png', 'lyrics.lrc', 'album.nfo', '.DS_Store'): + (d / n).write_text('x') + (d / 'booklet.pdf').write_text('keep') # unrecognized → must survive + + _delete_album_sidecars(str(d)) + + survivors = {p.name for p in d.iterdir()} + assert survivors == {'booklet.pdf'} # every residual swept, booklet kept diff --git a/tests/matching/test_numeric_release_guard.py b/tests/matching/test_numeric_release_guard.py index b2ba79c8..23639b29 100644 --- a/tests/matching/test_numeric_release_guard.py +++ b/tests/matching/test_numeric_release_guard.py @@ -19,6 +19,12 @@ from core.musicbrainz_service import MusicBrainzService VOL4 = "B小町 - TVアニメ「【推しの子】」キャラクターソングCD Vol.4" VOL45 = "B小町 - TVアニメ「【推しの子】」キャラクターソングCD Vol.4.5" +# Sokhi #2: the sequel number is glued straight onto a CJK word ('…トラック2'), +# with the SAME digit already present elsewhere ('第2期' = season 2). Stripping +# to [a-z0-9] collapsed both titles to {'2'} and the wrong (cour-2) cover won. +OST = "『無職転生 〜異世界行ったら本気だす〜』 第2期 オリジナル・サウンドトラック" +OST2 = "『無職転生 〜異世界行ったら本気だす〜』 第2期 オリジナル・サウンドトラック2" + def test_helper_volume_and_sequel_differ(): assert numeric_tokens_differ(VOL4, VOL45) @@ -26,11 +32,20 @@ def test_helper_volume_and_sequel_differ(): assert numeric_tokens_differ("Now 99", "Now 100") +def test_helper_cjk_trailing_sequel_digit_differs(): + # The trailing '2' must register as a difference even though '第2期' already + # puts a '2' on both sides. + assert numeric_tokens_differ(OST, OST2) + assert numeric_tokens_differ(OST2, OST) + + def test_helper_shared_or_no_digits_match(): assert not numeric_tokens_differ("1989", "1989 (Deluxe)") assert not numeric_tokens_differ(VOL4, VOL4) assert not numeric_tokens_differ("IGOR", "IGOR (Deluxe)") assert not numeric_tokens_differ("", "") + # Same CJK album on both sides (incl. the shared 第2期) still matches. + assert not numeric_tokens_differ(OST, OST) def _service_with_results(results): diff --git a/tests/metadata/test_art_lookup.py b/tests/metadata/test_art_lookup.py index 92a77a82..335974ee 100644 --- a/tests/metadata/test_art_lookup.py +++ b/tests/metadata/test_art_lookup.py @@ -202,6 +202,19 @@ def test_album_matches_rejects_numeric_difference(): assert art_lookup._album_matches("Taylor Swift", "1989", "Taylor Swift", "1989 (Deluxe)") +def test_album_matches_rejects_cjk_trailing_sequel_digit(): + """Sokhi #2: the sequel '2' is glued straight onto a CJK word + ('…サウンドトラック2'), and '第2期' (season 2) already puts a '2' on both + sides — so the digit-strip collapsed both to {'2'} and the cour-2 + soundtrack's cover hung on the base soundtrack.""" + ART = "藤澤慶昌" + OST = "『無職転生 〜異世界行ったら本気だす〜』 第2期 オリジナル・サウンドトラック" + assert not art_lookup._album_matches(ART, OST, ART, OST + "2") + assert not art_lookup._album_matches(ART, OST + "2", ART, OST) + # The genuine base-album hit still matches (incl. its shared 第2期). + assert art_lookup._album_matches(ART, OST, ART, OST) + + # --------------------------------------------------------------------------- # build_art_lookup — caching + guarding # --------------------------------------------------------------------------- diff --git a/tests/metadata/test_metadata_discography.py b/tests/metadata/test_metadata_discography.py index 65ac0624..a5109dbb 100644 --- a/tests/metadata/test_metadata_discography.py +++ b/tests/metadata/test_metadata_discography.py @@ -731,3 +731,26 @@ def test_get_artist_discography_prefers_source_specific_artist_ids(monkeypatch): ) ] assert deezer.album_calls == [] + + +def test_get_artist_detail_discography_splits_eps_from_singles(monkeypatch): + """#877: get_artist_detail_discography must put EPs in their OWN bucket (not + lumped into singles). The Download Discography modal now reads this split, so + its EPs filter has cards to act on and stays in sync with Artist Detail.""" + spotify = _FakeSourceClient( + album_results=[ + _album("a1", "An Album", "2024-01-01", album_type="album"), + _album("e1", "An EP", "2024-02-01", album_type="ep"), + _album("s1", "A Single", "2024-03-01", album_type="single"), + ] + ) + clients = {"deezer": _FakeSourceClient(), "spotify": spotify, "itunes": _FakeSourceClient()} + monkeypatch.setattr(metadata_registry, "get_primary_source", lambda spotify_client_factory=None: "deezer") + monkeypatch.setattr(metadata_registry, "get_source_priority", lambda primary: [primary, "spotify", "itunes"]) + monkeypatch.setattr(metadata_registry, "get_client_for_source", lambda source, **kwargs: clients.get(source)) + + result = metadata_discography.get_artist_detail_discography("artist-1", "Artist One", MetadataLookupOptions()) + + assert [r["id"] for r in result["albums"]] == ["a1"] + assert [r["id"] for r in result["eps"]] == ["e1"] + assert [r["id"] for r in result["singles"]] == ["s1"] 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_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_base_title_search_fallback.py b/tests/test_base_title_search_fallback.py new file mode 100644 index 00000000..f9c2b936 --- /dev/null +++ b/tests/test_base_title_search_fallback.py @@ -0,0 +1,88 @@ +"""Find & Add: Spotify "Title - Qualifier" must find the base-titled library track. + +wolf's report: Spotify shows "Calma - Remix", Find & Add searches that literal +string, the library stores the track as just "Calma" (only the duration marks it +as the remix) → the literal search misses and the OR-fuzzy fallback floods 20 +unrelated "... remix" hits. Dropping "- Remix" (searching "Calma") finds it. + +Fix: search_tracks retries on the base title (before Spotify's " - " separator) +before the OR-fuzzy flood. +""" + +from __future__ import annotations + +import pytest + +from core.text.title_match import base_title_before_dash +from database.music_database import MusicDatabase + + +# --- pure helper ----------------------------------------------------------- + +def test_base_title_before_dash_strips_spotify_version_suffix(): + assert base_title_before_dash('Calma - Remix') == 'Calma' + assert base_title_before_dash('Closer - Radio Edit') == 'Closer' + assert base_title_before_dash('Crocodile Rock - Remastered 2014') == 'Crocodile Rock' + + +def test_base_title_before_dash_leaves_plain_titles_alone(): + assert base_title_before_dash('Tom Sawyer') == 'Tom Sawyer' + assert base_title_before_dash('21st Century Schizoid Man') == '21st Century Schizoid Man' + assert base_title_before_dash('Up-Tight') == 'Up-Tight' # bare hyphen, not a separator + assert base_title_before_dash('') == '' + + +def test_base_title_before_dash_splits_first_separator_only(): + assert base_title_before_dash('A - B - C') == 'A' + + +# --- integration: the real search path ------------------------------------- + +@pytest.fixture +def db(tmp_path): + return MusicDatabase(str(tmp_path / "music.db")) + + +def _insert(db, tid, title, artist_id, artist_name): + with db._get_connection() as conn: + conn.execute("INSERT OR IGNORE INTO artists (id, name) VALUES (?, ?)", (artist_id, artist_name)) + conn.execute("INSERT OR IGNORE INTO albums (id, title, artist_id) VALUES (?, ?, ?)", + (artist_id, "Alb", artist_id)) + conn.execute( + "INSERT INTO tracks (id, album_id, artist_id, title, track_number, duration, file_path) " + "VALUES (?, ?, ?, ?, 1, 238, ?)", + (tid, artist_id, artist_id, title, f"/m/{tid}.flac"), + ) + conn.commit() + + +def test_spotify_dash_remix_finds_base_titled_track(db): + # Library stores the remix as just "Calma" (the wolf case). + _insert(db, 1, "Calma", 1, "Pedro Capó") + results = db.search_tracks(title="Calma - Remix", rank_artist="Pedro Capó") + titles = [t.title for t in results] + assert "Calma" in titles, "base-title fallback should find 'Calma' for 'Calma - Remix'" + + +def test_spotify_dash_remix_finds_parenthesized_remix(db): + # …and still matches when the library DID label it "(Remix)". + _insert(db, 1, "Calma (Remix)", 1, "Pedro Capó") + results = db.search_tracks(title="Calma - Remix", rank_artist="Pedro Capó") + assert any("Calma" in t.title for t in results) + + +def test_plain_title_unaffected_uses_basic_search(db): + _insert(db, 1, "Tom Sawyer", 1, "Rush") + results = db.search_tracks(title="Tom Sawyer") + assert [t.title for t in results] == ["Tom Sawyer"] + + +def test_dash_query_does_not_flood_when_base_matches(db): + # The base-title retry must short-circuit BEFORE the OR-fuzzy flood, so an + # unrelated "... Remix" track doesn't drown the real one. + _insert(db, 1, "Calma", 1, "Pedro Capó") + _insert(db, 2, "Some Other Song (KAIZ Remix)", 2, "Someone Else") + results = db.search_tracks(title="Calma - Remix", rank_artist="Pedro Capó") + titles = [t.title for t in results] + assert "Calma" in titles + assert "Some Other Song (KAIZ Remix)" not in titles diff --git a/tests/test_empty_folder_cleaner.py b/tests/test_empty_folder_cleaner.py index 54f65ca0..776a8e5a 100644 --- a/tests/test_empty_folder_cleaner.py +++ b/tests/test_empty_folder_cleaner.py @@ -36,6 +36,23 @@ def test_is_junk(): assert is_junk('.DS_Store') and is_junk('thumbs.db') and not is_junk('cover.jpg') +# ── #891: residual (image / sidecar only) folders ─────────────────────────── +def test_image_only_dir_kept_by_default_removed_with_residual_opt(): + # Default (junk only): a cover.jpg keeps the folder (the conservative behavior). + assert dir_is_removable(['cover.jpg'], []) is False + # Opt-in: image/sidecar-only folders become removable. + assert dir_is_removable(['cover.jpg'], [], ignore_disposable=True) is True + assert dir_is_removable(['back.jpg', 'lyrics.lrc', '.DS_Store'], [], ignore_disposable=True) is True + assert dir_is_removable(['folder.png', 'album.nfo'], [], ignore_disposable=True) is True + + +def test_residual_opt_still_keeps_real_content(): + # Audio, or anything not recognized as a leftover (a booklet pdf), still blocks. + assert dir_is_removable(['cover.jpg', 'song.flac'], [], ignore_disposable=True) is False + assert dir_is_removable(['cover.jpg', 'booklet.pdf'], [], ignore_disposable=True) is False + assert dir_is_removable([], ['Album'], ignore_disposable=True) is False # surviving subdir + + # ── apply re-check (real FS) ──────────────────────────────────────────────── def _fx(): return dict(listdir=os.listdir, isdir=os.path.isdir, islink=os.path.islink, @@ -72,3 +89,34 @@ def test_apply_refuses_library_root(tmp_path): 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() + + +def test_apply_sweeps_residual_then_folder_when_enabled(tmp_path): + root = tmp_path / 'lib'; root.mkdir() + d = root / 'Artist' / 'Old Album'; d.mkdir(parents=True) + (d / 'cover.jpg').write_text('img') + (d / 'back.jpg').write_text('img') + (d / 'lyrics.lrc').write_text('la') + res = remove_empty_folder(str(d), junk_files=[], remove_junk=True, + remove_disposable=True, root=str(root), **_fx()) + assert res['removed'] is True and not d.exists() + + +def test_apply_without_residual_opt_leaves_image_folder(tmp_path): + # The default apply (no residual opt) must NOT delete a cover.jpg folder. + root = tmp_path / 'lib'; root.mkdir() + d = root / 'HasCover'; d.mkdir() + (d / 'cover.jpg').write_text('img') + res = remove_empty_folder(str(d), junk_files=[], remove_junk=True, root=str(root), **_fx()) + assert res['removed'] is False and d.exists() + + +def test_apply_residual_opt_still_refuses_real_content(tmp_path): + root = tmp_path / 'lib'; root.mkdir() + d = root / 'Mixed'; d.mkdir() + (d / 'cover.jpg').write_text('img') + (d / 'booklet.pdf').write_text('pdf') # unrecognized → real content + res = remove_empty_folder(str(d), junk_files=[], remove_junk=True, + remove_disposable=True, root=str(root), **_fx()) + assert res['removed'] is False and d.exists() + assert (d / 'booklet.pdf').exists() and (d / 'cover.jpg').exists() # nothing deleted diff --git a/tests/test_enrichment_tag_preservation.py b/tests/test_enrichment_tag_preservation.py new file mode 100644 index 00000000..22127ee9 --- /dev/null +++ b/tests/test_enrichment_tag_preservation.py @@ -0,0 +1,100 @@ +"""Sokhi: tracks occasionally land 'untagged' after a processing failure. + +enhance_file_metadata clears the file's tags and saves it UP FRONT (so stale +tags never linger), then does the failure-prone enrichment (external source-id +embed, cover-art fetch) and saves again at the end. The core tags +(album/artist/title/track) come from the already-matched context and are written +to the in-memory object BEFORE those external steps — but the on-disk file is +still the cleared one until the final save. + +The #764 fix made the error handler restore ART, but it gated the re-save on +there being original art to restore. So a file with NO embedded art that hit a +mid-enrichment crash had its in-memory core tags thrown away and was left on disk +exactly as the up-front clear saved it: UNTAGGED. + +These tests run the REAL enhance_file_metadata against a REAL art-less FLAC and +assert the core tags survive a crash in the external step. +""" + +from __future__ import annotations + +import os +import tempfile +from unittest.mock import patch + +import pytest + +pytest.importorskip("mutagen") +from mutagen.flac import FLAC # noqa: E402 + +import core.metadata.enrichment as enrichment # noqa: E402 + + +class _Cfg: + def get(self, key, default=None): + return default + + +def _make_flac_no_art(path): + minimal = ( + b"fLaC" + + b"\x80\x00\x00\x22" + + b"\x00\x10\x00\x10" + + b"\x00\x00\x00\x00\x00\x00" + + b"\x0a\xc4\x42\xf0\x00\x00\x00\x00" + + b"\x00" * 16 + ) + with open(path, "wb") as f: + f.write(minimal) + FLAC(path).save() # valid FLAC, no tags, no pictures + + +@pytest.fixture +def flac_path(): + fd, path = tempfile.mkstemp(suffix=".flac") + os.close(fd) + _make_flac_no_art(path) + yield path + try: + os.remove(path) + except OSError: + pass + + +_CORE = {"title": "Yellow", "artist": "Coldplay", "album_artist": "Coldplay", + "album": "Parachutes", "track_number": 1, "total_tracks": 9, "disc_number": 1} + + +def _run(flac_path, *, metadata, embed_side_effect): + with patch.object(enrichment, "get_config_manager", return_value=_Cfg()), \ + patch.object(enrichment, "strip_all_non_audio_tags"), \ + patch.object(enrichment, "extract_source_metadata", return_value=metadata), \ + patch.object(enrichment, "embed_source_ids"), \ + patch.object(enrichment, "verify_metadata_written", return_value=True), \ + patch.object(enrichment, "embed_album_art_metadata", side_effect=embed_side_effect): + return enrichment.enhance_file_metadata( + flac_path, context={}, artist={"name": "Coldplay"}, album_info={}, + ) + + +def test_core_tags_survive_when_art_step_raises_on_artless_file(flac_path): + """The regression: art-less file + a crash in the external art step must NOT + leave the file untagged — the matched core tags must be on disk.""" + def boom(audio_file, metadata): + raise RuntimeError("art backend exploded") + + result = _run(flac_path, metadata=dict(_CORE), embed_side_effect=boom) + assert result is False # enrichment reported failure + f = FLAC(flac_path) + assert f.get("title") == ["Yellow"] # ...but core tags persisted + assert f.get("artist") == ["Coldplay"] + assert f.get("album") == ["Parachutes"] # the tag Rockbox buckets on + assert f.get("tracknumber") == ["1/9"] + + +def test_core_tags_written_on_happy_path_artless_file(flac_path): + result = _run(flac_path, metadata=dict(_CORE), embed_side_effect=lambda *a, **k: False) + assert result is True + f = FLAC(flac_path) + assert f.get("album") == ["Parachutes"] + assert f.get("artist") == ["Coldplay"] diff --git a/tests/test_repair_scheduler_tz.py b/tests/test_repair_scheduler_tz.py new file mode 100644 index 00000000..ff869db4 --- /dev/null +++ b/tests/test_repair_scheduler_tz.py @@ -0,0 +1,73 @@ +"""#885: repair-job scheduling must be timezone-independent. + +`finished_at` is written by SQLite's CURRENT_TIMESTAMP (always UTC), but the +scheduler compared it against `datetime.now()` (naive LOCAL). With TZ=Australia/ +Sydney (UTC+11) every job looked ~11h stale and ran every poll; America/New_York +(behind UTC) masked it. The fix parses finished_at as UTC and compares against a +UTC now, so the machine timezone no longer leaks into elapsed time. +""" + +from __future__ import annotations + +import time +from datetime import datetime, timezone + +import pytest + +from core.repair_worker import RepairWorker + + +# ── pure helper ─────────────────────────────────────────────────────────────── +def test_hours_since_treats_naive_timestamp_as_utc(): + now = datetime(2026, 6, 18, 6, 0, 0, tzinfo=timezone.utc) + # SQLite CURRENT_TIMESTAMP style: UTC, no tz suffix. + assert RepairWorker._hours_since('2026-06-18 00:00:00', now) == pytest.approx(6.0) + + +def test_hours_since_handles_aware_timestamp(): + now = datetime(2026, 6, 18, 6, 0, 0, tzinfo=timezone.utc) + assert RepairWorker._hours_since('2026-06-18T00:00:00+00:00', now) == pytest.approx(6.0) + + +def test_hours_since_recent_is_near_zero(): + now = datetime(2026, 6, 18, 0, 0, 30, tzinfo=timezone.utc) + assert RepairWorker._hours_since('2026-06-18 00:00:00', now) == pytest.approx(30 / 3600, abs=1e-6) + + +# ── the #885 repro: a just-run job is never due, regardless of timezone ──────── +def _set_tz(monkeypatch, tz): + monkeypatch.setenv('TZ', tz) + try: + time.tzset() + except AttributeError: + pytest.skip('time.tzset() unavailable on this platform') + + +def test_just_run_job_not_due_under_any_timezone(monkeypatch): + w = RepairWorker.__new__(RepairWorker) + w._jobs = {'cache_evictor': object()} + monkeypatch.setattr(RepairWorker, 'get_job_config', + lambda self, jid: {'enabled': True, 'interval_hours': 6}) + # Job finished "now" in UTC (exactly how CURRENT_TIMESTAMP records it). + finished = datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S') + monkeypatch.setattr(RepairWorker, '_get_last_run', + lambda self, jid: {'finished_at': finished}) + + # Australia/Sydney is the exact repro; check the Americas + UTC too. + for tz in ('Australia/Sydney', 'America/New_York', 'UTC'): + _set_tz(monkeypatch, tz) + assert w._pick_next_job() is None, f"just-run job wrongly due under TZ={tz}" + + +def test_stale_job_is_still_picked_under_sydney(monkeypatch): + # Sanity: a genuinely-overdue job IS picked (we didn't break due-detection). + w = RepairWorker.__new__(RepairWorker) + w._jobs = {'cache_evictor': object()} + monkeypatch.setattr(RepairWorker, 'get_job_config', + lambda self, jid: {'enabled': True, 'interval_hours': 6}) + # Finished ~10h ago in UTC. + old = datetime(2026, 1, 1, 0, 0, 0, tzinfo=timezone.utc).strftime('%Y-%m-%d %H:%M:%S') + monkeypatch.setattr(RepairWorker, '_get_last_run', + lambda self, jid: {'finished_at': old}) + _set_tz(monkeypatch, 'Australia/Sydney') + assert w._pick_next_job() == 'cache_evictor' diff --git a/tests/test_resolve_secret.py b/tests/test_resolve_secret.py new file mode 100644 index 00000000..3ff53a36 --- /dev/null +++ b/tests/test_resolve_secret.py @@ -0,0 +1,43 @@ +"""ConfigManager.resolve_secret — test the EFFECTIVE secret, not the mask (#870). + +The settings UI renders a saved-but-untouched secret as the redaction sentinel +(shown as dots). The Deezer ARL connection test was sending that sentinel as the +token, so Deezer rejected it ("Invalid ARL token — USER_ID=0") and it looked like +the ARL kept resetting — even though the saved value was fine. resolve_secret maps +an empty/sentinel posted value back to the stored token. +""" + +from __future__ import annotations + +from config.settings import ConfigManager + +S = ConfigManager.REDACTED_SENTINEL + + +def _cm(config_data): + cm = object.__new__(ConfigManager) # bypass __init__/DB — get() reads config_data + cm.config_data = config_data + return cm + + +def test_sentinel_resolves_to_stored(): + cm = _cm({'deezer_download': {'arl': 'real_arl_123'}}) + assert cm.resolve_secret('deezer_download.arl', S) == 'real_arl_123' + + +def test_empty_or_none_resolves_to_stored(): + cm = _cm({'deezer_download': {'arl': 'real_arl_123'}}) + assert cm.resolve_secret('deezer_download.arl', '') == 'real_arl_123' + assert cm.resolve_secret('deezer_download.arl', ' ') == 'real_arl_123' + assert cm.resolve_secret('deezer_download.arl', None) == 'real_arl_123' + + +def test_real_value_passes_through_trimmed(): + cm = _cm({'deezer_download': {'arl': 'old'}}) + assert cm.resolve_secret('deezer_download.arl', 'new_token') == 'new_token' + assert cm.resolve_secret('deezer_download.arl', ' spaced ') == 'spaced' + + +def test_sentinel_with_nothing_stored_returns_empty(): + cm = _cm({}) + assert cm.resolve_secret('deezer_download.arl', S) == '' diff --git a/tests/test_settings_redaction.py b/tests/test_settings_redaction.py index 3aea7a2a..a27e4d25 100644 --- a/tests/test_settings_redaction.py +++ b/tests/test_settings_redaction.py @@ -24,6 +24,23 @@ def _cm(config_data): return cm +# ── #879: the GET /api/settings handler calls config_manager.redacted_config(). +# If that method is ever renamed/removed the endpoint 500s, the web UI treats the +# error body as settings, blanks the form to defaults, and autosaves over the +# user's real config. Pin the method's presence on the class so that can't ship. + +def test_redacted_config_is_a_method_on_the_class(): + assert callable(getattr(ConfigManager, 'redacted_config', None)), ( + "GET /api/settings depends on ConfigManager.redacted_config(); removing or " + "renaming it 500s the settings endpoint and the UI then wipes the config (#879)") + + +def test_redacted_config_callable_on_instance_returns_dict(): + cm = _cm({'spotify': {'client_secret': 'REAL'}}) + out = cm.redacted_config() + assert isinstance(out, dict) and out.get('spotify', {}).get('client_secret') == S + + # ── redacted_config: secrets out, everything else intact ──────────────────── def test_configured_secrets_are_masked(): diff --git a/tests/test_spotify_worker_status.py b/tests/test_spotify_worker_status.py new file mode 100644 index 00000000..a75b0382 --- /dev/null +++ b/tests/test_spotify_worker_status.py @@ -0,0 +1,68 @@ +"""Issue #887: the Spotify enrichment worker's get_stats() must report +``using_free`` when it's enriching via the no-creds Spotify Free source — even +with no official auth — so the dashboard shows "Running (Spotify Free)" instead +of a misleading "Not Authenticated". + +Builds the worker via __new__ (bypassing the real SpotifyClient()) and stubs the +db-querying helpers, so the get_stats() free/auth logic is tested in isolation. +""" + +from __future__ import annotations + +import types + +from core.spotify_worker import SpotifyWorker + + +def _worker(*, serving_via_free, sp=None, rate_limited=False, budget_free=False): + w = SpotifyWorker.__new__(SpotifyWorker) + w.running = True + w.paused = False + w.thread = types.SimpleNamespace(is_alive=lambda: True) + w.current_item = None + w.stats = {'pending': 0, 'processed': 0} + w._serving_via_free = serving_via_free + w.client = types.SimpleNamespace( + sp=sp, + is_rate_limited=lambda: rate_limited, + get_rate_limit_info=lambda: None, + get_post_ban_cooldown_remaining=lambda: 0, + is_spotify_metadata_available=lambda: True, + _budget_exhausted_use_free=budget_free, + ) + # db-backed helpers stubbed — we only exercise the auth/free reporting. + w._count_pending_items = lambda: 100 + w._get_progress_breakdown = lambda: {} + w._get_daily_budget_info = lambda: {'exhausted': False} + return w + + +def test_no_auth_but_serving_via_free_reports_using_free(): + # #887: no official auth (sp is None), worker enriching via Spotify Free. + stats = _worker(serving_via_free=True, sp=None).get_stats() + assert stats['authenticated'] is False # no official auth + assert stats['using_free'] is True # ...but Free is carrying it + + +def test_no_auth_and_not_serving_free_is_not_using_free(): + # Genuinely can't enrich (no auth, free not active) -> Not Authenticated stands. + stats = _worker(serving_via_free=False, sp=None).get_stats() + assert stats['authenticated'] is False + assert stats['using_free'] is False + + +def test_rate_limit_bridge_still_reports_using_free(): + # Pre-existing bridge path must still work (cache False, but rate-limited). + stats = _worker(serving_via_free=False, sp=object(), rate_limited=True).get_stats() + assert stats['using_free'] is True + + +def test_budget_bridge_still_reports_using_free(): + stats = _worker(serving_via_free=False, sp=object(), budget_free=True).get_stats() + assert stats['using_free'] is True + + +def test_authed_and_not_on_free_reports_authenticated_not_free(): + stats = _worker(serving_via_free=False, sp=object()).get_stats() + assert stats['authenticated'] is True + assert stats['using_free'] is False diff --git a/tests/test_tidal_collection_tracks.py b/tests/test_tidal_collection_tracks.py index b821a93c..88695e99 100644 --- a/tests/test_tidal_collection_tracks.py +++ b/tests/test_tidal_collection_tracks.py @@ -252,6 +252,55 @@ class TestIterCollectionTrackIds: assert ids == [] + def test_429_mid_walk_retries_and_completes(self): + """Regression for #880: a 429 mid-pagination must RETRY the same + cursor page (with backoff), not truncate the collection. Before the + fix a transient 429 on page ~5 capped a 513-track favorites list at + ~98 (the log showed `status=429` then `Retrieved 98/100`).""" + client = _make_authed_client() + # page1 (200) → 429 (transient) → page2 (200, end of chain) + responses = iter([ + _FakeResp(200, _PAGE_ONE), + _FakeResp(429, text=""), # rate limited — retry, don't truncate + _FakeResp(200, _PAGE_TWO), + ]) + with patch.object(client, '_ensure_valid_token', return_value=True), \ + patch('core.tidal_client.requests.get', side_effect=lambda *a, **kw: next(responses)), \ + patch('core.tidal_client.time.sleep'): + ids = client._iter_collection_track_ids() + + assert ids == ['1001', '1002', '1003', '1004', '1005'] # full chain, nothing dropped + + def test_429_does_not_set_reconnect_flag(self): + """A rate-limit is transient, NOT a scope problem — must not tell the + user to reconnect.""" + client = _make_authed_client() + responses = iter([_FakeResp(200, _PAGE_ONE), _FakeResp(429, text=""), _FakeResp(200, _PAGE_TWO)]) + with patch.object(client, '_ensure_valid_token', return_value=True), \ + patch('core.tidal_client.requests.get', side_effect=lambda *a, **kw: next(responses)), \ + patch('core.tidal_client.time.sleep'): + client._iter_collection_track_ids() + + assert client.collection_needs_reconnect() is False + + def test_429_exhausts_retries_returns_partial(self): + """If the 429s never clear, give up after the retry budget and return + what we have (PARTIAL) rather than looping forever.""" + client = _make_authed_client() + + def gen(): + yield _FakeResp(200, _PAGE_ONE) + while True: + yield _FakeResp(429, text="") + + responses = gen() + with patch.object(client, '_ensure_valid_token', return_value=True), \ + patch('core.tidal_client.requests.get', side_effect=lambda *a, **kw: next(responses)), \ + patch('core.tidal_client.time.sleep'): + ids = client._iter_collection_track_ids() + + assert ids == ['1001', '1002', '1003'] # page 1 survives; no hang + # --------------------------------------------------------------------------- # get_collection_tracks_count 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_usenet_client_adapters.py b/tests/test_usenet_client_adapters.py index 1445a36c..095d100d 100644 --- a/tests/test_usenet_client_adapters.py +++ b/tests/test_usenet_client_adapters.py @@ -714,6 +714,67 @@ def test_nzbget_parse_group_computes_progress() -> None: assert status.download_speed == 500_000 +def test_nzbget_queue_group_never_offers_a_save_path() -> None: + """A queued group's DestDir is the in-progress '….#NZBID' dir, which is gone + after the move — never expose it as a final save_path. Finalisation must come + from the HISTORY entry. (Swigs: imported from /…/incomplete/….#2141.)""" + adapter = _nzbget_with_config() + status = adapter._parse_group({ + 'NZBID': 2141, 'NZBName': 'xRepentancex-The.Sickness.Of.Eden', + 'Status': 'DOWNLOADING', + 'DestDir': '/data/usenet/incomplete/xRepentancex-The.Sickness.Of.Eden.#2141', + 'Category': 'soulsync', + }) + assert status.save_path is None + + +def test_nzbget_pp_finished_group_is_completed_but_has_no_path() -> None: + """PP_FINISHED maps to 'completed' but is still a QUEUE group on the + in-progress dir — it must report no save_path so the plugin waits for the + history entry instead of finalising on the incomplete folder.""" + adapter = _nzbget_with_config() + status = adapter._parse_group({ + 'NZBID': 2141, 'NZBName': 'Album', 'Status': 'PP_FINISHED', + 'DestDir': '/data/usenet/incomplete/Album.#2141', 'Category': 'soulsync', + }) + assert status.state == 'completed' + assert status.save_path is None + + +def test_nzbget_history_prefers_finaldir_over_destdir() -> None: + """After a post-processing move, FinalDir is the real location; DestDir can + still be the intermediate dir. Swigs' exact case.""" + adapter = _nzbget_with_config() + status = adapter._parse_history({ + 'NZBID': 2141, 'Name': 'xRepentancex-The.Sickness.Of.Eden', + 'Status': 'SUCCESS/ALL', + 'DestDir': '/data/usenet/incomplete/xRepentancex-The.Sickness.Of.Eden.#2141', + 'FinalDir': '/data/soulseek/xRepentancex-The.Sickness.Of.Eden-CD-FLAC-2015-CATARACT', + 'Category': 'soulsync', + }) + assert status.state == 'completed' + assert status.save_path == '/data/soulseek/xRepentancex-The.Sickness.Of.Eden-CD-FLAC-2015-CATARACT' + + +def test_nzbget_history_falls_back_to_destdir_when_no_finaldir() -> None: + """No PP move -> FinalDir empty -> use DestDir (the final dest in that case).""" + adapter = _nzbget_with_config() + status = adapter._parse_history({ + 'NZBID': 7, 'Name': 'Album', 'Status': 'SUCCESS/HEALTH', + 'DestDir': '/data/usenet/completed/Album', 'FinalDir': '', 'Category': 'c', + }) + assert status.save_path == '/data/usenet/completed/Album' + + +def test_nzbget_history_empty_dirs_yield_none() -> None: + adapter = _nzbget_with_config() + status = adapter._parse_history({ + 'NZBID': 7, 'Name': 'Album', 'Status': 'SUCCESS/ALL', + 'DestDir': ' ', 'FinalDir': '', 'Category': 'c', + }) + assert status.save_path is None + + def test_nzbget_remove_rejects_non_numeric_id() -> None: """NZBGet IDs are ints; passing a string id like 'abc' must fail fast instead of corrupting the editqueue call.""" 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/wishlist/test_wishlist_ignore.py b/tests/wishlist/test_wishlist_ignore.py new file mode 100644 index 00000000..783511f2 --- /dev/null +++ b/tests/wishlist/test_wishlist_ignore.py @@ -0,0 +1,190 @@ +"""#874 — wishlist ignore-list: TTL skip-gate for user-removed/cancelled tracks. + +Two layers: + * pure logic (core.wishlist.ignore) — TTL, id-normalization, display extract + * DB seam (MusicDatabase on a temp db) — add/check/remove/list/clear, the + add_to_wishlist gate, the manual-add bypass, and the regression that the + success-cleanup removal path does NOT ignore. +""" + +from datetime import datetime, timedelta + +import pytest + +from core.wishlist.ignore import ( + IGNORE_TTL_DAYS, + REASON_CANCELLED, + REASON_REMOVED, + active_ignored_ids, + extract_display, + is_expired, + is_ignored, + normalize_ignore_id, +) +from database.music_database import MusicDatabase + + +# ── pure logic ────────────────────────────────────────────────────────── + +def test_normalize_strips_composite_album_suffix(): + assert normalize_ignore_id("track123::album456") == "track123" + assert normalize_ignore_id(" track123 ") == "track123" + assert normalize_ignore_id("") == "" + assert normalize_ignore_id(None) == "" + + +def test_is_expired_true_past_ttl_false_within(): + now = datetime(2026, 6, 15, 12, 0, 0) + fresh = (now - timedelta(days=5)).strftime("%Y-%m-%d %H:%M:%S") + stale = (now - timedelta(days=40)).strftime("%Y-%m-%d %H:%M:%S") + assert is_expired(fresh, now) is False + assert is_expired(stale, now) is True + + +def test_is_expired_unparseable_is_treated_expired_fail_open(): + # Corrupt timestamp must lapse (never wedge a track out of the wishlist). + assert is_expired("not-a-date", datetime(2026, 6, 15)) is True + assert is_expired("", datetime(2026, 6, 15)) is True + assert is_expired(None, datetime(2026, 6, 15)) is True + + +def test_is_ignored_matches_composite_and_bare_ids(): + now = datetime(2026, 6, 15, 12, 0, 0) + created = (now - timedelta(days=1)).strftime("%Y-%m-%d %H:%M:%S") + rows = [{"track_id": "abc", "created_at": created}] + # Stored bare, queried with composite (and vice-versa) — both match. + assert is_ignored(rows, "abc::album9", now) is True + rows2 = [{"track_id": "abc", "created_at": created}] + assert is_ignored(rows2, "abc", now) is True + assert is_ignored(rows2, "different", now) is False + + +def test_active_ignored_ids_drops_expired(): + now = datetime(2026, 6, 15, 12, 0, 0) + fresh = (now - timedelta(days=2)).strftime("%Y-%m-%d %H:%M:%S") + stale = (now - timedelta(days=99)).strftime("%Y-%m-%d %H:%M:%S") + rows = [ + {"track_id": "keep", "created_at": fresh}, + {"track_id": "drop", "created_at": stale}, + ] + assert active_ignored_ids(rows, now) == {"keep"} + + +def test_extract_display_handles_dict_and_string_artists(): + assert extract_display({"name": "Song", "artists": [{"name": "A"}]}) == ("Song", "A") + assert extract_display({"name": "Song", "artists": ["B"]}) == ("Song", "B") + assert extract_display({}) == ("", "") + assert extract_display(None) == ("", "") + + +# ── DB seam (temp database — never the live db) ───────────────────────── + +@pytest.fixture +def db(tmp_path): + return MusicDatabase(str(tmp_path / "m.db")) + + +def _track(track_id="t1", name="Some Song", artist="Some Artist", album_id="alb1"): + return { + "id": track_id, + "name": name, + "artists": [{"name": artist}], + "album": {"id": album_id, "name": "Some Album", "images": []}, + } + + +def test_add_check_remove_roundtrip(db): + assert db.is_track_ignored("t1") is False + assert db.add_to_wishlist_ignore("t1", "Song", "Artist", REASON_REMOVED) is True + assert db.is_track_ignored("t1") is True + # Composite id of the same base track is also considered ignored. + assert db.is_track_ignored("t1::albX") is True + assert db.remove_from_wishlist_ignore("t1") is True + assert db.is_track_ignored("t1") is False + + +def test_get_and_clear_ignore_list(db): + db.add_to_wishlist_ignore("a", "SongA", "ArtA", REASON_REMOVED) + db.add_to_wishlist_ignore("b", "SongB", "ArtB", REASON_CANCELLED) + entries = db.get_wishlist_ignore() + assert {e["track_id"] for e in entries} == {"a", "b"} + assert any(e["reason"] == "cancelled" for e in entries) + assert db.clear_wishlist_ignore() == 2 + assert db.get_wishlist_ignore() == [] + + +def test_expired_entry_is_not_active_and_gets_purged(db): + db.add_to_wishlist_ignore("old", "Old", "Art", REASON_REMOVED) + # Backdate it well past the TTL. + with db._get_connection() as conn: + conn.execute( + "UPDATE wishlist_ignore SET created_at = ? WHERE track_id = ?", + ((datetime.now() - timedelta(days=IGNORE_TTL_DAYS + 5)).strftime("%Y-%m-%d %H:%M:%S"), "old"), + ) + conn.commit() + assert db.is_track_ignored("old") is False # lapsed + assert db.get_wishlist_ignore() == [] # and purged on read + with db._get_connection() as conn: + remaining = conn.execute("SELECT COUNT(*) c FROM wishlist_ignore").fetchone()["c"] + assert remaining == 0 + + +# ── the gate: add_to_wishlist honours the ignore-list ─────────────────── + +def test_gate_blocks_auto_readd_but_manual_bypasses_and_clears(db): + track = _track("t1") + # Auto add works first time. + assert db.add_to_wishlist(track, source_type="playlist") is True + # User removes + ignores it. + db.remove_from_wishlist("t1") + db.add_to_wishlist_ignore("t1", "Some Song", "Some Artist", REASON_REMOVED) + # Auto re-add (watchlist / failed-capture / cancel) is now blocked. + assert db.add_to_wishlist(track, source_type="playlist") is False + assert db.is_track_ignored("t1") is True + # A MANUAL add bypasses the gate AND clears the ignore so it sticks. + assert db.add_to_wishlist(track, source_type="manual") is True + assert db.is_track_ignored("t1") is False + + +def test_gate_failopen_when_ignore_table_errors(db, monkeypatch): + # If the ignore check raises, the add must still succeed (never block). + monkeypatch.setattr(db, "is_track_ignored", lambda *a, **k: (_ for _ in ()).throw(RuntimeError("boom"))) + assert db.add_to_wishlist(_track("t9"), source_type="playlist") is True + + +def test_route_remove_track_records_ignore(db, monkeypatch): + # Pin the route → ignore wiring (not just the DB layer): a user-initiated + # remove via the route function must drop the row AND ignore the track. + import types + from core.wishlist import routes as routes_module + + db.add_to_wishlist(_track("rt1", name="Route Song", artist="Route Artist"), + source_type="playlist") + + class _Svc: + database = db + + def remove_track_from_wishlist(self, tid, profile_id=1): + return db.remove_from_wishlist(tid, profile_id=profile_id) + + monkeypatch.setattr(routes_module, "get_wishlist_service", lambda: _Svc()) + runtime = types.SimpleNamespace(profile_id=1, logger=routes_module.module_logger) + + payload, status = routes_module.remove_track_from_wishlist(runtime, "rt1") + assert status == 200 + assert db.is_track_ignored("rt1") is True + # The ignore carries the captured label. + entry = db.get_wishlist_ignore()[0] + assert entry["track_name"] == "Route Song" + assert entry["reason"] == REASON_REMOVED + + +def test_regression_success_cleanup_does_not_ignore(db): + # The post-download success path calls remove_from_wishlist directly — it + # must NOT add anything to the ignore-list (only user remove/cancel do). + track = _track("t5") + assert db.add_to_wishlist(track, source_type="playlist") is True + db.remove_from_wishlist("t5") # simulate success cleanup + assert db.is_track_ignored("t5") is False # NOT ignored + # And so a later legitimate auto-add still works. + assert db.add_to_wishlist(track, source_type="playlist") is True diff --git a/web_server.py b/web_server.py index 0b8aaefb..8bbd4091 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.2" +_SOULSYNC_BASE_VERSION = "2.7.4" def _build_version_string(): """Append short commit hash to version when available (e.g. 2.35+abc1234).""" @@ -990,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 = { @@ -1253,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, @@ -1859,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"), @@ -7573,6 +7556,18 @@ def approve_quarantine_item(entry_id): docker_resolve_path(config_manager.get('soulseek.download_path', './downloads')), 'Transfer', ) + _req = request.get_json(silent=True) or {} + # #876: capture the sibling alternatives BEFORE approving — the approve + # restores (moves) this entry's file out of quarantine, after which its + # own group_key can no longer be looked up by id. Read-only here; the + # actual deletion happens only after the re-import is safely kicked off. + _sibling_ids = [] + if _req.get('remove_siblings'): + from core.imports.quarantine import find_quarantine_siblings + try: + _sibling_ids = find_quarantine_siblings(_get_quarantine_dir(), entry_id) + except Exception as sib_exc: + logger.warning(f"[Quarantine] Sibling lookup for {entry_id} failed: {sib_exc}") result = approve_quarantine_entry(_get_quarantine_dir(), entry_id, restore_dir) if result is None: return jsonify({ @@ -7591,7 +7586,6 @@ def approve_quarantine_item(entry_id): # context lost task_id/batch_id (the wrapper pops them before quarantine), # so we re-supply them here. Manager-tab approvals (no task_id) keep the # original inner-pipeline path. - _req = request.get_json(silent=True) or {} _task_id = (_req.get('task_id') or '').strip() or None _batch_id = None if _task_id: @@ -7611,7 +7605,28 @@ def approve_quarantine_item(entry_id): _reprocess = lambda: _post_process_matched_download(context_key, context, restored_path) threading.Thread(target=_reprocess, daemon=True).start() logger.info(f"[Quarantine] Approved {entry_id} (original_trigger={trigger}, bypass=all, task={_task_id}) → re-running pipeline") - return jsonify({"success": True, "trigger_bypassed": "all", "original_trigger": trigger}) + # #876: once one alternative for a song is accepted, the other + # quarantined attempts at the SAME intended target are redundant + # failed downloads of a track the user now owns. Delete the siblings + # captured above (scoped to the quarantine manager via `remove_siblings` + # — the download-modal chooser passes no flag and is unaffected). + removed_siblings = [] + if _sibling_ids: + from core.imports.quarantine import delete_quarantine_entry + try: + for sib_id in _sibling_ids: + if delete_quarantine_entry(_get_quarantine_dir(), sib_id): + removed_siblings.append(sib_id) + if removed_siblings: + logger.info(f"[Quarantine] Auto-removed {len(removed_siblings)} sibling alternative(s) of {entry_id}: {removed_siblings}") + except Exception as sib_exc: + logger.warning(f"[Quarantine] Sibling cleanup for {entry_id} failed: {sib_exc}") + return jsonify({ + "success": True, + "trigger_bypassed": "all", + "original_trigger": trigger, + "removed_siblings": removed_siblings, + }) except Exception as e: logger.error(f"[Quarantine] Error approving {entry_id}: {e}") return jsonify({"success": False, "error": str(e)}), 500 @@ -9131,7 +9146,11 @@ def get_artist_discography(artist_id): effective_override_source = 'spotify' from core.metadata.lookup import MetadataLookupOptions - from core.metadata_service import get_artist_discography as _get_artist_discography + # #877: use the artist-DETAIL discography so the Download Discography modal + # gets the SAME release-type split (albums / eps / singles) the Artist + # Detail view shows — EPs were being lumped into singles before, leaving + # the modal's EPs toggle dead. + from core.metadata.discography import get_artist_detail_discography as _get_artist_discography # Server-side per-source ID resolution. Look up the library row # by ANY of the IDs the frontend might send: library DB id, @@ -9209,6 +9228,7 @@ def get_artist_discography(artist_id): ) album_list = discography['albums'] + eps_list = discography.get('eps', []) singles_list = discography['singles'] active_source = discography['source'] source_priority = discography['source_priority'] @@ -9320,6 +9340,7 @@ def get_artist_discography(artist_id): return jsonify({ "albums": album_list, + "eps": eps_list, "singles": singles_list, "source": active_source or (source_priority[0] if source_priority else "unknown"), "artist_info": artist_info, @@ -10023,6 +10044,147 @@ def get_artist_enhanced_detail(artist_id): except Exception as e: return jsonify({"success": False, "error": str(e)}), 500 + +# ── Re-identify an imported track (#889) ── +@app.route('/api/reidentify/sources', methods=['GET']) +def reidentify_sources(): + """Source tabs for the Re-identify modal — every metadata source with a live + client, the active one flagged so the UI selects it by default.""" + try: + from core.imports.rematch_search import available_sources + return jsonify({"success": True, "sources": available_sources()}) + except Exception as e: + return jsonify({"success": False, "error": str(e), "sources": []}), 500 + + +@app.route('/api/reidentify/search', methods=['GET']) +def reidentify_search(): + """Search one metadata source for the releases a track appears on. + + Query params: ``source`` (defaults to the active source), ``q`` (the query), + ``limit``. Returns display rows — the SAME song across single/EP/album, each + with a type badge — for the user to pick. album_id is resolved later, only for + the chosen row.""" + try: + from core.imports.rematch_search import available_sources, search_release_candidates + query = (request.args.get('q') or '').strip() + if not query: + return jsonify({"success": True, "results": []}) + source = (request.args.get('source') or '').strip() + if not source: + actives = [s for s in available_sources() if s.get('active')] + source = actives[0]['source'] if actives else 'spotify' + try: + limit = max(1, min(50, int(request.args.get('limit', 25)))) + except (TypeError, ValueError): + limit = 25 + rows = search_release_candidates(source, query, limit=limit) + return jsonify({"success": True, "source": source, "results": rows}) + except Exception as e: + logger.error(f"Re-identify search error: {e}") + return jsonify({"success": False, "error": str(e), "results": []}), 500 + + +@app.route('/api/reidentify/apply', methods=['POST']) +def reidentify_apply(): + """Apply a re-identify: stage the track's library file + write a single-use hint + so the auto-import worker re-files it under the chosen release (Phase 2). + + Body: ``{library_track_id, source, track_id, replace}``. Admin-only (mutates the + library). COPIES the file — the original is removed only after the re-import + succeeds, and only when ``replace`` is true.""" + try: + database = get_database() + pid = get_current_profile_id() + prof = database.get_profile(pid) if pid else None + if not prof or not prof.get('is_admin'): + return jsonify({"success": False, "error": "Admin only"}), 403 + + data = request.get_json(silent=True) or {} + library_track_id = data.get('library_track_id') + source = (data.get('source') or '').strip() + track_id = (data.get('track_id') or '').strip() + replace = bool(data.get('replace', True)) + if not library_track_id or not source or not track_id: + return jsonify({"success": False, "error": "library_track_id, source and track_id are required"}), 400 + + from core.imports.rematch_search import resolve_hint_fields + from core.imports.rematch_apply import stage_file_for_reidentify, build_reidentify_hint + from core.imports.rematch_hints import create_hint + + # 1) Resolve the picked release → the IDs the hint needs (album_id critically). + hint_fields = resolve_hint_fields(source, track_id) + if not hint_fields: + return jsonify({"success": False, "error": "Could not resolve the selected release (no album id)"}), 400 + + # 2) Locate the library file for this track. + conn = database._get_connection() + try: + cur = conn.cursor() + cur.execute("SELECT file_path FROM tracks WHERE id = ?", (str(library_track_id),)) + row = cur.fetchone() + finally: + conn.close() + if not row or not row['file_path']: + return jsonify({"success": False, "error": "Library track has no file on disk"}), 404 + stored_path = row['file_path'] + + # Resolve the stored DB path to a file THIS process can actually read, using + # the SAME strong resolver the rest of the app uses (transfer/download/library/ + # Plex search + #833 confusable folding via find_on_disk). + real_path = _resolve_library_file_path(stored_path) + if not real_path: + # On a miss, run the diagnostic variant purely to tell us (and the user) + # what was tried — instead of failing on the raw, possibly-stale path. + from core.library.path_resolver import resolve_library_file_path_with_diagnostic + try: + _plex = media_server_engine.client('plex') if media_server_engine else None + except Exception: + _plex = None + _, attempt = resolve_library_file_path_with_diagnostic( + stored_path, config_manager=config_manager, plex_client=_plex) + searched = ", ".join(attempt.base_dirs_tried) or "(no library/transfer/download dirs configured)" + logger.warning("[Re-identify] could not locate track %s file — stored=%s raw_exists=%s searched=[%s]", + library_track_id, stored_path, attempt.raw_path_existed, searched) + return jsonify({"success": False, "error": ( + f"SoulSync couldn't find this track's file on disk.\nStored path: {stored_path}\n" + f"Searched: {searched}.\nIf the file lives on a media server SoulSync can't read directly " + f"(or the stored path is stale), re-identify isn't available for it.")}), 404 + + # 3) Copy into staging + fingerprint the copy. + staging_dir = docker_resolve_path(config_manager.get('import.staging_path', './Staging')) + staged = stage_file_for_reidentify(real_path, staging_dir, library_track_id) + + # 4) Persist the single-use hint. + hint = build_reidentify_hint(library_track_id, hint_fields, + staged['staged_path'], staged['content_hash'], replace=replace) + conn = database._get_connection() + try: + cur = conn.cursor() + hint_id = create_hint(cur, hint) + conn.commit() + finally: + conn.close() + + # 5) Nudge the worker so it doesn't wait for the next timer tick. + try: + if auto_import_worker is not None: + auto_import_worker.trigger_scan() + except Exception as _e: + logger.debug("Re-identify: scan nudge failed (worker will catch it on its timer): %s", _e) + + logger.info("[Re-identify] staged track %s → %s '%s' (%s), replace=%s", + library_track_id, hint.album_type or 'release', hint.album_name or '?', + source, replace) + return jsonify({"success": True, "hint_id": hint_id, "staged_path": staged['staged_path'], + "album_name": hint.album_name, "album_type": hint.album_type}) + except FileNotFoundError as e: + return jsonify({"success": False, "error": f"Source file not found: {e}"}), 404 + except Exception as e: + logger.error(f"Re-identify apply error: {e}") + return jsonify({"success": False, "error": str(e)}), 500 + + @app.route('/api/library/artist//quality-analysis') def get_artist_quality_analysis(artist_id): """Analyze track quality for an artist — returns tier classification for each track.""" @@ -16845,6 +17007,48 @@ def remove_batch_from_wishlist(): logger.error(f"Error batch removing from wishlist: {e}") return jsonify({"success": False, "error": str(e)}), 500 +@app.route('/api/wishlist/ignore-list', methods=['GET']) +def get_wishlist_ignore_list(): + """#874: active (non-expired) wishlist ignore entries for this profile.""" + try: + from core.wishlist_service import get_wishlist_service + runtime = _build_wishlist_route_runtime() + entries = get_wishlist_service().database.get_wishlist_ignore(profile_id=runtime.profile_id) + from core.wishlist.ignore import IGNORE_TTL_DAYS + return jsonify({"success": True, "entries": entries, "ttl_days": IGNORE_TTL_DAYS}) + except Exception as e: + logger.error(f"Error reading wishlist ignore-list: {e}") + return jsonify({"success": False, "error": str(e), "entries": []}), 500 + +@app.route('/api/wishlist/ignore-list/remove', methods=['POST']) +def remove_from_wishlist_ignore_list(): + """#874: un-ignore a track so it can be auto-acquired again.""" + try: + data = request.get_json() or {} + track_id = data.get('track_id') or data.get('spotify_track_id') + if not track_id: + return jsonify({"success": False, "error": "No track_id provided"}), 400 + from core.wishlist_service import get_wishlist_service + runtime = _build_wishlist_route_runtime() + ok = get_wishlist_service().database.remove_from_wishlist_ignore( + track_id, profile_id=runtime.profile_id) + return jsonify({"success": True, "removed": ok}) + except Exception as e: + logger.error(f"Error removing from wishlist ignore-list: {e}") + return jsonify({"success": False, "error": str(e)}), 500 + +@app.route('/api/wishlist/ignore-list/clear', methods=['POST']) +def clear_wishlist_ignore_list(): + """#874: clear the entire wishlist ignore-list for this profile.""" + try: + from core.wishlist_service import get_wishlist_service + runtime = _build_wishlist_route_runtime() + count = get_wishlist_service().database.clear_wishlist_ignore(profile_id=runtime.profile_id) + return jsonify({"success": True, "cleared": count}) + except Exception as e: + logger.error(f"Error clearing wishlist ignore-list: {e}") + return jsonify({"success": False, "error": str(e)}), 500 + @app.route('/api/add-album-to-wishlist', methods=['POST']) def add_album_track_to_wishlist(): """Endpoint to add a single track from an album to the wishlist.""" @@ -17686,30 +17890,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, - probe_audio_quality=_probe_audio_quality, - resolve_library_file_path=_resolve_library_file_path, - ) - - -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, @@ -17722,60 +17903,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}") - - # Re-arm the path-resolve diagnostic so each scan logs the searched dirs - # once (otherwise it only ever fires on the first scan after a restart). - global _resolve_library_diag_logged - _resolve_library_diag_logged = False - - # 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""" @@ -19060,26 +19187,44 @@ def _check_batch_completion_v2(batch_id): def _add_cancelled_task_to_wishlist(task): - """ - Helper function to add cancelled task to wishlist. - Separated for clarity and error isolation. + """Handle a user-cancelled download's wishlist state. + + #874: a manual cancel means "stop auto-retrying this release". The + previous behaviour re-added the cancelled track to the wishlist, which + the auto-processor then re-downloaded → re-cancelled → re-added, + forever. Instead we record a TTL'd ignore entry (so the watchlist / + auto-processor skip it) and drop it from the active wishlist. The user + can still force-download it manually (manual paths bypass the gate), + and the ignore lapses after core.wishlist.ignore.IGNORE_TTL_DAYS so it + is reconsidered later. Error-isolated; never raises into the caller. """ if not task: return - + try: from core.wishlist_service import get_wishlist_service + from core.wishlist.ignore import extract_display, REASON_CANCELLED wishlist_service = get_wishlist_service() - payload = _build_cancelled_task_wishlist_payload(task, profile_id=get_current_profile_id()) - success = wishlist_service.add_spotify_track_to_wishlist(**payload) - - if success: - logger.info(f"[Atomic Cancel] Added '{task.get('track_info', {}).get('name')}' to wishlist") - else: - logger.error(f"[Atomic Cancel] Failed to add '{task.get('track_info', {}).get('name')}' to wishlist") - + profile_id = get_current_profile_id() + track_info = task.get('track_info', {}) or {} + track_id = track_info.get('id') + name = track_info.get('name') + + if not track_id: + logger.warning("[Atomic Cancel] No track id on cancelled task — cannot ignore '%s'", name) + return + + disp_name, disp_artist = extract_display(track_info) + wishlist_service.database.add_to_wishlist_ignore( + track_id, track_name=disp_name, artist_name=disp_artist, + reason=REASON_CANCELLED, profile_id=profile_id) + # Drop it from the active wishlist so the auto-processor stops + # re-attempting it; the gate then blocks any automatic re-add. + wishlist_service.remove_track_from_wishlist(track_id, profile_id=profile_id) + logger.info("[Atomic Cancel] Ignored (TTL) + removed from wishlist: '%s'", name or track_id) + except Exception as e: - logger.error(f"[Atomic Cancel] Critical error adding to wishlist: {e}") + logger.error(f"[Atomic Cancel] Critical error handling cancelled task: {e}") @app.route('/api/playlists//cancel_batch', methods=['POST']) def cancel_batch(batch_id): @@ -21720,7 +21865,10 @@ def deezer_download_test(): """Test Deezer ARL token authentication.""" try: data = request.get_json() or {} - arl = data.get('arl', '') + # An empty/redaction-sentinel value means "test the SAVED token" — the + # settings field round-trips a mask for a saved-but-untouched secret, so + # testing it must use the stored ARL, not the mask (#870). + arl = config_manager.resolve_secret('deezer_download.arl', data.get('arl')) if not arl: return jsonify({'success': False, 'error': 'No ARL token provided'}) @@ -37396,12 +37544,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: diff --git a/webui/index.html b/webui/index.html index 236f83a9..0641e7a7 100644 --- a/webui/index.html +++ b/webui/index.html @@ -5629,6 +5629,13 @@ Order the sources to choose whose cover art is used. The first source that has a cover wins; misses fall through to the next, and if none match, your download's own art is kept. Only sources you're connected to are shown — leave all off to keep current behavior.
+
+ + When a track matches a single release, look up the album that contains it and tag it as that album — so every song in an album gets the same (album) cover instead of some getting the single's art. Off by default: adds an extra metadata lookup per single-matched track. +
-
-
-

Quality Scanner

- -
-

Scan library for tracks below quality preferences

-
-
- Processed: - 0 -
-
- Quality Met: - 0 -
-
- Low Quality: - 0 -
-
- Matched: - 0 -
-
-
- - -
-
-

Ready to scan

-
-
-
-
-

0 / 0 tracks scanned - (0.0%)

-
-
-

Import IDs from File Tags

@@ -7671,6 +7635,50 @@
+ + +