diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index cc63693b..e5f75e23 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.6.4)' + description: 'Version tag (e.g. 2.6.7)' required: true - default: '2.6.4' + default: '2.6.7' jobs: build-and-push: diff --git a/config/settings.py b/config/settings.py index aa10bff5..5ff13f6a 100644 --- a/config/settings.py +++ b/config/settings.py @@ -620,7 +620,13 @@ class ConfigManager: "embed_tags": True }, "playlist_sync": { - "create_backup": True + "create_backup": True, + # How a re-sync writes to the server playlist: + # replace — delete + recreate (default; today's behavior) + # reconcile — edit in place (add/remove delta), preserving the + # playlist's custom image, description, and identity (#792) + # append — only add new tracks, never remove + "mode": "replace" }, "settings": { "audio_quality": "flac" diff --git a/core/acoustid_verification.py b/core/acoustid_verification.py index 24930ec0..42cc5500 100644 --- a/core/acoustid_verification.py +++ b/core/acoustid_verification.py @@ -17,6 +17,7 @@ from utils.logging_config import get_logger from core.acoustid_client import AcoustIDClient from core.matching_engine import MusicMatchingEngine from core.matching.version_mismatch import is_acceptable_version_mismatch +from core.matching.script_compat import is_cross_script_mismatch from core.musicbrainz_client import MusicBrainzClient logger = get_logger("acoustid.verification") @@ -655,10 +656,32 @@ class AcoustIDVerification: and title_sim >= 0.80 and artist_sim >= ARTIST_MATCH_THRESHOLD ) - if language_script_skip or high_confidence_strong_match_skip: + # Issue #797 — the EXPECTED artist and the AcoustID-matched + # artist are written in different scripts (e.g. "Joe Hisaishi" + # vs "久石譲") yet the alias-aware comparison still confirmed + # them as the same artist (artist_sim >= threshold, bridged via + # MusicBrainz aliases). When the artist itself spans scripts the + # title almost always does too — and a romanized-vs-native title + # comparison is meaningless, so it can't be evidence the file is + # wrong. Trust the confirmed artist + the fingerprint (already + # >= MIN_ACOUSTID_SCORE to reach here) and SKIP rather than + # quarantine a correct download of a non-English artist. + # + # Deliberately narrow (the "tight" scope): keyed on the ARTIST + # spanning scripts AND being confirmed. A same-script artist + # with only a cross-script TITLE (romaji artist + kanji title) + # is NOT covered — that case keeps the stricter 0.95 floor + # above, preserving the #607 wrong-file protection. + cross_script_artist_skip = ( + best_score >= MIN_ACOUSTID_SCORE + and artist_sim >= ARTIST_MATCH_THRESHOLD + and is_cross_script_mismatch(expected_artist_name, display_artist) + ) + if (language_script_skip or high_confidence_strong_match_skip + or cross_script_artist_skip): reason = ( "likely same song in different language/script" - if language_script_skip + if (language_script_skip or cross_script_artist_skip) else "title/artist match within tolerance" ) msg = ( diff --git a/core/artist_source_lookup.py b/core/artist_source_lookup.py index 60fff775..e43274de 100644 --- a/core/artist_source_lookup.py +++ b/core/artist_source_lookup.py @@ -53,7 +53,11 @@ def find_library_artist_for_source( Lookup order: 1. Direct match on the source-specific ID column (server-agnostic — any - library record with the right external ID is a hit). + library record with the right external ID is a hit). If that id is + stamped on MORE than one library artist, the mapping is corrupt / + ambiguous (e.g. an enrichment bug wrote one Deezer id onto several + artists) — we refuse to guess and fall through, so the caller can + show the source artist directly instead of an arbitrary wrong one. 2. Case-insensitive name match within ``active_server`` (defaults to the active media server when not provided), so we don't jump the user across server contexts on a name collision. @@ -67,13 +71,23 @@ def find_library_artist_for_source( try: with database._get_connection() as conn: cursor = conn.cursor() + # LIMIT 2 so we can tell a unique match from an ambiguous one. cursor.execute( - f"SELECT id, name FROM artists WHERE {column} = ? LIMIT 1", + f"SELECT id FROM artists WHERE {column} = ? LIMIT 2", (str(source_artist_id),), ) - row = cursor.fetchone() - if row: - return row[0] + rows = cursor.fetchall() + if len(rows) == 1: + return rows[0][0] + if len(rows) > 1: + # Same source id on multiple artists — corrupt mapping. Don't + # upgrade on the id; fall through to the name match (and, if + # that misses, let the caller render the source artist). + logger.warning( + f"Source id {source}:{source_artist_id} maps to " + f"{len(rows)}+ library artists — ambiguous, skipping " + f"id-based library upgrade" + ) if artist_name and active_server: cursor.execute( diff --git a/core/audiodb_worker.py b/core/audiodb_worker.py index a8767cb8..20bfd16e 100644 --- a/core/audiodb_worker.py +++ b/core/audiodb_worker.py @@ -8,7 +8,7 @@ from datetime import datetime, timedelta from utils.logging_config import get_logger from database.music_database import MusicDatabase from core.audiodb_client import AudioDBClient -from core.worker_utils import interruptible_sleep +from core.worker_utils import accept_artist_match, interruptible_sleep logger = get_logger("audiodb_worker") @@ -273,8 +273,13 @@ class AudioDBWorker: def _verify_artist_id(self, item: Dict[str, Any], result: Dict[str, Any]) -> bool: """Verify that the result's artist ID matches the parent artist's stored AudioDB ID. + If mismatched, the album/track search is more specific (uses artist+title), - so we trust it and correct the parent artist's audiodb_id.""" + so we trust it and correct the parent artist's audiodb_id — BUT only when + the result's artist *name* matches our parent artist. Without that guard, + a collaboration/compilation (a track our library credits to one artist + that lives on another artist's album) would stamp the wrong AudioDB id + onto our artist. See the Deezer fix for the full write-up.""" parent_audiodb_id = item.get('artist_audiodb_id') if not parent_audiodb_id: return True @@ -284,6 +289,18 @@ class AudioDBWorker: return True if str(result_artist_id) != str(parent_audiodb_id): + parent_name = item.get('artist') or '' + result_artist_name = result.get('strArtist') or '' + if (result_artist_name and parent_name + and not self._name_matches(parent_name, result_artist_name)): + logger.info( + f"Skipping artist-ID correction from {item['type']} " + f"'{item['name']}': result artist '{result_artist_name}' " + f"≠ parent '{parent_name}' (collab/compilation, not a " + f"correction)" + ) + return True + logger.info( f"Artist ID correction from {item['type']} '{item['name']}': " f"updating parent artist AudioDB ID from {parent_audiodb_id} to {result_artist_id}" @@ -409,14 +426,18 @@ class AudioDBWorker: result = self.client.search_artist(item_name) if result: result_name = result.get('strArtist', '') - if self._name_matches(item_name, result_name): + ok, reason = accept_artist_match( + self.db, 'audiodb_id', result.get('idArtist'), item_id, + item_name, result_name, + ) + if ok: self._update_artist(item_id, result) self.stats['matched'] += 1 logger.info(f"Matched artist '{item_name}' -> AudioDB ID: {result.get('idArtist')}") else: self._mark_status('artist', item_id, 'not_found') self.stats['not_found'] += 1 - logger.debug(f"Name mismatch for artist '{item_name}' (got '{result_name}')") + logger.debug(f"Artist '{item_name}' not matched: {reason}") else: self._mark_status('artist', item_id, 'not_found') self.stats['not_found'] += 1 diff --git a/core/database_update_worker.py b/core/database_update_worker.py index 3262ee30..466ad1ae 100644 --- a/core/database_update_worker.py +++ b/core/database_update_worker.py @@ -41,7 +41,19 @@ class DatabaseUpdateWorker: self.database_path = database_path self.full_refresh = full_refresh self.should_stop = False - + + # Track ids of rows newly INSERTED this run (not updates). The web + # layer reads this to gap-fill embedded provider IDs for the new files + # (auto-reconcile), so newly-added music contributes its + # Spotify/MusicBrainz/etc. ids without a manual backfill. + self._new_track_ids = set() + + # Optional callback(worker) run as the FINAL scan phase, immediately + # before the 'finished' signal — so the auto-reconcile is inside the + # scan's running window (automations/UI treat it as a normal phase and + # wait for it). Injected by the web layer (which owns path resolution). + self.post_scan_hook = None + # Statistics tracking self.processed_artists = 0 self.processed_albums = 0 @@ -79,7 +91,26 @@ class DatabaseUpdateWorker: callback(*args) except Exception as e: logger.error(f"Error in callback for {signal_name}: {e}") - + + def _emit_finished(self, *args): + """Run the post-scan hook (auto-reconcile) as the final phase, THEN + emit 'finished'. + + Running the hook before 'finished' keeps the scan's status at + 'running' through the reconcile, so every caller (automations that + poll for completion, the dashboard card, the Tools page) treats it as + a normal scan phase and waits for it — rather than seeing 'finished' + and missing the tail. Best-effort: a hook failure never blocks the + completion signal. + """ + if self.post_scan_hook: + try: + self.post_scan_hook(self) + except Exception as e: + logger.warning(f"post-scan hook failed (non-fatal): {e}") + self._emit_signal('finished', *args) + + def connect_callback(self, signal_name: str, callback: Callable): """Connect a callback for progress notifications.""" self.callbacks.setdefault(signal_name, []).append(callback) @@ -146,7 +177,7 @@ class DatabaseUpdateWorker: logger.info(f"Merged {merged} duplicate artists") except Exception as e: logger.warning(f"Could not merge duplicate artists: {e}") - self._emit_signal('finished', 0, 0, 0, 0, 0) + self._emit_finished(0, 0, 0, 0, 0) return logger.info(f"Incremental update: Found {len(artists_to_process)} artists to process") @@ -230,7 +261,7 @@ class DatabaseUpdateWorker: self.removed_tracks = removal.get('tracks_removed', 0) if removal else 0 # Emit final results - self._emit_signal('finished', + self._emit_finished( self.processed_artists, self.processed_albums, self.processed_tracks, @@ -331,7 +362,7 @@ class DatabaseUpdateWorker: f"{self.processed_albums} albums, {self.processed_tracks} new tracks, " f"{stale_removed} stale tracks removed") - self._emit_signal('finished', + self._emit_finished( self.processed_artists, self.processed_albums, self.processed_tracks, @@ -880,6 +911,8 @@ class DatabaseUpdateWorker: track_success = self.database.insert_or_update_media_track(track, album_id, artist_id, server_source=self.server_type) if track_success: total_processed_tracks += 1 + if track_success == 'inserted': + self._new_track_ids.add(str(track.ratingKey)) logger.debug(f"Processed new track: {track.title}") except Exception as e: logger.warning(f"Failed to process track '{getattr(track, 'title', 'Unknown')}': {e}") @@ -1344,6 +1377,8 @@ class DatabaseUpdateWorker: skipped_count += 1 elif track_success: track_count += 1 + if track_success == 'inserted': + self._new_track_ids.add(track_id_str) except Exception as e: logger.warning(f"Failed to process track '{getattr(track, 'title', 'Unknown')}': {e}") diff --git a/core/deezer_worker.py b/core/deezer_worker.py index 9b592627..15441c1e 100644 --- a/core/deezer_worker.py +++ b/core/deezer_worker.py @@ -8,7 +8,7 @@ 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 interruptible_sleep, set_album_api_track_count +from core.worker_utils import accept_artist_match, interruptible_sleep, set_album_api_track_count from core.enrichment.manual_match_honoring import honor_stored_match logger = get_logger("deezer_worker") @@ -278,10 +278,19 @@ class DeezerWorker: logger.debug(f"Name similarity: '{query_name}' vs '{result_name}' = {similarity:.2f}") return similarity >= self.name_similarity_threshold - def _verify_artist_id(self, item: Dict[str, Any], result_artist_id) -> bool: + def _verify_artist_id(self, item: Dict[str, Any], result_artist_id, + result_artist_name: Optional[str] = None) -> bool: """Verify that the result's artist ID matches the parent artist's stored Deezer ID. + If mismatched, the album/track search is more specific (uses artist+title), - so we trust it and correct the parent artist's deezer_id.""" + so we trust it and correct the parent artist's deezer_id — BUT only when + the result's artist *name* actually matches our parent artist. Without + that guard, a collaboration or compilation track (e.g. a track our + library credits to Jorja Smith that lives on Kendrick Lamar's curated + "Black Panther" album) would search up to an album whose Deezer primary + artist is someone else (Kendrick), and we'd stamp that wrong Deezer ID + onto our artist — corrupting it (and causing duplicate ids shared across + unrelated artists).""" parent_deezer_id = item.get('artist_deezer_id') if not parent_deezer_id: return True @@ -290,6 +299,20 @@ class DeezerWorker: return True if str(result_artist_id) != str(parent_deezer_id): + # Guard: only correct when the album/track's primary artist is the + # SAME artist by name. A mismatch means it's a collab/compilation, + # not a stale-id correction. + parent_name = item.get('artist') or '' + if (result_artist_name and parent_name + and not self._name_matches(parent_name, result_artist_name)): + logger.info( + f"Skipping artist-ID correction from {item['type']} " + f"'{item['name']}': result artist '{result_artist_name}' " + f"≠ parent '{parent_name}' (collab/compilation, not a " + f"correction)" + ) + return True + logger.info( f"Artist ID correction from {item['type']} '{item['name']}': " f"updating parent artist Deezer ID from {parent_deezer_id} to {result_artist_id}" @@ -381,14 +404,18 @@ class DeezerWorker: result = self.client.search_artist(artist_name) if result: result_name = result.get('name', '') - if self._name_matches(artist_name, result_name): + ok, reason = accept_artist_match( + self.db, 'deezer_id', result.get('id'), artist_id, + artist_name, result_name, + ) + if ok: self._update_artist(artist_id, result) self.stats['matched'] += 1 logger.info(f"Matched artist '{artist_name}' -> Deezer ID: {result.get('id')}") else: self._mark_status('artist', artist_id, 'not_found') self.stats['not_found'] += 1 - logger.debug(f"Name mismatch for artist '{artist_name}' (got '{result_name}')") + logger.debug(f"Artist '{artist_name}' not matched: {reason}") else: self._mark_status('artist', artist_id, 'not_found') self.stats['not_found'] += 1 @@ -430,7 +457,8 @@ class DeezerWorker: # Verify artist ID result_artist = result.get('artist', {}) result_artist_id = result_artist.get('id') if result_artist else None - self._verify_artist_id(item, result_artist_id) + result_artist_name = result_artist.get('name') if result_artist else None + self._verify_artist_id(item, result_artist_id, result_artist_name) # Fetch full album details for label, genres, explicit deezer_album_id = result.get('id') @@ -481,7 +509,8 @@ class DeezerWorker: # Verify artist ID result_artist = result.get('artist', {}) result_artist_id = result_artist.get('id') if result_artist else None - self._verify_artist_id(item, result_artist_id) + result_artist_name = result_artist.get('name') if result_artist else None + self._verify_artist_id(item, result_artist_id, result_artist_name) # Fetch full track details for BPM deezer_track_id = result.get('id') diff --git a/core/discogs_worker.py b/core/discogs_worker.py index 429ba320..994ee259 100644 --- a/core/discogs_worker.py +++ b/core/discogs_worker.py @@ -18,7 +18,7 @@ from datetime import datetime, timedelta from utils.logging_config import get_logger from database.music_database import MusicDatabase from core.discogs_client import DiscogsClient -from core.worker_utils import interruptible_sleep, set_album_api_track_count +from core.worker_utils import accept_artist_match, interruptible_sleep, set_album_api_track_count logger = get_logger("discogs_worker") @@ -332,9 +332,13 @@ class DiscogsWorker: self.stats['not_found'] += 1 return - # Find best match by name similarity + # Find best match by name similarity (skipping ids already claimed by + # a differently-named artist, so we don't create a shared/duplicate id). for result in results: - if self._name_matches(artist_name, result.name): + ok, reason = accept_artist_match( + self.db, 'discogs_id', result.id, artist_id, artist_name, result.name, + ) + if ok: # Fetch full artist detail (uses cache) data = self.client._fetch_and_cache_artist(result.id) if data: diff --git a/core/discovery/manual_match.py b/core/discovery/manual_match.py index 8c56bbdf..02179ca0 100644 --- a/core/discovery/manual_match.py +++ b/core/discovery/manual_match.py @@ -16,6 +16,10 @@ to test in isolation: overwrites the user's deliberate pick with whatever the auto-search ranks first, so manual matches are exempt regardless of provider drift. `is_drifted_for_redo` encapsulates the decision. + +3. *Should the Playlist Pipeline pre-scan (re)discover this track at all?* + — `should_rediscover` encapsulates that gate, with the manual match + checked FIRST so a leftover Wing It flag can't override the user's pick. """ from __future__ import annotations @@ -68,3 +72,49 @@ def is_drifted_for_redo( return False cached_provider = extra_data.get('provider', 'spotify') return cached_provider != active_provider + + +def should_rediscover(extra_data: Optional[Dict[str, Any]]) -> bool: + """Return True when a mirrored track needs (re)discovery, False to skip it. + + This is the gate the Playlist Pipeline pre-scan runs over every mirrored + track before discovering. The **ordering is the fix**: a manual match is + authoritative and is checked FIRST. + + ``extra_data`` is *merged* on save (see ``update_mirrored_track_extra_data``), + so a track that was a Wing It stub and is then manually fixed still carries + ``wing_it_fallback: True`` alongside the new ``manual_match: True``. The old + pre-scan tested ``wing_it_fallback`` before ``manual_match``, so the stale + flag won and the pipeline re-discovered the track — silently reverting the + user's pick to Wing It. Checking ``manual_match`` first makes the fix stick. + + Decision order: + * manual_match -> skip (authoritative; never re-discover) + * wing_it_fallback -> redo (stub — keep trying for a real match) + * discovered + complete -> skip (full metadata already stored) + * discovered + incomplete -> redo (backfill track_number / album fields) + * unmatched_by_user -> skip (user deliberately removed the match) + * never discovered -> redo (first-time discovery) + """ + extra = extra_data if isinstance(extra_data, dict) else {} + + if extra.get('discovered'): + if extra.get('manual_match'): + return False + if extra.get('wing_it_fallback'): + return True + # Otherwise re-discover only when the stored match is missing the + # enriched fields (track_number + release_date/album.id) that older + # discoveries dropped via the Track dataclass. + matched = extra.get('matched_data') + matched = matched if isinstance(matched, dict) else {} + album = matched.get('album') + album = album if isinstance(album, dict) else {} + has_track_num = matched.get('track_number') + has_release = album.get('release_date') + has_album_id = album.get('id') + return not (has_track_num and (has_release or has_album_id)) + + if extra.get('unmatched_by_user'): + return False + return True diff --git a/core/discovery/playlist.py b/core/discovery/playlist.py index 9761310d..0e71ec85 100644 --- a/core/discovery/playlist.py +++ b/core/discovery/playlist.py @@ -37,9 +37,35 @@ import time from dataclasses import dataclass from typing import Any, Callable +from core.discovery.manual_match import should_rediscover + logger = logging.getLogger(__name__) +def _canonical_best_score(deps, title, artist, duration_ms, results): + """Score search results against the source track, trying the canonicalized + title/artist too and keeping the better confidence (#785). + + YouTube playlists have their "Artist - Title" / channel decoration stripped + at ingest, but file/CSV-imported playlists keep raw titles — so a track + titled "Arctic Monkeys - Do I Wanna Know?" scored verbatim against the + library's "Do I Wanna Know?" never matched. canonical_source_track is + conservative (only strips an " - " prefix when it equals the + artist), so this can only ADD a better candidate, never weaken a match. + Returns (match, confidence).""" + match, confidence, _ = deps.discovery_score_candidates(title, artist, duration_ms, results) + try: + from core.text.source_title import canonical_source_track + canon_title, canon_artist = canonical_source_track(title or '', artist or '') + except Exception: + return match, confidence + if (canon_title, canon_artist) != (title, artist): + alt_match, alt_conf, _ = deps.discovery_score_candidates(canon_title, canon_artist, duration_ms, results) + if alt_match and alt_conf > confidence: + return alt_match, alt_conf + return match, confidence + + @dataclass class PlaylistDiscoveryDeps: """Bundle of cross-cutting deps the playlist discovery worker needs.""" @@ -121,44 +147,14 @@ def run_playlist_discovery_worker(playlists, automation_id=None, deps: PlaylistD existing_extra = json.loads(track['extra_data']) if isinstance(track['extra_data'], str) else track['extra_data'] except (json.JSONDecodeError, TypeError): pass - if existing_extra.get('discovered'): - if existing_extra.get('wing_it_fallback'): - # Wing It stub — always re-attempt to find a real match - undiscovered_tracks.append(track) - elif existing_extra.get('manual_match'): - # User explicitly picked this match via the Fix popup. - # Manual fixes are authoritative: they may lack - # track_number / album.id / release_date (the Fix-popup - # save shape is intentionally lean — search-result rows - # don't include track_number, and the MBID-lookup flat - # shape doesn't carry album.id), but re-running discovery - # against the active source would overwrite the user's - # deliberate pick with whatever the auto-search ranks - # first. Skip — pipeline only re-discovers when the user - # has cleared the match. - pl_skipped += 1 - total_skipped += 1 - else: - # Check if matched_data is complete — old discoveries may be missing - # track_number/release_date due to the Track dataclass stripping them. - # Re-discover these so the enriched pipeline fills in the gaps. - md = existing_extra.get('matched_data', {}) - album = md.get('album', {}) - has_track_num = md.get('track_number') - has_release = album.get('release_date') if isinstance(album, dict) else None - has_album_id = album.get('id') if isinstance(album, dict) else None - if has_track_num and (has_release or has_album_id): - pl_skipped += 1 - total_skipped += 1 - else: - # Incomplete discovery — re-discover to get full metadata - undiscovered_tracks.append(track) - elif existing_extra.get('unmatched_by_user'): - # User explicitly removed this match — respect their choice + # `should_rediscover` is the single source of truth for this + # gate (manual match checked FIRST so a stale Wing It flag can't + # revert a user's deliberate fix — see its docstring). + if should_rediscover(existing_extra): + undiscovered_tracks.append(track) + else: pl_skipped += 1 total_skipped += 1 - else: - undiscovered_tracks.append(track) if pl_skipped > 0: deps.update_automation_progress(automation_id, @@ -223,6 +219,20 @@ def run_playlist_discovery_worker(playlists, automation_id=None, deps: PlaylistD except Exception: search_queries = [f"{artist_name} {track_name}", track_name] + # #785: file/CSV playlists keep raw "Artist - Title" titles, so the + # queries above search for the artist prefix too. Also search the + # canonicalized title so the right candidates are actually returned + # (the scorer best-of then matches them). + try: + from core.text.source_title import canonical_source_track + _cq_title, _cq_artist = canonical_source_track(track_name, artist_name) + if (_cq_title, _cq_artist) != (track_name, artist_name): + for _q in (f"{_cq_artist} {_cq_title}", _cq_title): + if _q not in search_queries: + search_queries.append(_q) + except Exception as _cq_err: + logger.debug("canonical search-query add failed: %s", _cq_err) + # Step 3: Search and score best_match = None best_confidence = 0.0 @@ -237,8 +247,8 @@ def run_playlist_discovery_worker(playlists, automation_id=None, deps: PlaylistD if not results: continue - match, confidence, _ = deps.discovery_score_candidates( - track_name, artist_name, duration_ms, results + match, confidence = _canonical_best_score( + deps, track_name, artist_name, duration_ms, results ) if match and confidence > best_confidence: @@ -259,8 +269,8 @@ def run_playlist_discovery_worker(playlists, automation_id=None, deps: PlaylistD else: extended = itunes_client_instance.search_tracks(query, limit=50) if extended: - match, confidence, _ = deps.discovery_score_candidates( - track_name, artist_name, duration_ms, extended + match, confidence = _canonical_best_score( + deps, track_name, artist_name, duration_ms, extended ) if match and confidence > best_confidence: best_confidence = confidence diff --git a/core/discovery/sync.py b/core/discovery/sync.py index c3b513ce..82ff0614 100644 --- a/core/discovery/sync.py +++ b/core/discovery/sync.py @@ -188,6 +188,22 @@ async def _database_only_find_track(spotify_track, candidate_pool=None): server_source=active_server ) + if not (db_track and confidence >= 0.80): + # #785: file/CSV playlists keep raw "Artist - Title" titles (unlike + # YouTube, cleaned at ingest), which don't match the clean library + # title. Retry with the canonical form (best-of, conservative). + try: + from core.text.source_title import canonical_source_track + _canon_title, _canon_artist = canonical_source_track(original_title, artist_name) + if (_canon_title, _canon_artist) != (original_title, artist_name): + _alt_track, _alt_conf = db.check_track_exists( + _canon_title, _canon_artist, + confidence_threshold=0.80, server_source=active_server) + if _alt_track and _alt_conf > confidence: + db_track, confidence = _alt_track, _alt_conf + except Exception as _canon_err: + logger.debug("canonical retry failed: %s", _canon_err) + if db_track and confidence >= 0.80: logger.info(f"Database match: '{db_track.title}' (confidence: {confidence:.2f})") if spotify_id: @@ -508,7 +524,13 @@ def run_sync_task( # don't want persisted to app.log. _synced = getattr(result, 'synced_tracks', 0) logger.info(f"[PLAYLIST IMAGE] has_image={bool(playlist_image_url)}, synced_tracks={_synced}") - if playlist_image_url and _synced > 0: + # In reconcile mode the whole point (#792) is to NOT touch the playlist's + # existing image — pushing the source image here would re-clobber a + # user's custom poster every sync, the exact bug reconcile fixes. So skip + # the auto image push for reconcile (the playlist keeps its own art). + if sync_mode == 'reconcile': + logger.info("[PLAYLIST IMAGE] reconcile mode — preserving existing playlist image") + elif playlist_image_url and _synced > 0: try: active_server = deps.config_manager.get_active_media_server() logger.info(f"[PLAYLIST IMAGE] active_server={active_server}") diff --git a/core/download_plugins/album_bundle.py b/core/download_plugins/album_bundle.py index 17d9d6fa..2c584a61 100644 --- a/core/download_plugins/album_bundle.py +++ b/core/download_plugins/album_bundle.py @@ -706,13 +706,19 @@ def resolve_reported_save_path( def copy_audio_files_atomically( - sources: Iterable[Path], staging_dir: Path, + sources: Iterable[Path], staging_dir: Path, remove_source: bool = False, ) -> list: """Convenience wrapper: pick a non-colliding staging path for each source, copy via ``atomic_copy_to_staging``. Returns the list of final destination paths (as strings). Files that fail to copy are logged and skipped; the caller decides what to do - with a partial result.""" + with a partial result. + + ``remove_source=True`` deletes each source AFTER it copies + successfully — used by the Soulseek bundle path so slskd's + completed downloads don't pile up in its download folder (#796). + Kept False for torrent/usenet, whose clients must retain the + originals (seeding / client-managed).""" staging_dir.mkdir(parents=True, exist_ok=True) out: list = [] for src in sources: @@ -720,6 +726,14 @@ def copy_audio_files_atomically( try: atomic_copy_to_staging(src, dest) out.append(str(dest)) + if remove_source: + # Only after a verified copy — never lose data on a failed stage. + try: + Path(src).unlink() + except FileNotFoundError: + pass + except Exception as e: + logger.debug("[album_bundle] Could not remove staged source %s: %s", src, e) except Exception as e: logger.warning("[album_bundle] Failed to stage %s -> %s: %s", src, dest, e) return out diff --git a/core/downloads/candidates.py b/core/downloads/candidates.py index 9510a0f5..ddce91fe 100644 --- a/core/downloads/candidates.py +++ b/core/downloads/candidates.py @@ -326,6 +326,17 @@ def attempt_download_with_candidates(task_id, candidates, track, batch_id=None, "task=%s username=%s filename=%s", task_id, username, os.path.basename(filename), ) + elif track_info and track_info.get('_skip_acoustid'): + # Issue #797 — the album-download request had the + # per-request "Skip AcoustID verification" toggle on. + # Bypass only the AcoustID gate (same as a manual + # pick); integrity + bit-depth still run. + matched_downloads_context[context_key]['_skip_quarantine_check'] = 'acoustid' + logger.info( + "[Context] Skip-AcoustID toggle — bypassing AcoustID for " + "task=%s filename=%s", + task_id, os.path.basename(filename), + ) logger.info(f"[Context] Set is_album_download: {is_album_context} (has clean data: {has_clean_spotify_data})") logger.debug(f"[Debug] Context creation - track_info: {track_info is not None}, playlist_folder_mode: {track_info.get('_playlist_folder_mode', False) if track_info else False}") diff --git a/core/downloads/master.py b/core/downloads/master.py index 3769dca3..dfcb4eab 100644 --- a/core/downloads/master.py +++ b/core/downloads/master.py @@ -347,6 +347,13 @@ def run_full_missing_tracks_process(batch_id, playlist_id, tracks_json, deps: Ma batch_playlist_name = 'Unknown Playlist' batch_playlist_id = playlist_id batch_source_playlist_ref = '' + # Issue #797 — per-request "Skip AcoustID verification" toggle from + # the album-download modal. When set, every track in this batch + # bypasses the AcoustID quarantine gate (the user has chosen to + # trust the metadata over fingerprint disagreement — useful for + # non-English artists whose native-script metadata AcoustID can't + # reconcile with the romanized request). + batch_skip_acoustid = False with tasks_lock: if batch_id in download_batches: force_download_all = download_batches[batch_id].get('force_download_all', False) @@ -362,6 +369,7 @@ def run_full_missing_tracks_process(batch_id, playlist_id, tracks_json, deps: Ma batch_source_playlist_ref = ( download_batches[batch_id].get('source_playlist_ref') or '' ).strip() + batch_skip_acoustid = bool(download_batches[batch_id].get('skip_acoustid', False)) from core.downloads.playlist_folder import ( resolve_playlist_folder_mode_for_batch, @@ -1031,6 +1039,14 @@ def run_full_missing_tracks_process(batch_id, playlist_id, tracks_json, deps: Ma logger.info(f"[Wishlist] Added album context for: '{track_info.get('name')}' -> '{album_ctx['name']}'") + # Issue #797 — propagate the batch-level "skip AcoustID" + # toggle onto each track so the per-track download context + # (built in core/downloads/candidates.py) can set the + # AcoustID quarantine bypass. Mirrors the _playlist_folder_mode + # threading pattern below. + if batch_skip_acoustid: + track_info['_skip_acoustid'] = True + # Add playlist folder mode flag for sync page playlists and wishlist # tracks tied to a mirrored playlist with organize_by_playlist enabled. task_pl_folder_mode = batch_playlist_folder_mode diff --git a/core/imports/pipeline.py b/core/imports/pipeline.py index dc6c9bf4..067e4559 100644 --- a/core/imports/pipeline.py +++ b/core/imports/pipeline.py @@ -40,7 +40,6 @@ from core.imports.side_effects import ( emit_track_downloaded, record_download_provenance, record_library_history_download, - record_retag_download, record_soulsync_library_entry, ) from core.wishlist.resolution import check_and_remove_from_wishlist @@ -892,13 +891,6 @@ def post_process_matched_download(context_key, context, file_path, runtime, meta record_download_provenance(context) record_soulsync_library_entry(context, artist_context, album_info) - try: - if not playlist_folder_mode: - completed_path = context.get('_final_processed_path', final_path) - record_retag_download(context, artist_context, album_info, completed_path) - except Exception as retag_err: - logger.error(f"[Post-Process] Retag data capture failed (non-fatal): {retag_err}") - try: completed_path = context.get('_final_processed_path', final_path) batch_id_for_repair = context.get('batch_id') diff --git a/core/imports/side_effects.py b/core/imports/side_effects.py index 6513aabf..7d9d92c9 100644 --- a/core/imports/side_effects.py +++ b/core/imports/side_effects.py @@ -676,88 +676,3 @@ def record_soulsync_library_entry(context: Dict[str, Any], artist_context: Dict[ logger.info("[SoulSync Library] Added: %s / %s / %s", artist_name, album_name, track_name) except Exception as exc: logger.error("[SoulSync Library] Could not record library entry: %s", exc) - - -def record_retag_download(context: Dict[str, Any], artist_context: Dict[str, Any], album_info: Dict[str, Any], final_path: str) -> None: - """Record a completed download for later re-tagging.""" - try: - db = get_database() - - context = normalize_import_context(context) - artist_context = get_import_context_artist(context) or (artist_context if isinstance(artist_context, dict) else {}) - album_context = get_import_context_album(context) - track_info = get_import_track_info(context) - original_search = get_import_original_search(context) - source = get_import_source(context) - source_ids = get_import_source_ids(context) - - artist_name = extract_artist_name(artist_context) or get_import_clean_artist(context, default="Unknown Artist") - is_album = album_info and album_info.get("is_album", False) - group_type = "album" if is_album else "single" - album_name = album_info.get("album_name", "") if album_info else get_import_clean_album(context, default=original_search.get("album", "Unknown")) - - image_url = album_info.get("album_image_url") if album_info else None - if not image_url: - image_url = album_context.get("image_url", "") - if not image_url and album_context.get("images"): - images = album_context.get("images", []) - if images and isinstance(images[0], dict): - image_url = images[0].get("url", "") - - total_tracks = album_context.get("total_tracks", 1) if album_context else 1 - release_date = album_context.get("release_date", "") if album_context else "" - - spotify_album_id = None - itunes_album_id = None - if source == "spotify": - spotify_album_id = source_ids.get("album_id", "") or None - elif source == "itunes": - itunes_album_id = source_ids.get("album_id", "") or None - - group_id = db.find_retag_group(artist_name, album_name) - if group_id is None: - group_id = db.add_retag_group( - group_type=group_type, - artist_name=artist_name, - album_name=album_name, - image_url=image_url, - spotify_album_id=spotify_album_id, - itunes_album_id=itunes_album_id, - total_tracks=total_tracks, - release_date=release_date, - ) - if group_id is None: - return - - track_number = album_info.get("track_number", 1) if album_info else (track_info.get("track_number", 1) or 1) - disc_number = original_search.get("disc_number") or (album_info.get("disc_number", 1) if album_info else track_info.get("disc_number", 1) or 1) - title = get_import_clean_title( - context, - album_info=album_info, - default=album_info.get("clean_track_name", "Unknown Track") if album_info else "Unknown Track", - ) - file_format = os.path.splitext(str(final_path))[1].lstrip(".").lower() - - source_track_id = None - itunes_track_id = None - if source == "spotify": - source_track_id = source_ids.get("track_id", "") or None - elif source == "itunes": - itunes_track_id = source_ids.get("track_id", "") or None - - if not db.retag_track_exists(group_id, str(final_path)): - db.add_retag_track( - group_id=group_id, - track_number=track_number, - disc_number=disc_number, - title=title, - file_path=str(final_path), - file_format=file_format, - spotify_track_id=source_track_id, - itunes_track_id=itunes_track_id, - ) - logger.info("[Retag] Recorded track for retag: '%s' in '%s'", title, album_name) - - db.trim_retag_groups(100) - except Exception as exc: - logger.error("[Retag] Could not record track for retag: %s", exc) diff --git a/core/itunes_worker.py b/core/itunes_worker.py index cca60db0..0d0e10a7 100644 --- a/core/itunes_worker.py +++ b/core/itunes_worker.py @@ -9,7 +9,7 @@ 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 interruptible_sleep, set_album_api_track_count +from core.worker_utils import accept_artist_match, interruptible_sleep, set_album_api_track_count from core.enrichment.manual_match_honoring import honor_stored_match logger = get_logger("itunes_worker") @@ -393,7 +393,11 @@ class iTunesWorker: return for artist_obj in results: - if self._name_matches(artist_name, artist_obj.name): + ok, reason = accept_artist_match( + self.db, 'itunes_artist_id', artist_obj.id, artist_id, + artist_name, artist_obj.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}'") self._mark_status('artist', artist_id, 'error') diff --git a/core/jellyfin_client.py b/core/jellyfin_client.py index 20a2594c..4937c2c0 100644 --- a/core/jellyfin_client.py +++ b/core/jellyfin_client.py @@ -1605,6 +1605,80 @@ class JellyfinClient(MediaServerClient): logger.error(f"Error appending to Jellyfin playlist '{playlist_name}': {e}") return False + def reconcile_playlist(self, playlist_name: str, tracks) -> bool: + """In-place reconcile (#792): add missing + remove gone on the existing + playlist (POST/DELETE /Playlists/{id}/Items) so its poster, name, and Id + survive — no delete/recreate. Removal needs each entry's PlaylistItemId, + which we fetch from /Playlists/{id}/Items. Creates the playlist if + missing. Returns False so the caller can fall back to replace.""" + if not self.ensure_connection(): + return False + try: + import requests + from core.sync.playlist_edit import plan_playlist_reconcile + existing = self.get_playlist_by_name(playlist_name) + if not existing: + logger.info(f"Jellyfin reconcile: '{playlist_name}' doesn't exist — creating") + return self.create_playlist(playlist_name, tracks) + + playlist_id = existing.id + # Entries carry both the track Id and the PlaylistItemId (entry id); + # removal is by EntryIds, not track Ids. + entries = [] # list of (track_id, entry_id) in playlist order + resp = self._make_request(f'/Playlists/{playlist_id}/Items', {'UserId': self.user_id}) + if resp: + for item in resp.get('Items', []): + tid = str(item.get('Id') or '') + eid = str(item.get('PlaylistItemId') or '') + if tid: + entries.append((tid, eid)) + + current_ids = [tid for tid, _ in entries] + desired_ids = [] + for t in tracks: + tid = (str(t.id) if hasattr(t, 'id') and t.id + else str(t.get('Id') or t.get('id') or '') if isinstance(t, dict) else '') + if tid and self._is_valid_guid(tid): + desired_ids.append(tid) + + plan = plan_playlist_reconcile(current_ids, desired_ids) + hdr = {'X-Emby-Token': self.api_key} + + if plan['add']: + for i in range(0, len(plan['add']), 100): + batch = plan['add'][i:i + 100] + r = requests.post( + f"{self.base_url}/Playlists/{playlist_id}/Items", + params={'Ids': ','.join(batch), 'UserId': self.user_id}, + headers=hdr, timeout=30, + ) + if r.status_code not in (200, 204): + logger.error(f"Jellyfin reconcile add failed: HTTP {r.status_code}") + return False + + if plan['remove']: + remove_set = set(plan['remove']) + entry_ids = [eid for tid, eid in entries if tid in remove_set and eid] + for i in range(0, len(entry_ids), 100): + batch = entry_ids[i:i + 100] + r = requests.delete( + f"{self.base_url}/Playlists/{playlist_id}/Items", + params={'EntryIds': ','.join(batch)}, + headers=hdr, timeout=30, + ) + if r.status_code not in (200, 204): + logger.error(f"Jellyfin reconcile remove failed: HTTP {r.status_code}") + return False + + logger.info( + f"Jellyfin reconcile '{playlist_name}': +{len(plan['add'])} / " + f"-{len(plan['remove'])} (playlist preserved)" + ) + return True + except Exception as e: + logger.error(f"Error reconciling Jellyfin playlist '{playlist_name}': {e}") + return False + def update_playlist(self, playlist_name: str, tracks) -> bool: """Update an existing playlist or create it if it doesn't exist""" if not self.ensure_connection(): diff --git a/core/library/embedded_id_reconcile.py b/core/library/embedded_id_reconcile.py new file mode 100644 index 00000000..b7bc5430 --- /dev/null +++ b/core/library/embedded_id_reconcile.py @@ -0,0 +1,415 @@ +"""Reconcile provider IDs embedded in audio files into the library DB. + +Enrichment workers (Spotify / iTunes / MusicBrainz / Deezer / Tidal / +AudioDB / Genius / Last.fm) resolve each artist / album / track to a provider ID +via API calls, gating their work queues on ``{provider}_match_status IS +NULL``. But files that SoulSync (or MusicBrainz Picard) already tagged +carry those IDs in their metadata. Reading them back and gap-filling the +``{provider}_id`` + ``{provider}_match_status = 'matched'`` columns lets +the workers skip the API lookup entirely — large API savings on an +already-tagged library. + +Split into a PURE planning layer and a thin DB apply layer: + +- :func:`plan_reconcile` takes the tags read from ONE file (via + ``core.library.file_tags.read_embedded_tags``) plus the current IDs of + that file's track + its parent album + artist, and produces the list of + :class:`Fill` operations to perform. It is gap-fill only: a provider id + that already has a value is never planned for change; a DISAGREEING + embedded id is reported as a conflict instead. + +- :func:`apply_reconcile_plan` writes a plan, one guarded ``UPDATE`` per + id column: ``WHERE id = ? AND ({id_col} IS NULL OR {id_col} = '')``. + The guard makes the gap-fill ATOMIC — even if an enrichment worker + matched the same entity between the plan's read and this write, the + fill simply affects 0 rows instead of clobbering the worker's value. + Columns are introspected first so a schema version missing a provider's + columns is skipped, not errored. + +Scope note: the MusicBrainz *recording* (track) ID is intentionally not +reconciled — on ID3 it lives in a ``UFID`` frame the shared reader +doesn't surface and the Vorbis ``musicbrainz_trackid`` convention is +format-ambiguous. MB *album* and *artist* IDs (which drive most worker +API calls) ARE reconciled, as are the clean per-provider track/album/ +artist IDs of the other services. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional + +# Each entry: (embedded-tag key from read_embedded_tags, entity, id column, +# match-status column). The id columns mirror web_server._SERVICE_ID_COLUMNS; +# they're spelled out here so this module stays importable without the Flask +# app. Single-column providers (deezer/tidal/audiodb/genius) reuse one id +# column across entity types — that's fine, fills are keyed by (entity, col). +_RECONCILE_FIELDS = ( + ('spotify_track_id', 'track', 'spotify_track_id', 'spotify_match_status'), + ('spotify_album_id', 'album', 'spotify_album_id', 'spotify_match_status'), + ('spotify_artist_id', 'artist', 'spotify_artist_id', 'spotify_match_status'), + ('itunes_track_id', 'track', 'itunes_track_id', 'itunes_match_status'), + ('itunes_album_id', 'album', 'itunes_album_id', 'itunes_match_status'), + ('itunes_artist_id', 'artist', 'itunes_artist_id', 'itunes_match_status'), + ('musicbrainz_albumid', 'album', 'musicbrainz_release_id', 'musicbrainz_match_status'), + ('musicbrainz_artistid', 'artist', 'musicbrainz_id', 'musicbrainz_match_status'), + ('deezer_track_id', 'track', 'deezer_id', 'deezer_match_status'), + ('deezer_album_id', 'album', 'deezer_id', 'deezer_match_status'), + ('deezer_artist_id', 'artist', 'deezer_id', 'deezer_match_status'), + ('tidal_track_id', 'track', 'tidal_id', 'tidal_match_status'), + ('tidal_album_id', 'album', 'tidal_id', 'tidal_match_status'), + ('tidal_artist_id', 'artist', 'tidal_id', 'tidal_match_status'), + ('audiodb_track_id', 'track', 'audiodb_id', 'audiodb_match_status'), + ('audiodb_album_id', 'album', 'audiodb_id', 'audiodb_match_status'), + ('audiodb_artist_id', 'artist', 'audiodb_id', 'audiodb_match_status'), + ('genius_track_id', 'track', 'genius_id', 'genius_match_status'), + # Last.fm embeds a single LASTFM_URL — sourced from get_track_info(), so it + # is the TRACK's url. Map to tracks.lastfm_url only (artist/album last.fm + # urls are different urls and aren't carried in the file). + ('lastfm_url', 'track', 'lastfm_url', 'lastfm_match_status'), +) + +_ENTITIES = ('track', 'album', 'artist') +_ENTITY_TABLE = {'track': 'tracks', 'album': 'albums', 'artist': 'artists'} + + +@dataclass(frozen=True) +class Fill: + """One provider-id column to gap-fill on one entity.""" + entity: str # 'track' | 'album' | 'artist' + id_column: str # e.g. 'spotify_artist_id' + status_column: str # e.g. 'spotify_match_status' + value: str # the embedded id to write + + +@dataclass +class ReconcilePlan: + """The outcome of planning one file against its current DB rows. + + ``fills`` are the gap-fill operations to apply (empty id columns only). + ``already_present`` counts embedded ids that matched a value already + stored (no-op). ``conflicts`` lists embedded ids that DISAGREE with a + stored value — never applied, surfaced for review. + """ + + fills: List[Fill] = field(default_factory=list) + already_present: int = 0 + conflicts: List[Dict[str, str]] = field(default_factory=list) + + @property + def filled(self) -> int: + return len(self.fills) + + @property + def has_updates(self) -> bool: + return bool(self.fills) + + def fills_for(self, entity: str) -> List[Fill]: + return [f for f in self.fills if f.entity == entity] + + +@dataclass +class ReconcileApplied: + """Counts from actually writing a plan (based on real ``rowcount``).""" + rows_updated: int = 0 # distinct entity rows touched + ids_filled: int = 0 # id columns that actually landed (guard passed) + + +def _clean(value: Any) -> Optional[str]: + """Normalise a tag/column value to a non-empty stripped string or None.""" + if value is None: + return None + s = str(value).strip() + return s or None + + +def plan_reconcile( + embedded_tags: Optional[Dict[str, Any]], + current_ids: Optional[Dict[str, Dict[str, Any]]], +) -> ReconcilePlan: + """Plan which provider-ID columns to gap-fill from one file's tags. + + Args: + embedded_tags: the ``tags`` dict from ``read_embedded_tags`` (flat + ``friendly_key -> value``). ``None`` / empty yields an empty plan. + current_ids: ``{'track': {...}, 'album': {...}, 'artist': {...}}`` + where each inner dict holds the entity's CURRENT column values + (at minimum the id columns this module touches). Missing + entities / keys are treated as empty (eligible to fill). + + Returns: + A :class:`ReconcilePlan`. Gap-fill only — an id column with any + existing value is never planned; a disagreeing embedded id is + recorded in ``conflicts``. + """ + plan = ReconcilePlan() + tags = embedded_tags or {} + current = current_ids or {} + queued: Dict[tuple, str] = {} # (entity, id_col) already queued this pass + + for embedded_key, entity, id_col, status_col in _RECONCILE_FIELDS: + new_val = _clean(tags.get(embedded_key)) + if not new_val: + continue + + row = current.get(entity) or {} + existing = _clean(row.get(id_col)) + if existing is not None: + if existing != new_val: + plan.conflicts.append({ + 'entity': entity, 'column': id_col, + 'existing': existing, 'embedded': new_val, + }) + else: + plan.already_present += 1 + continue + + key = (entity, id_col) + if key in queued: + # A single-column provider already queued this id col this pass. + if queued[key] != new_val: + plan.conflicts.append({ + 'entity': entity, 'column': id_col, + 'existing': queued[key], 'embedded': new_val, + }) + continue + + queued[key] = new_val + plan.fills.append(Fill(entity, id_col, status_col, new_val)) + + return plan + + +@dataclass +class TrackReconcileResult: + """Outcome of reconciling one track row against its file's tags.""" + applied: 'ReconcileApplied' + conflicts: int = 0 + readable: bool = True # False when the file's tags couldn't be read + + +def reconcile_track_row( + cursor, + track_row: Dict[str, Any], + album_map: Dict[str, Dict[str, Any]], + artist_map: Dict[str, Dict[str, Any]], + embedded_tags: Optional[Dict[str, Any]], +) -> TrackReconcileResult: + """Reconcile one track row + its parent album/artist against one file. + + Pure orchestration over :func:`plan_reconcile` / :func:`apply_reconcile_plan`, + extracted so the per-track logic (id extraction, plan→apply chaining, + keeping the in-memory parent maps fresh for sibling tracks) is testable + without the Flask job. ``embedded_tags`` is the ``tags`` dict from + ``read_embedded_tags`` (``None`` => unreadable file). + + ``album_map`` / ``artist_map`` map entity-id -> current column dict; this + function UPDATES them in place with any fills it applies so a later track + on the same album/artist sees the value and doesn't re-plan it. (DB safety + is the guarded UPDATE in apply, never these maps.) + """ + if not embedded_tags: + return TrackReconcileResult(ReconcileApplied(), 0, readable=False) + + album_id = str(track_row['album_id']) if track_row.get('album_id') is not None else None + artist_id = str(track_row['artist_id']) if track_row.get('artist_id') is not None else None + + plan = plan_reconcile(embedded_tags, { + 'track': track_row, + 'album': album_map.get(album_id, {}) if album_id else {}, + 'artist': artist_map.get(artist_id, {}) if artist_id else {}, + }) + applied = apply_reconcile_plan(cursor, { + 'track': track_row.get('id'), 'album': album_id, 'artist': artist_id, + }, plan) + + if album_id: + for f in plan.fills_for('album'): + album_map.setdefault(album_id, {})[f.id_column] = f.value + if artist_id: + for f in plan.fills_for('artist'): + artist_map.setdefault(artist_id, {})[f.id_column] = f.value + + return TrackReconcileResult(applied, len(plan.conflicts), readable=True) + + +@dataclass +class ReconcileTotals: + """Accumulated counts over a reconcile run.""" + total: int = 0 + processed: int = 0 + ids_filled: int = 0 + entities_updated: int = 0 + conflicts: int = 0 + unreadable: int = 0 + + +def _load_missing_rows(cursor, ids, table, target: Dict[str, Dict[str, Any]]) -> None: + """Load any not-yet-cached entity rows for ``ids`` into ``target`` in place. + + Ids with no row get an empty dict so they're never re-queried. Chunked to + keep the IN clause bounded. + """ + missing = [i for i in {x for x in ids if x} if i not in target] + for start in range(0, len(missing), 500): + chunk = missing[start:start + 500] + ph = ','.join('?' * len(chunk)) + cursor.execute(f"SELECT * FROM {table} WHERE id IN ({ph})", chunk) + for r in cursor.fetchall(): + target[str(r['id'])] = dict(r) + for i in missing: + target.setdefault(i, {}) # mark absent → don't re-query + + +def reconcile_library( + conn, + read_tags, + track_ids=None, + page_size: int = 500, + on_progress=None, + should_stop=None, +) -> ReconcileTotals: + """Gap-fill embedded provider IDs into the DB for a set of tracks. + + Shared orchestration used by both the manual backfill job and the + auto-reconcile hook on library scans. Pages the track list (bounded + memory), lazily loads only the parent album/artist rows actually + referenced (cheap when scoped to a handful of new tracks), and commits + per page so concurrent enrichment workers aren't starved of the write + lock. + + Args: + conn: open DB connection; this function commits per page. + read_tags: callable ``(file_path) -> tags dict | None``. The caller + injects path resolution + ``read_embedded_tags`` so this module + stays free of Flask / docker-path concerns. ``None`` => unreadable. + track_ids: iterable of track ids to reconcile, or ``None`` for every + track that has a ``file_path``. + page_size: rows materialised per page. + on_progress: optional ``(totals, current_title) -> None`` after each + track (for live UI). + should_stop: optional ``() -> bool`` checked between tracks/pages to + abort early. + + Returns: + :class:`ReconcileTotals`. + """ + from utils.logging_config import get_logger + logger = get_logger("library.reconcile") + + totals = ReconcileTotals() + cur = conn.cursor() + + if track_ids is None: + cur.execute("SELECT id FROM tracks WHERE file_path IS NOT NULL AND TRIM(file_path) != ''") + ids = [str(r[0]) for r in cur.fetchall()] + else: + ids = [str(t) for t in track_ids if t is not None] + totals.total = len(ids) + + album_map: Dict[str, Dict[str, Any]] = {} + artist_map: Dict[str, Dict[str, Any]] = {} + + for start in range(0, len(ids), page_size): + if should_stop and should_stop(): + break + page = ids[start:start + page_size] + ph = ','.join('?' * len(page)) + cur.execute(f"SELECT * FROM tracks WHERE id IN ({ph})", page) + rows = [dict(r) for r in cur.fetchall()] + + _load_missing_rows(cur, [str(r['album_id']) for r in rows if r.get('album_id') is not None], + 'albums', album_map) + _load_missing_rows(cur, [str(r['artist_id']) for r in rows if r.get('artist_id') is not None], + 'artists', artist_map) + + for tr in rows: + if should_stop and should_stop(): + break + title = tr.get('title') or '?' + try: + tags = read_tags(tr.get('file_path')) + result = reconcile_track_row(cur, tr, album_map, artist_map, tags) + if not result.readable: + totals.unreadable += 1 + else: + totals.ids_filled += result.applied.ids_filled + totals.entities_updated += result.applied.rows_updated + totals.conflicts += result.conflicts + except Exception as e: + logger.debug("reconcile: skipped track %s: %s", tr.get('id'), e) + totals.unreadable += 1 + finally: + totals.processed += 1 + if on_progress: + on_progress(totals, title) + + conn.commit() + + return totals + + +def _existing_columns(cursor, table: str) -> set: + """Return the set of column names on ``table`` (migration-safe guard).""" + cursor.execute(f"PRAGMA table_info({table})") + return {r[1] for r in cursor.fetchall()} + + +def apply_reconcile_plan(cursor, entity_ids: Dict[str, Any], plan: ReconcilePlan) -> ReconcileApplied: + """Apply a :class:`ReconcilePlan` to the DB via ``cursor``. + + Each fill is a single guarded ``UPDATE``: + + UPDATE {table} SET {id}=?, {status}='matched', {attempted}=now + WHERE id=? AND ({id} IS NULL OR {id}='') + + The ``id IS NULL OR id=''`` guard makes the gap-fill atomic: if the + column became non-empty between the plan's read and now (an enrichment + worker matched it concurrently), the UPDATE affects 0 rows and the + worker's value is preserved. Only columns that exist on the table are + written (introspected + cached per call), so a schema missing a + provider's columns is silently skipped. + + Args: + cursor: an open DB cursor (caller owns the transaction/commit). + entity_ids: ``{'track': id, 'album': id, 'artist': id}``. An entity + with no id is skipped. + + Returns: + A :class:`ReconcileApplied` with counts derived from real rowcounts. + """ + result = ReconcileApplied() + touched: set = set() + col_cache: Dict[str, set] = {} + + for fill in plan.fills: + ent_id = entity_ids.get(fill.entity) + if ent_id is None or ent_id == '': + continue + table = _ENTITY_TABLE[fill.entity] + if table not in col_cache: + col_cache[table] = _existing_columns(cursor, table) + cols = col_cache[table] + if fill.id_column not in cols: + continue + + assignments = [f"{fill.id_column} = ?"] + values: List[Any] = [fill.value] + if fill.status_column in cols: + assignments.append(f"{fill.status_column} = ?") + values.append('matched') + attempted = fill.status_column.replace('_match_status', '_last_attempted') + if attempted in cols: + assignments.append(f"{attempted} = CURRENT_TIMESTAMP") + + cursor.execute( + f"UPDATE {table} SET {', '.join(assignments)} " + f"WHERE id = ? AND ({fill.id_column} IS NULL OR {fill.id_column} = '')", + values + [str(ent_id)], + ) + if cursor.rowcount: + result.ids_filled += 1 + touched.add((fill.entity, str(ent_id))) + + result.rows_updated = len(touched) + return result diff --git a/core/library/retag.py b/core/library/retag.py deleted file mode 100644 index f5cf74e7..00000000 --- a/core/library/retag.py +++ /dev/null @@ -1,350 +0,0 @@ -"""Library retag worker. - -`execute_retag(group_id, album_id, deps)` rewrites tags + filenames for a -group of audio files when the user has matched them to a different -album. The worker: - -1. Fetches album + track metadata for the new `album_id` (Spotify or - iTunes — Spotify client transparently falls back). -2. Loads existing files in the retag group from the DB. -3. Matches each existing track to a new Spotify track: - - Priority 1: same disc + track number. - - Priority 2: title similarity >= 0.6 (SequenceMatcher). -4. For each matched pair: - - Re-write metadata tags via `_enhance_file_metadata`. - - Compute the new path via `_build_final_path_for_track` and move - the audio file (plus .lrc / .txt sidecars) if the path changes. - - Drop an orphaned cover.jpg if it's left in an empty directory. - - Clean up empty parent directories left behind. - - Download the new cover art into the new album dir. -5. Update the retag group record with the new artist / album / image / - total_tracks / release_date and the appropriate Spotify-or-iTunes - album ID. -6. Mark the retag state 'finished' (or 'error' on exception). - -The original mutated `retag_state` as a module global. Here it's exposed -through the `RetagDeps` proxy as a Python property so the lifted body -keeps the same `name[key] = value` syntax. The property setter rebinds -the web_server.py reference if needed (currently the function only -mutates in place via .update() and key assignment, so the setter never -fires). -""" - -from __future__ import annotations - -import logging -import os -import traceback -from dataclasses import dataclass -from difflib import SequenceMatcher -from typing import Any, Callable, Optional - -logger = logging.getLogger(__name__) - - -@dataclass -class RetagDeps: - """Bundle of cross-cutting deps the retag worker needs. - - `retag_state` is exposed as a property so the lifted body keeps - `name[key] = value` / `name.update(...)` syntax. - """ - config_manager: Any - retag_lock: Any # threading.Lock - spotify_client: Any - get_audio_quality_string: Callable[[str], str] - enhance_file_metadata: Callable - build_final_path_for_track: Callable - safe_move_file: Callable - cleanup_empty_directories: Callable - download_cover_art: Callable - docker_resolve_path: Callable[[str], str] - _get_retag_state: Callable[[], dict] - _set_retag_state: Callable[[dict], None] - get_database: Callable[[], Any] - # Discord report (Netti93) — retag was clearing the LYRICS / USLT - # tag without rewriting it, while the download pipeline calls - # `generate_lrc_file` after enrichment to refetch + embed lyrics. - # Injected here so retag mirrors the same post-enrichment step. - # Optional for backward compat with any test caller that builds - # RetagDeps without the new field — empty default no-ops the call. - generate_lrc_file: Optional[Callable] = None - - @property - def retag_state(self) -> dict: - return self._get_retag_state() - - @retag_state.setter - def retag_state(self, value: dict) -> None: - self._set_retag_state(value) - - -def execute_retag(group_id, album_id, deps: RetagDeps): - """Execute a retag operation: re-tag files in a group with metadata from a new album match.""" - try: - with deps.retag_lock: - deps.retag_state.update({ - "status": "running", - "phase": "Fetching album metadata...", - "progress": 0, - "current_track": "", - "total_tracks": 0, - "processed": 0, - "error_message": "" - }) - - # 1. Fetch new album metadata from Spotify/iTunes - album_data = deps.spotify_client.get_album(album_id) - if not album_data: - raise ValueError(f"Could not fetch album data for ID: {album_id}") - - album_tracks_response = deps.spotify_client.get_album_tracks(album_id) - if not album_tracks_response: - raise ValueError(f"Could not fetch album tracks for ID: {album_id}") - - album_tracks_items = album_tracks_response.get('items', []) - - # Extract artist info - album_artists = album_data.get('artists', []) - new_artist = album_artists[0] if album_artists else {'name': 'Unknown Artist', 'id': ''} - # Ensure artist is a dict with expected fields - if not isinstance(new_artist, dict): - new_artist = {'name': str(new_artist), 'id': ''} - new_album_name = album_data.get('name', 'Unknown Album') - new_images = album_data.get('images', []) - new_image_url = new_images[0]['url'] if new_images else None - new_release_date = album_data.get('release_date', '') - total_tracks = album_data.get('total_tracks', len(album_tracks_items)) - - # Build spotify track list - spotify_tracks = [] - for item in album_tracks_items: - track_artists = item.get('artists', []) - spotify_tracks.append({ - 'name': item.get('name', ''), - 'track_number': item.get('track_number', 1), - 'disc_number': item.get('disc_number', 1), - 'id': item.get('id', ''), - 'artists': track_artists, - 'duration_ms': item.get('duration_ms', 0) - }) - - total_discs = max((t['disc_number'] for t in spotify_tracks), default=1) - - # 2. Load existing tracks for this group - db = deps.get_database() - existing_tracks = db.get_retag_tracks(group_id) - if not existing_tracks: - raise ValueError(f"No tracks found for retag group {group_id}") - - with deps.retag_lock: - deps.retag_state['total_tracks'] = len(existing_tracks) - deps.retag_state['phase'] = "Matching tracks..." - - # 3. Match existing files to new tracklist - matched_pairs = [] - for existing_track in existing_tracks: - best_match = None - best_score = 0 - - # Priority 1: Match by track number - for st in spotify_tracks: - if (st['track_number'] == existing_track.get('track_number') and - st['disc_number'] == existing_track.get('disc_number', 1)): - best_match = st - best_score = 1.0 - break - - # Priority 2: Match by title similarity - if not best_match: - from difflib import SequenceMatcher - existing_title = (existing_track.get('title') or '').lower().strip() - for st in spotify_tracks: - st_title = (st.get('name') or '').lower().strip() - score = SequenceMatcher(None, existing_title, st_title).ratio() - if score > best_score and score > 0.6: - best_score = score - best_match = st - - if best_match: - matched_pairs.append((existing_track, best_match)) - else: - logger.warning(f"[Retag] No match found for track: '{existing_track.get('title')}'") - matched_pairs.append((existing_track, None)) - - with deps.retag_lock: - deps.retag_state['phase'] = "Retagging files..." - - # 4. Retag each matched track - for existing_track, matched_spotify in matched_pairs: - current_file_path = existing_track.get('file_path', '') - track_title = matched_spotify['name'] if matched_spotify else existing_track.get('title', 'Unknown') - - with deps.retag_lock: - deps.retag_state['current_track'] = track_title - - if not matched_spotify: - with deps.retag_lock: - deps.retag_state['processed'] += 1 - deps.retag_state['progress'] = int(deps.retag_state['processed'] / deps.retag_state['total_tracks'] * 100) - continue - - # Verify file exists - if not os.path.exists(current_file_path): - logger.warning(f"[Retag] File not found, skipping: {current_file_path}") - with deps.retag_lock: - deps.retag_state['processed'] += 1 - deps.retag_state['progress'] = int(deps.retag_state['processed'] / deps.retag_state['total_tracks'] * 100) - continue - - # Build synthetic context for _enhance_file_metadata - track_artists = matched_spotify.get('artists', []) - context = { - 'original_search_result': { - 'spotify_clean_title': matched_spotify['name'], - 'spotify_clean_album': new_album_name, - 'track_number': matched_spotify['track_number'], - 'disc_number': matched_spotify.get('disc_number', 1), - 'artists': track_artists, - 'title': matched_spotify['name'] - }, - 'spotify_album': { - 'id': album_id, - 'name': new_album_name, - 'release_date': new_release_date, - 'total_tracks': total_tracks, - 'image_url': new_image_url, - 'total_discs': total_discs - }, - 'track_info': {'id': matched_spotify['id']}, - 'spotify_artist': new_artist, - '_audio_quality': deps.get_audio_quality_string(current_file_path) or '' - } - - album_info = { - 'is_album': total_tracks > 1, - 'album_name': new_album_name, - 'track_number': matched_spotify['track_number'], - 'disc_number': matched_spotify.get('disc_number', 1), - 'clean_track_name': matched_spotify['name'], - 'album_image_url': new_image_url - } - - # Re-write metadata tags - try: - deps.enhance_file_metadata(current_file_path, context, new_artist, album_info) - logger.info(f"[Retag] Re-tagged: '{track_title}'") - except Exception as meta_err: - logger.error(f"[Retag] Metadata write failed for '{track_title}': {meta_err}") - - # Discord report (Netti93) — `enhance_file_metadata` clears - # ALL tags (incl. USLT lyrics) and rewrites only the source - # metadata. The download pipeline calls `generate_lrc_file` - # after enrichment to refetch + embed lyrics — retag was - # missing that step and dropped the LYRICS tag with no - # rewrite. Mirroring the download path's post-enrichment - # step. Same args, same `lrclib_enabled` config gate, same - # idempotency (skip when sidecar already present). - if deps.generate_lrc_file: - try: - deps.generate_lrc_file(current_file_path, context, new_artist, album_info) - except Exception as lrc_err: - logger.debug("[Retag] generate_lrc_file failed for '%s': %s", track_title, lrc_err) - - # Compute new path and move if different - file_ext = os.path.splitext(current_file_path)[1] - try: - new_path, _ = deps.build_final_path_for_track(context, new_artist, album_info, file_ext) - - if os.path.normpath(current_file_path) != os.path.normpath(new_path): - logger.info(f"[Retag] Moving '{os.path.basename(current_file_path)}' -> '{new_path}'") - old_dir = os.path.dirname(current_file_path) - os.makedirs(os.path.dirname(new_path), exist_ok=True) - deps.safe_move_file(current_file_path, new_path) - - # Move lyrics sidecar file alongside audio file if it exists - for lyrics_ext in ('.lrc', '.txt'): - old_lyrics = os.path.splitext(current_file_path)[0] + lyrics_ext - if os.path.exists(old_lyrics): - new_lyrics = os.path.splitext(new_path)[0] + lyrics_ext - try: - deps.safe_move_file(old_lyrics, new_lyrics) - logger.info(f"[Retag] Moved {lyrics_ext} file alongside audio") - except Exception as lrc_err: - logger.error(f"[Retag] Failed to move {lyrics_ext} file: {lrc_err}") - - # Remove old cover.jpg if directory changed and old dir is now empty of audio - new_dir = os.path.dirname(new_path) - if os.path.normpath(old_dir) != os.path.normpath(new_dir): - old_cover = os.path.join(old_dir, 'cover.jpg') - if os.path.exists(old_cover): - # Check if any audio files remain in old directory - audio_exts = {'.flac', '.mp3', '.m4a', '.ogg', '.opus', '.wav', '.aac'} - remaining_audio = [f for f in os.listdir(old_dir) - if os.path.splitext(f)[1].lower() in audio_exts] - if not remaining_audio: - try: - os.remove(old_cover) - logger.warning("[Retag] Removed orphaned cover.jpg from old directory") - except Exception as e: - logger.debug("remove orphaned cover failed: %s", e) - - # Cleanup old empty directories - transfer_dir = deps.docker_resolve_path(deps.config_manager.get('soulseek.transfer_path', './Transfer')) - deps.cleanup_empty_directories(transfer_dir, current_file_path) - - # Update DB record - db.update_retag_track_path(existing_track['id'], str(new_path)) - current_file_path = new_path - else: - logger.warning(f"[Retag] Path unchanged for '{track_title}', no move needed") - except Exception as move_err: - logger.error(f"[Retag] Path/move failed for '{track_title}': {move_err}") - - # Download cover art to album directory - try: - deps.download_cover_art(album_info, os.path.dirname(current_file_path), context) - except Exception as cover_err: - logger.error(f"[Retag] Cover art download failed: {cover_err}") - - with deps.retag_lock: - deps.retag_state['processed'] += 1 - deps.retag_state['progress'] = int(deps.retag_state['processed'] / deps.retag_state['total_tracks'] * 100) - - # 5. Update the retag group record with new metadata - update_kwargs = { - 'artist_name': new_artist.get('name', 'Unknown Artist'), - 'album_name': new_album_name, - 'image_url': new_image_url, - 'total_tracks': total_tracks, - 'release_date': new_release_date - } - # Set the correct ID field based on Spotify vs iTunes - if str(album_id).isdigit(): - update_kwargs['itunes_album_id'] = album_id - update_kwargs['spotify_album_id'] = None - else: - update_kwargs['spotify_album_id'] = album_id - update_kwargs['itunes_album_id'] = None - - db.update_retag_group(group_id, **update_kwargs) - - with deps.retag_lock: - deps.retag_state.update({ - "status": "finished", - "phase": "Retag complete!", - "progress": 100, - "current_track": "" - }) - logger.info(f"[Retag] Retag operation complete for group {group_id}") - - except Exception as e: - import traceback - logger.error(f"[Retag] Error during retag: {e}") - logger.error(traceback.format_exc()) - with deps.retag_lock: - deps.retag_state.update({ - "status": "error", - "phase": "Error", - "error_message": str(e) - }) diff --git a/core/library/retag_planner.py b/core/library/retag_planner.py new file mode 100644 index 00000000..e8457aa3 --- /dev/null +++ b/core/library/retag_planner.py @@ -0,0 +1,218 @@ +"""Pure planning logic for the library re-tag job. + +Given a source album's metadata + tracklist and the library's tracks (with their +*current* file tags), this works out — per track — exactly which tags would +change (the dry-run diff the finding shows) and the ``db_data`` payload to feed +``core.tag_writer.write_tags_to_file`` at apply time. + +No file IO, no network, no DB: the job feeds in current tags + fetched source +data, so all the matching/diff logic stays unit-testable. Tags are only ever +ADDED/overwritten per-field — never a full tag-block wipe. +""" + +from __future__ import annotations + +import re +from difflib import SequenceMatcher +from typing import Any, Dict, List, Optional, Tuple + +# Fields this job manages. Keys are the internal/display names; the diff and the +# write payload are both built from these. +MANAGED_FIELDS = ('title', 'artist', 'album', 'year', 'genre', 'track_number', 'disc_number') + +# Modes: overwrite everything the source provides, or only fill blanks. +MODE_OVERWRITE = 'overwrite' +MODE_FILL_MISSING = 'fill_missing' + + +def _get(obj: Any, *keys: str, default=None): + """First non-empty value across keys, from a dict or an object.""" + for k in keys: + v = obj.get(k) if isinstance(obj, dict) else getattr(obj, k, None) + if v not in (None, ''): + return v + return default + + +def _first_artist(obj: Any) -> str: + arts = _get(obj, 'artists', 'artist', 'artist_name') + if isinstance(arts, list) and arts: + a0 = arts[0] + return ((a0.get('name') if isinstance(a0, dict) else str(a0)) or '').strip() + if isinstance(arts, dict): + return (arts.get('name') or '').strip() + return str(arts).strip() if arts else '' + + +def _genres_list(obj: Any) -> List[str]: + g = _get(obj, 'genres', 'genre') + if isinstance(g, list): + return [str(x).strip() for x in g if str(x).strip()] + if isinstance(g, str) and g.strip(): + return [p.strip() for p in g.split(',') if p.strip()] + return [] + + +def _year(obj: Any) -> str: + v = _get(obj, 'year', 'release_date', 'date') + if not v: + return '' + m = re.search(r'\d{4}', str(v)) + return m.group(0) if m else '' + + +def _int_or_none(v) -> Optional[int]: + try: + return int(v) + except (TypeError, ValueError): + return None + + +def _norm_title(s: Any) -> str: + s = (s or '') + s = s.lower() if isinstance(s, str) else str(s).lower() + s = re.sub(r'[\(\[].*?[\)\]]', ' ', s) + s = re.sub(r'[^a-z0-9]+', ' ', s) + return ' '.join(s.split()) + + +def match_source_tracks( + source_tracks: List[Any], + library_tracks: List[Dict[str, Any]], + title_threshold: float = 0.6, +) -> List[Tuple[Dict[str, Any], Optional[Any]]]: + """Pair each library track to a source track. + + Disc+track number is authoritative; falls back to title similarity. A source + track is consumed once. Returns ``[(library_track, source_track_or_None)]`` + in library order, so unmatched library tracks surface as ``None``. + """ + by_pos: Dict[Tuple[int, int], int] = {} + for i, st in enumerate(source_tracks): + t = _int_or_none(_get(st, 'track_number')) + if t is None: + continue + d = _int_or_none(_get(st, 'disc_number', default=1)) or 1 + by_pos.setdefault((d, t), i) + + used: set = set() + pairs: List[Tuple[Dict[str, Any], Optional[Any]]] = [] + for lt in library_tracks: + t = _int_or_none(lt.get('track_number')) + d = _int_or_none(lt.get('disc_number')) or 1 + idx = by_pos.get((d, t)) if t is not None else None + if idx is not None and idx not in used: + used.add(idx) + pairs.append((lt, source_tracks[idx])) + continue + # Title-similarity fallback over still-unused source tracks. + lt_norm = _norm_title(lt.get('title')) + best_idx, best_score = None, 0.0 + if lt_norm: + for i, st in enumerate(source_tracks): + if i in used: + continue + score = SequenceMatcher(None, lt_norm, _norm_title(_get(st, 'name', 'title', 'track_name'))).ratio() + if score > best_score: + best_score, best_idx = score, i + if best_idx is not None and best_score >= title_threshold: + used.add(best_idx) + pairs.append((lt, source_tracks[best_idx])) + else: + pairs.append((lt, None)) + return pairs + + +def _target_for_track(source_track: Any, album_meta: Dict[str, Any]) -> Dict[str, Any]: + """Normalized target tag values from the source for one track.""" + album_artist = _first_artist(album_meta) + track_artist = _first_artist(source_track) or album_artist + return { + 'title': (_get(source_track, 'name', 'title', 'track_name') or '').strip(), + 'artist': album_artist, # album-level artist + 'track_artist': track_artist, # per-track (may equal album artist) + 'album': (_get(album_meta, 'name', 'title', 'album_name') or '').strip(), + 'year': _year(album_meta), + 'genre': _genres_list(album_meta), # list + 'track_number': _int_or_none(_get(source_track, 'track_number')), + 'disc_number': _int_or_none(_get(source_track, 'disc_number', default=1)) or 1, + 'track_count': _int_or_none(_get(album_meta, 'total_tracks', 'track_count')), + } + + +def _current_value(current_tags: Dict[str, Any], field: str): + if field == 'artist': + # _read_tags stores album_artist + artist; prefer album_artist for the album-level compare. + return current_tags.get('album_artist') or current_tags.get('artist') or '' + if field == 'genre': + return current_tags.get('genre') or '' + return current_tags.get(field) + + +def _display(value) -> str: + if isinstance(value, list): + return ', '.join(str(v) for v in value) + return '' if value is None else str(value) + + +def _is_empty(value) -> bool: + if value is None: + return True + if isinstance(value, str): + return value.strip() == '' + if isinstance(value, list): + return len(value) == 0 + return False + + +def plan_track(current_tags: Dict[str, Any], source_track: Any, album_meta: Dict[str, Any], + mode: str = MODE_OVERWRITE) -> Dict[str, Any]: + """Diff one library track's current tags against the source target. + + Returns ``{changes, db_data}`` where ``changes`` is ``{field: {old, new}}`` + for display, and ``db_data`` is the (minimal) payload for + ``write_tags_to_file`` — it contains ONLY the fields that should be written + under ``mode``, so applying never touches unrelated/unchanged tags. + """ + target = _target_for_track(source_track, album_meta) + changes: Dict[str, Dict[str, str]] = {} + db_data: Dict[str, Any] = {} + + for field in MANAGED_FIELDS: + new_val = target.get(field) + if _is_empty(new_val): + continue # source gave us nothing for this field — leave the file alone + old_val = _current_value(current_tags, field) + + if mode == MODE_FILL_MISSING and not _is_empty(old_val): + continue # fill-missing only writes blanks + + old_disp, new_disp = _display(old_val), _display(new_val) + if old_disp == new_disp: + continue # already correct — nothing to write + + changes[field] = {'old': old_disp, 'new': new_disp} + # Map managed field → write_tags_to_file db_data key. + if field == 'title': + db_data['title'] = new_val + elif field == 'artist': + db_data['artist_name'] = new_val # = album artist for the writer + ta = target.get('track_artist') + if ta and ta != new_val: + db_data['track_artist'] = ta + elif field == 'album': + db_data['album_title'] = new_val + elif field == 'year': + db_data['year'] = new_val + elif field == 'genre': + db_data['genres'] = new_val # list + elif field == 'track_number': + db_data['track_number'] = new_val + elif field == 'disc_number': + db_data['disc_number'] = new_val + + # Always carry track_count alongside a track_number write (writers want both). + if 'track_number' in db_data and target.get('track_count'): + db_data['track_count'] = target['track_count'] + + return {'changes': changes, 'db_data': db_data} diff --git a/core/maintenance/__init__.py b/core/maintenance/__init__.py new file mode 100644 index 00000000..e782806e --- /dev/null +++ b/core/maintenance/__init__.py @@ -0,0 +1 @@ +"""Maintenance / one-off data-repair helpers.""" diff --git a/core/maintenance/dedupe_source_ids.py b/core/maintenance/dedupe_source_ids.py new file mode 100644 index 00000000..5e21e1fe --- /dev/null +++ b/core/maintenance/dedupe_source_ids.py @@ -0,0 +1,138 @@ +"""Find and clear corrupted source-id assignments on the ``artists`` table. + +Background +---------- +The metadata enrichment workers (Deezer / AudioDB / Qobuz / Tidal) historically +"corrected" an artist's source id from an album/track match **without a name +check**. A track our library credits to one artist but which lives on another +artist's curated/compilation album (e.g. anyone featured on Kendrick Lamar's +"Black Panther" album) resolved to that album, whose primary artist is someone +else — and the worker stamped that wrong id onto our artist. The upshot: one +source id (Kendrick's Deezer ``525046``) ends up shared across several unrelated +artists. + +That bug is now fixed in the workers (they name-check before correcting). This +module is the one-off repair for libraries that already got corrupted. + +What counts as corruption +------------------------- +A *corrupt cluster* is one source id held by artists with **different names**. +Legitimate duplicates — the SAME artist indexed on two media servers, sharing +one id — have identical names and are left untouched. + +The repair +---------- +For every corrupt cluster, clear the source id AND its match-status column on +each member artist, so the (now name-checked) worker re-derives each artist's id +correctly on the next enrichment pass. Only the ``artists`` table is touched; +album/track rows keep their match status, so the album/track correction path +isn't re-run during re-enrichment. + +``clear_corrupt_source_ids`` defaults to ``dry_run=True`` — it reports exactly +what it would change and writes nothing unless explicitly told to apply. +""" + +from __future__ import annotations + +import logging +from typing import Any + +logger = logging.getLogger(__name__) + +# source -> (id column, match-status column) on the ``artists`` table. +SOURCE_COLUMNS = { + 'deezer': ('deezer_id', 'deezer_match_status'), + 'spotify': ('spotify_artist_id', 'spotify_match_status'), + 'itunes': ('itunes_artist_id', 'itunes_match_status'), + 'musicbrainz': ('musicbrainz_id', 'musicbrainz_match_status'), + 'discogs': ('discogs_id', 'discogs_match_status'), + 'audiodb': ('audiodb_id', 'audiodb_match_status'), + 'qobuz': ('qobuz_id', 'qobuz_match_status'), + 'tidal': ('tidal_id', 'tidal_match_status'), +} + + +def _norm(name: str) -> str: + """Loose name key — lowercased, whitespace-collapsed.""" + return ' '.join((name or '').lower().split()) + + +def _artists_columns(conn) -> set: + return {r[1] for r in conn.execute("PRAGMA table_info(artists)")} + + +def find_corrupt_clusters(database: Any) -> list[dict]: + """Return corrupt source-id clusters across every known source column. + + Each cluster is a dict: ``{source, id_column, status_column, source_id, + members: [(artist_id, name), ...]}``. A cluster is corrupt when one id is + held by artists with more than one distinct (normalized) name. + """ + clusters: list[dict] = [] + with database._get_connection() as conn: + existing = _artists_columns(conn) + for source, (id_col, status_col) in SOURCE_COLUMNS.items(): + if id_col not in existing: + continue + rows = conn.execute( + f"SELECT {id_col}, id, name FROM artists " + f"WHERE {id_col} IS NOT NULL AND {id_col} != ''" + ).fetchall() + by_id: dict = {} + for sid, aid, name in rows: + by_id.setdefault(str(sid), []).append((aid, name)) + for sid, members in by_id.items(): + if len(members) > 1 and len({_norm(n) for _, n in members}) > 1: + clusters.append({ + 'source': source, + 'id_column': id_col, + 'status_column': status_col, + 'source_id': sid, + 'members': members, + }) + return clusters + + +def clear_corrupt_source_ids(database: Any, dry_run: bool = True) -> dict: + """Clear source id + match status on every artist in a corrupt cluster. + + ``dry_run=True`` (default) writes nothing — the returned report shows + exactly what would change so the operator can review first. Pass + ``dry_run=False`` to apply. + """ + clusters = find_corrupt_clusters(database) + report = { + 'dry_run': dry_run, + 'cluster_count': len(clusters), + 'artist_count': sum(len(c['members']) for c in clusters), + 'by_source': {}, + 'clusters': [], + } + for c in clusters: + report['by_source'][c['source']] = ( + report['by_source'].get(c['source'], 0) + len(c['members']) + ) + report['clusters'].append({ + 'source': c['source'], + 'source_id': c['source_id'], + 'artists': sorted(n for _, n in c['members']), + }) + + if not dry_run and clusters: + with database._get_connection() as conn: + for c in clusters: + ids = [aid for aid, _ in c['members']] + placeholders = ','.join('?' for _ in ids) + conn.execute( + f"UPDATE artists SET {c['id_column']} = NULL, " + f"{c['status_column']} = NULL WHERE id IN ({placeholders})", + ids, + ) + conn.commit() + logger.info( + f"Cleared {report['artist_count']} corrupt source ids across " + f"{report['cluster_count']} clusters — re-run enrichment to " + f"re-derive them correctly" + ) + + return report diff --git a/core/matching/script_compat.py b/core/matching/script_compat.py new file mode 100644 index 00000000..88b1cb96 --- /dev/null +++ b/core/matching/script_compat.py @@ -0,0 +1,96 @@ +"""Writing-system (script) compatibility helpers for metadata comparison. + +Issue #797 — AcoustID returns a recording's title/artist in their +*original* script (e.g. ``久石譲`` for Joe Hisaishi) while SoulSync's +expected metadata is romanized / English (``Joe Hisaishi``). A raw +string-similarity comparison between two different writing systems +scores ~0 even when they name the very same artist, so correct +downloads of non-English artists get false-quarantined. + +These pure helpers let callers DETECT that situation — "one side is +written in a non-Latin script, the other in Latin" — so the comparison +logic can stop treating an untranslatable title/artist as evidence the +file is wrong. + +Deliberately conservative: a single accented Latin character (``é``, +``ñ``, ``ü``) is still Latin, NOT a script mismatch. Only genuinely +different writing systems (CJK, Hangul, Cyrillic, Greek, Arabic, +Hebrew, Thai, …) count as "non-Latin". +""" + +from __future__ import annotations + +# Unicode ranges for non-Latin writing systems we treat as a "hard" +# script difference. Latin (incl. Latin-1 Supplement / Extended with +# diacritics) is intentionally absent — accented Latin is still Latin. +# CJK ranges mirror core.matching_engine's issue #722 detection so the +# two stay consistent. +_NONLATIN_RANGES = ( + ('Ͱ', 'Ͽ'), # Greek and Coptic + ('Ѐ', 'ӿ'), # Cyrillic + ('Ԁ', 'ԯ'), # Cyrillic Supplement + ('֐', '׿'), # Hebrew + ('؀', 'ۿ'), # Arabic + ('ݐ', 'ݿ'), # Arabic Supplement + ('฀', '๿'), # Thai + ('⺀', '⻿'), # CJK Radicals Supplement + ('぀', 'ゟ'), # Hiragana + ('゠', 'ヿ'), # Katakana + ('㐀', '䶿'), # CJK Unified Ideographs Extension A + ('一', '鿿'), # CJK Unified Ideographs + ('가', '힯'), # Hangul Syllables + ('豈', '﫿'), # CJK Compatibility Ideographs + ('ヲ', 'ᅵ'), # Halfwidth Katakana / Hangul +) + + +def _is_nonlatin_char(c: str) -> bool: + """True when ``c`` belongs to a non-Latin writing system.""" + for lo, hi in _NONLATIN_RANGES: + if lo <= c <= hi: + return True + return False + + +def has_strong_nonlatin(text: str) -> bool: + """True when ``text`` contains at least one non-Latin-script letter. + + Accented Latin (``Beyoncé``, ``Sigur Rós``, ``Mötley Crüe``) returns + False — those are Latin. ``久石譲``, ``Дмитрий``, ``방탄소년단`` return True. + """ + if not text: + return False + return any(_is_nonlatin_char(c) for c in text) + + +def _has_latin_letter(text: str) -> bool: + """True when ``text`` contains an ASCII A–Z / a–z letter.""" + if not text: + return False + return any(('a' <= c <= 'z') or ('A' <= c <= 'Z') for c in text) + + +def is_cross_script_mismatch(a: str, b: str) -> bool: + """True when ``a`` and ``b`` are written in different scripts. + + Specifically: exactly one side uses a non-Latin writing system while + the other is genuine Latin text. This is the signal that a raw + similarity score between the two is meaningless (a romanized name vs + its native-script form), NOT that they name different things. + + Symmetric. Returns False when: + - both sides are Latin (ordinary English-vs-English comparison), + - both sides are non-Latin (same-script comparison still works), + - either side is empty / has no comparable letters. + """ + a_nonlatin = has_strong_nonlatin(a) + b_nonlatin = has_strong_nonlatin(b) + if a_nonlatin == b_nonlatin: + # Same script class on both sides (or neither has non-Latin) — + # similarity comparison is meaningful, no script bridge needed. + return False + # Exactly one side is non-Latin. It's only a true cross-script case + # if the OTHER side is real Latin text (not punctuation / digits). + if a_nonlatin: + return _has_latin_letter(b) + return _has_latin_letter(a) diff --git a/core/metadata/canonical_version.py b/core/metadata/canonical_version.py index ccc07451..9871d89e 100644 --- a/core/metadata/canonical_version.py +++ b/core/metadata/canonical_version.py @@ -207,4 +207,24 @@ def pick_canonical_release( return best, best_score -__all__ = ["score_release_against_files", "pick_canonical_release"] +# Album sources the canonical system reads (mirror of +# core.library_reorganize._ALBUM_ID_COLUMNS — a test pins them in sync). A manual +# match on any of these should pin/lock the canonical version (#758); a match on +# a source the canonical tools don't read (e.g. lastfm) has no version to pin. +CANONICAL_ALBUM_SOURCES = frozenset({'spotify', 'itunes', 'deezer', 'discogs', 'hydrabase'}) + + +def should_pin_manual_canonical(entity_type: str, source: str) -> bool: + """Whether a manual match should also pin (and lock) the canonical album + version (#758). True only for an ALBUM match on a canonical-recognised + source — so the user's chosen edition becomes the authority every downstream + tool (track-number repair, reorganize, missing-tracks) reads, and the auto + resolve job won't override it. + """ + return entity_type == 'album' and source in CANONICAL_ALBUM_SOURCES + + +__all__ = [ + "score_release_against_files", "pick_canonical_release", + "CANONICAL_ALBUM_SOURCES", "should_pin_manual_canonical", +] diff --git a/core/metadata/source.py b/core/metadata/source.py index fcd69569..2be1444c 100644 --- a/core/metadata/source.py +++ b/core/metadata/source.py @@ -1122,6 +1122,60 @@ def extract_source_metadata(context: dict, artist: dict, album_info: dict) -> di return metadata +def embed_known_source_ids(audio_file, metadata: dict) -> list: + """Embed ALREADY-KNOWN source IDs into a file's tags, no API re-fetch. + + For the library re-tag / "Write Tags" paths: ``metadata`` carries flat id + keys already stored in the DB (``spotify_track_id``, ``spotify_album_id``, + ``itunes_track_id``, ``itunes_album_id``, ``musicbrainz_recording_id``, + ``musicbrainz_release_id``, …). Reuses the SAME canonical, format-specific, + Picard-compatible frame writer as the import post-process + (``_write_embedded_metadata``) so the frames never diverge — it just skips + the heavy multi-source re-search that ``embed_source_ids`` does. + + The caller is responsible for saving the file. Returns the canonical tag + names written (for logging/diagnostics). + """ + cfg = get_config_manager() + symbols = get_mutagen_symbols() + if not symbols or audio_file is None: + return [] + try: + id_tags = _collect_source_ids(metadata, cfg) + # MusicBrainz ids aren't in _collect_source_ids (they come from the MB + # processor at import time); add them from the DB when present, using + # the canonical names the frame map already knows. + if cfg.get("musicbrainz.embed_tags", True) is not False: + if metadata.get("musicbrainz_recording_id"): + id_tags["MUSICBRAINZ_RECORDING_ID"] = metadata["musicbrainz_recording_id"] + if metadata.get("musicbrainz_release_id"): + id_tags["MUSICBRAINZ_RELEASE_ID"] = metadata["musicbrainz_release_id"] + if not id_tags: + return [] + pp = _blank_post_process_state() + pp["id_tags"] = id_tags + _write_embedded_metadata(audio_file, metadata, pp, cfg, symbols) + return list(id_tags.keys()) + except Exception as exc: + logger.debug("embed_known_source_ids failed: %s", exc) + return [] + + +def _blank_post_process_state() -> dict: + """A fully-defaulted post-process state so _write_embedded_metadata can run + with only id_tags set (every enrichment branch no-ops on the blanks).""" + return { + "id_tags": {}, "track_title": "", "artist_name": "", "batch_artist_name": None, + "metadata": {}, "recording_mbid": None, "artist_mbid": None, "release_mbid": "", + "mb_genres": [], "isrc": None, "deezer_bpm": None, "deezer_isrc": None, + "tidal_bpm": None, "hifi_bpm": None, "hifi_copyright": None, "audiodb_mood": None, + "audiodb_style": None, "audiodb_genre": None, "tidal_isrc": None, + "tidal_copyright": None, "hifi_isrc": None, "qobuz_isrc": None, + "qobuz_copyright": None, "qobuz_label": None, "lastfm_tags": [], + "lastfm_url": None, "genius_url": None, "release_year": None, + } + + def embed_source_ids(audio_file, metadata: dict, context: dict = None, runtime=None): cfg = get_config_manager() symbols = get_mutagen_symbols() diff --git a/core/musicbrainz_worker.py b/core/musicbrainz_worker.py index 4f3adf55..8f7c1241 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 +from core.worker_utils import interruptible_sleep, source_id_conflict logger = get_logger("musicbrainz_worker") @@ -367,8 +367,17 @@ class MusicBrainzWorker: if item_type == 'artist': result = self.mb_service.match_artist(item_name) - if result and result.get('mbid'): - self.mb_service.update_artist_mbid(item_id, result['mbid'], 'matched') + 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 + # assigning an mbid a differently-named artist already holds, so + # one mbid can't be smeared across unrelated artists. + conflict = ( + source_id_conflict(self.db, 'musicbrainz_id', mbid, item_id, item_name) + if mbid else None + ) + if mbid and not conflict: + self.mb_service.update_artist_mbid(item_id, mbid, 'matched') # Issue #442 — pull alternate-spelling aliases (Japanese # kanji, Cyrillic, etc.) so the verifier can recognise # cross-script artist names without re-querying MB on @@ -377,7 +386,7 @@ class MusicBrainzWorker: # empty list) so a transient MB outage never regresses # the enrichment outcome. try: - aliases = self.mb_service.fetch_artist_aliases(result['mbid']) + aliases = self.mb_service.fetch_artist_aliases(mbid) if aliases: self.mb_service.update_artist_aliases(item_id, aliases) logger.debug( @@ -388,11 +397,17 @@ class MusicBrainzWorker: "Alias enrichment failed for artist '%s': %s", item_name, alias_err, ) self.stats['matched'] += 1 - logger.info(f"Matched artist '{item_name}' → MBID: {result['mbid']}") + logger.info(f"Matched artist '{item_name}' → MBID: {mbid}") else: self.mb_service.update_artist_mbid(item_id, None, 'not_found') self.stats['not_found'] += 1 - logger.debug(f"No match for artist '{item_name}'") + if conflict: + logger.debug( + f"Artist '{item_name}' → MBID {mbid} skipped: " + f"already claimed by '{conflict}'" + ) + else: + logger.debug(f"No match for artist '{item_name}'") elif item_type == 'album': artist_name = item.get('artist') diff --git a/core/navidrome_client.py b/core/navidrome_client.py index 765153bc..0374c4b7 100644 --- a/core/navidrome_client.py +++ b/core/navidrome_client.py @@ -192,6 +192,14 @@ class NavidromeClient(MediaServerClient): """Get available music folders from Navidrome.""" if not self.ensure_connection(): return [] + return self._fetch_music_folders() + + def _fetch_music_folders(self) -> list: + """Fetch + parse the Navidrome music folders. Assumes the connection is + already established (base_url/username set) and does NOT call + ensure_connection — so it is safe to call from inside _setup_client's + restore step without re-entering the connection guard (which would + otherwise bail with _is_connecting=True and return an empty list).""" try: response = self._make_request('getMusicFolders') if not response: @@ -216,7 +224,10 @@ class NavidromeClient(MediaServerClient): logger.info(f"Set music folder to: {folder_name} (ID: {self.music_folder_id})") from database.music_database import MusicDatabase db = MusicDatabase() + # Persist the id (stable across renames) as the primary key, + # keep the name for display + back-compat fallback. db.set_preference('navidrome_music_folder', folder_name) + db.set_preference('navidrome_music_folder_id', folder['key']) return True # If folder_name is empty, clear the selection if not folder_name: @@ -225,6 +236,7 @@ class NavidromeClient(MediaServerClient): from database.music_database import MusicDatabase db = MusicDatabase() db.set_preference('navidrome_music_folder', '') + db.set_preference('navidrome_music_folder_id', '') logger.info("Cleared music folder selection — will use all libraries") return True logger.warning(f"Music folder '{folder_name}' not found") @@ -276,14 +288,35 @@ class NavidromeClient(MediaServerClient): try: from database.music_database import MusicDatabase db = MusicDatabase() + saved_id = db.get_preference('navidrome_music_folder_id') saved_folder = db.get_preference('navidrome_music_folder') - if saved_folder: - folders = self.get_music_folders() - for folder in folders: - if folder['title'] == saved_folder: - self.music_folder_id = folder['key'] - logger.info(f"Restored music folder preference: {saved_folder} (ID: {self.music_folder_id})") - break + if saved_id or saved_folder: + # Use the non-reentrant fetch: we're still inside + # ensure_connection() here (_is_connecting=True), so the + # public get_music_folders() would re-enter the guard and + # return [], silently dropping the saved selection. + folders = self._fetch_music_folders() + # Match by id first (stable across renames in Navidrome); + # fall back to name for installs saved before the id was + # persisted. + matched = None + if saved_id: + matched = next((f for f in folders if f['key'] == str(saved_id)), None) + if matched is None and saved_folder: + matched = next((f for f in folders if f['title'] == saved_folder), None) + if matched is not None: + self.music_folder_id = matched['key'] + logger.info(f"Restored music folder preference: {matched['title']} (ID: {self.music_folder_id})") + # Self-heal drifted prefs: a pre-id install (no saved + # id) or a folder renamed in Navidrome (stale name). + # id stays the durable key; name is kept fresh so the + # settings dropdown highlights the right option. + if str(saved_id or '') != matched['key'] or (saved_folder or '') != matched['title']: + try: + db.set_preference('navidrome_music_folder_id', matched['key']) + db.set_preference('navidrome_music_folder', matched['title']) + except Exception as heal_err: + logger.debug(f"Could not self-heal music folder prefs: {heal_err}") except Exception as e: logger.warning(f"Could not restore music folder preference: {e}") else: @@ -1091,6 +1124,60 @@ class NavidromeClient(MediaServerClient): logger.error(f"Error appending to Navidrome playlist '{playlist_name}': {e}") return False + def reconcile_playlist(self, playlist_name: str, tracks) -> bool: + """In-place reconcile (#792): add missing + remove gone via Subsonic + updatePlaylist (songIdToAdd / songIndexToRemove), keeping the existing + playlist object so its comment/identity survive — no delete/recreate. + Creates the playlist if missing. Returns False so the caller can fall + back to replace on any failure.""" + if not self.ensure_connection(): + return False + try: + from core.sync.playlist_edit import plan_playlist_reconcile + existing_playlists = self.get_playlists_by_name(playlist_name) + if not existing_playlists: + logger.info(f"Navidrome reconcile: '{playlist_name}' doesn't exist — creating") + return self.create_playlist(playlist_name, tracks) + + primary = existing_playlists[0] + existing_tracks = self.get_playlist_tracks(primary.id) + current_ids = [str(t.id) for t in existing_tracks if getattr(t, 'id', None)] + desired_ids = [] + for t in tracks: + tid = (str(t.ratingKey) if hasattr(t, 'ratingKey') + else str(t.id) if getattr(t, 'id', None) + else str(t.get('id', '')) if isinstance(t, dict) else '') + if tid: + desired_ids.append(tid) + + plan = plan_playlist_reconcile(current_ids, desired_ids) + if not plan['add'] and not plan['remove']: + return True + + params = {'playlistId': primary.id} + if plan['add']: + params['songIdToAdd'] = plan['add'] + if plan['remove']: + # Indices into the CURRENT list; remove descending so earlier + # removals don't shift the indices of later ones. + remove_set = set(plan['remove']) + params['songIndexToRemove'] = sorted( + (i for i, cid in enumerate(current_ids) if cid in remove_set), + reverse=True, + ) + response = self._make_request('updatePlaylist', params) + if response and response.get('status') == 'ok': + logger.info( + f"Navidrome reconcile '{playlist_name}': +{len(plan['add'])} / " + f"-{len(plan['remove'])} (playlist preserved)" + ) + return True + logger.error(f"Navidrome reconcile failed for '{playlist_name}'") + return False + except Exception as e: + logger.error(f"Error reconciling Navidrome playlist '{playlist_name}': {e}") + return False + def update_playlist(self, playlist_name: str, tracks) -> bool: """Update an existing playlist or create it if it doesn't exist. Handles duplicates.""" if not self.ensure_connection(): diff --git a/core/plex_client.py b/core/plex_client.py index 9dcc0dda..29310ab9 100644 --- a/core/plex_client.py +++ b/core/plex_client.py @@ -667,6 +667,53 @@ class PlexClient(MediaServerClient): logger.error(f"Error appending to Plex playlist '{playlist_name}': {e}") return False + def reconcile_playlist(self, playlist_name: str, tracks: List[TrackInfo]) -> bool: + """In-place reconcile (#792): bring an existing playlist's tracks to match + ``tracks`` by adding the missing ones and removing the gone ones, WITHOUT + deleting/recreating the playlist — so its custom poster, summary, and + ratingKey survive the sync. Creates the playlist if it doesn't exist. + + Returns False on any failure so the caller can fall back to replace.""" + if not self.ensure_connection(): + return False + try: + from core.sync.playlist_edit import plan_playlist_reconcile + try: + existing = self.server.playlist(playlist_name) + except NotFound: + logger.info(f"Plex reconcile: '{playlist_name}' doesn't exist — creating") + return self.create_playlist(playlist_name, tracks) + + current_items = [i for i in existing.items() if hasattr(i, 'ratingKey')] + current_ids = [str(i.ratingKey) for i in current_items] + desired_ids = [str(t.ratingKey) for t in tracks if hasattr(t, 'ratingKey')] + plan = plan_playlist_reconcile(current_ids, desired_ids) + + add_set, remove_set = set(plan['add']), set(plan['remove']) + to_add = [t for t in tracks if hasattr(t, 'ratingKey') and str(t.ratingKey) in add_set] + to_remove = [i for i in current_items if str(i.ratingKey) in remove_set] + + if to_add: + existing.addItems(to_add) + if to_remove: + try: + existing.removeItems(to_remove) + except (AttributeError, TypeError): + # Older plexapi: no batch removeItems — drop one at a time. + for it in to_remove: + try: + existing.removeItem(it) + except Exception as one_err: + logger.debug("Plex reconcile: removeItem failed: %s", one_err) + logger.info( + f"Plex reconcile '{playlist_name}': +{len(to_add)} / -{len(to_remove)} " + f"(playlist preserved)" + ) + return True + except Exception as e: + logger.error(f"Error reconciling Plex playlist '{playlist_name}': {e}") + return False + def update_playlist(self, playlist_name: str, tracks: List[TrackInfo]) -> bool: if not self.ensure_connection(): return False diff --git a/core/qobuz_worker.py b/core/qobuz_worker.py index 706d6295..8051178c 100644 --- a/core/qobuz_worker.py +++ b/core/qobuz_worker.py @@ -8,7 +8,7 @@ from datetime import datetime, timedelta from utils.logging_config import get_logger from database.music_database import MusicDatabase from core.qobuz_client import _qobuz_is_rate_limited -from core.worker_utils import interruptible_sleep +from core.worker_utils import accept_artist_match, interruptible_sleep from core.enrichment.manual_match_honoring import honor_stored_match logger = get_logger("qobuz_worker") @@ -301,13 +301,29 @@ class QobuzWorker: logger.debug(f"Name similarity: '{query_name}' vs '{result_name}' = {similarity:.2f}") return similarity >= self.name_similarity_threshold - def _verify_artist_id(self, item: Dict[str, Any], result_artist_id) -> bool: - """Verify/correct parent artist's Qobuz ID based on album/track match""" + def _verify_artist_id(self, item: Dict[str, Any], result_artist_id, + result_artist_name: Optional[str] = None) -> bool: + """Verify/correct parent artist's Qobuz ID based on album/track match. + + Only corrects when the result's artist *name* matches our parent artist — + otherwise a collaboration/compilation would stamp the wrong Qobuz id onto + our artist. See the Deezer fix for the full write-up.""" parent_qobuz_id = item.get('artist_qobuz_id') if not parent_qobuz_id or not result_artist_id: return True if str(result_artist_id) != str(parent_qobuz_id): + parent_name = item.get('artist') or '' + if (result_artist_name and parent_name + and not self._name_matches(parent_name, result_artist_name)): + logger.info( + f"Skipping artist-ID correction from {item['type']} " + f"'{item['name']}': result artist '{result_artist_name}' " + f"≠ parent '{parent_name}' (collab/compilation, not a " + f"correction)" + ) + return True + logger.info( f"Artist ID correction from {item['type']} '{item['name']}': " f"updating parent artist Qobuz ID from {parent_qobuz_id} to {result_artist_id}" @@ -407,14 +423,19 @@ class QobuzWorker: if result: result_name = result.get('name', '') - if self._name_matches(artist_name, result_name): - qobuz_artist_id = result.get('id') - if not qobuz_artist_id: - self._mark_status('artist', artist_id, 'error') - self.stats['errors'] += 1 - logger.warning(f"Qobuz search result for '{artist_name}' has no ID") - return - + qobuz_artist_id = result.get('id') + ok, reason = accept_artist_match( + self.db, 'qobuz_id', qobuz_artist_id, artist_id, artist_name, result_name, + ) + if not ok: + self._mark_status('artist', artist_id, 'not_found') + self.stats['not_found'] += 1 + logger.debug(f"Artist '{artist_name}' not matched: {reason}") + elif not qobuz_artist_id: + self._mark_status('artist', artist_id, 'error') + self.stats['errors'] += 1 + logger.warning(f"Qobuz search result for '{artist_name}' has no ID") + else: # Fetch full artist details full_artist = None try: @@ -425,10 +446,6 @@ class QobuzWorker: self._update_artist(artist_id, result, full_artist) self.stats['matched'] += 1 logger.info(f"Matched artist '{artist_name}' -> Qobuz ID: {qobuz_artist_id}") - else: - self._mark_status('artist', artist_id, 'not_found') - self.stats['not_found'] += 1 - logger.debug(f"Name mismatch for artist '{artist_name}' (got '{result_name}')") else: if _qobuz_is_rate_limited(): logger.warning(f"Rate limited while searching artist '{artist_name}', will retry") @@ -467,7 +484,8 @@ class QobuzWorker: # Verify artist ID result_artist = result.get('artist', {}) result_artist_id = result_artist.get('id') if result_artist else None - self._verify_artist_id(item, result_artist_id) + result_artist_name = result_artist.get('name') if result_artist else None + self._verify_artist_id(item, result_artist_id, result_artist_name) # Fetch full album details qobuz_album_id = result.get('id') @@ -528,7 +546,8 @@ class QobuzWorker: # Verify artist ID result_artist = result.get('artist', result.get('performer', {})) result_artist_id = result_artist.get('id') if result_artist else None - self._verify_artist_id(item, result_artist_id) + result_artist_name = result_artist.get('name') if result_artist else None + self._verify_artist_id(item, result_artist_id, result_artist_name) # Fetch full track details qobuz_track_id = result.get('id') diff --git a/core/repair_jobs/__init__.py b/core/repair_jobs/__init__.py index 40fd7a04..57996608 100644 --- a/core/repair_jobs/__init__.py +++ b/core/repair_jobs/__init__.py @@ -45,6 +45,7 @@ _JOB_MODULES = [ 'core.repair_jobs.unknown_artist_fixer', 'core.repair_jobs.discography_backfill', 'core.repair_jobs.canonical_version_resolve', + 'core.repair_jobs.library_retag', ] diff --git a/core/repair_jobs/library_retag.py b/core/repair_jobs/library_retag.py new file mode 100644 index 00000000..ad1325e4 --- /dev/null +++ b/core/repair_jobs/library_retag.py @@ -0,0 +1,477 @@ +"""Library Re-tag Job — rewrite audio tags from a fresh metadata-source pull. + +Re-does ONLY the tagging part of post-processing (tags + cover art), IN PLACE: +no file moves, no renames, no re-matching, no library reorganization. Only +albums that are matched to a metadata source are eligible — the album's stored +source id is used to pull fresh data to write. + +Dry-run by design: scan creates detailed, per-track findings (old -> new for +every field that would change); nothing touches a file until you apply a +finding. The apply handler lives in repair_worker (_fix_library_retag). +""" + +import os + +from core.library.retag_planner import ( + MODE_FILL_MISSING, + MODE_OVERWRITE, + match_source_tracks, + plan_track, +) +from core.metadata.album_tracks import get_album_for_source, get_album_tracks_for_source +from core.metadata_service import get_primary_source, get_source_priority +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_job.library_retag") + +# (source, albums-table column) in resolution-preference order is decided at +# runtime from the configured source priority; this maps source -> column. +_ALBUM_SOURCE_COLUMNS = { + 'spotify': 'spotify_album_id', + 'itunes': 'itunes_album_id', + 'deezer': 'deezer_id', + 'musicbrainz': 'musicbrainz_release_id', +} + + +def _read_current_tags(file_path): + try: + from core.soulsync_client import _read_tags + return _read_tags(file_path) or {} + except Exception as exc: + logger.debug("read tags failed for %s: %s", file_path, exc) + return {} + + +def _run_full_enrich(file_path, full_meta) -> bool: + """'full' depth: run the same multi-source enrichment a fresh download gets + (MusicBrainz/Deezer/AudioDB/Tidal/… via embed_source_ids), ADDITIVELY — it + adds rich frames without clearing existing tags. Slow + API-heavy per track. + """ + if not full_meta: + return False + try: + from core.metadata.common import get_mutagen_symbols + from core.metadata.source import embed_source_ids + symbols = get_mutagen_symbols() + if not symbols: + return False + audio = symbols.File(file_path) + if audio is None: + return False + if getattr(audio, 'tags', None) is None and hasattr(audio, 'add_tags'): + audio.add_tags() + embed_source_ids(audio, full_meta, context=None, runtime=None) + audio.save() + return True + except Exception as e: + logger.warning("full enrich failed for %s: %s", file_path, e) + return False + + +def apply_track_plans(track_plans, cover_action=None, cover_url=None, full=False) -> dict: + """Write each plan's tags in place (+ optionally embed/refresh cover art), + reusing tag_writer.write_tags_to_file. ``file_path`` on each plan must be a + real, reachable path (caller resolves Docker paths). Shared by the dry-run= + False auto-apply and the repair_worker fix handler. Never raises. + """ + import os as _os + result = {'written': 0, 'failed': 0, 'skipped': 0, 'cover_written': False} + embed_cover = bool(cover_action and cover_url) + cover_data = None + if embed_cover: + try: + from core.tag_writer import download_cover_art + cover_data = download_cover_art(cover_url) + except Exception as e: + logger.debug("retag cover download failed: %s", e) + embed_cover = embed_cover and cover_data is not None + + from core.tag_writer import write_tags_to_file + last_dir = None + for tp in track_plans or []: + fp = tp.get('file_path') + db_data = tp.get('db_data') or {} + if not fp or not _os.path.isfile(fp) or (not db_data and not embed_cover): + result['skipped'] += 1 + continue + try: + res = write_tags_to_file(fp, db_data, embed_cover=embed_cover, cover_data=cover_data) + if res.get('success'): + result['written'] += 1 + last_dir = _os.path.dirname(fp) + if full and tp.get('full_meta'): + _run_full_enrich(fp, tp['full_meta']) + else: + result['failed'] += 1 + except Exception as e: + logger.warning("retag write failed for %s: %s", fp, e) + result['failed'] += 1 + + if cover_action and cover_data and last_dir: + try: + cover_path = _os.path.join(last_dir, 'cover.jpg') + if cover_action == 'replace' or not _os.path.exists(cover_path): + with open(cover_path, 'wb') as fh: + fh.write(cover_data[0]) + result['cover_written'] = True + except Exception as e: + logger.debug("retag cover.jpg write failed: %s", e) + return result + + +def _add_source_ids(db_data, source, album_source_id, source_track): + """Stamp the album/track source IDs onto the write payload so the canonical + writer embeds them too (Spotify / iTunes / MusicBrainz).""" + album_key = {'spotify': 'spotify_album_id', 'itunes': 'itunes_album_id', + 'musicbrainz': 'musicbrainz_release_id'}.get(source) + track_key = {'spotify': 'spotify_track_id', 'itunes': 'itunes_track_id', + 'musicbrainz': 'musicbrainz_recording_id'}.get(source) + if album_key and album_source_id: + db_data[album_key] = album_source_id + if track_key: + tid = None + for k in ('id', 'track_id', 'source_track_id'): + v = source_track.get(k) if isinstance(source_track, dict) else getattr(source_track, k, None) + if v: + tid = v + break + if tid: + db_data[track_key] = tid + + +_FULL_META_ID_KEYS = ( + 'spotify_album_id', 'spotify_track_id', + 'itunes_album_id', 'itunes_track_id', + 'musicbrainz_release_id', 'musicbrainz_recording_id', + 'deezer_id', +) + + +def _build_full_meta(db_data, src, album_title, artist_name, lib_title): + """Metadata dict for the 'full' depth enrichment cascade. Carries the matched + source's ids so embed_source_ids resolves the right entity instead of guessing + by name.""" + src_title = None + for k in ('name', 'title', 'track_name'): + v = src.get(k) if isinstance(src, dict) else getattr(src, k, None) + if v: + src_title = v + break + meta = { + 'title': src_title or lib_title, + 'album': album_title, + 'album_artist': artist_name, + 'artist': artist_name, + } + meta.update({k: db_data[k] for k in _FULL_META_ID_KEYS if db_data.get(k)}) + return meta + + +def _track_list(result): + """Normalize a get_album_tracks result into a plain list of track items.""" + if result is None: + return [] + if isinstance(result, list): + return result + if isinstance(result, dict): + for key in ('tracks', 'items', 'data'): + v = result.get(key) + if isinstance(v, list): + return v + v = getattr(result, 'tracks', None) + return v if isinstance(v, list) else [] + + +@register_job +class LibraryRetagJob(RepairJob): + job_id = 'library_retag' + display_name = 'Library Re-tag' + description = 'Rewrites tags + cover art from a fresh metadata-source pull, in place' + help_text = ( + 'Re-tags albums in your library using a fresh pull from the metadata source ' + 'they are matched to — writing the tags (and optionally cover art) directly ' + 'into the files. It only does the tagging step: files are NOT moved, renamed, ' + 're-matched, or reorganized.\n\n' + 'Only albums matched to a metadata source (Spotify / iTunes / Deezer / ' + 'MusicBrainz album id) are eligible, since the source is where the fresh data ' + 'comes from. Each finding lists every tag that would change (old -> new) per ' + 'track so you can review before applying — nothing is written until you do.\n\n' + 'Settings:\n' + '- Depth: "light" writes the core tags + the matched source\'s ids (fast, ' + 'additive). "full" also runs the same multi-source enrichment a fresh ' + 'download gets (MusicBrainz / Deezer / AudioDB / Tidal / etc. — BPM, ISRC, ' + 'lyrics, moods, …); much richer but slower and API-heavy on a big library.\n' + '- Dry run (default ON): only create findings to review; nothing is written. ' + 'Turn it off to auto-apply on scan.\n' + '- Mode: "overwrite" rewrites every field the source provides; "fill_missing" ' + 'only fills blank tags (keeps your existing values).\n' + '- Cover art: replace / fill-missing / skip. "replace" force-refreshes ' + 'art on every matched album (use this after changing your cover-art ' + 'sources to re-pull fresh covers). When you have configured cover-art ' + 'sources (Settings > metadata enhancement art order), the art is pulled ' + 'from those; otherwise it falls back to the matched source\'s album image.\n' + '- Source: which matched source to pull from (default: your source priority).' + ) + icon = 'repair-icon-retag' + default_enabled = False + default_interval_hours = 168 + default_settings = { + 'dry_run': True, + 'depth': 'light', + 'mode': MODE_OVERWRITE, + 'cover_art': 'replace', + 'source': 'auto', + } + setting_options = { + 'depth': ['light', 'full'], + 'mode': [MODE_OVERWRITE, MODE_FILL_MISSING], + 'cover_art': ['replace', 'fill_missing', 'skip'], + 'source': ['auto', 'spotify', 'itunes', 'deezer', 'musicbrainz'], + } + auto_fix = True + + def _get_settings(self, context: JobContext) -> dict: + merged = dict(self.default_settings) + if context.config_manager: + cfg = context.config_manager.get(f'repair.jobs.{self.job_id}.settings', {}) or {} + merged.update(cfg) + return merged + + def _source_order(self, settings) -> list: + override = (settings.get('source') or '').strip() + if override in _ALBUM_SOURCE_COLUMNS: + return [override] + return [s for s in get_source_priority(get_primary_source()) if s in _ALBUM_SOURCE_COLUMNS] + + def scan(self, context: JobContext) -> JobResult: + result = JobResult() + settings = self._get_settings(context) + mode = settings.get('mode', MODE_OVERWRITE) + cover_mode = settings.get('cover_art', 'replace') + dry_run = settings.get('dry_run', True) + depth = settings.get('depth', 'light') + source_order = self._source_order(settings) + if not source_order: + logger.warning("Library re-tag: no usable metadata sources configured") + return result + + # Albums that carry at least one usable source id. + cols = ', '.join(f'al.{c}' for c in _ALBUM_SOURCE_COLUMNS.values()) + try: + with context.db._get_connection() as conn: + cursor = conn.cursor() + cursor.execute(f""" + SELECT al.id, al.title, ar.name, {cols} + FROM albums al + LEFT JOIN artists ar ON ar.id = al.artist_id + WHERE al.title IS NOT NULL AND al.title != '' + """) + albums = cursor.fetchall() + except Exception as e: + logger.error("Library re-tag: album query failed: %s", e, exc_info=True) + result.errors += 1 + return result + + total = len(albums) + if context.update_progress: + context.update_progress(0, total) + if context.report_progress: + context.report_progress(phase=f'Checking {total} albums for tag drift...', total=total) + + for i, row in enumerate(albums): + if context.check_stop(): + return result + if i % 5 == 0 and context.wait_if_paused(): + return result + result.scanned += 1 + if context.update_progress and (i + 1) % 5 == 0: + context.update_progress(i + 1, total) + + album_id, album_title, artist_name = row[0], row[1], row[2] + source_ids = {src: row[3 + idx] for idx, src in enumerate(_ALBUM_SOURCE_COLUMNS)} + + source = next((s for s in source_order if source_ids.get(s)), None) + if not source: + continue # not matched to a usable source — skip + album_source_id = str(source_ids[source]) + + try: + self._scan_album(context, result, album_id, album_title, artist_name, + source, album_source_id, mode, cover_mode, dry_run, depth) + except Exception as e: + logger.debug("Library re-tag: album %s failed: %s", album_id, e) + result.errors += 1 + + if context.update_progress: + context.update_progress(total, total) + logger.info("Library re-tag scan: %d albums checked, %d findings", + result.scanned, result.findings_created) + return result + + def _scan_album(self, context, result, album_id, album_title, artist_name, + source, album_source_id, mode, cover_mode, dry_run=True, depth='light'): + # Local tracks for this album. + with context.db._get_connection() as conn: + cur = conn.cursor() + cur.execute(""" + SELECT id, title, track_number, disc_number, file_path + FROM tracks + WHERE album_id = ? AND file_path IS NOT NULL AND file_path != '' + ORDER BY disc_number, track_number + """, (album_id,)) + library_tracks = [ + {'id': r[0], 'title': r[1], 'track_number': r[2], + 'disc_number': r[3], 'file_path': r[4]} + for r in cur.fetchall() + ] + if not library_tracks: + return + + album_meta = get_album_for_source(source, album_source_id) + source_tracks = _track_list(get_album_tracks_for_source(source, album_source_id)) + if not album_meta or not source_tracks: + logger.debug("Library re-tag: no source data for album %s (%s)", album_id, source) + return + + cover_url = None + for k in ('image_url', 'album_image_url', 'cover_url', 'thumb_url'): + v = album_meta.get(k) if isinstance(album_meta, dict) else getattr(album_meta, k, None) + if v: + cover_url = v + break + + # Honor the user's configured cover-art sources (the same + # `metadata_enhancement.album_art_order` the post-process embed uses), so + # changing those sources and re-tagging pulls fresh art FROM them rather + # than always using the matched metadata source's album image. Non- + # breaking: select_preferred_art_url returns None when no order is + # configured, so we keep the source image. Skipped when not embedding art. + if cover_mode != 'skip': + try: + from core.metadata.art_lookup import select_preferred_art_url + order = (context.config_manager.get('metadata_enhancement.album_art_order') + if context.config_manager else None) + preferred = select_preferred_art_url(artist_name, album_title, album_meta, order) + if preferred: + cover_url = preferred + except Exception as e: + logger.debug("preferred cover-art lookup failed for album %s: %s", album_id, e) + + # Cover action (album-level), independent of tag changes. Decided first + # so cover-only albums (tags fine, art missing) still include their + # tracks for the apply to embed art into. + cover_action = self._cover_action(cover_mode, cover_url, library_tracks) + + pairs = match_source_tracks(source_tracks, library_tracks) + track_plans = [] + unmatched = [] + for lib, src in pairs: + if src is None: + unmatched.append(lib['title'] or os.path.basename(lib['file_path'])) + continue + if not os.path.isfile(lib['file_path']): + continue # not reachable at the stored path — skip (apply resolves paths) + current = _read_current_tags(lib['file_path']) + plan = plan_track(current, src, album_meta, mode=mode) + # Include a track when its tags change, OR when there's a cover action + # to apply to it (db_data may be empty — apply embeds art either way). + if plan['changes'] or cover_action: + db_data = plan['db_data'] + _add_source_ids(db_data, source, album_source_id, src) + tp = { + 'file_path': lib['file_path'], + 'track_id': lib['id'], + 'title': lib['title'], + 'changes': plan['changes'], + 'db_data': db_data, + } + if depth == 'full': + tp['full_meta'] = _build_full_meta( + db_data, src, album_title, artist_name, lib['title']) + track_plans.append(tp) + + tag_change_tracks = sum(1 for tp in track_plans if tp['changes']) + if not tag_change_tracks and not cover_action: + result.skipped += 1 + return + + # Not dry-run: apply the tags in place now (the track paths were already + # isfile-checked above) and count it as an auto-fix — no finding. + if not dry_run: + res = apply_track_plans(track_plans, cover_action, cover_url, full=(depth == 'full')) + if res['written'] or res['cover_written']: + result.auto_fixed += 1 + else: + result.errors += 1 + return + + total_changes = sum(len(tp['changes']) for tp in track_plans) + summary_bits = [] + if tag_change_tracks: + summary_bits.append(f"{tag_change_tracks} track(s), {total_changes} tag change(s)") + if cover_action: + summary_bits.append(f"cover art ({cover_action})") + if depth == 'full': + summary_bits.append("full multi-source enrichment") + desc = (f'Album "{album_title}" by {artist_name or "Unknown"} would be re-tagged from ' + f'{source} ({", ".join(summary_bits)}).') + if unmatched: + desc += f' {len(unmatched)} track(s) could not be matched to the source and are left untouched.' + + if context.create_finding: + inserted = context.create_finding( + job_id=self.job_id, + finding_type='library_retag', + severity='info', + entity_type='album', + entity_id=str(album_id), + file_path=None, + title=f'Re-tag: {album_title or "Unknown"} ({tag_change_tracks} track(s))', + description=desc, + details={ + 'album_id': album_id, + 'album_title': album_title, + 'artist': artist_name, + 'source': source, + 'album_source_id': album_source_id, + 'depth': depth, + 'mode': mode, + 'cover_mode': cover_mode, + 'cover_url': cover_url, + 'cover_action': cover_action, + 'tracks': track_plans, # each carries its db_data for a deterministic apply + 'unmatched': unmatched, + }, + ) + if inserted: + result.findings_created += 1 + else: + result.findings_skipped_dedup += 1 + + @staticmethod + def _cover_action(cover_mode, cover_url, library_tracks): + """Return 'replace' / 'fill' / None for the album's cover under the mode.""" + if cover_mode == 'skip' or not cover_url: + return None + if cover_mode == 'replace': + return 'replace' + # fill_missing — only if the album has no art on disk + try: + from core.metadata.art_apply import album_has_art_on_disk + rep = library_tracks[0]['file_path'] if library_tracks else '' + return None if album_has_art_on_disk(rep) else 'fill' + except Exception: + return None + + def estimate_scope(self, context: JobContext) -> int: + try: + with context.db._get_connection() as conn: + cur = conn.cursor() + cur.execute("SELECT COUNT(*) FROM albums WHERE title IS NOT NULL AND title != ''") + row = cur.fetchone() + return row[0] if row else 0 + except Exception: + return 0 diff --git a/core/repair_jobs/missing_cover_art.py b/core/repair_jobs/missing_cover_art.py index e6690981..c4ae85b9 100644 --- a/core/repair_jobs/missing_cover_art.py +++ b/core/repair_jobs/missing_cover_art.py @@ -46,9 +46,10 @@ class MissingCoverArtJob(RepairJob): description = 'Finds albums missing artwork and locates art from metadata sources' help_text = ( 'Scans your library for albums that have no cover art stored in the database. ' - 'For each missing cover, it searches the configured metadata sources using the ' - 'album name and artist to find matching artwork. If Prefer Source is set, that ' - 'source is tried first; otherwise the primary metadata source is used.\n\n' + 'For each missing cover, it searches for matching artwork using the album name ' + 'and artist. If you have configured cover-art sources (Settings > metadata ' + 'enhancement art order), those are used first; otherwise it falls back to ' + 'Prefer Source (if set) or the primary metadata source.\n\n' 'When artwork is found, a finding is created with the image URL so you can review ' 'and apply it. The job does not download or embed artwork automatically.\n\n' 'Settings:\n' @@ -66,6 +67,10 @@ class MissingCoverArtJob(RepairJob): settings = self._get_settings(context) primary_source = get_primary_source() source_priority = get_source_priority(primary_source) + # User's configured cover-art source order (caa/deezer/itunes/spotify/ + # audiodb). Empty by default → falls back to the source-priority loop. + art_order = (context.config_manager.get('metadata_enhancement.album_art_order') + if context.config_manager else None) prefer_source = settings.get('prefer_source') if prefer_source and prefer_source != primary_source and prefer_source in source_priority: source_priority.remove(prefer_source) @@ -170,11 +175,27 @@ class MissingCoverArtJob(RepairJob): artwork_url = None + # Honor the user's configured cover-art sources first (the same + # metadata_enhancement.album_art_order the post-process embed and the + # Library Re-tag job use) so "cover art sources" means one thing + # app-wide. Non-breaking: returns None when no order is configured + # (the default), so we fall back to the prefer_source / metadata + # source-priority loop below — unchanged behavior for existing users. + try: + from core.metadata.art_lookup import select_preferred_art_url + artwork_url = select_preferred_art_url( + artist_name, title, + {'musicbrainz_release_id': None}, art_order, + ) + except Exception as e: + logger.debug("preferred cover-art lookup failed for album %s: %s", album_id, e) + # Try source-specific IDs first, then title/artist search, in priority order. - for source in source_priority: - artwork_url = self._try_source(source, source_album_ids.get(source), title, artist_name) - if artwork_url: - break + if not artwork_url: + for source in source_priority: + artwork_url = self._try_source(source, source_album_ids.get(source), title, artist_name) + if artwork_url: + break if artwork_url: if context.report_progress: diff --git a/core/repair_worker.py b/core/repair_worker.py index 4ab4af0c..74d3a634 100644 --- a/core/repair_worker.py +++ b/core/repair_worker.py @@ -976,6 +976,7 @@ class RepairWorker: 'unknown_artist': self._fix_unknown_artist, 'acoustid_mismatch': self._fix_acoustid_mismatch, 'missing_discography_track': self._fix_discography_backfill, + 'library_retag': self._fix_library_retag, } handler = handlers.get(finding_type) if not handler: @@ -1356,6 +1357,43 @@ class RepairWorker: msg = 'Updated database thumbnail, but could not write art to files (read-only?)' return {'success': True, 'action': 'applied_cover_art', 'message': msg, 'art_result': art_result} + def _fix_library_retag(self, entity_type, entity_id, file_path, details): + """Apply a library re-tag finding: write each track's planned tags in + place (core.tag_writer.write_tags_to_file) + optionally embed/refresh + cover art. Only ADDS/overwrites the planned fields — no moves/renames.""" + tracks = details.get('tracks') or [] + if not tracks: + return {'success': False, 'error': 'No tracks to re-tag in finding'} + + # Resolve container/host path mismatches, then delegate to the shared + # apply path the job's auto-fix mode also uses. + download_folder = self._config_manager.get('soulseek.download_path', '') if self._config_manager else None + resolved_plans = [] + for t in tracks: + raw = t.get('file_path') + if not raw: + continue + rp = _resolve_file_path(raw, self.transfer_folder, download_folder, + config_manager=self._config_manager) or raw + plan = {'file_path': rp, 'db_data': t.get('db_data') or {}} + if t.get('full_meta'): + plan['full_meta'] = t['full_meta'] + resolved_plans.append(plan) + + from core.repair_jobs.library_retag import apply_track_plans + res = apply_track_plans(resolved_plans, details.get('cover_action'), details.get('cover_url'), + full=(details.get('depth') == 'full')) + + if res['written'] == 0 and not res['cover_written']: + return {'success': False, + 'error': 'Nothing could be written — files unreachable or read-only?'} + msg = f"Re-tagged {res['written']} track(s)" + if res['failed']: + msg += f" ({res['failed']} failed)" + if res['cover_written']: + msg += ' + refreshed cover.jpg' + return {'success': True, 'action': 'library_retag', 'message': msg, **res} + def _fix_metadata_gap(self, entity_type, entity_id, file_path, details): """Apply found metadata fields to the track.""" found_fields = details.get('found_fields') diff --git a/core/search/by_id.py b/core/search/by_id.py new file mode 100644 index 00000000..7b8f73d1 --- /dev/null +++ b/core/search/by_id.py @@ -0,0 +1,371 @@ +"""Resolve a pasted metadata link to a single album/track/artist result. + +This backs the Search page's "Link / ID" mode (#775): instead of a fuzzy +name search, the user pastes a provider URL (or a bare ID) and we look the +entity up *directly* on the owning source — no scoring, no guessing. + +Design notes +------------ +- **Links only.** A full URL carries its source in the domain + (``open.spotify.com`` → spotify, ``musicbrainz.org`` → musicbrainz, …) + and its kind in the path (``/album/`` vs ``/track/``), so it resolves to + exactly one unambiguous lookup. The ``spotify:album:ID`` URI is accepted + too since it's equally explicit. Bare IDs are intentionally rejected: a + bare number like ``525046`` carries no source and no entity type, so it + would resolve to whatever album/track happens to own that id on some + source — often an unrelated entity. Paste the link instead. + +- **Reuses existing per-source get-by-id.** Spotify/iTunes/MusicBrainz all + expose ``get_album``; Deezer exposes ``get_album_metadata``; all four + expose ``get_track_details``. Those already normalize to a common + "Spotify-shaped" dict, so a single adapter projects them onto the same + card shape the enhanced-search dropdown renders (see + ``core/search/sources.py``). + +- **Purely additive.** Nothing here mutates existing search behavior; the + route layer calls :func:`resolve_identifier` only for the new mode. + +The module is import-safe and side-effect free: clients are resolved through +an injected ``client_resolver`` (defaulting to the orchestrator's +``resolve_client``) so the seam is unit-testable with fakes. +""" + +from __future__ import annotations + +import logging +from typing import Any, Callable, NamedTuple, Optional +from urllib.parse import parse_qs, urlparse + +logger = logging.getLogger(__name__) + +# Sources we can resolve a link/ID against. These are exactly the metadata +# providers whose public links a user would paste AND whose get-by-id returns +# the common Spotify-shaped dict. Streaming download backends (Tidal/Qobuz) +# return raw API shapes and aren't metadata-link sources, so they're omitted. +SUPPORTED_SOURCES = ('spotify', 'itunes', 'musicbrainz', 'deezer') + +# Domains we recognize — used to detect a pasted URL even when the user +# omitted the scheme (e.g. "open.spotify.com/album/…"). +_KNOWN_HOSTS = ( + 'open.spotify.com', 'music.apple.com', 'itunes.apple.com', + 'musicbrainz.org', 'deezer.com', +) + + +class LookupTarget(NamedTuple): + """One (source, kind, id) lookup to attempt. + + ``kind`` is ``'album'`` or ``'track'`` — always pinned by the URL path or + URI type (links-only input). The ``Optional`` typing is kept defensively: + the resolver falls back to album-then-track if a future parser path ever + yields ``None``. + """ + + source: str + kind: Optional[str] + id: str + + +# -------------------------------------------------------------------------- +# Parsing +# -------------------------------------------------------------------------- + +def _kind_from_keyword(keyword: str) -> Optional[str]: + """Map a URL/URI path keyword to a lookup kind.""" + if keyword in ('album', 'release', 'release-group'): + return 'album' + if keyword in ('track', 'recording', 'song'): + return 'track' + if keyword == 'artist': + return 'artist' + return None + + +def _parse_spotify_uri(raw: str) -> Optional[LookupTarget]: + """``spotify:album:ID`` / ``spotify:track:ID``.""" + parts = raw.split(':') + if len(parts) >= 3 and parts[0] == 'spotify': + kind = _kind_from_keyword(parts[1]) + if kind: + return LookupTarget('spotify', kind, parts[-1]) + return None + + +def _parse_url(raw: str) -> list[LookupTarget]: + """Parse a provider URL into lookup targets (empty if unrecognized).""" + parsed = urlparse(raw) + host = (parsed.netloc or '').lower() + segs = [s for s in (parsed.path or '').split('/') if s] + + def _by_keyword(source: str) -> list[LookupTarget]: + """Find the first album/track-style keyword and take the next seg as id.""" + for i, seg in enumerate(segs): + kind = _kind_from_keyword(seg.lower()) + if kind and i + 1 < len(segs): + return [LookupTarget(source, kind, segs[i + 1])] + return [] + + if 'open.spotify.com' in host: + return _by_keyword('spotify') + + if 'music.apple.com' in host or 'itunes.apple.com' in host: + # Apple track links are an album URL with ?i=; otherwise the + # trailing path segment is the album/song id. + qs = parse_qs(parsed.query or '') + track_id = (qs.get('i') or [None])[0] + if track_id: + return [LookupTarget('itunes', 'track', track_id)] + for i, seg in enumerate(segs): + kind = _kind_from_keyword(seg.lower()) + if kind and i + 1 < len(segs): + # Apple's id is the last segment, not necessarily i+1. + return [LookupTarget('itunes', kind, segs[-1])] + return [] + + if 'musicbrainz.org' in host: + return _by_keyword('musicbrainz') + + if 'deezer.com' in host: + # link.deezer.com short links can't be resolved without a network + # redirect; only handle canonical /album/ /track/ paths. + return _by_keyword('deezer') + + return [] + + +def parse_metadata_identifier(raw: str) -> list[LookupTarget]: + """Parse a pasted provider link (or ``spotify:`` URI) into lookup targets. + + Links only — a bare ID has no source/type and is rejected (returns ``[]``). + A URL resolves to exactly one target; the list type is kept for the + ``spotify:`` URI path and future multi-target patterns. + """ + raw = (raw or '').strip() + if not raw: + return [] + + if raw.lower().startswith('spotify:'): + uri = _parse_spotify_uri(raw) + return [uri] if uri else [] + + lowered = raw.lower() + looks_like_url = ( + '://' in raw + or lowered.startswith('www.') + or any(host in lowered for host in _KNOWN_HOSTS) + ) + if looks_like_url: + url = raw if '://' in raw else f'https://{raw}' + return _parse_url(url) + + # Bare ID (or anything we don't recognize as a link) — rejected. + return [] + + +# -------------------------------------------------------------------------- +# Shaping — project a get-by-id dict onto the dropdown's card shape +# -------------------------------------------------------------------------- + +def _join_artists(artists: Any) -> str: + """Normalize an artists field (list of str OR list of {'name': ...}) to a + display string.""" + names: list[str] = [] + for a in artists or []: + if isinstance(a, dict): + n = a.get('name') + else: + n = a + if n: + names.append(str(n)) + return ', '.join(names) if names else 'Unknown Artist' + + +def _first_image(d: dict) -> str: + """Pull the first image URL from a Spotify-shaped images list.""" + imgs = d.get('images') or [] + if imgs and isinstance(imgs[0], dict): + return imgs[0].get('url', '') or '' + return d.get('image_url', '') or '' + + +def album_dict_to_card(d: dict) -> dict: + """Project a get_album / get_album_metadata dict onto the album card shape + (mirrors ``core/search/sources.py`` ``search_kind('albums')``).""" + return { + 'id': str(d.get('id', '')), + 'name': d.get('name', ''), + 'artist': _join_artists(d.get('artists')), + 'image_url': _first_image(d), + 'release_date': d.get('release_date', ''), + 'total_tracks': d.get('total_tracks', 0), + 'album_type': d.get('album_type', 'album'), + 'format': d.get('format'), + 'country': d.get('country'), + 'status': d.get('status'), + 'label': d.get('label'), + 'disambiguation': d.get('disambiguation'), + 'release_group_id': d.get('release_group_id'), + 'external_urls': d.get('external_urls') or {}, + } + + +def track_dict_to_card(d: dict) -> dict: + """Project a get_track_details dict onto the track card shape (mirrors + ``core/search/sources.py`` ``search_kind('tracks')``).""" + album = d.get('album') + if isinstance(album, dict): + album_name = album.get('name', '') + image_url = _first_image(album) + release_date = album.get('release_date', '') + else: + album_name = album or '' + image_url = _first_image(d) + release_date = d.get('release_date', '') + return { + 'id': str(d.get('id', '')), + 'name': d.get('name', ''), + 'artist': _join_artists(d.get('artists')), + 'album': album_name, + 'duration_ms': d.get('duration_ms', 0), + 'image_url': image_url or _first_image(d), + 'release_date': release_date, + 'external_urls': d.get('external_urls') or {}, + } + + +def artist_dict_to_card(d: dict) -> dict: + """Project a get_artist / get_artist_info dict onto the artist card shape + (mirrors ``core/search/sources.py`` ``search_kind('artists')``).""" + return { + 'id': str(d.get('id', '')), + 'name': d.get('name', ''), + 'image_url': _first_image(d), + 'external_urls': d.get('external_urls') or {}, + } + + +# -------------------------------------------------------------------------- +# Fetch dispatch — per-source method names differ slightly +# -------------------------------------------------------------------------- + +def _fetch_album(client: Any, source: str, identifier: str) -> Optional[dict]: + """Fetch album metadata by id. Deezer names the method differently; the + rest share ``get_album``. ``include_tracks=False`` keeps the lookup cheap + (the modal re-fetches the full tracklist on open).""" + if source == 'deezer': + return client.get_album_metadata(identifier, include_tracks=False) + if source in ('itunes', 'musicbrainz'): + return client.get_album(identifier, include_tracks=False) + return client.get_album(identifier) # spotify + + +def _fetch_track(client: Any, source: str, identifier: str) -> Optional[dict]: + """Fetch track metadata by id — uniform across all supported sources.""" + return client.get_track_details(identifier) + + +def _fetch_artist(client: Any, source: str, identifier: str) -> Optional[dict]: + """Fetch artist metadata by id. Deezer names the method differently; the + rest share ``get_artist``.""" + if source == 'deezer': + return client.get_artist_info(identifier) + return client.get_artist(identifier) + + +# Shown in the dropdown's empty state so the user knows what to do next. +_MSG_NOT_A_LINK = ( + 'Paste a full link from Spotify, Apple Music, MusicBrainz, or Deezer ' + '(a bare ID is ambiguous).' +) +_MSG_NOT_FOUND = "Couldn't resolve that link — double-check it's correct." + + +def _empty_result(raw: str, source: str = '', message: str = '') -> dict: + return { + 'source': source, + 'albums': [], + 'tracks': [], + 'artists': [], + 'available': False, + 'query': raw, + 'message': message, + } + + +def _hit_result(raw: str, source: str, key: str, card: dict) -> dict: + """Build a success result carrying the single resolved card under ``key`` + ('albums' | 'tracks' | 'artists'); the other lists stay empty.""" + result = { + 'source': source, + 'albums': [], + 'tracks': [], + 'artists': [], + 'available': True, + 'query': raw, + 'message': '', + } + result[key] = [card] + return result + + +def resolve_identifier( + raw: str, + deps: Any, + client_resolver: Optional[Callable[[str], Any]] = None, +) -> dict: + """Resolve a pasted provider link to a single album, track, or artist card. + + Returns a dropdown-compatible dict: + ``{source, albums, tracks, artists, available, query, message}``. ``available`` is + True iff a source returned a hit; the first resolving target wins, so the + result carries exactly one card (and the ``source`` that owns it). + ``message`` is a user-facing hint when nothing resolved. + + ``client_resolver`` maps a source name to a client (or None). It defaults + to the orchestrator's ``resolve_client``; tests inject fakes. + """ + if client_resolver is None: + from core.search.orchestrator import resolve_client + + def client_resolver(source: str) -> Any: # noqa: E306 + return resolve_client(source, deps)[0] + + targets = parse_metadata_identifier(raw) + if not targets: + logger.info(f"Link/ID resolve: not a recognized link {raw!r}") + return _empty_result(raw, message=_MSG_NOT_A_LINK) + + for target in targets: + try: + client = client_resolver(target.source) + except Exception as e: + logger.debug(f"Link/ID resolve: client for {target.source} failed: {e}") + client = None + if client is None: + continue + + kinds = (target.kind,) if target.kind else ('album', 'track') + for kind in kinds: + try: + if kind == 'album': + data = _fetch_album(client, target.source, target.id) + if data: + return _hit_result(raw, target.source, 'albums', + album_dict_to_card(data)) + elif kind == 'artist': + data = _fetch_artist(client, target.source, target.id) + if data: + return _hit_result(raw, target.source, 'artists', + artist_dict_to_card(data)) + else: + data = _fetch_track(client, target.source, target.id) + if data: + return _hit_result(raw, target.source, 'tracks', + track_dict_to_card(data)) + except Exception as e: + logger.debug( + f"Link/ID resolve: {target.source} {kind} {target.id} failed: {e}" + ) + + logger.info(f"Link/ID resolve: no source resolved {raw!r}") + return _empty_result(raw, source=targets[0].source, message=_MSG_NOT_FOUND) diff --git a/core/search/orchestrator.py b/core/search/orchestrator.py index a1961424..8e1bd6ca 100644 --- a/core/search/orchestrator.py +++ b/core/search/orchestrator.py @@ -65,7 +65,9 @@ class SearchDeps: def resolve_client(source_name: str, deps: SearchDeps) -> tuple[Any, bool]: """Return (client, is_available) for an explicit metadata source request.""" if source_name == 'spotify': - if deps.spotify_client and deps.spotify_client.is_spotify_authenticated(): + # Available when real auth OR the no-creds SpotipyFree fallback can serve + # (the client routes to free internally when auth is missing/limited). + if deps.spotify_client and deps.spotify_client.is_spotify_metadata_available(): return deps.spotify_client, True return None, False if source_name == 'itunes': diff --git a/core/soulseek_client.py b/core/soulseek_client.py index 80d7d4bc..9843b21e 100644 --- a/core/soulseek_client.py +++ b/core/soulseek_client.py @@ -1613,7 +1613,10 @@ class SoulseekClient(DownloadSourcePlugin): return result _emit('staging', release=getattr(picked, 'album_title', folder_path) if picked else folder_path) - copied = copy_audio_files_atomically(completed, Path(staging_dir)) + # remove_source=True: clean slskd's completed files once staged so they + # don't pile up in the download folder (#796). Soulseek has no seeding, + # unlike the torrent/usenet bundle paths which keep their originals. + copied = copy_audio_files_atomically(completed, Path(staging_dir), remove_source=True) if not copied: result['error'] = 'No Soulseek album files copied to staging' return result diff --git a/core/spotify_client.py b/core/spotify_client.py index 09ede9c4..bb7af518 100644 --- a/core/spotify_client.py +++ b/core/spotify_client.py @@ -569,6 +569,72 @@ class SpotifyClient: return self._itunes # Fall back to iTunes if no Discogs token return self._itunes + def _free_selected(self) -> bool: + """Whether the user picked 'Spotify Free' as their metadata source + (the no-creds option, for when they haven't connected Spotify).""" + try: + return bool(config_manager.get('metadata.spotify_free', False)) + except Exception: + return False + + def _free_installed(self) -> bool: + from core.spotify_free_metadata import spotify_free_installed + return spotify_free_installed() + + def _free_wanted(self) -> bool: + """Does the user actually want the free source? OPT-IN: only when they + picked 'Spotify Free'. This keeps free from auto-scraping for anyone who + didn't choose it — a plain-'Spotify' user just waits out a rate-limit ban + as before. A user on 'Spotify Free' who also connects an account uses the + official account normally and free only bridges their bans.""" + return self._free_selected() + + def _free_available(self) -> bool: + """Free CAN serve: package installed AND the user wants Spotify.""" + return self._free_installed() and self._free_wanted() + + def is_spotify_metadata_available(self) -> bool: + """Whether SoulSync can serve Spotify metadata — real auth OR the + no-creds source. Availability gates (search resolve, enrichment worker, + watchlist) use THIS instead of ``is_spotify_authenticated()`` so the + free source is reachable. Does NOT change auth semantics.""" + from core.spotify_free_metadata import should_offer_spotify_metadata + try: + authed = self.is_spotify_authenticated() + except Exception: + authed = False + return should_offer_spotify_metadata(authed, self._free_available()) + + def _free_active(self) -> bool: + """Whether the no-creds source may serve THIS request: free available + AND official can't (no auth, or rate-limited). ``is_spotify_authenticated()`` + already returns False during a rate-limit ban; the explicit rate-limit + term covers the brief window before the auth cache refreshes. When authed + + healthy the official path returns first, so this never opens. + + Two activations fall out of this: a no-auth user who chose Spotify Free + (free is their source), and a connected user mid-rate-limit (free bridges + the ban) — see _free_wanted().""" + from core.spotify_free_metadata import should_use_free_fallback + if not self._free_available(): + return False + try: + authed = self.is_spotify_authenticated() + except Exception: + authed = False + return should_use_free_fallback(authed, _is_globally_rate_limited()) + + @property + def _free_meta(self): + """Lazy SpotipyFree-backed metadata client (soft import — absence is + not fatal; methods degrade to the iTunes/Deezer fallback).""" + client = getattr(self, '_free_meta_client', None) + if client is None: + from core.spotify_free_metadata import SpotifyFreeMetadataClient + client = SpotifyFreeMetadataClient() + self._free_meta_client = client + return client + def reload_config(self): """Reload configuration and re-initialize client""" self._invalidate_auth_cache() @@ -1276,6 +1342,17 @@ class SpotifyClient: logger.error(f"Error searching tracks via Spotify: {e}") # Fall through to fallback + # No-creds Spotify (SpotipyFree) before the iTunes/Deezer fallback — + # only when official Spotify is unavailable (no auth / rate-limited). + if allow_fallback and self._free_active(): + try: + objs = [Track.from_spotify_track(t) + for t in self._free_meta.search_tracks(query, effective_limit)] + if objs: + return objs + except Exception as e: + logger.debug("SpotipyFree track search failed: %s", e) + # Fallback (iTunes or Deezer — configured in settings) if allow_fallback: logger.debug(f"Using {self._fallback_source} fallback for track search: {query}") @@ -1336,6 +1413,21 @@ class SpotifyClient: logger.error(f"Error searching artists via Spotify: {e}") # Fall through to iTunes fallback + # No-creds Spotify (SpotipyFree): keep Spotify catalog/matching when + # official Spotify can't serve us (no auth / rate-limited), before the + # iTunes/Deezer fallback. Gated by _free_active() so it never runs while + # auth is healthy. + if allow_fallback and self._free_active(): + try: + objs = [Artist.from_spotify_artist(a) + for a in self._free_meta.search_artists(query, limit)] + if objs: + query_lower = query.lower().strip() + objs.sort(key=lambda a: (0 if a.name.lower().strip() == query_lower else 1)) + return objs + except Exception as e: + logger.debug("SpotipyFree artist search failed: %s", e) + # Fallback (iTunes or Deezer) if allow_fallback: logger.debug(f"Using {self._fallback_source} fallback for artist search: {query}") @@ -1437,6 +1529,13 @@ class SpotifyClient: logger.error(f"Error fetching track details via Spotify: {e}") # Fall through to iTunes fallback + # No-creds Spotify (SpotipyFree) for a real Spotify track id when + # official Spotify is unavailable (no auth / rate-limited). + if allow_fallback and self._free_active() and not self._is_itunes_id(track_id): + free = self._free_meta.get_track_details(track_id) + if free: + return free + # Fallback - only if ID is numeric (non-Spotify format) if allow_fallback and self._is_itunes_id(track_id): logger.debug(f"Using {fallback_src} fallback for track details: {track_id}") @@ -1528,6 +1627,13 @@ class SpotifyClient: logger.error(f"Error fetching album via Spotify: {e}") # Fall through to fallback + # No-creds Spotify (SpotipyFree) for a real Spotify album id when + # official Spotify is unavailable (no auth / rate-limited). + if allow_fallback and self._free_active() and not self._is_itunes_id(album_id): + free = self._free_meta.get_album(album_id) + if free: + return free + # Fallback - only if ID is numeric (non-Spotify format) if allow_fallback and self._is_itunes_id(album_id): logger.debug(f"Using {fallback_src} fallback for album: {album_id}") @@ -1605,6 +1711,13 @@ class SpotifyClient: logger.error(f"Error fetching album tracks via Spotify: {e}") # Fall through to iTunes fallback + # No-creds Spotify (SpotipyFree) for a real Spotify album id when + # official Spotify is unavailable (no auth / rate-limited). + if allow_fallback and self._free_active() and not self._is_itunes_id(album_id): + free = self._free_meta.get_album_tracks(album_id) + if free: + return free + # Fallback - only if ID is numeric (non-Spotify format) if allow_fallback and self._is_itunes_id(album_id): logger.debug(f"Using {fallback_src} fallback for album tracks: {album_id}") @@ -1691,6 +1804,18 @@ class SpotifyClient: logger.error(f"Error fetching artist albums via Spotify: {e}") # Fall through to iTunes fallback + # No-creds Spotify (SpotipyFree) for a real Spotify artist id when + # official Spotify is unavailable (no auth / rate-limited). The free + # discography is already album-shaped — project to Album dataclasses. + if allow_fallback and self._free_active() and not self._is_itunes_id(artist_id): + try: + free = [Album.from_spotify_album(a) + for a in self._free_meta.get_artist_albums_list(artist_id, limit)] + if free: + return free + except Exception as e: + logger.debug("SpotipyFree artist albums failed: %s", e) + # Fallback - only if ID is numeric (non-Spotify format) if allow_fallback and self._is_itunes_id(artist_id): logger.debug(f"Using {fallback_src} fallback for artist albums: {artist_id}") @@ -1745,6 +1870,13 @@ class SpotifyClient: logger.error(f"Error fetching artist via Spotify: {e}") # Fall through to iTunes fallback + # No-creds Spotify (SpotipyFree) for a real Spotify artist id when + # official Spotify is unavailable (no auth / rate-limited). + if allow_fallback and self._free_active() and not self._is_itunes_id(artist_id): + free = self._free_meta.get_artist(artist_id) + if free: + return free + # Fallback - only if ID is numeric (non-Spotify format) if allow_fallback and self._is_itunes_id(artist_id): logger.debug(f"Using {fallback_src} fallback for artist: {artist_id}") diff --git a/core/spotify_free_metadata.py b/core/spotify_free_metadata.py new file mode 100644 index 00000000..5b037183 --- /dev/null +++ b/core/spotify_free_metadata.py @@ -0,0 +1,224 @@ +"""PROTOTYPE — no-credentials Spotify metadata via SpotipyFree / spotapi. + +Goal: stand in as a READ-ONLY Spotify metadata source when the user has no +Spotify auth (or is rate-limited), mapping SpotipyFree's outputs onto the same +Spotify-compatible shapes the rest of SoulSync already consumes +(see core/spotify_client.py + core/search/sources.py). + +Unofficial / web-player scraping — best-effort, fragile, and NOT a substitute +for the user-account features (those need a real login). + +Capabilities (verified live, 2026-06): + search_tracks ✅ SpotipyFree.search (already official-shaped) + search_artists ✅ spotapi.Public().artist_search (normalized here) + search_albums ❌ no album-name search exists upstream → returns [] + get_album ✅ SpotipyFree.album (already official-shaped) + get_artist_albums_list ✅ SpotipyFree.artist_albums (already official-shaped) + get_track_details ✅ SpotipyFree.track (already official-shaped) + get_artist ✅ SpotipyFree.artist (RAW GraphQL → normalized here) + get_artist_top_tracks ❌ unavailable + audio_features ❌ unavailable (Spotify deprecated it anyway) + +This module is import-safe even when SpotipyFree isn't installed — it +soft-imports inside the client factory. +""" + +from __future__ import annotations + +import importlib.util +import logging +from typing import Any, Optional + +logger = logging.getLogger(__name__) + +_installed_cache: Optional[bool] = None + + +def spotify_free_installed() -> bool: + """Cheap, cached check: is the optional SpotipyFree package importable? + + Used by the availability gates — never constructs a client or hits the + network. Absence just means we degrade to the iTunes/Deezer fallback. + """ + global _installed_cache + if _installed_cache is None: + _installed_cache = importlib.util.find_spec('SpotipyFree') is not None + return _installed_cache + + +def should_use_free_fallback(authenticated: bool, rate_limited: bool) -> bool: + """The per-request gate: the no-creds SpotipyFree source may serve a request + ONLY when official Spotify can't — i.e. the user has no Spotify auth, or + we're currently rate-limited. When authed AND healthy the official path + returns before any fallback, so this never opens. + """ + return (not authenticated) or rate_limited + + +def should_offer_spotify_metadata(authenticated: bool, free_available: bool) -> bool: + """The availability gate: SoulSync can serve *some* Spotify metadata when + either real auth is present, or the no-creds fallback is available. The + upstream gates (search resolve, enrichment worker, watchlist) use this so + the fallback is actually reachable — without changing what + ``is_spotify_authenticated()`` means anywhere. + """ + return authenticated or free_available + + +def should_block_rate_limited_resume(rate_limited: bool, metadata_available: bool) -> bool: + """Whether to refuse resuming the Spotify enrichment worker. + + The worker's own loop bridges to the no-creds free source during a ban + (its rate-limit guard checks ``is_spotify_metadata_available()``). The + resume button must mirror that: block ONLY when rate-limited AND nothing + can serve (plain auth, no free) — otherwise resuming just sleeps. When the + free fallback is available, ``metadata_available`` is True during a ban + (``is_spotify_authenticated()`` returns False while banned), so resume is + allowed and the worker bridges via free. + """ + return rate_limited and not metadata_available + + +# -------------------------------------------------------------------------- +# Normalizers (pure — unit-testable against captured fixtures) +# -------------------------------------------------------------------------- + +def normalize_artist(raw: dict) -> dict: + """Map a raw SpotipyFree/spotapi artist object (from ``artist()`` or an + ``artist_search`` item's ``data``) onto the Spotify-compatible artist dict + SoulSync expects: ``{id, name, images, genres, followers, external_urls}``. + + Artist-search items carry no usable image (only color swatches), so + ``images`` may be empty there — SoulSync lazy-loads artist art separately. + Genres aren't provided by the web player at all. + """ + raw = raw or {} + profile = raw.get('profile') or {} + uri = raw.get('uri') or '' + artist_id = raw.get('id') or (uri.split(':')[-1] if uri else '') + name = profile.get('name') or raw.get('name') or '' + + images = [] + avatar = (raw.get('visuals') or {}).get('avatarImage') or {} + for src in (avatar.get('sources') or []): + if src.get('url'): + images.append({ + 'url': src['url'], + 'height': src.get('height'), + 'width': src.get('width'), + }) + + followers = (raw.get('stats') or {}).get('followers') + + return { + 'id': str(artist_id), + 'name': name, + 'images': images, + 'genres': [], # web player doesn't expose genres + 'followers': {'total': followers or 0}, + 'external_urls': ( + {'spotify': f'https://open.spotify.com/artist/{artist_id}'} + if artist_id else {} + ), + } + + +# -------------------------------------------------------------------------- +# Client +# -------------------------------------------------------------------------- + +class SpotifyFreeMetadataClient: + """Read-only Spotify metadata via SpotipyFree, normalized to SoulSync's + Spotify-compatible shapes. Methods mirror the metadata-source interface.""" + + def __init__(self): + self._sf = None # SpotipyFree.Spotify instance + self._public = None # spotapi.Public() for artist_search + + # -- lazy clients (soft import; absence is not fatal) ------------------ + def _sf_client(self): + if self._sf is None: + from SpotipyFree import Spotify # optional, user-installed + self._sf = Spotify() + return self._sf + + def _public_client(self): + if self._public is None: + import spotapi + self._public = spotapi.Public() + return self._public + + def is_available(self) -> bool: + try: + self._sf_client() + return True + except Exception as e: + logger.debug(f"SpotipyFree unavailable: {e}") + return False + + # -- search ----------------------------------------------------------- + def search_tracks(self, query: str, limit: int = 10) -> list[dict]: + try: + res = self._sf_client().search(query, limit=limit) or {} + items = ((res.get('tracks') or {}).get('items')) or [] + return items[:limit] + except Exception as e: + logger.debug(f"SpotipyFree search_tracks failed: {e}") + return [] + + def search_artists(self, query: str, limit: int = 10) -> list[dict]: + try: + pages = self._public_client().artist_search(query) + first = next(iter(pages), []) + out = [] + for item in first[:limit]: + data = item.get('data') if isinstance(item, dict) else None + if data: + out.append(normalize_artist(data)) + return out + except Exception as e: + logger.debug(f"SpotipyFree search_artists failed: {e}") + return [] + + def search_albums(self, query: str, limit: int = 10) -> list[dict]: + # No album-name search exists in SpotipyFree/spotapi. Albums are only + # reachable by id or via an artist's discography. + return [] + + # -- entity lookups --------------------------------------------------- + def get_album(self, album_id: str, include_tracks: bool = True) -> Optional[dict]: + try: + return self._sf_client().album(album_id) + except Exception as e: + logger.debug(f"SpotipyFree get_album({album_id}) failed: {e}") + return None + + def get_track_details(self, track_id: str) -> Optional[dict]: + try: + return self._sf_client().track(track_id) + except Exception as e: + logger.debug(f"SpotipyFree get_track_details({track_id}) failed: {e}") + return None + + def get_album_tracks(self, album_id: str) -> Optional[dict]: + try: + return self._sf_client().album_tracks(album_id) + except Exception as e: + logger.debug(f"SpotipyFree get_album_tracks({album_id}) failed: {e}") + return None + + def get_artist(self, artist_id: str) -> Optional[dict]: + try: + raw = self._sf_client().artist(artist_id) + return normalize_artist(raw) if raw else None + except Exception as e: + logger.debug(f"SpotipyFree get_artist({artist_id}) failed: {e}") + return None + + def get_artist_albums_list(self, artist_id: str, limit: int = 50) -> list[dict]: + try: + res = self._sf_client().artist_albums(artist_id) or {} + return (res.get('items') or [])[:limit] + except Exception as e: + logger.debug(f"SpotipyFree get_artist_albums_list({artist_id}) failed: {e}") + return [] diff --git a/core/spotify_worker.py b/core/spotify_worker.py index 4e0b3e66..95060d97 100644 --- a/core/spotify_worker.py +++ b/core/spotify_worker.py @@ -9,7 +9,12 @@ from datetime import datetime, date, timedelta from utils.logging_config import get_logger from database.music_database import MusicDatabase from core.spotify_client import SpotifyClient, SpotifyRateLimitError -from core.worker_utils import interruptible_sleep, set_album_api_track_count +from core.worker_utils import ( + ARTIST_NAME_MATCH_THRESHOLD, + interruptible_sleep, + set_album_api_track_count, + source_id_conflict, +) from core.enrichment.manual_match_honoring import honor_stored_match logger = get_logger("spotify_worker") @@ -121,11 +126,19 @@ class SpotifyWorker: rate_limit_info = self.client.get_rate_limit_info() if rate_limited else None in_cooldown = self.client.get_post_ban_cooldown_remaining() > 0 authenticated = self.client.sp is not None + # Is the worker still serving via the no-creds Spotify Free source + # despite the real-API ban? Only check WHEN rate-limited: during a + # ban is_spotify_authenticated() returns False without an API probe, + # so is_spotify_metadata_available() reduces to "is free available" + # (no quota cost). Lets the UI show "via Spotify Free" instead of a + # misleading "rate limited / waiting" while the worker keeps matching. + using_free = bool(rate_limited and self.client.is_spotify_metadata_available()) except Exception: authenticated = False rate_limited = False rate_limit_info = None in_cooldown = False + using_free = False return { 'enabled': True, @@ -134,6 +147,7 @@ class SpotifyWorker: 'idle': is_idle, 'authenticated': authenticated, 'rate_limited': rate_limited, + 'using_free': using_free, 'rate_limit': rate_limit_info, 'daily_budget': self._get_daily_budget_info(), 'current_item': self.current_item, @@ -181,16 +195,33 @@ class SpotifyWorker: interruptible_sleep(self._stop_event, 1) continue - # Rate limit guard — if globally rate limited, sleep until ban expires - if self.client.is_rate_limited(): + # Rate limit guard — if globally rate limited, sleep until ban + # expires. EXCEPT: when Spotify Free is available it bridges the + # ban (is_spotify_metadata_available() is True via the no-creds + # source, and the client routes there), so we keep enriching + # instead of stalling. Purely additive: with Spotify Free off, + # is_spotify_metadata_available() is False during a ban and this + # sleeps exactly as before. + if self.client.is_rate_limited() and not self.client.is_spotify_metadata_available(): info = self.client.get_rate_limit_info() remaining = info['remaining_seconds'] if info else 60 logger.debug(f"Spotify globally rate limited, sleeping {remaining}s...") interruptible_sleep(self._stop_event, min(remaining, 60)) # Check again every 60s max continue - # Daily budget guard — worker-only cap to avoid saturating Spotify rate limits - if self._is_daily_budget_exhausted(): + # Is the worker serving via the no-creds Spotify Free source this + # iteration? The daily budget and post-ban cooldown both exist to + # protect the REAL authenticated API from bans — they don't apply + # to free (a different, anonymous path). Computed once and reused + # below; the loop already probes auth, so no extra quota cost. + try: + free_serving = self.client._free_active() + except Exception: + free_serving = False + + # Daily budget guard — worker-only cap to avoid saturating the REAL + # Spotify API. Skipped while serving via free (free isn't that API). + if not free_serving and self._is_daily_budget_exhausted(): budget = self._get_daily_budget_info() resets_in = budget['resets_in_seconds'] logger.info(f"Daily enrichment budget exhausted ({budget['used']}/{budget['limit']}), " @@ -199,8 +230,9 @@ class SpotifyWorker: continue # Post-ban cooldown guard — after ban expires, wait before resuming - # to avoid immediately re-triggering the rate limit - cooldown = self.client.get_post_ban_cooldown_remaining() + # to avoid immediately re-triggering the rate limit. Only matters + # for the real API, so skip it while serving via free. + cooldown = 0 if free_serving else self.client.get_post_ban_cooldown_remaining() if cooldown > 0: logger.debug(f"Post-ban cooldown active ({cooldown}s left), sleeping...") interruptible_sleep(self._stop_event, min(cooldown, 60)) @@ -210,10 +242,12 @@ class SpotifyWorker: # We intentionally avoid calling is_spotify_authenticated() here # because it makes an API probe that can re-trigger rate limits # and lock users in an infinite rate-limit loop. - if not self.client.is_spotify_authenticated(): + # Available = real auth OR the no-creds SpotipyFree fallback + # (enrichment is metadata-only, so the free source can serve it). + if not self.client.is_spotify_metadata_available(): self.client.reload_config() - if not self.client.is_spotify_authenticated(): - logger.debug("Spotify not authenticated, sleeping 30s...") + if not self.client.is_spotify_metadata_available(): + logger.debug("Spotify metadata unavailable, sleeping 30s...") interruptible_sleep(self._stop_event, 30) continue @@ -239,7 +273,10 @@ class SpotifyWorker: continue self._process_item(item) - self._increment_daily_budget() + # Only real-API work counts toward the daily cap — free-served + # items don't touch the authenticated API's quota. + if not free_serving: + self._increment_daily_budget() interruptible_sleep(self._stop_event, self.inter_item_sleep) except SpotifyRateLimitError: @@ -483,12 +520,14 @@ class SpotifyWorker: logger.debug(f"No Spotify results for artist '{artist_name}'") return - # Find best fuzzy match — score all candidates, pick highest above threshold + # 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 >= self.name_similarity_threshold and score > best_score: + if score >= ARTIST_NAME_MATCH_THRESHOLD and score > best_score: best_obj = artist_obj best_score = score @@ -498,6 +537,19 @@ class SpotifyWorker: self._mark_status('artist', artist_id, 'error') self.stats['errors'] += 1 return + # Don't assign a Spotify id another (differently-named) artist + # already holds — prevents one id smeared across artists. + conflict = source_id_conflict( + self.db, 'spotify_artist_id', best_obj.id, artist_id, artist_name + ) + if conflict: + self._mark_status('artist', artist_id, 'not_found') + self.stats['not_found'] += 1 + logger.debug( + f"Artist '{artist_name}' -> Spotify {best_obj.id} skipped: " + f"already claimed by '{conflict}'" + ) + return self._update_artist(artist_id, best_obj) self.stats['matched'] += 1 logger.info(f"Matched artist '{artist_name}' -> Spotify ID: {best_obj.id} (score: {best_score:.2f})") diff --git a/core/sync/match_overrides.py b/core/sync/match_overrides.py index f83c007d..3bff435d 100644 --- a/core/sync/match_overrides.py +++ b/core/sync/match_overrides.py @@ -24,6 +24,10 @@ from __future__ import annotations from typing import Any, Callable, Dict, List, Optional +from utils.logging_config import get_logger + +logger = get_logger("sync.match_overrides") + def resolve_match_overrides( source_tracks: List[Dict[str, Any]], @@ -117,3 +121,76 @@ def record_manual_match( )) except Exception: return False + + +def resolve_durable_match_server_id( + db: Any, + profile_id: int, + source_track_id: str, + server_source: str, + valid_server_ids: set, +) -> Optional[str]: + """Current server track id for a DURABLE manual library match, or None. + + Unlike ``sync_match_cache`` (wiped on every rescan), the + ``manual_library_track_matches`` table survives a scan — so consulting it + here is what makes a user's Find & Add / manual match persist across a + library rescan (#787). If the stored ``library_track_id`` went stale + (a rescan re-keyed the track — common on Jellyfin/Navidrome), re-resolve + it from the stored file path and self-heal the row so the next lookup is + a direct hit. + + Pure helper: ``db`` is injected. ``valid_server_ids`` is the set of + string ids that currently exist in the server playlist's track list — + a re-resolved id is only returned if it's actually present. Never raises. + """ + if not source_track_id: + return None + finder = getattr(db, "find_manual_library_match_by_source_track_id", None) + if finder is None: + return None + try: + match = finder(profile_id, str(source_track_id), server_source or "") + except Exception: + return None + if not match: + return None + + lib_id = match.get("library_track_id") + if lib_id is not None and str(lib_id) in valid_server_ids: + return str(lib_id) + + # Stale pointer — re-resolve via the stored file path and self-heal. + file_path = match.get("library_file_path") + resolver = getattr(db, "find_track_id_by_file_path", None) + if file_path and resolver is not None: + try: + new_id = resolver(file_path) + except Exception: + new_id = None + if new_id and str(new_id) in valid_server_ids: + _self_heal_match_id(db, match, str(new_id)) + return str(new_id) + return None + + +def _self_heal_match_id(db: Any, match: Dict[str, Any], new_library_track_id: str) -> None: + """Rewrite a manual match's library_track_id after re-resolution. Best-effort.""" + saver = getattr(db, "save_manual_library_match", None) + if saver is None: + return + try: + saver( + match.get("profile_id", 1), + match.get("source", ""), + match.get("source_track_id", ""), + new_library_track_id, + source_title=match.get("source_title"), + source_artist=match.get("source_artist"), + source_album=match.get("source_album"), + source_context_json=match.get("source_context_json"), + server_source=match.get("server_source", ""), + library_file_path=match.get("library_file_path"), + ) + except Exception as e: + logger.debug("manual match self-heal failed: %s", e) diff --git a/core/sync/playlist_edit.py b/core/sync/playlist_edit.py index c2cf34d5..80325238 100644 --- a/core/sync/playlist_edit.py +++ b/core/sync/playlist_edit.py @@ -71,4 +71,64 @@ def remove_one_occurrence( return ids, False -__all__ = ["plan_playlist_add", "remove_one_occurrence"] +def plan_playlist_reconcile( + current_ids: List[str], + desired_ids: List[str], +) -> dict: + """Plan an in-place reconcile of a server playlist toward a desired tracklist. + + Used by ``sync_mode='reconcile'`` (#792): instead of deleting + recreating + the playlist (which destroys its custom image, description, and identity), + the caller keeps the existing playlist object and applies only the delta — + adding the tracks that are missing and removing the ones no longer in the + source. Pure, no I/O. + + Returns ``{'add': [...], 'remove': [...]}`` (both lists of string ids): + - ``add`` — desired ids not currently present, in desired order. + - ``remove`` — current ids no longer desired (each occurrence kept once; + duplicates of a still-desired id are left for the caller's + dedupe to handle, never mass-removed). + + Order-preserving and duplicate-safe: a desired id already present is not + re-added; a current id that's still desired is not removed even if it + appears more than once. + """ + desired = [str(t) for t in desired_ids] + current = [str(t) for t in current_ids] + current_set = set(current) + desired_set = set(desired) + add = [d for d in desired if d not in current_set] + # Preserve order of removal as it appears in the current list; one entry per + # id (the caller maps ids back to concrete playlist entries to delete). + seen_remove = set() + remove = [] + for c in current: + if c not in desired_set and c not in seen_remove: + seen_remove.add(c) + remove.append(c) + return {"add": add, "remove": remove} + + +VALID_SYNC_MODES = ("replace", "append", "reconcile") + + +def normalize_sync_mode(requested, configured, default: str = "replace") -> str: + """Resolve the effective playlist sync mode. + + An explicit per-request value wins; otherwise the configured default + (Settings > Playlist sync mode); anything unrecognized falls back to + ``default``. Keeping ``reconcile`` in ``VALID_SYNC_MODES`` is load-bearing — + a validation list that omits it silently downgrades reconcile to replace, + which is exactly the #792 regression this helper exists to prevent. + """ + mode = (requested or "") or (configured or "") or default + return mode if mode in VALID_SYNC_MODES else default + + +__all__ = [ + "plan_playlist_add", + "remove_one_occurrence", + "plan_playlist_reconcile", + "normalize_sync_mode", + "VALID_SYNC_MODES", +] diff --git a/core/tag_writer.py b/core/tag_writer.py index e6edbc12..707454ac 100644 --- a/core/tag_writer.py +++ b/core/tag_writer.py @@ -136,10 +136,49 @@ def read_file_tags(file_path: str) -> Dict[str, Any]: return result +# Known placeholder / "we don't really know" metadata values. A match in the +# DB never warrants overwriting a real value already in the file (issue #800: +# a mis-grouped track sits under a "Various Artists" / "[Unknown Album]" record, +# and Write Tags would otherwise stamp that junk over the file's correct tags). +_PLACEHOLDER_META_VALUES = frozenset({ + 'various artists', 'various artist', + 'unknown artist', 'unknown album', 'unknown', + '[unknown album]', '[unknown artist]', '[unknown]', + 'untitled album', +}) + + +def is_placeholder_meta(value: Any) -> bool: + """True for empty or known-placeholder metadata strings (case-insensitive).""" + if value is None: + return True + s = str(value).strip().lower() + return s == '' or s in _PLACEHOLDER_META_VALUES + + +def guard_placeholder_overwrite(db_val: Any, file_val: Any) -> Any: + """#800 guard: never replace a real file value with a placeholder. + + Returns ``None`` (skip the write → preserve the file's value) ONLY when the + DB value is a placeholder/empty AND the file already holds a real, + non-placeholder value. Otherwise returns ``db_val`` unchanged — so a + legitimate value still writes, including a genuine ``Various Artists`` album + artist on a real compilation (there the file has no conflicting real value, + so the guard doesn't fire). + """ + if is_placeholder_meta(db_val) and not is_placeholder_meta(file_val): + return None + return db_val + + def build_tag_diff(file_tags: Dict[str, Any], db_data: Dict[str, Any]) -> List[Dict[str, Any]]: """ Compare file tags against DB metadata. Returns a list of diffs: - [{ field, file_value, db_value, changed }] + [{ field, file_value, db_value, changed, protected }] + + ``protected`` marks a field the #800 guard is holding back: the DB value is + a placeholder and the file's real value would be preserved, so it's shown + as no-change rather than a wrong overwrite. """ fields = [ ('title', 'title', 'Title'), @@ -179,12 +218,22 @@ def build_tag_diff(file_tags: Dict[str, Any], db_data: Dict[str, Any]) -> List[D # Only mark as changed if DB has a value AND it differs from file # (writer skips fields where DB value is empty, so don't show them as diffs) changed = bool(db_str) and file_str != db_str + + # #800 — if the change would replace a real file value with a + # placeholder (Various Artists / [Unknown Album] / …), hold it back: + # the writer preserves the file's value, so show it as no-change. + protected = False + if changed and guard_placeholder_overwrite(db_val, file_val) is None: + changed = False + protected = True + diffs.append({ 'field': label, 'file_key': file_key, 'file_value': str(file_val) if file_val is not None else '', 'db_value': str(db_val) if db_val is not None else '', 'changed': changed, + 'protected': protected, }) # Cover art — special row @@ -253,6 +302,23 @@ def write_tags_to_file(file_path: str, db_data: Dict[str, Any], artist = db_data.get('track_artist') or db_data.get('artist_name') # Per-track artist for compilations/DJ mixes album = db_data.get('album_title') album_artist = db_data.get('artist_name') # Album artist stays as the album-level artist + + # #800 — never overwrite a real value already in the file with a + # placeholder DB value (Various Artists / [Unknown Album] / …). A + # mis-grouped track sits under such a record; writing it would destroy + # the file's correct tags. Reads the file's current values and skips + # only the placeholder-over-real fields (legit values, incl. a genuine + # compilation's Various Artists, still write — see guard docstring). + try: + _current = read_file_tags(file_path) + except Exception: + _current = {} + if not _current.get('error'): + title = guard_placeholder_overwrite(title, _current.get('title')) + artist = guard_placeholder_overwrite(artist, _current.get('artist')) + album = guard_placeholder_overwrite(album, _current.get('album')) + album_artist = guard_placeholder_overwrite(album_artist, _current.get('album_artist')) + year = db_data.get('year') genres = db_data.get('genres') track_num = db_data.get('track_number') @@ -294,6 +360,24 @@ def write_tags_to_file(file_path: str, db_data: Dict[str, Any], year, genre_str, track_num, total_tracks, disc_num, bpm, artists_list=artists_list) + # Embed already-known source IDs (Spotify / iTunes / MusicBrainz) from + # db_data, reusing the canonical import-time frame writer — no API + # re-fetch. Only fires when db_data carries id keys, so the plain + # "write the core tags" callers are unaffected. + _src_meta = {k: db_data[k] for k in ( + 'source', 'source_track_id', 'source_album_id', 'source_artist_id', + 'spotify_track_id', 'spotify_album_id', 'spotify_artist_id', + 'itunes_track_id', 'itunes_album_id', 'itunes_artist_id', + 'musicbrainz_recording_id', 'musicbrainz_release_id', + ) if db_data.get(k)} + if _src_meta: + try: + from core.metadata.source import embed_known_source_ids + if embed_known_source_ids(audio, _src_meta): + written.append('source_ids') + except Exception as e: + logger.debug("source-id embed skipped for %s: %s", file_path, e) + # Embed cover art if requested if embed_cover: art_ok = False diff --git a/core/tidal_worker.py b/core/tidal_worker.py index 051f2843..01ac795f 100644 --- a/core/tidal_worker.py +++ b/core/tidal_worker.py @@ -7,7 +7,7 @@ from datetime import datetime, timedelta from utils.logging_config import get_logger from database.music_database import MusicDatabase from core.tidal_client import TidalClient -from core.worker_utils import interruptible_sleep +from core.worker_utils import accept_artist_match, interruptible_sleep from core.enrichment.manual_match_honoring import honor_stored_match logger = get_logger("tidal_worker") @@ -313,13 +313,29 @@ class TidalWorker: logger.debug(f"Name similarity: '{query_name}' vs '{result_name}' = {similarity:.2f}") return similarity >= self.name_similarity_threshold - def _verify_artist_id(self, item: Dict[str, Any], result_artist_id) -> bool: - """Verify/correct parent artist's Tidal ID based on album/track match""" + def _verify_artist_id(self, item: Dict[str, Any], result_artist_id, + result_artist_name: Optional[str] = None) -> bool: + """Verify/correct parent artist's Tidal ID based on album/track match. + + Only corrects when the result's artist *name* matches our parent artist — + otherwise a collaboration/compilation would stamp the wrong Tidal id onto + our artist. See the Deezer fix for the full write-up.""" parent_tidal_id = item.get('artist_tidal_id') if not parent_tidal_id or not result_artist_id: return True if str(result_artist_id) != str(parent_tidal_id): + parent_name = item.get('artist') or '' + if (result_artist_name and parent_name + and not self._name_matches(parent_name, result_artist_name)): + logger.info( + f"Skipping artist-ID correction from {item['type']} " + f"'{item['name']}': result artist '{result_artist_name}' " + f"≠ parent '{parent_name}' (collab/compilation, not a " + f"correction)" + ) + return True + logger.info( f"Artist ID correction from {item['type']} '{item['name']}': " f"updating parent artist Tidal ID from {parent_tidal_id} to {result_artist_id}" @@ -419,14 +435,19 @@ class TidalWorker: result = self.client.search_artist(artist_name) if result: result_name = result.get('name', '') - if self._name_matches(artist_name, result_name): - tidal_artist_id = result.get('id') - if not tidal_artist_id: - self._mark_status('artist', artist_id, 'error') - self.stats['errors'] += 1 - logger.warning(f"Tidal search result for '{artist_name}' has no ID") - return - + tidal_artist_id = result.get('id') + ok, reason = accept_artist_match( + self.db, 'tidal_id', tidal_artist_id, artist_id, artist_name, result_name, + ) + if not ok: + self._mark_status('artist', artist_id, 'not_found') + self.stats['not_found'] += 1 + logger.debug(f"Artist '{artist_name}' not matched: {reason}") + elif not tidal_artist_id: + self._mark_status('artist', artist_id, 'error') + self.stats['errors'] += 1 + logger.warning(f"Tidal search result for '{artist_name}' has no ID") + else: # Fetch full artist details for image full_artist = None try: @@ -437,10 +458,6 @@ class TidalWorker: self._update_artist(artist_id, result, full_artist) self.stats['matched'] += 1 logger.info(f"Matched artist '{artist_name}' -> Tidal ID: {tidal_artist_id}") - else: - self._mark_status('artist', artist_id, 'not_found') - self.stats['not_found'] += 1 - logger.debug(f"Name mismatch for artist '{artist_name}' (got '{result_name}')") else: self._mark_status('artist', artist_id, 'not_found') self.stats['not_found'] += 1 @@ -479,7 +496,8 @@ class TidalWorker: # Verify artist ID result_artist = result.get('artist', {}) result_artist_id = result_artist.get('id') if result_artist else None - self._verify_artist_id(item, result_artist_id) + result_artist_name = result_artist.get('name') if result_artist else None + self._verify_artist_id(item, result_artist_id, result_artist_name) # Fetch full album details tidal_album_id = result.get('id') @@ -533,7 +551,8 @@ class TidalWorker: # Verify artist ID result_artist = result.get('artist', {}) result_artist_id = result_artist.get('id') if result_artist else None - self._verify_artist_id(item, result_artist_id) + result_artist_name = result_artist.get('name') if result_artist else None + self._verify_artist_id(item, result_artist_id, result_artist_name) # Fetch full track details tidal_track_id = result.get('id') diff --git a/core/torrent_clients/base.py b/core/torrent_clients/base.py index 4efe936e..f95cdbbc 100644 --- a/core/torrent_clients/base.py +++ b/core/torrent_clients/base.py @@ -20,6 +20,24 @@ from dataclasses import dataclass from typing import List, Optional, Protocol, runtime_checkable +def normalize_client_url(raw: str) -> str: + """Clean a user-entered WebUI URL into something ``requests`` accepts. + + Users routinely type a bare host like ``192.168.1.5:8080`` or + ``qbittorrent.lan:8080`` with no scheme. ``requests`` then raises + "No connection adapters were found for '...'" because it can't pick an + http/https adapter (a bare ``host:port`` even gets misparsed as + ``scheme=host``). Default a missing scheme to ``http://`` and trim a + trailing slash. Empty input passes through unchanged. + """ + url = (raw or '').strip() + if not url: + return '' + if '://' not in url: + url = 'http://' + url + return url.rstrip('/') + + @dataclass class TorrentStatus: """Adapter-uniform view of one torrent's live state. diff --git a/core/torrent_clients/deluge.py b/core/torrent_clients/deluge.py index 7c7e6ae9..a1c23e69 100644 --- a/core/torrent_clients/deluge.py +++ b/core/torrent_clients/deluge.py @@ -20,7 +20,7 @@ from typing import Any, List, Optional import requests as http_requests from config.settings import config_manager -from core.torrent_clients.base import TorrentStatus +from core.torrent_clients.base import TorrentStatus, normalize_client_url from utils.logging_config import get_logger logger = get_logger("torrent.deluge") @@ -67,7 +67,7 @@ class DelugeAdapter: self._load_config() def _load_config(self) -> None: - self._url = (config_manager.get('torrent_client.url', '') or '').rstrip('/') + self._url = normalize_client_url(config_manager.get('torrent_client.url', '')) # Deluge's WebUI auth uses a single password, not username+password. # We accept whichever field the user filled in — keeps the UI uniform. self._password = ( diff --git a/core/torrent_clients/qbittorrent.py b/core/torrent_clients/qbittorrent.py index 147c359b..537fdd97 100644 --- a/core/torrent_clients/qbittorrent.py +++ b/core/torrent_clients/qbittorrent.py @@ -18,7 +18,7 @@ from typing import List, Optional import requests as http_requests from config.settings import config_manager -from core.torrent_clients.base import TorrentStatus +from core.torrent_clients.base import TorrentStatus, normalize_client_url from utils.logging_config import get_logger logger = get_logger("torrent.qbittorrent") @@ -64,7 +64,7 @@ class QBittorrentAdapter: self._load_config() def _load_config(self) -> None: - self._url = (config_manager.get('torrent_client.url', '') or '').rstrip('/') + self._url = normalize_client_url(config_manager.get('torrent_client.url', '')) self._username = config_manager.get('torrent_client.username', '') or '' self._password = config_manager.get('torrent_client.password', '') or '' self._category = config_manager.get('torrent_client.category', 'soulsync') or 'soulsync' diff --git a/core/torrent_clients/transmission.py b/core/torrent_clients/transmission.py index 266bda79..050d7968 100644 --- a/core/torrent_clients/transmission.py +++ b/core/torrent_clients/transmission.py @@ -18,7 +18,7 @@ from typing import List, Optional import requests as http_requests from config.settings import config_manager -from core.torrent_clients.base import TorrentStatus +from core.torrent_clients.base import TorrentStatus, normalize_client_url from utils.logging_config import get_logger logger = get_logger("torrent.transmission") @@ -57,7 +57,7 @@ class TransmissionAdapter: self._load_config() def _load_config(self) -> None: - url = (config_manager.get('torrent_client.url', '') or '').rstrip('/') + url = normalize_client_url(config_manager.get('torrent_client.url', '')) # Transmission's RPC endpoint is always /transmission/rpc — if the # user pasted a bare host URL, append it. If they pasted the full # /transmission/rpc URL, leave it. diff --git a/core/watchlist_scanner.py b/core/watchlist_scanner.py index 9fdb967b..c330de4c 100644 --- a/core/watchlist_scanner.py +++ b/core/watchlist_scanner.py @@ -489,12 +489,16 @@ class WatchlistScanner: self._spotify_disabled_reason = reason def _spotify_available_for_run(self) -> bool: - """Check if Spotify should be used for this run.""" + """Check if Spotify should be used for this run. + + Available = real auth OR the no-creds SpotipyFree fallback (new-release + detection is metadata-only — get_artist_albums — so the free source can + serve it; the client routes internally when auth is missing/limited).""" if self._spotify_disabled_for_run: return False if not self.spotify_client: return False - return self.spotify_client.is_spotify_authenticated() + return self.spotify_client.is_spotify_metadata_available() def _spotify_is_primary_source(self) -> bool: """Check if Spotify is both authenticated and the configured primary metadata source. diff --git a/core/worker_utils.py b/core/worker_utils.py index a8d965b9..7a8b57cb 100644 --- a/core/worker_utils.py +++ b/core/worker_utils.py @@ -1,10 +1,105 @@ """Shared helpers for background workers.""" import logging +import re import threading +from difflib import SequenceMatcher +from typing import Optional logger = logging.getLogger(__name__) +# Artist-match acceptance gate. Stricter than the 0.80 each worker uses for +# album/track titles: artist names are short, so 0.80 lets distinct artists +# slip through ("ODESZA"/"odessa", "Blance"/"Blanke", "Lady A"/"Lady Gaga" all +# score 0.80-0.83). 0.85 rejects those while still tolerating real variation +# that survives normalization. +ARTIST_NAME_MATCH_THRESHOLD = 0.85 + +# Whitelist of artist source-id columns we'll interpolate into SQL — guards the +# conflict query against any unexpected column name. +_ARTIST_ID_COLUMNS = frozenset({ + 'deezer_id', 'spotify_artist_id', 'itunes_artist_id', 'musicbrainz_id', + 'discogs_id', 'audiodb_id', 'qobuz_id', 'tidal_id', 'amazon_id', 'soul_id', +}) + + +def normalize_artist_name(name: str) -> str: + """Lowercase, drop ' - ...' suffixes / parentheticals / punctuation, and + collapse whitespace — the same normalization the per-worker matchers use.""" + name = (name or '').lower().strip() + name = re.sub(r'\s+[-–—]\s+.*$', '', name) + name = re.sub(r'\s*\(.*?\)\s*', ' ', name) + name = re.sub(r'[^\w\s]', '', name) + name = re.sub(r'\s+', ' ', name).strip() + return name + + +def artist_name_matches(query: str, result: str, + threshold: float = ARTIST_NAME_MATCH_THRESHOLD) -> bool: + """True if two artist names match at/above ``threshold`` after normalization.""" + nq, nr = normalize_artist_name(query), normalize_artist_name(result) + if not nq or not nr: + return False + return SequenceMatcher(None, nq, nr).ratio() >= threshold + + +def _names_equivalent(a: str, b: str) -> bool: + return normalize_artist_name(a) == normalize_artist_name(b) + + +def source_id_conflict(database, id_column: str, source_id, artist_id, + artist_name: str) -> Optional[str]: + """Return the name of a DIFFERENTLY-named library artist that already holds + ``source_id`` in ``id_column``, or None. + + A same-named holder (the same artist indexed on two media servers) is NOT a + conflict — both legitimately share the id. Only a different artist holding + the id signals the kind of corruption where one source id gets smeared + across unrelated artists. + """ + if source_id in (None, ''): + return None + if id_column not in _ARTIST_ID_COLUMNS: + logger.debug(f"source_id_conflict: refusing unknown column {id_column!r}") + return None + try: + with database._get_connection() as conn: + rows = conn.execute( + f"SELECT name FROM artists WHERE {id_column} = ? AND id != ?", + (str(source_id), artist_id), + ).fetchall() + except Exception as e: + logger.debug(f"source_id_conflict check failed for {id_column}={source_id}: {e}") + return None + for (other_name,) in rows: + if not _names_equivalent(artist_name, other_name): + return other_name + return None + + +def accept_artist_match(database, id_column: str, source_id, artist_id, + query_name: str, result_name: str, + threshold: float = ARTIST_NAME_MATCH_THRESHOLD) -> tuple: + """Decide whether to store ``source_id`` on an artist. + + Returns ``(ok: bool, reason: str)``. Accepts only when the result's name + matches the library artist at/above ``threshold`` AND the id isn't already + claimed by a differently-named artist. ``reason`` explains a rejection (for + debug logging). This is the single gate every worker's artist match should + pass through, so the 'one id smeared across many artists' bug can't recur. + """ + if not artist_name_matches(query_name, result_name, threshold): + return False, ( + f"name mismatch '{query_name}' vs '{result_name}' (< {threshold})" + ) + conflict = source_id_conflict(database, id_column, source_id, artist_id, query_name) + if conflict: + return False, ( + f"{id_column}={source_id} already claimed by '{conflict}' — " + f"skipping to avoid a shared/duplicate id" + ) + return True, "" + def interruptible_sleep(stop_event: threading.Event, seconds: float, step: float = 0.5) -> bool: """Sleep in chunks so shutdown can interrupt long waits.""" diff --git a/database/music_database.py b/database/music_database.py index e978df0c..71c7aa23 100644 --- a/database/music_database.py +++ b/database/music_database.py @@ -790,6 +790,55 @@ class MusicDatabase: except Exception as e: logger.debug("Failed to purge cached tracks/albums with junk artist names: %s", e) + # One-time migration: clear source ids that enrichment wrongly + # SHARED across differently-named artists. The album/track "artist + # id correction" path (Deezer/AudioDB/Qobuz/Tidal) used to overwrite + # an artist's source id from a match without a name check, so e.g. + # everyone featured on Kendrick Lamar's curated "Black Panther" album + # got stamped with Kendrick's Deezer id. The workers are now + # name-guarded so this can't recur; clearing the bad rows lets the + # next enrichment pass re-derive each artist's correct id. + # Same-name duplicates (one artist indexed on two media servers, + # legitimately sharing an id) are left alone via the DISTINCT-name + # check, so this only touches genuine corruption. + try: + cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='_source_id_dedupe_v1'") + if not cursor.fetchone(): + _dedupe_id_cols = [ + ('deezer_id', 'deezer_match_status'), + ('spotify_artist_id', 'spotify_match_status'), + ('itunes_artist_id', 'itunes_match_status'), + ('musicbrainz_id', 'musicbrainz_match_status'), + ('discogs_id', 'discogs_match_status'), + ('audiodb_id', 'audiodb_match_status'), + ('qobuz_id', 'qobuz_match_status'), + ('tidal_id', 'tidal_match_status'), + ] + total_cleared = 0 + for id_col, status_col in _dedupe_id_cols: + try: + cursor.execute(f""" + UPDATE artists + SET {id_col} = NULL, {status_col} = NULL + WHERE {id_col} IN ( + SELECT {id_col} FROM artists + WHERE {id_col} IS NOT NULL AND {id_col} != '' + GROUP BY {id_col} + HAVING COUNT(DISTINCT LOWER(TRIM(name))) > 1 + ) + """) + total_cleared += cursor.rowcount + except Exception as col_err: + logger.debug("Source-id dedupe skipped %s: %s", id_col, col_err) + cursor.execute("CREATE TABLE _source_id_dedupe_v1 (applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)") + if total_cleared > 0: + logger.info( + f"Cleared {total_cleared} duplicated source ids shared across " + f"differently-named artists — they'll re-derive on next enrichment" + ) + except Exception as e: + logger.debug("Failed to dedupe shared source ids: %s", e) + # HiFi API instances table cursor.execute(""" CREATE TABLE IF NOT EXISTS hifi_instances ( @@ -970,6 +1019,10 @@ class MusicDatabase: 'canonical_album_id': 'TEXT DEFAULT NULL', 'canonical_score': 'REAL DEFAULT NULL', 'canonical_resolved_at': 'TIMESTAMP DEFAULT NULL', + # #758 — set when the user MANUALLY pins an album version. The + # auto resolve job (and any re-resolution) must never overwrite + # a locked pin, so a manual match stays put across cycles. + 'canonical_locked': 'INTEGER DEFAULT 0', } for _col, _typedef in _canonical_cols.items(): if album_cols and _col not in album_cols: @@ -978,17 +1031,27 @@ class MusicDatabase: except Exception as e: logger.error("Error repairing core media schema columns: %s", e) - def set_album_canonical(self, album_id, source: str, canonical_album_id: str, score: float) -> bool: + def set_album_canonical(self, album_id, source: str, canonical_album_id: str, + score: float, locked: bool = False) -> bool: """Persist the resolved canonical (source, album_id, score) for an album - (#765 Stage 2). Returns True if a row was updated.""" + (#765 Stage 2). Returns True if a row was updated. + + ``locked=True`` marks a MANUAL pin (#758): the user explicitly chose this + album version. A manual write always wins (overwrites any existing pin). + An AUTO write (``locked=False``, the resolve job) will NOT overwrite a + locked pin — the guard is in the WHERE clause so it's atomic. + """ conn = self._get_connection() try: cursor = conn.cursor() + # Auto writes can't clobber a manual lock; manual writes always apply. + guard = "" if locked else " AND (canonical_locked IS NULL OR canonical_locked = 0)" cursor.execute( "UPDATE albums SET canonical_source = ?, canonical_album_id = ?, " - "canonical_score = ?, canonical_resolved_at = CURRENT_TIMESTAMP " - "WHERE id = ?", - (source, str(canonical_album_id), float(score), album_id), + "canonical_score = ?, canonical_locked = ?, " + "canonical_resolved_at = CURRENT_TIMESTAMP " + f"WHERE id = ?{guard}", + (source, str(canonical_album_id), float(score), 1 if locked else 0, album_id), ) conn.commit() return cursor.rowcount > 0 @@ -999,15 +1062,16 @@ class MusicDatabase: conn.close() def get_album_canonical(self, album_id) -> Optional[dict]: - """Return ``{'source','album_id','score','resolved_at'}`` for an album's - pinned canonical release, or ``None`` when unresolved (#765 Stage 2). - Consumers treat ``None`` as 'fall back to today's behavior'.""" + """Return ``{'source','album_id','score','resolved_at','locked'}`` for an + album's pinned canonical release, or ``None`` when unresolved (#765 Stage + 2). ``locked`` is True for a manual pin (#758). Consumers treat ``None`` + as 'fall back to today's behavior'.""" conn = self._get_connection() try: cursor = conn.cursor() cursor.execute( "SELECT canonical_source, canonical_album_id, canonical_score, " - "canonical_resolved_at FROM albums WHERE id = ?", + "canonical_resolved_at, canonical_locked FROM albums WHERE id = ?", (album_id,), ) row = cursor.fetchone() @@ -1018,6 +1082,7 @@ class MusicDatabase: 'album_id': row[1], 'score': row[2], 'resolved_at': row[3], + 'locked': bool(row[4]), } except Exception as e: logger.error("Error reading album canonical for %s: %s", album_id, e) @@ -4621,21 +4686,29 @@ class MusicDatabase: CREATE INDEX IF NOT EXISTS idx_mltm_lib_track ON manual_library_track_matches (library_track_id) """) + # Stable re-resolution key: a library rescan can drop/re-key + # tracks (esp. Jellyfin/Navidrome GUIDs), leaving library_track_id + # dangling. Storing the file path lets us re-find the current + # track id after a scan so manual matches survive it. + cursor.execute("PRAGMA table_info(manual_library_track_matches)") + _mltm_cols = {r[1] for r in cursor.fetchall()} + if 'library_file_path' not in _mltm_cols: + cursor.execute("ALTER TABLE manual_library_track_matches ADD COLUMN library_file_path TEXT") except Exception as e: logger.error(f"Error creating manual_library_track_matches table: {e}") def save_manual_library_match(self, profile_id: int, source: str, source_track_id: str, library_track_id: str, **meta) -> bool: """Insert or replace a manual match. meta keys: source_title, source_artist, - source_album, source_context_json, server_source.""" + source_album, source_context_json, server_source, library_file_path.""" try: with self._get_connection() as conn: conn.execute(""" INSERT INTO manual_library_track_matches (profile_id, source, source_track_id, library_track_id, source_title, source_artist, source_album, - source_context_json, server_source, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) + source_context_json, server_source, library_file_path, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) ON CONFLICT(profile_id, source, source_track_id, server_source) DO UPDATE SET library_track_id = excluded.library_track_id, @@ -4643,18 +4716,46 @@ class MusicDatabase: source_artist = excluded.source_artist, source_album = excluded.source_album, source_context_json = excluded.source_context_json, + library_file_path = excluded.library_file_path, updated_at = CURRENT_TIMESTAMP """, ( profile_id, source, source_track_id, library_track_id, meta.get('source_title'), meta.get('source_artist'), meta.get('source_album'), meta.get('source_context_json'), - meta.get('server_source', ''), + meta.get('server_source', ''), meta.get('library_file_path'), )) return True except Exception as e: logger.error(f"save_manual_library_match error: {e}") return False + def find_track_id_by_file_path(self, file_path: str) -> Optional[str]: + """Return the current tracks.id for a file path, or None. + + Used to re-resolve a manual match whose stored library_track_id went + stale after a rescan re-keyed the track. Exact path first, then a + basename fallback (handles server-vs-local path differences).""" + if not file_path: + return None + try: + conn = self._get_connection() + cursor = conn.cursor() + cursor.execute("SELECT id FROM tracks WHERE file_path = ? LIMIT 1", (file_path,)) + row = cursor.fetchone() + if row: + return str(row[0]) + import os as _os + fname = _os.path.basename(str(file_path).replace('\\', '/')) + if fname: + cursor.execute("SELECT id FROM tracks WHERE file_path LIKE ? LIMIT 1", (f"%{fname}",)) + row = cursor.fetchone() + if row: + return str(row[0]) + return None + except Exception as e: + logger.error(f"find_track_id_by_file_path error: {e}") + return None + def get_manual_library_match(self, profile_id: int, source: str, source_track_id: str, server_source: str = '') -> Optional[Dict[str, Any]]: """Return match row dict or None.""" @@ -5948,8 +6049,12 @@ class MusicDatabase: except Exception as e: logger.debug("history logging: %s", e) - return True - + # Truthy on success (existing `if track_success` callers keep + # working); the specific value lets the scan worker tell a + # genuinely new row from an updated one so it can reconcile + # embedded IDs only for new arrivals. + return 'inserted' if is_new_track else 'updated' + except Exception as e: error_text = str(e).lower() if ( diff --git a/scripts/dedupe_source_ids.py b/scripts/dedupe_source_ids.py new file mode 100644 index 00000000..5cee6ba3 --- /dev/null +++ b/scripts/dedupe_source_ids.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +"""One-off repair for source ids that enrichment wrongly shared across multiple +artists (the Kendrick/Jorja corruption — one Deezer/AudioDB/Qobuz/Tidal id +stamped onto several unrelated artists). + +Dry-run by default — shows exactly what it would clear and writes nothing. + +Usage: + python scripts/dedupe_source_ids.py # dry-run (review first) + python scripts/dedupe_source_ids.py --apply # actually clear them + +After --apply, run metadata enrichment so the (now name-checked) workers +re-derive each artist's id correctly. Stop the app first so the DB isn't locked. +""" + +import logging +import os +import sys + +# Allow running directly (`python scripts/dedupe_source_ids.py`) — put the repo +# root on the path so `core` / `database` import. +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from core.maintenance.dedupe_source_ids import clear_corrupt_source_ids # noqa: E402 +from database.music_database import MusicDatabase # noqa: E402 + +if not logging.getLogger().handlers: + logging.basicConfig(level=logging.INFO, format="%(message)s") +logger = logging.getLogger("dedupe_source_ids") + + +def main(): + apply = "--apply" in sys.argv[1:] + db = MusicDatabase() + report = clear_corrupt_source_ids(db, dry_run=not apply) + + mode = "APPLYING" if apply else "DRY-RUN (no changes written)" + logger.info(f"=== Source-id corruption repair — {mode} ===") + logger.info( + f"Corrupt clusters: {report['cluster_count']} | " + f"artists affected: {report['artist_count']}" + ) + if report['by_source']: + logger.info("By source: " + ", ".join( + f"{s}={n}" for s, n in sorted(report['by_source'].items()) + )) + for c in report['clusters']: + logger.info(f" [{c['source']}] id {c['source_id']} -> {', '.join(c['artists'])}") + + if not report['cluster_count']: + logger.info("Nothing to clean — no shared source ids across differently-named artists.") + elif apply: + logger.info("Cleared. Now run metadata enrichment to re-derive these ids correctly.") + else: + logger.info("Re-run with --apply to clear these (then run enrichment to re-derive).") + + +if __name__ == "__main__": + main() diff --git a/services/sync_service.py b/services/sync_service.py index 072185d4..803f2e8e 100644 --- a/services/sync_service.py +++ b/services/sync_service.py @@ -195,6 +195,26 @@ class PlaylistSyncService: failed_tracks=failed_tracks )) + def _reconcile_or_replace(self, client, playlist_name: str, tracks) -> bool: + """Reconcile mode (#792): edit the playlist in place (add/remove delta, + preserving its image + description + identity). If the client lacks + reconcile or it fails, fall back to the destructive replace so the sync + still succeeds — logged loudly so a failing in-place edit is diagnosable. + """ + fn = getattr(client, 'reconcile_playlist', None) + if fn is None: + return client.update_playlist(playlist_name, tracks) + try: + if fn(playlist_name, tracks): + return True + logger.warning( + "Reconcile sync failed for '%s' — falling back to replace " + "(playlist will be recreated this once)", playlist_name) + except Exception as e: + logger.warning( + "Reconcile sync errored for '%s' (%s) — falling back to replace", playlist_name, e) + return client.update_playlist(playlist_name, tracks) + async def sync_playlist(self, playlist: SpotifyPlaylist, download_missing: bool = False, profile_id: int = None, sync_mode: str = 'replace') -> SyncResult: self._active_profile_id = profile_id # Check if THIS specific playlist is already syncing @@ -378,6 +398,8 @@ class PlaylistSyncService: ) if sync_mode == 'append': sync_success = media_client.append_to_playlist(playlist.name, valid_tracks) + elif sync_mode == 'reconcile': + sync_success = self._reconcile_or_replace(media_client, playlist.name, valid_tracks) else: sync_success = media_client.update_playlist(playlist.name, valid_tracks) diff --git a/tests/discovery/test_discovery_playlist.py b/tests/discovery/test_discovery_playlist.py index 0c6cfea3..3c5de2a6 100644 --- a/tests/discovery/test_discovery_playlist.py +++ b/tests/discovery/test_discovery_playlist.py @@ -496,3 +496,45 @@ def test_multi_playlist_aggregates_grand_total(): # All 3 tracks discovered → 3 extra_data writes assert len(deps._db.extra_data_writes) == 3 + + +# --------------------------------------------------------------------------- +# _canonical_best_score — #785: file/CSV playlists keep raw "Artist - Title" +# titles (YouTube is cleaned at ingest); the worker must try the canonical form. +# --------------------------------------------------------------------------- + +from types import SimpleNamespace # noqa: E402 + + +def test_canonical_best_score_matches_file_style_title(): + # Raw "Artist - Title" scores low; canonical "Title" scores high → take it. + def score(title, artist, dur, results): + if title == 'Do I Wanna Know?': + return ('MATCH', 0.95, None) + return (None, 0.2, None) + deps = SimpleNamespace(discovery_score_candidates=score) + match, conf = dp._canonical_best_score( + deps, 'Arctic Monkeys - Do I Wanna Know?', 'Arctic Monkeys', 0, ['r']) + assert match == 'MATCH' + assert conf == 0.95 + + +def test_canonical_best_score_clean_title_scored_once(): + calls = [] + def score(title, artist, dur, results): + calls.append(title) + return ('M', 0.9, None) + deps = SimpleNamespace(discovery_score_candidates=score) + match, conf = dp._canonical_best_score(deps, 'Do I Wanna Know?', 'Arctic Monkeys', 0, ['r']) + assert (match, conf) == ('M', 0.9) + assert calls == ['Do I Wanna Know?'] # canonical == original → no second score + + +def test_canonical_best_score_keeps_original_when_better(): + # Best-of: if the raw title actually scores higher, keep it. + def score(title, artist, dur, results): + return ('CANON', 0.6, None) if title == 'Do I Wanna Know?' else ('ORIG', 0.9, None) + deps = SimpleNamespace(discovery_score_candidates=score) + match, conf = dp._canonical_best_score( + deps, 'Arctic Monkeys - Do I Wanna Know?', 'Arctic Monkeys', 0, ['r']) + assert (match, conf) == ('ORIG', 0.9) diff --git a/tests/discovery/test_manual_match.py b/tests/discovery/test_manual_match.py index 8bc34b09..cf474cc3 100644 --- a/tests/discovery/test_manual_match.py +++ b/tests/discovery/test_manual_match.py @@ -10,6 +10,7 @@ AST-parsing the route file). from core.discovery.manual_match import ( derive_manual_match_provider, is_drifted_for_redo, + should_rediscover, ) @@ -104,3 +105,149 @@ def test_drift_default_provider_is_spotify_when_absent(): extra = {'discovered': True} # no provider field assert is_drifted_for_redo(extra, 'spotify') is False assert is_drifted_for_redo(extra, 'musicbrainz') is True + + +# --------------------------------------------------------------------------- +# should_rediscover — the Playlist Pipeline pre-scan gate +# --------------------------------------------------------------------------- + + +def test_rediscovers_never_discovered_track(): + assert should_rediscover({}) is True + assert should_rediscover(None) is True + + +def test_skips_complete_discovery(): + extra = { + 'discovered': True, + 'matched_data': {'track_number': 3, 'album': {'release_date': '2020'}}, + } + assert should_rediscover(extra) is False + + +def test_rediscovers_incomplete_discovery(): + # Missing track_number / release_date / album.id — re-discover to backfill. + extra = {'discovered': True, 'matched_data': {'name': 'X'}} + assert should_rediscover(extra) is True + + +def test_album_id_satisfies_completeness(): + extra = { + 'discovered': True, + 'matched_data': {'track_number': 1, 'album': {'id': 'al-1'}}, + } + assert should_rediscover(extra) is False + + +def test_rediscovers_wing_it_stub(): + extra = {'discovered': True, 'wing_it_fallback': True} + assert should_rediscover(extra) is True + + +def test_skips_manual_match(): + extra = {'discovered': True, 'manual_match': True} + assert should_rediscover(extra) is False + + +def test_skips_unmatched_by_user(): + extra = {'unmatched_by_user': True} + assert should_rediscover(extra) is False + + +def test_regression_manual_match_wins_over_stale_wing_it_flag(): + """The #799 revert bug: extra_data is MERGED on save, so a track fixed + after being a Wing It stub still carries wing_it_fallback=True alongside + the new manual_match=True. The manual match MUST win — otherwise the + pipeline re-discovers and silently reverts the user's pick to Wing It. + + Before the fix the pre-scan checked wing_it_fallback first and returned + True (re-discover). It must now skip.""" + extra = { + 'discovered': True, + 'wing_it_fallback': True, # stale flag left by the merge + 'manual_match': True, # the user's authoritative fix + 'matched_data': {'name': 'The Real Match'}, + } + assert should_rediscover(extra) is False + + +def test_manual_match_wins_even_without_other_fields(): + # Lean Fix-popup save shape (no track_number/album) must still be honored. + extra = {'discovered': True, 'manual_match': True, 'wing_it_fallback': True} + assert should_rediscover(extra) is False + + +# --------------------------------------------------------------------------- +# Equivalence guard: should_rediscover must match the ORIGINAL inline pre-scan +# logic for EVERY flag combination except the one intended fix (manual_match +# beating a stale wing_it_fallback). This pins that the auto Playlist Pipeline +# behaves identically post-refactor — no regression. +# --------------------------------------------------------------------------- + +import itertools + + +def _original_pre_scan(extra): + """Verbatim reproduction of the pre-refactor playlist.py branch logic. + Returns True = re-discover (undiscovered_tracks), False = skip.""" + if extra.get('discovered'): + if extra.get('wing_it_fallback'): + return True + elif extra.get('manual_match'): + return False + else: + md = extra.get('matched_data', {}) + album = md.get('album', {}) + has_track_num = md.get('track_number') + has_release = album.get('release_date') if isinstance(album, dict) else None + has_album_id = album.get('id') if isinstance(album, dict) else None + if has_track_num and (has_release or has_album_id): + return False + else: + return True + elif extra.get('unmatched_by_user'): + return False + else: + return True + + +_MATCHED_VARIANTS = { + 'absent': None, # key omitted + 'complete': {'track_number': 1, 'album': {'release_date': '2020'}}, + 'incomplete': {'name': 'x'}, +} + + +def test_should_rediscover_matches_original_logic_for_all_combinations(): + bools = [True, False] + diverged = 0 + for discovered, wing, manual, unmatched, md_key in itertools.product( + bools, bools, bools, bools, _MATCHED_VARIANTS + ): + extra = {} + if discovered: + extra['discovered'] = True + if wing: + extra['wing_it_fallback'] = True + if manual: + extra['manual_match'] = True + if unmatched: + extra['unmatched_by_user'] = True + if _MATCHED_VARIANTS[md_key] is not None: + extra['matched_data'] = _MATCHED_VARIANTS[md_key] + + new = should_rediscover(extra) + old = _original_pre_scan(extra) + + # The single intended divergence: a discovered track carrying BOTH a + # stale wing_it_fallback AND a manual_match. Old re-discovered (the + # bug); new skips (manual is authoritative). + is_intended_fix = discovered and wing and manual + if is_intended_fix: + assert old is True and new is False, (extra, old, new) + diverged += 1 + else: + assert new == old, (extra, new, old) + + # Sanity: the fix actually triggered on the expected subset. + assert diverged > 0 diff --git a/tests/downloads/test_downloads_candidates.py b/tests/downloads/test_downloads_candidates.py index 95ed1328..8a80a3ab 100644 --- a/tests/downloads/test_downloads_candidates.py +++ b/tests/downloads/test_downloads_candidates.py @@ -461,6 +461,45 @@ def test_auto_search_pick_does_not_inject_acoustid_bypass(): assert "_user_manual_pick" not in ctx +def test_skip_acoustid_track_flag_injects_bypass(): + """Issue #797: when the album-download request had the per-request + 'Skip AcoustID verification' toggle on, the master worker stamps + `_skip_acoustid=True` onto each track's track_info. The candidates + helper must propagate that into the post-process context as + `_skip_quarantine_check='acoustid'` so AcoustID never quarantines + this request's files (e.g. correct downloads of non-English artists + whose native-script metadata AcoustID can't reconcile).""" + deps = _build_deps() + _seed_task("t_skip_aid", track_info={"_skip_acoustid": True}) + + candidates = [_Candidate(filename="skip.flac", confidence=0.99)] + track = _Track() + + result = dc.attempt_download_with_candidates("t_skip_aid", candidates, track, batch_id="b1", deps=deps) + + assert result is True + ctx = matched_downloads_context["user1::skip.flac"] + assert ctx["_skip_quarantine_check"] == "acoustid" + # It's the toggle path, NOT a manual pick. + assert "_user_manual_pick" not in ctx + + +def test_no_skip_acoustid_flag_keeps_verification(): + """Without the toggle (no `_skip_acoustid` on track_info), AcoustID + verification must still run — the bypass is opt-in per request.""" + deps = _build_deps() + _seed_task("t_no_skip_aid", track_info={}) # no _skip_acoustid + + candidates = [_Candidate(filename="verify.flac", confidence=0.99)] + track = _Track() + + result = dc.attempt_download_with_candidates("t_no_skip_aid", candidates, track, batch_id="b1", deps=deps) + + assert result is True + ctx = matched_downloads_context["user1::verify.flac"] + assert "_skip_quarantine_check" not in ctx + + def test_equal_confidence_candidates_prefer_better_peer_quality(): """Equal-confidence Soulseek candidates use peer quality as the tiebreaker.""" deps = _build_deps() diff --git a/tests/imports/test_import_pipeline.py b/tests/imports/test_import_pipeline.py index 13684241..b283cb7e 100644 --- a/tests/imports/test_import_pipeline.py +++ b/tests/imports/test_import_pipeline.py @@ -209,7 +209,6 @@ def test_post_process_matched_download_forwards_separate_metadata_runtime(tmp_pa monkeypatch.setattr(import_pipeline, "record_soulsync_library_entry", _record_library) monkeypatch.setattr(import_pipeline, "check_and_remove_from_wishlist", lambda *args, **kwargs: None) - monkeypatch.setattr(import_pipeline, "record_retag_download", lambda *args, **kwargs: None) context = { "track_info": {"_playlist_folder_mode": True, "_playlist_name": "Playlist"}, diff --git a/tests/library/test_embedded_id_reconcile.py b/tests/library/test_embedded_id_reconcile.py new file mode 100644 index 00000000..b60f1643 --- /dev/null +++ b/tests/library/test_embedded_id_reconcile.py @@ -0,0 +1,363 @@ +"""Tests for core/library/embedded_id_reconcile.py. + +The reconcile job reads provider IDs already embedded in a file's tags +(by SoulSync or MusicBrainz Picard) and gap-fills them into the library +DB so enrichment workers skip the API call. These pin the guarantees that +make it safe to run across a whole library while workers run concurrently: + + 1. gap-fill only — an existing id is NEVER overwritten, + 2. disagreements are reported as conflicts, not applied, + 3. the write is ATOMICALLY guarded — if a worker fills the column + between plan and apply, the apply no-ops (no clobber). +""" + +from __future__ import annotations + +import sqlite3 + +from core.library.embedded_id_reconcile import ( + Fill, + ReconcileApplied, + ReconcilePlan, + ReconcileTotals, + apply_reconcile_plan, + plan_reconcile, + reconcile_library, + reconcile_track_row, +) + + +# --------------------------------------------------------------------------- +# plan_reconcile — the pure planning layer +# --------------------------------------------------------------------------- + +def test_empty_inputs_yield_empty_plan(): + plan = plan_reconcile(None, None) + assert isinstance(plan, ReconcilePlan) + assert plan.has_updates is False + assert plan.filled == 0 + assert plan.conflicts == [] + + +def test_fills_all_three_entities_from_one_file(): + tags = {'spotify_track_id': 'TRK', 'spotify_album_id': 'ALB', 'spotify_artist_id': 'ART'} + plan = plan_reconcile(tags, {'track': {}, 'album': {}, 'artist': {}}) + + assert plan.filled == 3 + by_entity = {(f.entity, f.id_column): f.value for f in plan.fills} + assert by_entity[('track', 'spotify_track_id')] == 'TRK' + assert by_entity[('album', 'spotify_album_id')] == 'ALB' + assert by_entity[('artist', 'spotify_artist_id')] == 'ART' + # status column pairing is carried on each Fill + track_fill = plan.fills_for('track')[0] + assert track_fill.status_column == 'spotify_match_status' + + +def test_never_overwrites_an_existing_id(): + plan = plan_reconcile({'spotify_artist_id': 'NEW'}, + {'artist': {'spotify_artist_id': 'EXISTING'}}) + assert plan.filled == 0 + assert plan.fills_for('artist') == [] + assert len(plan.conflicts) == 1 + c = plan.conflicts[0] + assert c['existing'] == 'EXISTING' and c['embedded'] == 'NEW' and c['entity'] == 'artist' + + +def test_matching_existing_id_is_noop_not_conflict(): + plan = plan_reconcile({'spotify_artist_id': 'SAME'}, + {'artist': {'spotify_artist_id': 'SAME'}}) + assert plan.filled == 0 + assert plan.conflicts == [] + assert plan.already_present == 1 + + +def test_blank_and_whitespace_values_ignored(): + tags = {'spotify_artist_id': ' ', 'spotify_album_id': '', 'itunes_track_id': None} + plan = plan_reconcile(tags, {'track': {}, 'album': {}, 'artist': {}}) + assert plan.has_updates is False + + +def test_whitespace_padded_embedded_id_is_trimmed_and_filled(): + plan = plan_reconcile({'spotify_track_id': ' TRK '}, {'track': {}}) + assert plan.fills_for('track')[0].value == 'TRK' + + +def test_single_column_provider_maps_per_entity(): + # Deezer/Tidal/AudioDB reuse one id column across entity types; fills + # must be keyed by entity so they don't collide. + tags = {'deezer_track_id': 'DT', 'deezer_album_id': 'DA', 'deezer_artist_id': 'DR'} + plan = plan_reconcile(tags, {'track': {}, 'album': {}, 'artist': {}}) + vals = {f.entity: f.value for f in plan.fills} + assert vals == {'track': 'DT', 'album': 'DA', 'artist': 'DR'} + assert plan.filled == 3 + + +def test_mb_album_and_artist_filled_track_recording_skipped(): + tags = {'musicbrainz_albumid': 'MBA', 'musicbrainz_artistid': 'MBR', 'musicbrainz_trackid': 'MBT'} + plan = plan_reconcile(tags, {'track': {}, 'album': {}, 'artist': {}}) + cols = {(f.entity, f.id_column): f.value for f in plan.fills} + assert cols[('album', 'musicbrainz_release_id')] == 'MBA' + assert cols[('artist', 'musicbrainz_id')] == 'MBR' + assert plan.fills_for('track') == [] # recording id not reconciled + + +def test_lastfm_url_maps_to_track_only(): + # The file carries a single LASTFM_URL = the TRACK's last.fm url. It must + # fill tracks.lastfm_url and NOT be smeared onto album/artist (whose + # last.fm urls are different urls entirely). + plan = plan_reconcile({'lastfm_url': 'https://last.fm/music/A/_/Song'}, + {'track': {}, 'album': {}, 'artist': {}}) + assert plan.filled == 1 + f = plan.fills_for('track')[0] + assert f.id_column == 'lastfm_url' and f.status_column == 'lastfm_match_status' + assert plan.fills_for('album') == [] and plan.fills_for('artist') == [] + + +def test_partial_fill_when_one_entity_already_matched(): + tags = {'spotify_artist_id': 'ART', 'spotify_album_id': 'ALB'} + current = {'artist': {'spotify_artist_id': 'ART'}, 'album': {}} + plan = plan_reconcile(tags, current) + assert plan.filled == 1 + assert plan.fills_for('album')[0].value == 'ALB' + assert plan.fills_for('artist') == [] + assert plan.already_present == 1 + + +# --------------------------------------------------------------------------- +# apply_reconcile_plan — the DB layer (in-memory sqlite) +# --------------------------------------------------------------------------- + +def _make_db(): + conn = sqlite3.connect(':memory:') + cur = conn.cursor() + for table, idcol in (('tracks', 'spotify_track_id'), ('albums', 'spotify_album_id'), + ('artists', 'spotify_artist_id')): + cur.execute(f"""CREATE TABLE {table} (id TEXT PRIMARY KEY, {idcol} TEXT, + spotify_match_status TEXT, spotify_last_attempted TIMESTAMP)""") + cur.execute("INSERT INTO tracks (id) VALUES ('t1')") + cur.execute("INSERT INTO albums (id) VALUES ('al1')") + cur.execute("INSERT INTO artists (id) VALUES ('ar1')") + conn.commit() + return conn, cur + + +def test_apply_writes_ids_status_and_timestamp(): + conn, cur = _make_db() + plan = plan_reconcile( + {'spotify_track_id': 'TRK', 'spotify_album_id': 'ALB', 'spotify_artist_id': 'ART'}, + {'track': {}, 'album': {}, 'artist': {}}, + ) + applied = apply_reconcile_plan(cur, {'track': 't1', 'album': 'al1', 'artist': 'ar1'}, plan) + conn.commit() + assert isinstance(applied, ReconcileApplied) + assert applied.rows_updated == 3 and applied.ids_filled == 3 + + cur.execute("SELECT spotify_track_id, spotify_match_status, spotify_last_attempted FROM tracks WHERE id='t1'") + tid, status, attempted = cur.fetchone() + assert tid == 'TRK' and status == 'matched' and attempted is not None + + +def test_apply_guard_blocks_overwrite_under_concurrency(): + # THE headline hardening: a worker fills the column AFTER we planned + # (plan saw empty) but BEFORE we apply. The guarded UPDATE must no-op + # and leave the worker's value intact. + conn, cur = _make_db() + plan = plan_reconcile({'spotify_artist_id': 'FROM_FILE'}, {'artist': {}}) # planned: empty + # Simulate a concurrent enrichment worker matching it in the meantime. + cur.execute("UPDATE artists SET spotify_artist_id='FROM_WORKER', spotify_match_status='matched' WHERE id='ar1'") + conn.commit() + + applied = apply_reconcile_plan(cur, {'artist': 'ar1'}, plan) + conn.commit() + assert applied.ids_filled == 0 and applied.rows_updated == 0 # guard blocked it + + cur.execute("SELECT spotify_artist_id FROM artists WHERE id='ar1'") + assert cur.fetchone()[0] == 'FROM_WORKER' # worker's value preserved + + +def test_apply_guard_treats_empty_string_as_fillable(): + conn, cur = _make_db() + cur.execute("UPDATE artists SET spotify_artist_id='' WHERE id='ar1'") # empty string, not NULL + conn.commit() + plan = plan_reconcile({'spotify_artist_id': 'ART'}, {'artist': {}}) + applied = apply_reconcile_plan(cur, {'artist': 'ar1'}, plan) + conn.commit() + assert applied.ids_filled == 1 + cur.execute("SELECT spotify_artist_id FROM artists WHERE id='ar1'") + assert cur.fetchone()[0] == 'ART' + + +def test_apply_skips_unknown_columns_without_erroring(): + # Schema missing a provider's columns must not raise — the plan targets + # tidal_id which this minimal schema lacks; it's silently skipped. + conn, cur = _make_db() + plan = plan_reconcile({'tidal_artist_id': 'TID', 'spotify_artist_id': 'ART'}, + {'track': {}, 'album': {}, 'artist': {}}) + applied = apply_reconcile_plan(cur, {'artist': 'ar1'}, plan) + conn.commit() + cur.execute("SELECT spotify_artist_id FROM artists WHERE id='ar1'") + assert cur.fetchone()[0] == 'ART' + assert applied.ids_filled == 1 # only the existing spotify column landed + + +def test_apply_skips_entity_with_no_id(): + conn, cur = _make_db() + plan = plan_reconcile({'spotify_album_id': 'ALB'}, {'album': {}}) + applied = apply_reconcile_plan(cur, {'track': 't1'}, plan) # no album id supplied + assert applied.rows_updated == 0 and applied.ids_filled == 0 + + +def test_apply_empty_plan_is_noop(): + conn, cur = _make_db() + applied = apply_reconcile_plan(cur, {'track': 't1'}, ReconcilePlan()) + assert applied.rows_updated == 0 and applied.ids_filled == 0 + + +# --------------------------------------------------------------------------- +# reconcile_track_row — the per-track orchestration (id extraction, plan→apply, +# sibling-map freshening) +# --------------------------------------------------------------------------- + +def test_reconcile_track_row_unreadable_file_is_noop(): + conn, cur = _make_db() + result = reconcile_track_row(cur, {'id': 't1'}, {}, {}, None) + assert result.readable is False + assert result.applied.ids_filled == 0 + + +def test_reconcile_track_row_fills_track_and_parents(): + conn, cur = _make_db() + track_row = {'id': 't1', 'album_id': 'al1', 'artist_id': 'ar1'} + album_map = {'al1': {}} + artist_map = {'ar1': {}} + tags = {'spotify_track_id': 'TRK', 'spotify_album_id': 'ALB', 'spotify_artist_id': 'ART'} + result = reconcile_track_row(cur, track_row, album_map, artist_map, tags) + conn.commit() + assert result.readable is True + assert result.applied.ids_filled == 3 and result.applied.rows_updated == 3 + # parent maps were freshened in place + assert album_map['al1']['spotify_album_id'] == 'ALB' + assert artist_map['ar1']['spotify_artist_id'] == 'ART' + + +def test_reconcile_sibling_tracks_dont_refill_shared_parent(): + # Two tracks on the same album/artist. The first fills the album+artist + # ids; the second must see them already present (via the freshened map) + # and NOT re-apply — proving the map keeps siblings from redundant work. + conn, cur = _make_db() + cur.execute("INSERT INTO tracks (id) VALUES ('t2')") + conn.commit() + album_map = {'al1': {}} + artist_map = {'ar1': {}} + tags = {'spotify_album_id': 'ALB', 'spotify_artist_id': 'ART', 'spotify_track_id': 'T1'} + + r1 = reconcile_track_row(cur, {'id': 't1', 'album_id': 'al1', 'artist_id': 'ar1'}, + album_map, artist_map, tags) + # Second track: same album/artist ids embedded, its own track id. + tags2 = {'spotify_album_id': 'ALB', 'spotify_artist_id': 'ART', 'spotify_track_id': 'T2'} + r2 = reconcile_track_row(cur, {'id': 't2', 'album_id': 'al1', 'artist_id': 'ar1'}, + album_map, artist_map, tags2) + conn.commit() + + assert r1.applied.ids_filled == 3 # track + album + artist + assert r2.applied.ids_filled == 1 # only t2's own track id; parents already filled + assert r2.conflicts == 0 + + +def test_reconcile_track_row_handles_null_parent_ids(): + conn, cur = _make_db() + # Track with no album/artist linkage — only its own id should fill. + result = reconcile_track_row(cur, {'id': 't1', 'album_id': None, 'artist_id': None}, + {}, {}, {'spotify_track_id': 'TRK', 'spotify_album_id': 'ALB'}) + conn.commit() + assert result.applied.ids_filled == 1 # album fill has no album id to land on + cur.execute("SELECT spotify_track_id FROM tracks WHERE id='t1'") + assert cur.fetchone()[0] == 'TRK' + + +# --------------------------------------------------------------------------- +# reconcile_library — the shared orchestration (paging, lazy maps, scope) +# --------------------------------------------------------------------------- + +def _make_library_db(): + conn = sqlite3.connect(':memory:') + conn.row_factory = sqlite3.Row + cur = conn.cursor() + cur.execute("""CREATE TABLE tracks (id TEXT PRIMARY KEY, album_id TEXT, artist_id TEXT, + file_path TEXT, title TEXT, spotify_track_id TEXT, spotify_match_status TEXT, + spotify_last_attempted TIMESTAMP)""") + cur.execute("""CREATE TABLE albums (id TEXT PRIMARY KEY, spotify_album_id TEXT, + spotify_match_status TEXT, spotify_last_attempted TIMESTAMP)""") + cur.execute("""CREATE TABLE artists (id TEXT PRIMARY KEY, spotify_artist_id TEXT, + spotify_match_status TEXT, spotify_last_attempted TIMESTAMP)""") + # Two tracks on the same album/artist, one orphan track with no file. + cur.execute("INSERT INTO artists (id) VALUES ('ar1')") + cur.execute("INSERT INTO albums (id) VALUES ('al1')") + cur.execute("INSERT INTO tracks (id, album_id, artist_id, file_path, title) VALUES ('t1','al1','ar1','/a.flac','A')") + cur.execute("INSERT INTO tracks (id, album_id, artist_id, file_path, title) VALUES ('t2','al1','ar1','/b.flac','B')") + cur.execute("INSERT INTO tracks (id, album_id, artist_id, file_path, title) VALUES ('t3','al1','ar1','','NoFile')") + conn.commit() + return conn, cur + + +def _reader(mapping): + """read_tags stub: file_path -> tags dict (or None).""" + return lambda fp: mapping.get(fp) + + +def test_reconcile_library_whole_library_fills_all(): + conn, cur = _make_library_db() + tags = { + '/a.flac': {'spotify_track_id': 'TA', 'spotify_album_id': 'ALB', 'spotify_artist_id': 'ART'}, + '/b.flac': {'spotify_track_id': 'TB', 'spotify_album_id': 'ALB', 'spotify_artist_id': 'ART'}, + } + totals = reconcile_library(conn, _reader(tags)) + assert isinstance(totals, ReconcileTotals) + # t3 has empty file_path -> excluded from the whole-library SELECT entirely. + assert totals.total == 2 and totals.processed == 2 + # t1: track+album+artist (3); t2: only its own track id (parents already filled) (1) + assert totals.ids_filled == 4 + cur.execute("SELECT spotify_track_id FROM tracks WHERE id='t2'") + assert cur.fetchone()[0] == 'TB' + cur.execute("SELECT spotify_album_id FROM albums WHERE id='al1'") + assert cur.fetchone()[0] == 'ALB' + + +def test_reconcile_library_scoped_to_given_ids(): + conn, cur = _make_library_db() + tags = {'/b.flac': {'spotify_track_id': 'TB'}, '/a.flac': {'spotify_track_id': 'TA'}} + totals = reconcile_library(conn, _reader(tags), track_ids=['t2']) + assert totals.total == 1 and totals.ids_filled == 1 + cur.execute("SELECT spotify_track_id FROM tracks WHERE id='t2'") + assert cur.fetchone()[0] == 'TB' + cur.execute("SELECT spotify_track_id FROM tracks WHERE id='t1'") + assert cur.fetchone()[0] is None # not in scope + + +def test_reconcile_library_unreadable_counted(): + conn, cur = _make_library_db() + totals = reconcile_library(conn, _reader({}), track_ids=['t1', 't2']) # reader returns None + assert totals.unreadable == 2 and totals.ids_filled == 0 + + +def test_reconcile_library_is_idempotent(): + conn, cur = _make_library_db() + tags = {'/a.flac': {'spotify_track_id': 'TA', 'spotify_album_id': 'ALB', 'spotify_artist_id': 'ART'}} + first = reconcile_library(conn, _reader(tags), track_ids=['t1']) + second = reconcile_library(conn, _reader(tags), track_ids=['t1']) + assert first.ids_filled == 3 + assert second.ids_filled == 0 # nothing left to fill + + +def test_reconcile_library_progress_and_stop(): + conn, cur = _make_library_db() + seen = [] + reconcile_library(conn, _reader({'/a.flac': {'spotify_track_id': 'TA'}}), + track_ids=['t1', 't2'], + on_progress=lambda totals, title: seen.append(title)) + assert seen == ['A', 'B'] + + # should_stop halts before processing anything + totals = reconcile_library(conn, _reader({}), track_ids=['t1', 't2'], + should_stop=lambda: True) + assert totals.processed == 0 diff --git a/tests/library/test_retag.py b/tests/library/test_retag.py deleted file mode 100644 index 5dd53a44..00000000 --- a/tests/library/test_retag.py +++ /dev/null @@ -1,321 +0,0 @@ -"""Tests for core/library/retag.py — retag worker.""" - -from __future__ import annotations - -import threading -from dataclasses import dataclass - -import pytest - -from core.library import retag as ret - - -# --------------------------------------------------------------------------- -# Fakes -# --------------------------------------------------------------------------- - -class _FakeSpotify: - def __init__(self, album=None, tracks=None): - self._album = album - self._tracks = tracks - - def get_album(self, album_id): - return self._album - - def get_album_tracks(self, album_id): - return self._tracks - - -class _FakeDB: - def __init__(self, retag_tracks=None): - self._tracks = retag_tracks or [] - self.path_updates = [] - self.group_updates = [] - - def get_retag_tracks(self, group_id): - return self._tracks - - def update_retag_track_path(self, track_id, new_path): - self.path_updates.append((track_id, new_path)) - - def update_retag_group(self, group_id, **kwargs): - self.group_updates.append((group_id, kwargs)) - - -def _build_deps( - *, - spotify_album=None, - spotify_tracks=None, - retag_tracks=None, - state=None, - enhance_calls=None, - move_calls=None, - cover_calls=None, - build_path_result=None, -): - state = state if state is not None else {} - enhance_calls = enhance_calls if enhance_calls is not None else [] - move_calls = move_calls if move_calls is not None else [] - cover_calls = cover_calls if cover_calls is not None else [] - db = _FakeDB(retag_tracks=retag_tracks or []) - - deps = ret.RetagDeps( - config_manager=type('C', (), {'get': lambda self, k, d=None: d})(), - retag_lock=threading.Lock(), - spotify_client=_FakeSpotify(album=spotify_album, tracks=spotify_tracks), - get_audio_quality_string=lambda fp: 'FLAC 16bit', - enhance_file_metadata=lambda fp, ctx, artist, ai: enhance_calls.append((fp, ctx, artist, ai)), - build_final_path_for_track=lambda ctx, artist, ai, ext: ( - (build_path_result if build_path_result is not None else ctx['original_search_result']['title'] + ext), - True, - ), - safe_move_file=lambda src, dst: move_calls.append((src, dst)), - cleanup_empty_directories=lambda transfer_dir, file_path: None, - download_cover_art=lambda ai, dest_dir, ctx: cover_calls.append((ai, dest_dir)), - docker_resolve_path=lambda p: p, - _get_retag_state=lambda: state, - _set_retag_state=lambda v: state.clear() or state.update(v), - get_database=lambda: db, - ) - deps._db = db - deps._state = state - deps._enhance_calls = enhance_calls - deps._move_calls = move_calls - deps._cover_calls = cover_calls - return deps - - -# --------------------------------------------------------------------------- -# Setup error paths -# --------------------------------------------------------------------------- - -def test_no_album_data_marks_state_error(tmp_path): - """spotify.get_album returning None → state.error_message set, state status='error'.""" - deps = _build_deps(spotify_album=None) - ret.execute_retag('g1', 'alb-1', deps) - assert deps._state['status'] == 'error' - assert 'Could not fetch album' in deps._state['error_message'] - - -def test_no_album_tracks_marks_state_error(): - """spotify.get_album_tracks returning None → error state.""" - deps = _build_deps(spotify_album={'name': 'A', 'artists': []}, spotify_tracks=None) - ret.execute_retag('g1', 'alb-1', deps) - assert deps._state['status'] == 'error' - - -def test_no_existing_tracks_marks_state_error(): - """retag_group has no tracks → error state.""" - deps = _build_deps( - spotify_album={'name': 'A', 'artists': [{'name': 'X', 'id': '1'}], 'images': [], 'release_date': '', 'total_tracks': 1}, - spotify_tracks={'items': [{'name': 'T1', 'track_number': 1, 'disc_number': 1, 'id': 'sp1', 'artists': [], 'duration_ms': 1000}]}, - retag_tracks=[], - ) - ret.execute_retag('g1', 'alb-1', deps) - assert deps._state['status'] == 'error' - assert 'No tracks found' in deps._state['error_message'] - - -# --------------------------------------------------------------------------- -# Successful retag — track-number match -# --------------------------------------------------------------------------- - -def test_track_number_match_priority_1(tmp_path): - """Existing track with matching track+disc number → matched even if title differs.""" - src_file = tmp_path / 'old.flac' - src_file.touch() - - deps = _build_deps( - spotify_album={'name': 'New Album', 'artists': [{'name': 'Artist A', 'id': 'a1'}], - 'images': [{'url': 'http://img'}], 'release_date': '2024-01-01', 'total_tracks': 1}, - spotify_tracks={'items': [{'name': 'Brand New Title', 'track_number': 5, - 'disc_number': 1, 'id': 'sp5', 'artists': [{'name': 'X'}], - 'duration_ms': 1000}]}, - retag_tracks=[{ - 'id': 1, - 'title': 'Completely Unrelated Old Name', - 'track_number': 5, - 'disc_number': 1, - 'file_path': str(src_file), - }], - build_path_result=str(tmp_path / 'new.flac'), - ) - - ret.execute_retag('g1', 'alb-x', deps) - - # Match found via priority 1 (track number) — enhance_file_metadata called - assert len(deps._enhance_calls) == 1 - fp, ctx, artist, _ai = deps._enhance_calls[0] - assert fp == str(src_file) - assert ctx['original_search_result']['spotify_clean_title'] == 'Brand New Title' - # State marks finished - assert deps._state['status'] == 'finished' - assert deps._state['progress'] == 100 - - -# --------------------------------------------------------------------------- -# Title-similarity fallback (priority 2) -# --------------------------------------------------------------------------- - -def test_title_similarity_fallback_when_no_track_number_match(): - """No track-number match → falls back to fuzzy title match.""" - deps = _build_deps( - spotify_album={'name': 'A', 'artists': [{'name': 'X', 'id': '1'}], 'images': [], - 'release_date': '', 'total_tracks': 1}, - spotify_tracks={'items': [{'name': 'Hello World', 'track_number': 99, - 'disc_number': 99, 'id': 'sp1', 'artists': [], - 'duration_ms': 1000}]}, - retag_tracks=[{ - 'id': 1, - 'title': 'Hello World', # title matches - 'track_number': 1, 'disc_number': 1, # but numbers don't - 'file_path': '/nonexistent/old.flac', - }], - ) - ret.execute_retag('g1', 'alb-x', deps) - - # File doesn't exist so enhance is skipped, but match was made - # (state.processed == 1 confirms loop iterated) - assert deps._state['processed'] == 1 - - -def test_no_match_skips_track(): - """No track-number AND title similarity below 0.6 → no match, no enhance call.""" - deps = _build_deps( - spotify_album={'name': 'A', 'artists': [{'name': 'X', 'id': '1'}], 'images': [], - 'release_date': '', 'total_tracks': 1}, - spotify_tracks={'items': [{'name': 'Completely Different', 'track_number': 99, - 'disc_number': 99, 'id': 'sp1', 'artists': [], - 'duration_ms': 1000}]}, - retag_tracks=[{ - 'id': 1, - 'title': 'Hello World', - 'track_number': 1, 'disc_number': 1, - 'file_path': '/nonexistent/old.flac', - }], - ) - ret.execute_retag('g1', 'alb-x', deps) - - # No match, no enhance call - assert deps._enhance_calls == [] - assert deps._state['processed'] == 1 - - -# --------------------------------------------------------------------------- -# Missing file -# --------------------------------------------------------------------------- - -def test_missing_file_skipped(tmp_path): - """If the audio file doesn't exist, enhance_file_metadata is NOT called.""" - deps = _build_deps( - spotify_album={'name': 'A', 'artists': [{'name': 'X', 'id': '1'}], 'images': [], - 'release_date': '', 'total_tracks': 1}, - spotify_tracks={'items': [{'name': 'T1', 'track_number': 1, 'disc_number': 1, - 'id': 'sp1', 'artists': [], 'duration_ms': 1000}]}, - retag_tracks=[{ - 'id': 1, 'title': 'T1', 'track_number': 1, 'disc_number': 1, - 'file_path': '/this/path/does/not/exist.flac', - }], - ) - ret.execute_retag('g1', 'alb-x', deps) - - assert deps._enhance_calls == [] - assert deps._state['status'] == 'finished' - - -# --------------------------------------------------------------------------- -# Path move -# --------------------------------------------------------------------------- - -def test_file_moved_when_path_changes(tmp_path): - """When build_final_path_for_track returns a different path, file is moved.""" - src_file = tmp_path / 'old.flac' - src_file.touch() - new_path = str(tmp_path / 'subdir' / 'new.flac') - - deps = _build_deps( - spotify_album={'name': 'A', 'artists': [{'name': 'X', 'id': '1'}], 'images': [], - 'release_date': '', 'total_tracks': 1}, - spotify_tracks={'items': [{'name': 'T1', 'track_number': 1, 'disc_number': 1, - 'id': 'sp1', 'artists': [], 'duration_ms': 1000}]}, - retag_tracks=[{ - 'id': 1, 'title': 'T1', 'track_number': 1, 'disc_number': 1, - 'file_path': str(src_file), - }], - build_path_result=new_path, - ) - - ret.execute_retag('g1', 'alb-x', deps) - - assert len(deps._move_calls) == 1 - assert deps._move_calls[0] == (str(src_file), new_path) - assert (1, new_path) in deps._db.path_updates - - -# --------------------------------------------------------------------------- -# Group record update -# --------------------------------------------------------------------------- - -def test_spotify_album_id_used_for_alphanumeric_id(): - """Non-numeric album IDs → spotify_album_id set, itunes_album_id None.""" - deps = _build_deps( - spotify_album={'name': 'A', 'artists': [{'name': 'X', 'id': '1'}], 'images': [], - 'release_date': '', 'total_tracks': 1}, - spotify_tracks={'items': [{'name': 'T1', 'track_number': 1, 'disc_number': 1, - 'id': 'sp1', 'artists': [], 'duration_ms': 1000}]}, - retag_tracks=[{ - 'id': 1, 'title': 'T1', 'track_number': 1, 'disc_number': 1, - 'file_path': '/missing.flac', - }], - ) - ret.execute_retag('g1', 'spotify_alpha_id_xyz', deps) - - assert len(deps._db.group_updates) == 1 - _gid, kwargs = deps._db.group_updates[0] - assert kwargs['spotify_album_id'] == 'spotify_alpha_id_xyz' - assert kwargs['itunes_album_id'] is None - - -def test_itunes_album_id_used_for_numeric_id(): - """Numeric album IDs → itunes_album_id set, spotify_album_id None.""" - deps = _build_deps( - spotify_album={'name': 'A', 'artists': [{'name': 'X', 'id': '1'}], 'images': [], - 'release_date': '', 'total_tracks': 1}, - spotify_tracks={'items': [{'name': 'T1', 'track_number': 1, 'disc_number': 1, - 'id': 'sp1', 'artists': [], 'duration_ms': 1000}]}, - retag_tracks=[{ - 'id': 1, 'title': 'T1', 'track_number': 1, 'disc_number': 1, - 'file_path': '/missing.flac', - }], - ) - ret.execute_retag('g1', '987654321', deps) - - _gid, kwargs = deps._db.group_updates[0] - assert kwargs['itunes_album_id'] == '987654321' - assert kwargs['spotify_album_id'] is None - - -# --------------------------------------------------------------------------- -# Multi-disc detection -# --------------------------------------------------------------------------- - -def test_multi_disc_total_discs_computed(): - """total_discs derived from max disc_number across all spotify tracks.""" - deps = _build_deps( - spotify_album={'name': 'A', 'artists': [{'name': 'X', 'id': '1'}], 'images': [], - 'release_date': '', 'total_tracks': 3}, - spotify_tracks={'items': [ - {'name': 'T1', 'track_number': 1, 'disc_number': 1, 'id': 'sp1', 'artists': [], 'duration_ms': 1000}, - {'name': 'T2', 'track_number': 1, 'disc_number': 2, 'id': 'sp2', 'artists': [], 'duration_ms': 1000}, - {'name': 'T3', 'track_number': 1, 'disc_number': 3, 'id': 'sp3', 'artists': [], 'duration_ms': 1000}, - ]}, - retag_tracks=[{ - 'id': 1, 'title': 'T1', 'track_number': 1, 'disc_number': 1, - 'file_path': '/missing.flac', - }], - ) - ret.execute_retag('g1', 'alb-x', deps) - - # Verify multi-disc reflected in retag — group update has total_tracks - # (total_discs not stored on group; check via state instead) - assert deps._state['status'] == 'finished' diff --git a/tests/matching/test_artist_alias_service.py b/tests/matching/test_artist_alias_service.py index a895f942..293c874f 100644 --- a/tests/matching/test_artist_alias_service.py +++ b/tests/matching/test_artist_alias_service.py @@ -270,6 +270,7 @@ class TestWorkerAliasEnrichment: worker = MusicBrainzWorker.__new__(MusicBrainzWorker) worker.database = temp_db + worker.db = temp_db # worker code uses self.db (e.g. source_id_conflict) worker.mb_service = MagicMock() worker.mb_service.match_artist.return_value = { 'mbid': '60d2ea34-1912-425f-bf9c-fc544e4448cd', 'name': 'Hiroyuki Sawano', @@ -300,6 +301,7 @@ class TestWorkerAliasEnrichment: worker = MusicBrainzWorker.__new__(MusicBrainzWorker) worker.database = temp_db + worker.db = temp_db # worker code uses self.db (e.g. source_id_conflict) worker.mb_service = MagicMock() worker.mb_service.match_artist.return_value = None worker.stats = {'matched': 0, 'not_found': 0, 'errors': 0} @@ -392,6 +394,7 @@ class TestWorkerAliasEnrichment: worker = MusicBrainzWorker.__new__(MusicBrainzWorker) worker.database = temp_db + worker.db = temp_db # worker code uses self.db (e.g. source_id_conflict) worker.mb_service = MagicMock() worker.mb_service.match_artist.return_value = {'mbid': 'mb-x', 'name': 'X'} worker.mb_service.fetch_artist_aliases.side_effect = Exception("boom") diff --git a/tests/matching/test_script_compat.py b/tests/matching/test_script_compat.py new file mode 100644 index 00000000..644d16b5 --- /dev/null +++ b/tests/matching/test_script_compat.py @@ -0,0 +1,79 @@ +"""Tests for core/matching/script_compat.py — writing-system detection. + +Issue #797 — these pin the exact boundary that the AcoustID verifier +relies on: accented Latin is still Latin (no false cross-script +trigger), but genuinely different writing systems (CJK / Hangul / +Cyrillic / Greek / Arabic / Hebrew / Thai) ARE flagged so a +romanized-vs-native artist comparison isn't treated as a real mismatch. +""" + +from __future__ import annotations + +import pytest + +from core.matching.script_compat import ( + has_strong_nonlatin, + is_cross_script_mismatch, +) + + +# --------------------------------------------------------------------------- +# has_strong_nonlatin — accented Latin must NOT count +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize('text', [ + 'Beyoncé', 'Sigur Rós', 'Mötley Crüe', 'Joe Hisaishi', + 'Kendrick Lamar', 'Dmitry Yablonsky', 'AC/DC', 'P!nk', + '', ' ', '12345', 'Café del Mar', +]) +def test_latin_and_accented_latin_is_not_nonlatin(text): + assert has_strong_nonlatin(text) is False + + +@pytest.mark.parametrize('text', [ + '久石譲', # kanji (Joe Hisaishi) + '残酷な天使のテーゼ', # kana + kanji + 'Дмитрий Яблонский', # Cyrillic + '방탄소년단', # Hangul (BTS) + 'Σωκράτης', # Greek + 'عمرو دياب', # Arabic + 'שלום', # Hebrew + 'ก้อง สหรัถ', # Thai +]) +def test_real_nonlatin_scripts_detected(text): + assert has_strong_nonlatin(text) is True + + +# --------------------------------------------------------------------------- +# is_cross_script_mismatch — the verifier's gate +# --------------------------------------------------------------------------- + +def test_romanized_vs_native_is_cross_script(): + # The reported case (#797): expected romanized, AcoustID native. + assert is_cross_script_mismatch('Joe Hisaishi', '久石譲') is True + assert is_cross_script_mismatch('Dmitry Yablonsky', 'Дмитрий Яблонский') is True + + +def test_is_symmetric(): + assert is_cross_script_mismatch('久石譲', 'Joe Hisaishi') is True + + +def test_same_latin_both_sides_is_not_mismatch(): + # English-vs-English — comparison is meaningful, no bridge. This is + # the Kendrick R.O.T.C protection surface: must stay False so the + # verifier keeps its strict FAIL path. + assert is_cross_script_mismatch('Kendrick Lamar', 'Kendrick Lamar feat. BJ') is False + assert is_cross_script_mismatch('Crown', 'Crown of Thorns') is False + + +def test_same_nonlatin_both_sides_is_not_mismatch(): + # Both native — same-script similarity still works, don't relax. + assert is_cross_script_mismatch('久石譲', '久石譲') is False + assert is_cross_script_mismatch('久石譲', '坂本龍一') is False + + +def test_empty_or_letterless_other_side_is_not_mismatch(): + # One side non-Latin but the other has no Latin LETTER to bridge to. + assert is_cross_script_mismatch('', '久石譲') is False + assert is_cross_script_mismatch('12345', '久石譲') is False + assert is_cross_script_mismatch('久石譲', ' ') is False diff --git a/tests/media_server/test_navidrome_pinning.py b/tests/media_server/test_navidrome_pinning.py index 7850743f..1f6201c5 100644 --- a/tests/media_server/test_navidrome_pinning.py +++ b/tests/media_server/test_navidrome_pinning.py @@ -79,6 +79,82 @@ def test_get_all_album_ids_returns_set(nav_client): assert result == {'nav-1', 'nav-2'} +def test_fetch_music_folders_parses_without_ensure_connection(nav_client): + """The seam: _fetch_music_folders does its own _make_request + parse and + does NOT gate on ensure_connection, so it is safe to call mid-connect.""" + nav_client.base_url = 'http://nav' + nav_client.username = 'u' + nav_client.password = 'p' + folders_envelope = {'musicFolders': {'musicFolder': [ + {'id': 1, 'name': 'Music'}, + {'id': 2, 'name': 'Audiobooks'}, + ]}} + with patch.object(nav_client, '_make_request', return_value=folders_envelope): + folders = nav_client._fetch_music_folders() + assert folders == [ + {'title': 'Music', 'key': '1'}, + {'title': 'Audiobooks', 'key': '2'}, + ] + + +def _fake_request(endpoint, params=None): + if endpoint == 'ping': + return {'status': 'ok', 'version': '1.16.1'} + if endpoint == 'getMusicFolders': + return {'musicFolders': {'musicFolder': [ + {'id': 1, 'name': 'Music'}, + {'id': 2, 'name': 'Audiobooks'}, + ]}} + return None + + +def _prefs(values): + """A MusicDatabase mock whose get_preference reads from `values`.""" + db = MagicMock() + db.get_preference.side_effect = lambda key, *a, **k: values.get(key) + return db + + +def _connect_with(nav_client, fake_db): + with patch('core.navidrome_client.config_manager.get_navidrome_config', + return_value={'base_url': 'http://nav', 'username': 'u', 'password': 'p'}), \ + patch('database.music_database.MusicDatabase', return_value=fake_db), \ + patch.object(nav_client, '_make_request', side_effect=_fake_request): + assert nav_client.ensure_connection() is True + + +def test_setup_client_restores_saved_music_folder(nav_client): + """Regression for #789: a saved music-folder selection must survive + _setup_client. The restore runs while still inside ensure_connection() + (_is_connecting=True); the old code called the public get_music_folders(), + which re-entered the guard, got [], and left music_folder_id=None — so + every scan imported all libraries regardless of the user's selection.""" + fake_db = _prefs({'navidrome_music_folder_id': '2', 'navidrome_music_folder': 'Audiobooks'}) + _connect_with(nav_client, fake_db) + assert nav_client.music_folder_id == '2' + # Nothing drifted → no self-heal writes. + fake_db.set_preference.assert_not_called() + + +def test_setup_client_restores_by_id_after_folder_rename(nav_client): + """Hardening: the id is the primary key, so a folder renamed in Navidrome + (stored name no longer matches its title) still resolves by id — and the + stale name is healed so the settings dropdown stays correct.""" + fake_db = _prefs({'navidrome_music_folder_id': '2', 'navidrome_music_folder': 'Old Stale Name'}) + _connect_with(nav_client, fake_db) + assert nav_client.music_folder_id == '2' + fake_db.set_preference.assert_any_call('navidrome_music_folder', 'Audiobooks') + + +def test_setup_client_name_fallback_self_heals_id(nav_client): + """Back-compat: installs saved before the id was persisted match by name, + and the id is written back so a later rename can't break the match.""" + fake_db = _prefs({'navidrome_music_folder_id': None, 'navidrome_music_folder': 'Audiobooks'}) + _connect_with(nav_client, fake_db) + assert nav_client.music_folder_id == '2' + fake_db.set_preference.assert_any_call('navidrome_music_folder_id', '2') + + def test_navidrome_album_exposes_cover_art_url(nav_client): album = NavidromeAlbum({ 'id': 'album-1', diff --git a/tests/metadata/test_artist_source_lookup.py b/tests/metadata/test_artist_source_lookup.py index 32c03cb6..949d42c5 100644 --- a/tests/metadata/test_artist_source_lookup.py +++ b/tests/metadata/test_artist_source_lookup.py @@ -164,6 +164,45 @@ class TestFindLibraryArtistForSource: ) assert result is None + def test_ambiguous_source_id_skips_id_upgrade(self, db): + """Regression for the Kendrick/Jorja bug: when one Deezer id is + stamped on several library artists (enrichment corruption), the id + match is ambiguous and must NOT pick an arbitrary row — it returns + None so the caller falls back to showing the source artist.""" + _insert_artist(db, artist_id="pk-kendrick", name="Kendrick Lamar", + deezer_id="525046", server_source="plex") + _insert_artist(db, artist_id="pk-jorja", name="Jorja Smith", + deezer_id="525046", server_source="plex") + _insert_artist(db, artist_id="pk-vince", name="Vince Staples", + deezer_id="525046", server_source="plex") + + # No name hint (the URL-driven path) → no id guess, no name fallback. + assert find_library_artist_for_source( + db, "deezer", "525046", active_server="plex" + ) is None + + def test_ambiguous_source_id_still_allows_name_fallback(self, db): + """An ambiguous id shouldn't block a correct name match when the + caller does have the name.""" + _insert_artist(db, artist_id="pk-kendrick", name="Kendrick Lamar", + deezer_id="525046", server_source="plex") + _insert_artist(db, artist_id="pk-jorja", name="Jorja Smith", + deezer_id="525046", server_source="plex") + + result = find_library_artist_for_source( + db, "deezer", "525046", artist_name="Kendrick Lamar", + active_server="plex", + ) + assert result == "pk-kendrick" + + def test_unique_source_id_still_matches(self, db): + """Positive control: a non-duplicated id still upgrades as before.""" + _insert_artist(db, artist_id="pk-solo", name="Solo Artist", + deezer_id="999999", server_source="plex") + assert find_library_artist_for_source( + db, "deezer", "999999" + ) == "pk-solo" + def test_id_match_wins_over_name_match(self, db): """If both a source-id match and a name match exist, the id match should take priority — it's the more reliable signal.""" diff --git a/tests/search/test_search_by_id.py b/tests/search/test_search_by_id.py new file mode 100644 index 00000000..172cb5b8 --- /dev/null +++ b/tests/search/test_search_by_id.py @@ -0,0 +1,451 @@ +"""Tests for core/search/by_id.py — paste-a-link/ID metadata resolution (#775). + +Covers the three seams: + +- ``parse_metadata_identifier`` — provider URLs, the ``spotify:`` URI, and + bare IDs (UUID → MusicBrainz, base62 → Spotify, numeric → Deezer/iTunes + fan-out with active-source bias). +- the shaping adapters — projecting each source's get-by-id dict (which + differ in their ``artists`` field shape) onto the common card shape. +- ``resolve_identifier`` — first-resolving-target-wins, kind fallback + (album→track), source fan-out, and the not-found regression. + +Clients are injected via ``client_resolver`` so nothing touches the network +or real config. +""" + +from __future__ import annotations + +from core.search import by_id +from core.search.by_id import LookupTarget + + +# --------------------------------------------------------------------------- +# Fakes — a client exposing only the get-by-id methods the resolver calls. +# Each "source" can return album/track dicts in its native shape. +# --------------------------------------------------------------------------- + +class _FakeClient: + def __init__(self, album=None, track=None, artist=None, name='fake'): + self._album = album + self._track = track + self._artist = artist + self._name = name + self.album_calls: list[str] = [] + self.track_calls: list[str] = [] + self.artist_calls: list[str] = [] + + # Spotify / iTunes / MusicBrainz album-by-id + def get_album(self, identifier, include_tracks=True): + self.album_calls.append(identifier) + return self._album + + # Deezer album-by-id (different method name) + def get_album_metadata(self, identifier, include_tracks=True): + self.album_calls.append(identifier) + return self._album + + # Uniform track-by-id + def get_track_details(self, identifier): + self.track_calls.append(identifier) + return self._track + + # Spotify / iTunes / MusicBrainz artist-by-id + def get_artist(self, identifier): + self.artist_calls.append(identifier) + return self._artist + + # Deezer artist-by-id (different method name) + def get_artist_info(self, identifier): + self.artist_calls.append(identifier) + return self._artist + + +def _resolver_from(mapping): + """Build a client_resolver from {source: client}.""" + return lambda source: mapping.get(source) + + +# --------------------------------------------------------------------------- +# parse_metadata_identifier — URLs +# --------------------------------------------------------------------------- + +def test_parse_spotify_album_url(): + out = by_id.parse_metadata_identifier( + 'https://open.spotify.com/album/4aawyAB9vmqN3uQ7FjRGTy' + ) + assert out == [LookupTarget('spotify', 'album', '4aawyAB9vmqN3uQ7FjRGTy')] + + +def test_parse_spotify_track_url_with_intl_prefix(): + out = by_id.parse_metadata_identifier( + 'https://open.spotify.com/intl-de/track/11dFghVXANMlKmJXsNCbNl' + ) + assert out == [LookupTarget('spotify', 'track', '11dFghVXANMlKmJXsNCbNl')] + + +def test_parse_spotify_uri(): + assert by_id.parse_metadata_identifier('spotify:album:ABC') == [ + LookupTarget('spotify', 'album', 'ABC') + ] + assert by_id.parse_metadata_identifier('spotify:track:XYZ') == [ + LookupTarget('spotify', 'track', 'XYZ') + ] + + +def test_parse_apple_album_url(): + out = by_id.parse_metadata_identifier( + 'https://music.apple.com/us/album/in-rainbows/1109714933' + ) + assert out == [LookupTarget('itunes', 'album', '1109714933')] + + +def test_parse_apple_track_url_uses_i_param(): + out = by_id.parse_metadata_identifier( + 'https://music.apple.com/us/album/in-rainbows/1109714933?i=1109714934' + ) + assert out == [LookupTarget('itunes', 'track', '1109714934')] + + +def test_parse_apple_song_url(): + out = by_id.parse_metadata_identifier( + 'https://music.apple.com/us/song/15-step/1109714938' + ) + assert out == [LookupTarget('itunes', 'track', '1109714938')] + + +def test_parse_musicbrainz_release_group_url(): + mbid = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890' + out = by_id.parse_metadata_identifier( + f'https://musicbrainz.org/release-group/{mbid}' + ) + assert out == [LookupTarget('musicbrainz', 'album', mbid)] + + +def test_parse_musicbrainz_recording_url_is_track(): + mbid = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890' + out = by_id.parse_metadata_identifier( + f'https://musicbrainz.org/recording/{mbid}' + ) + assert out == [LookupTarget('musicbrainz', 'track', mbid)] + + +def test_parse_deezer_album_url_with_locale(): + out = by_id.parse_metadata_identifier('https://www.deezer.com/en/album/302127') + assert out == [LookupTarget('deezer', 'album', '302127')] + + +def test_parse_deezer_track_url_no_scheme(): + out = by_id.parse_metadata_identifier('www.deezer.com/track/3135556') + assert out == [LookupTarget('deezer', 'track', '3135556')] + + +def test_parse_spotify_url_without_scheme_or_www(): + # Known host detected even without a scheme. + out = by_id.parse_metadata_identifier('open.spotify.com/album/4aawyAB9vmqN3uQ7FjRGTy') + assert out == [LookupTarget('spotify', 'album', '4aawyAB9vmqN3uQ7FjRGTy')] + + +# --------------------------------------------------------------------------- +# parse_metadata_identifier — artist links +# --------------------------------------------------------------------------- + +def test_parse_spotify_artist_url(): + out = by_id.parse_metadata_identifier( + 'https://open.spotify.com/artist/3TVXtAsR1Inumwj472S9r4' + ) + assert out == [LookupTarget('spotify', 'artist', '3TVXtAsR1Inumwj472S9r4')] + + +def test_parse_spotify_artist_uri(): + assert by_id.parse_metadata_identifier('spotify:artist:ABC') == [ + LookupTarget('spotify', 'artist', 'ABC') + ] + + +def test_parse_apple_artist_url(): + out = by_id.parse_metadata_identifier( + 'https://music.apple.com/us/artist/kendrick-lamar/368183298' + ) + assert out == [LookupTarget('itunes', 'artist', '368183298')] + + +def test_parse_musicbrainz_artist_url(): + mbid = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890' + out = by_id.parse_metadata_identifier(f'https://musicbrainz.org/artist/{mbid}') + assert out == [LookupTarget('musicbrainz', 'artist', mbid)] + + +def test_parse_deezer_artist_url(): + out = by_id.parse_metadata_identifier('https://www.deezer.com/artist/13') + assert out == [LookupTarget('deezer', 'artist', '13')] + + +# --------------------------------------------------------------------------- +# parse_metadata_identifier — bare IDs are rejected (links only) +# --------------------------------------------------------------------------- + +def test_parse_bare_numeric_id_rejected(): + # The footgun case (#775 follow-up): a bare number has no source/type, so + # it must NOT resolve to whatever album happens to own that id. + assert by_id.parse_metadata_identifier('525046') == [] + + +def test_parse_bare_uuid_rejected(): + assert by_id.parse_metadata_identifier( + 'a1b2c3d4-e5f6-7890-abcd-ef1234567890' + ) == [] + + +def test_parse_bare_base62_rejected(): + assert by_id.parse_metadata_identifier('4aawyAB9vmqN3uQ7FjRGTy') == [] + + +def test_parse_empty_and_garbage_return_empty(): + assert by_id.parse_metadata_identifier('') == [] + assert by_id.parse_metadata_identifier(' ') == [] + assert by_id.parse_metadata_identifier('not an id!!') == [] + # Unknown domain → no targets. + assert by_id.parse_metadata_identifier('https://example.com/album/1') == [] + + +# --------------------------------------------------------------------------- +# Shaping adapters +# --------------------------------------------------------------------------- + +def test_album_card_from_spotify_shaped_dict(): + d = { + 'id': 'abc', + 'name': 'OK Computer', + 'artists': [{'name': 'Radiohead', 'id': 'r1'}], + 'images': [{'url': 'http://img/big.jpg', 'height': 640, 'width': 640}], + 'release_date': '1997-05-21', + 'total_tracks': 12, + 'album_type': 'album', + 'external_urls': {'spotify': 'http://spot/abc'}, + } + card = by_id.album_dict_to_card(d) + assert card['id'] == 'abc' + assert card['name'] == 'OK Computer' + assert card['artist'] == 'Radiohead' + assert card['image_url'] == 'http://img/big.jpg' + assert card['total_tracks'] == 12 + assert card['external_urls'] == {'spotify': 'http://spot/abc'} + + +def test_album_card_carries_optional_musicbrainz_fields(): + d = { + 'id': 'mbid', 'name': 'Kid A', 'artists': [{'name': 'Radiohead'}], + 'images': [], 'release_date': '2000', 'total_tracks': 10, + 'album_type': 'album', 'country': 'GB', 'label': 'Parlophone', + 'release_group_id': 'rg1', 'external_urls': {}, + } + card = by_id.album_dict_to_card(d) + assert card['country'] == 'GB' + assert card['label'] == 'Parlophone' + assert card['release_group_id'] == 'rg1' + + +def test_track_card_handles_list_of_string_artists(): + # Spotify/iTunes shape: artists is a list of plain strings. + d = { + 'id': 't1', 'name': 'Paranoid Android', + 'artists': ['Radiohead'], + 'album': {'name': 'OK Computer', 'release_date': '1997'}, + 'duration_ms': 387000, + } + card = by_id.track_dict_to_card(d) + assert card['artist'] == 'Radiohead' + assert card['album'] == 'OK Computer' + assert card['duration_ms'] == 387000 + assert card['release_date'] == '1997' + + +def test_track_card_handles_list_of_dict_artists_and_album_image(): + # MusicBrainz shape: artists is a list of dicts; album carries images. + d = { + 'id': 't2', 'name': 'Idioteque', + 'artists': [{'name': 'Radiohead', 'id': ''}], + 'album': { + 'name': 'Kid A', + 'images': [{'url': 'http://img/kida.jpg', 'height': 250, 'width': 250}], + 'release_date': '2000', + }, + 'duration_ms': 300000, + 'external_urls': {'musicbrainz': 'http://mb/t2'}, + } + card = by_id.track_dict_to_card(d) + assert card['artist'] == 'Radiohead' + assert card['album'] == 'Kid A' + assert card['image_url'] == 'http://img/kida.jpg' + assert card['external_urls'] == {'musicbrainz': 'http://mb/t2'} + + +def test_join_artists_empty_is_unknown(): + assert by_id._join_artists([]) == 'Unknown Artist' + assert by_id._join_artists(None) == 'Unknown Artist' + + +def test_artist_card_from_spotify_shaped_dict(): + d = { + 'id': 'a1', 'name': 'Radiohead', + 'images': [{'url': 'http://img/rh.jpg', 'height': 640, 'width': 640}], + 'external_urls': {'spotify': 'http://spot/a1'}, + 'genres': ['rock'], 'popularity': 80, + } + card = by_id.artist_dict_to_card(d) + assert card == { + 'id': 'a1', + 'name': 'Radiohead', + 'image_url': 'http://img/rh.jpg', + 'external_urls': {'spotify': 'http://spot/a1'}, + } + + +# --------------------------------------------------------------------------- +# resolve_identifier — end-to-end with fake clients +# --------------------------------------------------------------------------- + +_SPOTIFY_ALBUM = { + 'id': 'abc', 'name': 'OK Computer', + 'artists': [{'name': 'Radiohead'}], + 'images': [{'url': 'http://i/a.jpg'}], + 'release_date': '1997', 'total_tracks': 12, 'album_type': 'album', + 'external_urls': {'spotify': 'http://s/abc'}, +} + + +def test_resolve_spotify_album_link(): + client = _FakeClient(album=_SPOTIFY_ALBUM) + res = by_id.resolve_identifier( + 'https://open.spotify.com/album/abc', deps=None, + client_resolver=_resolver_from({'spotify': client}), + ) + assert res['available'] is True + assert res['source'] == 'spotify' + assert len(res['albums']) == 1 + assert res['albums'][0]['name'] == 'OK Computer' + assert res['tracks'] == [] + assert client.album_calls == ['abc'] + assert client.track_calls == [] # kind pinned to album — no track probe + + +def test_resolve_deezer_album_uses_get_album_metadata(): + client = _FakeClient(album={'id': '302127', 'name': 'Discovery', + 'artists': [{'name': 'Daft Punk'}], 'images': [], + 'release_date': '2001', 'total_tracks': 14, + 'album_type': 'album', 'external_urls': {}}) + res = by_id.resolve_identifier( + 'https://www.deezer.com/album/302127', deps=None, + client_resolver=_resolver_from({'deezer': client}), + ) + assert res['available'] is True + assert res['source'] == 'deezer' + assert res['albums'][0]['name'] == 'Discovery' + assert client.album_calls == ['302127'] + + +def test_resolve_track_link(): + # A track URL pins kind=track — only get_track_details is called. + client = _FakeClient(album=None, track={ + 'id': 't1', 'name': 'Creep', 'artists': ['Radiohead'], + 'album': {'name': 'Pablo Honey'}, 'duration_ms': 238000, + }) + res = by_id.resolve_identifier( + 'https://open.spotify.com/track/t1', deps=None, + client_resolver=_resolver_from({'spotify': client}), + ) + assert res['available'] is True + assert res['tracks'][0]['name'] == 'Creep' + assert res['albums'] == [] + assert client.album_calls == [] # kind pinned to track — no album probe + assert client.track_calls == ['t1'] + + +def test_resolve_artist_link(): + # An artist URL pins kind=artist — only get_artist is called. + client = _FakeClient(artist={ + 'id': 'a1', 'name': 'Radiohead', + 'images': [{'url': 'http://i/rh.jpg'}], + 'external_urls': {'spotify': 'http://s/a1'}, + }) + res = by_id.resolve_identifier( + 'https://open.spotify.com/artist/a1', deps=None, + client_resolver=_resolver_from({'spotify': client}), + ) + assert res['available'] is True + assert res['source'] == 'spotify' + assert res['artists'][0]['name'] == 'Radiohead' + assert res['albums'] == [] and res['tracks'] == [] + assert client.artist_calls == ['a1'] + assert client.album_calls == [] and client.track_calls == [] + + +def test_resolve_deezer_artist_uses_get_artist_info(): + client = _FakeClient(artist={'id': '13', 'name': 'Daft Punk', + 'images': [], 'external_urls': {}}) + res = by_id.resolve_identifier( + 'https://www.deezer.com/artist/13', deps=None, + client_resolver=_resolver_from({'deezer': client}), + ) + assert res['available'] is True + assert res['source'] == 'deezer' + assert res['artists'][0]['name'] == 'Daft Punk' + assert client.artist_calls == ['13'] + + +def test_resolve_bare_id_rejected_with_hint(): + # The #775 follow-up regression: a bare number must not resolve; it + # returns not-found with a link hint instead of an unrelated album. + called = [] + res = by_id.resolve_identifier( + '525046', deps=None, + client_resolver=lambda s: called.append(s), # must never be invoked + ) + assert res['available'] is False + assert res['albums'] == [] and res['tracks'] == [] + assert 'link' in res['message'].lower() + assert called == [] # no source was even probed + + +def test_resolve_unavailable_client_is_skipped(): + # Spotify client is None (unauthed) — resolver returns not-found, no crash. + res = by_id.resolve_identifier( + 'https://open.spotify.com/album/abc', deps=None, + client_resolver=_resolver_from({'spotify': None}), + ) + assert res['available'] is False + assert res['albums'] == [] and res['tracks'] == [] + # The source we tried is reported even on miss. + assert res['source'] == 'spotify' + assert res['message'] + + +def test_resolve_client_exception_does_not_propagate(): + def boom(_source): + raise RuntimeError('client init failed') + res = by_id.resolve_identifier( + 'https://open.spotify.com/album/abc', deps=None, client_resolver=boom, + ) + assert res['available'] is False + + +def test_resolve_unrecognized_identifier_returns_empty(): + res = by_id.resolve_identifier( + 'definitely not a link', deps=None, + client_resolver=_resolver_from({}), + ) + assert res['available'] is False + assert res['query'] == 'definitely not a link' + + +def test_resolve_get_album_returning_none_yields_not_found(): + # Regression: a pinned-kind link whose lookup returns None must report + # not-found, not raise or fabricate a card. + client = _FakeClient(album=None) + res = by_id.resolve_identifier( + 'https://open.spotify.com/album/missing', deps=None, + client_resolver=_resolver_from({'spotify': client}), + ) + assert res['available'] is False + assert res['albums'] == [] diff --git a/tests/search/test_search_orchestrator.py b/tests/search/test_search_orchestrator.py index 02766746..951b1303 100644 --- a/tests/search/test_search_orchestrator.py +++ b/tests/search/test_search_orchestrator.py @@ -50,7 +50,7 @@ class _Track: class _Client: def __init__(self, *, name='fake', artists=None, albums=None, tracks=None, - fail_search=False, authed=True, connected=True): + fail_search=False, authed=True, connected=True, meta_available=None): self.name = name self._artists = artists or [] self._albums = albums or [] @@ -58,6 +58,10 @@ class _Client: self._fail = fail_search self._authed = authed self._connected = connected + # When unset, metadata availability tracks auth (the common case). Set + # explicitly to model the no-creds SpotipyFree fallback: not authed but + # metadata still available. + self._meta_available = meta_available def search_artists(self, q, limit=10): if self._fail: @@ -77,6 +81,9 @@ class _Client: def is_spotify_authenticated(self): return self._authed + def is_spotify_metadata_available(self): + return self._authed if self._meta_available is None else self._meta_available + def is_connected(self): return self._connected @@ -157,12 +164,22 @@ def test_resolve_spotify_authed_returns_client(): def test_resolve_spotify_unauthed_returns_none(): - deps = _build_deps(spotify_client=_Client(authed=False)) + # No auth AND no free fallback available → Spotify source unavailable. + deps = _build_deps(spotify_client=_Client(authed=False, meta_available=False)) client, ok = orchestrator.resolve_client('spotify', deps) assert client is None assert ok is False +def test_resolve_spotify_unauthed_but_free_available_returns_client(): + # #798: no Spotify auth but the no-creds SpotipyFree fallback is available → + # the Spotify source stays usable (the client routes to free internally). + deps = _build_deps(spotify_client=_Client(authed=False, meta_available=True)) + client, ok = orchestrator.resolve_client('spotify', deps) + assert client is deps.spotify_client + assert ok is True + + def test_resolve_spotify_missing_returns_none(): deps = _build_deps(spotify_client=None) client, ok = orchestrator.resolve_client('spotify', deps) diff --git a/tests/sync/test_match_overrides.py b/tests/sync/test_match_overrides.py index 4779ca54..63a5e40a 100644 --- a/tests/sync/test_match_overrides.py +++ b/tests/sync/test_match_overrides.py @@ -1,6 +1,10 @@ from unittest.mock import MagicMock -from core.sync.match_overrides import record_manual_match, resolve_match_overrides +from core.sync.match_overrides import ( + record_manual_match, + resolve_durable_match_server_id, + resolve_match_overrides, +) # ────────────────────────────────────────────────────────────────────── @@ -191,3 +195,84 @@ def test_record_handles_empty_optional_strings(): assert kwargs["normalized_title"] == "" assert kwargs["normalized_artist"] == "" assert kwargs["server_track_title"] == "" + + +# ────────────────────────────────────────────────────────────────────── +# resolve_durable_match_server_id — manual match survives a rescan (#787) +# ────────────────────────────────────────────────────────────────────── + +class _FakeMatchDB: + """Minimal DB stub for the durable-match resolver.""" + def __init__(self, match=None, file_path_id=None): + self._match = match + self._file_path_id = file_path_id + self.saved = [] + + def find_manual_library_match_by_source_track_id(self, profile_id, source_track_id, server_source): + return dict(self._match) if self._match else None + + def find_track_id_by_file_path(self, file_path): + return self._file_path_id + + def save_manual_library_match(self, profile_id, source, source_track_id, library_track_id, **meta): + self.saved.append((library_track_id, meta)) + return True + + +def test_durable_match_returns_id_when_still_valid(): + # Stored library id still present in the server playlist → direct hit. + db = _FakeMatchDB(match={"library_track_id": "5001", "library_file_path": "/m/a.flac", + "profile_id": 1, "source": "spotify", "source_track_id": "iron", + "server_source": "plex"}) + out = resolve_durable_match_server_id(db, 1, "iron", "plex", {"5001", "5002"}) + assert out == "5001" + assert db.saved == [] # no self-heal needed + + +def test_durable_match_reresolves_stale_id_via_file_path_and_self_heals(): + # Rescan re-keyed the track: old id 5001 gone, file now lives at id 7777. + db = _FakeMatchDB( + match={"library_track_id": "5001", "library_file_path": "/m/a.flac", + "profile_id": 1, "source": "spotify", "source_track_id": "iron", "server_source": "plex"}, + file_path_id="7777", + ) + out = resolve_durable_match_server_id(db, 1, "iron", "plex", {"7777", "9000"}) + assert out == "7777" # re-resolved to the current id + assert db.saved and db.saved[0][0] == "7777" # self-healed the stored id + + +def test_durable_match_none_when_no_match(): + db = _FakeMatchDB(match=None) + assert resolve_durable_match_server_id(db, 1, "iron", "plex", {"5001"}) is None + + +def test_durable_match_none_when_stale_and_file_path_unresolvable(): + # Stale id AND the file path no longer resolves (file moved/removed) → no pair. + db = _FakeMatchDB( + match={"library_track_id": "5001", "library_file_path": "/m/gone.flac", + "profile_id": 1, "source": "spotify", "source_track_id": "iron", "server_source": "plex"}, + file_path_id=None, + ) + assert resolve_durable_match_server_id(db, 1, "iron", "plex", {"7777"}) is None + assert db.saved == [] + + +def test_durable_match_none_when_reresolved_id_not_in_playlist(): + # File resolves to a track, but that track isn't in THIS playlist → don't pair. + db = _FakeMatchDB( + match={"library_track_id": "5001", "library_file_path": "/m/a.flac", + "profile_id": 1, "source": "spotify", "source_track_id": "iron", "server_source": "plex"}, + file_path_id="7777", + ) + assert resolve_durable_match_server_id(db, 1, "iron", "plex", {"1234"}) is None + + +def test_durable_match_safe_when_db_lacks_methods(): + class Bare: + pass + assert resolve_durable_match_server_id(Bare(), 1, "iron", "plex", {"5001"}) is None + + +def test_durable_match_empty_source_id_returns_none(): + db = _FakeMatchDB(match={"library_track_id": "5001"}) + assert resolve_durable_match_server_id(db, 1, "", "plex", {"5001"}) is None diff --git a/tests/sync/test_reconcile_or_replace.py b/tests/sync/test_reconcile_or_replace.py new file mode 100644 index 00000000..2c884997 --- /dev/null +++ b/tests/sync/test_reconcile_or_replace.py @@ -0,0 +1,67 @@ +"""sync_mode='reconcile' dispatch + fallback (#792). + +_reconcile_or_replace tries the client's in-place reconcile and falls back to +the destructive replace only when reconcile is unavailable or fails, so a sync +always succeeds while preferring the non-destructive path. +""" + +import sys +import types + +# Stub optional Spotify dep so services.sync_service imports in the test env. +if 'spotipy' not in sys.modules: + sp = types.ModuleType('spotipy'); oa = types.ModuleType('spotipy.oauth2') + sp.Spotify = type('S', (), {}); oa.SpotifyOAuth = oa.SpotifyClientCredentials = type('O', (), {}) + sp.oauth2 = oa; sys.modules['spotipy'] = sp; sys.modules['spotipy.oauth2'] = oa + +from services.sync_service import PlaylistSyncService + + +def _service(): + return PlaylistSyncService.__new__(PlaylistSyncService) + + +class _Client: + def __init__(self, reconcile=None): + self._reconcile = reconcile + self.reconcile_calls = [] + self.replace_calls = [] + if reconcile is not None: + def reconcile_playlist(name, tracks): + self.reconcile_calls.append(name) + if isinstance(reconcile, Exception): + raise reconcile + return reconcile + self.reconcile_playlist = reconcile_playlist + + def update_playlist(self, name, tracks): + self.replace_calls.append(name) + return True + + +def test_reconcile_success_does_not_fall_back(): + c = _Client(reconcile=True) + assert _service()._reconcile_or_replace(c, 'P', []) is True + assert c.reconcile_calls == ['P'] + assert c.replace_calls == [] # never recreated + + +def test_reconcile_false_falls_back_to_replace(): + c = _Client(reconcile=False) + assert _service()._reconcile_or_replace(c, 'P', []) is True + assert c.reconcile_calls == ['P'] + assert c.replace_calls == ['P'] # fell back so the sync still happens + + +def test_reconcile_exception_falls_back_to_replace(): + c = _Client(reconcile=RuntimeError('boom')) + assert _service()._reconcile_or_replace(c, 'P', []) is True + assert c.reconcile_calls == ['P'] + assert c.replace_calls == ['P'] + + +def test_client_without_reconcile_uses_replace(): + c = _Client(reconcile=None) # no reconcile_playlist attribute + assert not hasattr(c, 'reconcile_playlist') + assert _service()._reconcile_or_replace(c, 'P', []) is True + assert c.replace_calls == ['P'] diff --git a/tests/test_acoustid_skip_logic.py b/tests/test_acoustid_skip_logic.py index 84c92017..0d820d1b 100644 --- a/tests/test_acoustid_skip_logic.py +++ b/tests/test_acoustid_skip_logic.py @@ -185,6 +185,80 @@ def test_high_score_but_artist_mismatch_no_longer_skipped(verifier): assert result == VerificationResult.FAIL +def test_low_fingerprint_score_never_skipped_same_script_artist(verifier): + """#797 guard — the #607 protection for a SAME-SCRIPT artist (Latin + 'Yoko Takahashi' on both sides) with only a cross-script TITLE must + stay FAIL below the 0.95 floor. The #797 relaxation is keyed on the + ARTIST spanning scripts, which this case is NOT, so nothing changes + here. (Duplicates test_low_fingerprint_score_never_skipped's intent + explicitly against the new code path.)""" + _stub_lookup(verifier, recordings=[ + {'title': '残酷な天使のテーゼ', 'artist': 'Yoko Takahashi'}, + ], best_score=0.85) + + result, _msg = verifier.verify_audio_file( + '/fake/path.flac', + 'Zankoku na Tenshi no Theze', + 'Yoko Takahashi', + ) + assert result == VerificationResult.FAIL + + +# --------------------------------------------------------------------------- +# Issue #797 — non-English ARTIST whose name spans scripts +# --------------------------------------------------------------------------- + + +def test_cross_script_artist_confirmed_via_alias_skips_below_095(verifier): + """#797 headline: requested 'Joe Hisaishi' (romanized), AcoustID + returns the recording with the artist/title in their native kanji + ('久石譲'). The alias bridge confirms 久石譲 IS Joe Hisaishi, the + fingerprint is solid (0.85, above the 0.80 trust floor) but below + the old 0.95 language/script bar. Pre-#797 this FAILed and the + correct file was quarantined. Now it SKIPs.""" + with patch( + 'core.acoustid_verification._resolve_expected_artist_aliases', + return_value=['久石譲'], + ): + _stub_lookup(verifier, recordings=[ + {'title': '風のとおり道', 'artist': '久石譲'}, + ], best_score=0.85) + + result, msg = verifier.verify_audio_file( + '/fake/path.flac', + 'The Path of the Wind', + 'Joe Hisaishi', + ) + assert result == VerificationResult.SKIP + assert 'language/script' in msg.lower() + + +def test_cross_script_artist_NOT_confirmed_still_fails(verifier): + """#797 tight scope: if the alias bridge can't confirm the artist + (lookup returns nothing), we have no positive evidence the kanji + artist IS the expected one — so the #797 relaxation must NOT fire. + The relaxation only rescues a CONFIRMED cross-script artist. + + Constructed so best_rec is set (partial title overlap → non-zero + combined score) and the title stays under the strict threshold, so + the flow reaches the same skip-decision point the rescue lives at — + proving it doesn't fire without a confirmed artist.""" + with patch( + 'core.acoustid_verification._resolve_expected_artist_aliases', + return_value=[], + ): + _stub_lookup(verifier, recordings=[ + {'title': 'Summer Night', 'artist': '久石譲'}, + ], best_score=0.85) + + result, _msg = verifier.verify_audio_file( + '/fake/path.flac', + 'Summer', + 'Joe Hisaishi', + ) + assert result == VerificationResult.FAIL + + def test_old_loose_threshold_no_longer_fires_for_unrelated_titles(verifier): """Pin the negative case for the old loose threshold (title_sim >= 0.55). 'Crown' vs 'Crown of Thorns' had similarity around 0.6 diff --git a/tests/test_album_bundle.py b/tests/test_album_bundle.py index e4ec05d2..8b85e6c4 100644 --- a/tests/test_album_bundle.py +++ b/tests/test_album_bundle.py @@ -360,6 +360,40 @@ def test_copy_audio_files_atomically_creates_staging_dir(tmp_path: Path) -> None assert staging.exists() +def test_copy_audio_files_atomically_keeps_source_by_default(tmp_path: Path) -> None: + """Default (torrent/usenet): originals stay put (client keeps seeding).""" + src = tmp_path / 'a.flac' + src.write_bytes(b'a') + staging = tmp_path / 'staging' + out = copy_audio_files_atomically([src], staging) + assert len(out) == 1 + assert src.exists() # source retained + + +def test_copy_audio_files_atomically_removes_source_when_requested(tmp_path: Path) -> None: + """#796: Soulseek path removes slskd's completed files once staged so they + don't pile up in the download folder.""" + src_a = tmp_path / 'a.flac'; src_a.write_bytes(b'a') + src_c = tmp_path / 'c.flac'; src_c.write_bytes(b'c') + staging = tmp_path / 'staging' + out = copy_audio_files_atomically([src_a, src_c], staging, remove_source=True) + assert len(out) == 2 # both staged + assert not src_a.exists() and not src_c.exists() # sources removed + assert sorted(Path(p).name for p in out) == ['a.flac', 'c.flac'] + + +def test_copy_audio_files_atomically_keeps_source_when_copy_fails(tmp_path: Path) -> None: + """Data safety: a source whose copy FAILS must never be deleted, even with + remove_source=True.""" + src_ok = tmp_path / 'ok.flac'; src_ok.write_bytes(b'ok') + src_missing = tmp_path / 'gone.flac' # never created -> copy fails + staging = tmp_path / 'staging' + out = copy_audio_files_atomically([src_ok, src_missing], staging, remove_source=True) + assert len(out) == 1 # only ok staged + assert not src_ok.exists() # staged source removed + assert not src_missing.exists() # never existed (and not created) + + # --------------------------------------------------------------------------- # Config-driven poll cadence # --------------------------------------------------------------------------- diff --git a/tests/test_canonical_manual_lock.py b/tests/test_canonical_manual_lock.py new file mode 100644 index 00000000..ad9fd99e --- /dev/null +++ b/tests/test_canonical_manual_lock.py @@ -0,0 +1,99 @@ +"""#758 — a manual album match pins (and LOCKS) the canonical album version, so +re-resolution / the auto canonical job can't drag it back to the deluxe edition. + +Two seams: + - should_pin_manual_canonical (pure): when a manual match should pin canonical. + - set_album_canonical / get_album_canonical (DB): the lock can't be overwritten + by an auto write, but a new manual write still wins. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from core.metadata.canonical_version import ( + CANONICAL_ALBUM_SOURCES, + should_pin_manual_canonical, +) +from database.music_database import MusicDatabase + + +# --------------------------------------------------------------------------- +# should_pin_manual_canonical — pure +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize('source', ['spotify', 'itunes', 'deezer', 'discogs', 'hydrabase']) +def test_pins_album_on_recognised_source(source): + assert should_pin_manual_canonical('album', source) is True + + +@pytest.mark.parametrize('entity', ['artist', 'track']) +def test_does_not_pin_non_album(entity): + assert should_pin_manual_canonical(entity, 'spotify') is False + + +@pytest.mark.parametrize('source', ['lastfm', 'genius', 'musicbrainz', 'audiodb', 'tidal']) +def test_does_not_pin_source_canonical_cant_read(source): + # No album-version data the canonical tools read → nothing to pin. + assert should_pin_manual_canonical('album', source) is False + + +def test_sources_stay_in_sync_with_album_id_columns(): + # The set must mirror the canonical reader's column map; if a source is + # added there, this fails until CANONICAL_ALBUM_SOURCES is updated. + from core.library_reorganize import _ALBUM_ID_COLUMNS + assert CANONICAL_ALBUM_SOURCES == set(_ALBUM_ID_COLUMNS) + + +# --------------------------------------------------------------------------- +# set_album_canonical / get_album_canonical — the lock (DB) +# --------------------------------------------------------------------------- + +def _insert_album(db, album_id): + conn = db._get_connection() + conn.execute("INSERT OR IGNORE INTO artists (id, name) VALUES ('ar1', 'Artist')") + conn.execute("INSERT INTO albums (id, artist_id, title) VALUES (?, 'ar1', 'Album')", (album_id,)) + conn.commit() + conn.close() + + +@pytest.fixture +def db(tmp_path: Path) -> MusicDatabase: + d = MusicDatabase(database_path=str(tmp_path / "ml.db")) + _insert_album(d, 'al1') + return d + + +def test_manual_lock_set_and_read(db): + assert db.set_album_canonical('al1', 'spotify', 'REG', 1.0, locked=True) is True + c = db.get_album_canonical('al1') + assert c['source'] == 'spotify' and c['album_id'] == 'REG' and c['locked'] is True + + +def test_auto_cannot_overwrite_manual_lock(db): + db.set_album_canonical('al1', 'spotify', 'REG', 1.0, locked=True) + # The auto resolve job tries to re-pin the deluxe — must be refused. + assert db.set_album_canonical('al1', 'spotify', 'DELUXE', 0.9, locked=False) is False + c = db.get_album_canonical('al1') + assert c['album_id'] == 'REG' and c['locked'] is True # unchanged + + +def test_new_manual_match_overrides_existing_pin(db): + db.set_album_canonical('al1', 'spotify', 'OLD', 0.8, locked=False) # auto pin + # User manually picks a different edition — manual always wins. + assert db.set_album_canonical('al1', 'itunes', 'NEW', 1.0, locked=True) is True + c = db.get_album_canonical('al1') + assert c['source'] == 'itunes' and c['album_id'] == 'NEW' and c['locked'] is True + + +def test_auto_overwrites_auto(db): + db.set_album_canonical('al1', 'spotify', 'A', 0.8, locked=False) + assert db.set_album_canonical('al1', 'spotify', 'B', 0.9, locked=False) is True + assert db.get_album_canonical('al1')['album_id'] == 'B' + + +def test_unresolved_album_returns_none(db): + _insert_album(db, 'al2') + assert db.get_album_canonical('al2') is None diff --git a/tests/test_database_update_reconcile_hook.py b/tests/test_database_update_reconcile_hook.py new file mode 100644 index 00000000..a1fd5694 --- /dev/null +++ b/tests/test_database_update_reconcile_hook.py @@ -0,0 +1,66 @@ +"""The auto-reconcile must run as the FINAL scan phase — inside the worker's +completion, BEFORE the 'finished' signal — so the scan's status stays +'running' through it. That ordering is what makes automations (which poll for +completion), the dashboard card, and the Tools page all treat the reconcile as +part of the scan and wait for it, rather than seeing 'finished' early and +missing the tail. These pin that contract on DatabaseUpdateWorker._emit_finished. +""" + +from __future__ import annotations + +from core.database_update_worker import DatabaseUpdateWorker + + +def _bare_worker(): + # __new__ avoids the full media-client/config init; _emit_finished only + # touches self.callbacks + self.post_scan_hook. + w = DatabaseUpdateWorker.__new__(DatabaseUpdateWorker) + w.callbacks = {'finished': [], 'error': [], 'progress_updated': [], + 'phase_changed': [], 'artist_processed': []} + w.post_scan_hook = None + return w + + +def test_post_scan_hook_runs_before_finished(): + w = _bare_worker() + order = [] + w.post_scan_hook = lambda worker: order.append('hook') + w.callbacks['finished'].append(lambda *a: order.append('finished')) + w._emit_finished(1, 2, 3, 4, 5) + assert order == ['hook', 'finished'] # reconcile happens inside the running window + + +def test_finished_receives_original_args(): + w = _bare_worker() + got = [] + w.callbacks['finished'].append(lambda *a: got.append(a)) + w._emit_finished(1, 2, 3, 4, 5) + assert got == [(1, 2, 3, 4, 5)] + + +def test_no_hook_still_emits_finished(): + # Backward-compatible: a worker with no hook signals finished exactly as before. + w = _bare_worker() + got = [] + w.callbacks['finished'].append(lambda *a: got.append(a)) + w._emit_finished(0, 0, 0, 0, 0) + assert got == [(0, 0, 0, 0, 0)] + + +def test_hook_exception_never_blocks_finished(): + # A reconcile failure must not strand the scan as perpetually 'running'. + w = _bare_worker() + fired = [] + w.post_scan_hook = lambda worker: (_ for _ in ()).throw(RuntimeError("boom")) + w.callbacks['finished'].append(lambda *a: fired.append(a)) + w._emit_finished(1, 1, 1, 1, 1) + assert fired == [(1, 1, 1, 1, 1)] + + +def test_hook_receives_the_worker(): + w = _bare_worker() + seen = [] + w.post_scan_hook = lambda worker: seen.append(worker) + w.callbacks['finished'].append(lambda *a: None) + w._emit_finished(0, 0, 0, 0, 0) + assert seen == [w] diff --git a/tests/test_dedupe_source_ids.py b/tests/test_dedupe_source_ids.py new file mode 100644 index 00000000..59e4dc06 --- /dev/null +++ b/tests/test_dedupe_source_ids.py @@ -0,0 +1,168 @@ +"""Tests for core/maintenance/dedupe_source_ids.py — the one-off repair for +source ids that enrichment wrongly shared across multiple artists. + +Corruption = one source id on artists with DIFFERENT names. Legit duplicates = +the SAME artist on two media servers, same name — must be left alone. +""" + +from __future__ import annotations + +import pytest + +import database.music_database as mdb_mod +from core.maintenance import dedupe_source_ids as dd +from database.music_database import MusicDatabase + + +@pytest.fixture +def db(tmp_path): + return MusicDatabase(str(tmp_path / "music.db")) + + +def _insert(db, *, artist_id, name, **extra): + cols = ["id", "name", "server_source"] + list(extra.keys()) + vals = [artist_id, name, "plex"] + list(extra.values()) + placeholders = ",".join("?" for _ in cols) + with db._get_connection() as conn: + conn.execute( + f"INSERT INTO artists ({','.join(cols)}) VALUES ({placeholders})", vals + ) + conn.commit() + + +def _get(db, artist_id, col): + with db._get_connection() as conn: + return conn.execute(f"SELECT {col} FROM artists WHERE id=?", (artist_id,)).fetchone()[0] + + +# --------------------------------------------------------------------------- +# Detection +# --------------------------------------------------------------------------- + +def test_detects_different_name_cluster_as_corrupt(db): + _insert(db, artist_id="1", name="Kendrick Lamar", deezer_id="525046") + _insert(db, artist_id="2", name="Jorja Smith", deezer_id="525046") + _insert(db, artist_id="3", name="Vince Staples", deezer_id="525046") + + clusters = dd.find_corrupt_clusters(db) + assert len(clusters) == 1 + c = clusters[0] + assert c['source'] == 'deezer' + assert c['source_id'] == '525046' + assert {n for _, n in c['members']} == {"Kendrick Lamar", "Jorja Smith", "Vince Staples"} + + +def test_same_name_duplicate_is_not_corrupt(db): + # Same artist on two servers — legit shared id, must be ignored. + _insert(db, artist_id="10", name="Radiohead", deezer_id="999") + _insert(db, artist_id="11", name="radiohead", deezer_id="999") # case-insensitive + assert dd.find_corrupt_clusters(db) == [] + + +def test_unique_ids_are_not_corrupt(db): + _insert(db, artist_id="20", name="A", deezer_id="1") + _insert(db, artist_id="21", name="B", deezer_id="2") + assert dd.find_corrupt_clusters(db) == [] + + +def test_detects_corruption_across_multiple_sources(db): + _insert(db, artist_id="1", name="Kendrick", deezer_id="525046", spotify_artist_id="sp-x") + _insert(db, artist_id="2", name="Jorja", deezer_id="525046") + _insert(db, artist_id="3", name="Someone", spotify_artist_id="sp-x") + sources = {c['source'] for c in dd.find_corrupt_clusters(db)} + assert sources == {'deezer', 'spotify'} + + +# --------------------------------------------------------------------------- +# Repair +# --------------------------------------------------------------------------- + +def test_dry_run_writes_nothing(db): + _insert(db, artist_id="1", name="Kendrick", deezer_id="525046", deezer_match_status="matched") + _insert(db, artist_id="2", name="Jorja", deezer_id="525046", deezer_match_status="matched") + + report = dd.clear_corrupt_source_ids(db, dry_run=True) + assert report['dry_run'] is True + assert report['cluster_count'] == 1 + assert report['artist_count'] == 2 + assert report['by_source'] == {'deezer': 2} + # Nothing changed. + assert _get(db, "1", "deezer_id") == "525046" + assert _get(db, "2", "deezer_id") == "525046" + + +def test_apply_clears_id_and_status_for_corrupt_rows(db): + _insert(db, artist_id="1", name="Kendrick", deezer_id="525046", deezer_match_status="matched") + _insert(db, artist_id="2", name="Jorja", deezer_id="525046", deezer_match_status="matched") + + report = dd.clear_corrupt_source_ids(db, dry_run=False) + assert report['dry_run'] is False + assert report['artist_count'] == 2 + # Both cleared so the (now name-checked) worker re-derives them. + for aid in ("1", "2"): + assert _get(db, aid, "deezer_id") is None + assert _get(db, aid, "deezer_match_status") is None + + +def test_apply_leaves_legit_duplicates_untouched(db): + # Corrupt deezer cluster + a legit same-name spotify duplicate. + _insert(db, artist_id="1", name="Kendrick", deezer_id="525046") + _insert(db, artist_id="2", name="Jorja", deezer_id="525046") + _insert(db, artist_id="3", name="Radiohead", spotify_artist_id="rh", server_source="plex") + _insert(db, artist_id="4", name="Radiohead", spotify_artist_id="rh", server_source="jellyfin") + + with db._get_connection() as conn: + conn.execute("UPDATE artists SET server_source='jellyfin' WHERE id='4'") + conn.commit() + + dd.clear_corrupt_source_ids(db, dry_run=False) + # Corrupt deezer ids cleared… + assert _get(db, "1", "deezer_id") is None + assert _get(db, "2", "deezer_id") is None + # …legit same-name spotify duplicate preserved. + assert _get(db, "3", "spotify_artist_id") == "rh" + assert _get(db, "4", "spotify_artist_id") == "rh" + + +def test_clean_library_is_a_noop(db): + _insert(db, artist_id="1", name="A", deezer_id="1") + _insert(db, artist_id="2", name="B", deezer_id="2") + report = dd.clear_corrupt_source_ids(db, dry_run=False) + assert report['cluster_count'] == 0 + assert report['artist_count'] == 0 + + +# --------------------------------------------------------------------------- +# The one-time startup migration (auto-repair for users who pull the fix) +# --------------------------------------------------------------------------- + +def test_startup_migration_clears_shared_source_ids(tmp_path): + """The _source_id_dedupe_v1 migration in MusicDatabase init must clear + differently-named shared ids and leave same-name cross-server dups.""" + path = str(tmp_path / "music.db") + db = MusicDatabase(path) # first init creates the marker on an empty db + + with db._get_connection() as conn: + conn.execute("INSERT INTO artists (id,name,server_source,deezer_id,deezer_match_status) " + "VALUES ('1','Kendrick Lamar','plex','525046','matched')") + conn.execute("INSERT INTO artists (id,name,server_source,deezer_id,deezer_match_status) " + "VALUES ('2','Jorja Smith','plex','525046','matched')") + # Legit same-name dup across two servers — must survive. + conn.execute("INSERT INTO artists (id,name,server_source,spotify_artist_id) " + "VALUES ('3','Radiohead','plex','rh')") + conn.execute("INSERT INTO artists (id,name,server_source,spotify_artist_id) " + "VALUES ('4','Radiohead','jellyfin','rh')") + conn.execute("DROP TABLE _source_id_dedupe_v1") + conn.commit() + + # Force the one-time migration to run again. + mdb_mod._database_initialized_paths.clear() + MusicDatabase(path) + + with db._get_connection() as conn: + k = conn.execute("SELECT deezer_id, deezer_match_status FROM artists WHERE id='1'").fetchone() + assert tuple(k) == (None, None) + assert conn.execute("SELECT deezer_id FROM artists WHERE id='2'").fetchone()[0] is None + rh = conn.execute("SELECT spotify_artist_id FROM artists WHERE id IN ('3','4')").fetchall() + assert all(r[0] == 'rh' for r in rh) + assert conn.execute("SELECT name FROM sqlite_master WHERE name='_source_id_dedupe_v1'").fetchone() diff --git a/tests/test_deezer_worker_artist_id_guard.py b/tests/test_deezer_worker_artist_id_guard.py new file mode 100644 index 00000000..2c46ed00 --- /dev/null +++ b/tests/test_deezer_worker_artist_id_guard.py @@ -0,0 +1,76 @@ +"""Regression: Deezer enrichment must not overwrite an artist's deezer_id from a +collaboration/compilation track whose primary artist is someone else. + +The Kendrick/Jorja bug: a track our library credits to Jorja Smith lives on +Kendrick Lamar's curated "Black Panther" album. The album/track search resolves +to that album, whose Deezer primary artist is Kendrick (id 525046). The old +``_verify_artist_id`` "corrected" Jorja's deezer_id to 525046 with no name +check — stamping one Deezer id across several unrelated artists, which later +broke the artist-detail page (it matched the wrong library artist by id). + +The fix gates the correction on a name match between the result's primary +artist and our parent artist. +""" + +from __future__ import annotations + +from core.deezer_worker import DeezerWorker + + +def _worker(): + # Bypass __init__ — it wants real clients/db. We only exercise the pure + # _verify_artist_id / _name_matches / _normalize_name logic. + w = DeezerWorker.__new__(DeezerWorker) + w.name_similarity_threshold = 0.80 + w._corrections = [] + w._correct_artist_deezer_id = lambda item, cid: w._corrections.append((item['id'], cid)) + return w + + +def _item(artist_name, parent_deezer_id): + return { + 'type': 'track', 'id': 1, 'name': 'Some Track', + 'artist': artist_name, 'artist_deezer_id': parent_deezer_id, + } + + +def test_no_correction_when_result_artist_name_differs(): + # Jorja Smith (deezer 999) but the track resolved to Kendrick's album + # (Deezer artist 525046, 'Kendrick Lamar') → must NOT overwrite. + w = _worker() + w._verify_artist_id(_item('Jorja Smith', '999'), '525046', 'Kendrick Lamar') + assert w._corrections == [] + + +def test_correction_when_names_match(): + # Same artist, stale/wrong stored id → legitimate correction proceeds. + w = _worker() + w._verify_artist_id(_item('Kendrick Lamar', '111'), '525046', 'Kendrick Lamar') + assert w._corrections == [(1, '525046')] + + +def test_name_match_tolerates_minor_variation(): + # Fuzzy match (feat. suffix / casing) still counts as the same artist. + w = _worker() + w._verify_artist_id(_item('Kendrick Lamar', '111'), '525046', 'KENDRICK LAMAR') + assert w._corrections == [(1, '525046')] + + +def test_no_correction_when_ids_already_equal(): + w = _worker() + w._verify_artist_id(_item('Whoever', '525046'), '525046', 'Anyone') + assert w._corrections == [] + + +def test_no_parent_id_is_noop(): + w = _worker() + w._verify_artist_id(_item('X', None), '525046', 'Y') + assert w._corrections == [] + + +def test_missing_result_name_preserves_old_behavior(): + # No artist name on the result → can't name-check; keep the original + # "trust the more specific album/track search" behavior. + w = _worker() + w._verify_artist_id(_item('Kendrick Lamar', '111'), '525046', None) + assert w._corrections == [(1, '525046')] diff --git a/tests/test_embed_known_source_ids.py b/tests/test_embed_known_source_ids.py new file mode 100644 index 00000000..9aabf53e --- /dev/null +++ b/tests/test_embed_known_source_ids.py @@ -0,0 +1,69 @@ +"""Seam test: embed_known_source_ids builds the right id_tags and routes them +through the canonical frame writer (no API re-fetch).""" + +import sys +import types +from types import SimpleNamespace + +# Stub spotipy/config so core.metadata.source imports cleanly in tests. +if 'spotipy' not in sys.modules: + sp = types.ModuleType('spotipy'); oa = types.ModuleType('spotipy.oauth2') + sp.Spotify = type('S', (), {}); oa.SpotifyOAuth = oa.SpotifyClientCredentials = type('O', (), {}) + sp.oauth2 = oa; sys.modules['spotipy'] = sp; sys.modules['spotipy.oauth2'] = oa +if 'config.settings' not in sys.modules: + cm = types.ModuleType('config'); sm = types.ModuleType('config.settings') + + class _Cfg: + def get(self, k, d=None): return d + def get_active_media_server(self): return 'plex' + sm.config_manager = _Cfg(); cm.settings = sm + sys.modules['config'] = cm; sys.modules['config.settings'] = sm + +from core.metadata import source as src + + +def test_builds_id_tags_from_flat_keys_and_calls_writer(monkeypatch): + captured = {} + monkeypatch.setattr(src, 'get_mutagen_symbols', lambda: SimpleNamespace()) + monkeypatch.setattr(src, 'get_config_manager', lambda: SimpleNamespace(get=lambda k, d=None: d)) + + def _fake_write(audio, metadata, pp, cfg, symbols): + captured['id_tags'] = dict(pp['id_tags']) + monkeypatch.setattr(src, '_write_embedded_metadata', _fake_write) + + meta = { + 'spotify_track_id': 'sp_t', 'spotify_album_id': 'sp_a', + 'itunes_track_id': 'it_t', + 'musicbrainz_recording_id': 'mb_rec', 'musicbrainz_release_id': 'mb_rel', + } + written = src.embed_known_source_ids(object(), meta) + + assert captured['id_tags']['SPOTIFY_TRACK_ID'] == 'sp_t' + assert captured['id_tags']['SPOTIFY_ALBUM_ID'] == 'sp_a' + assert captured['id_tags']['ITUNES_TRACK_ID'] == 'it_t' + assert captured['id_tags']['MUSICBRAINZ_RECORDING_ID'] == 'mb_rec' + assert captured['id_tags']['MUSICBRAINZ_RELEASE_ID'] == 'mb_rel' + assert set(written) >= {'SPOTIFY_TRACK_ID', 'MUSICBRAINZ_RECORDING_ID'} + + +def test_no_ids_writes_nothing(monkeypatch): + called = {'n': 0} + monkeypatch.setattr(src, 'get_mutagen_symbols', lambda: SimpleNamespace()) + monkeypatch.setattr(src, 'get_config_manager', lambda: SimpleNamespace(get=lambda k, d=None: d)) + monkeypatch.setattr(src, '_write_embedded_metadata', + lambda *a, **k: called.__setitem__('n', called['n'] + 1)) + assert src.embed_known_source_ids(object(), {'title': 'x'}) == [] + assert called['n'] == 0 # nothing to embed → writer never called + + +def test_musicbrainz_gated_off(monkeypatch): + captured = {} + monkeypatch.setattr(src, 'get_mutagen_symbols', lambda: SimpleNamespace()) + # mb embed disabled + monkeypatch.setattr(src, 'get_config_manager', + lambda: SimpleNamespace(get=lambda k, d=None: False if k == 'musicbrainz.embed_tags' else d)) + monkeypatch.setattr(src, '_write_embedded_metadata', + lambda audio, m, pp, cfg, sym: captured.update(pp['id_tags'])) + src.embed_known_source_ids(object(), {'spotify_track_id': 'sp', 'musicbrainz_recording_id': 'mb'}) + assert 'SPOTIFY_TRACK_ID' in captured + assert 'MUSICBRAINZ_RECORDING_ID' not in captured # gated off diff --git a/tests/test_enrichment_artist_id_guard.py b/tests/test_enrichment_artist_id_guard.py new file mode 100644 index 00000000..0bb374e4 --- /dev/null +++ b/tests/test_enrichment_artist_id_guard.py @@ -0,0 +1,100 @@ +"""Regression: the album/track-driven 'artist id correction' in every +enrichment worker that has it must NOT overwrite an artist's source id unless +the result's artist name actually matches. + +This is the same Kendrick/Jorja bug fixed in the Deezer worker (see +tests/test_deezer_worker_artist_id_guard.py), proven here to be closed in the +three other workers that copy-pasted the pattern: AudioDB, Qobuz, Tidal. + +A track our library credits to Jorja Smith that lives on Kendrick's curated +'Black Panther' album resolves to that album, whose primary artist is Kendrick. +Without a name check, each worker would 'correct' Jorja's source id to +Kendrick's — corrupting it (and sharing one id across unrelated artists). +""" + +from __future__ import annotations + +from core.audiodb_worker import AudioDBWorker +from core.qobuz_worker import QobuzWorker +from core.tidal_worker import TidalWorker + + +def _stub(cls, correct_attr): + """Build a bare worker instance (no __init__/clients) wired to record + corrections instead of writing to a db.""" + w = cls.__new__(cls) + w.name_similarity_threshold = 0.80 + w._corrections = [] + setattr(w, correct_attr, lambda item, cid: w._corrections.append((item['id'], cid))) + return w + + +def _item(artist_name, parent_id, id_key): + return {'type': 'track', 'id': 1, 'name': 'Some Track', + 'artist': artist_name, id_key: parent_id} + + +# -------------------------------------------------------------------------- +# AudioDB — _verify_artist_id(item, result_dict); name is result['strArtist']. +# -------------------------------------------------------------------------- + +def test_audiodb_skips_correction_on_name_mismatch(): + w = _stub(AudioDBWorker, '_correct_artist_audiodb_id') + item = _item('Jorja Smith', '111', 'artist_audiodb_id') + w._verify_artist_id(item, {'idArtist': '999', 'strArtist': 'Kendrick Lamar'}) + assert w._corrections == [] + + +def test_audiodb_corrects_on_name_match(): + w = _stub(AudioDBWorker, '_correct_artist_audiodb_id') + item = _item('Kendrick Lamar', '111', 'artist_audiodb_id') + w._verify_artist_id(item, {'idArtist': '999', 'strArtist': 'Kendrick Lamar'}) + assert w._corrections == [(1, '999')] + + +# -------------------------------------------------------------------------- +# Qobuz — _verify_artist_id(item, result_artist_id, result_artist_name). +# -------------------------------------------------------------------------- + +def test_qobuz_skips_correction_on_name_mismatch(): + w = _stub(QobuzWorker, '_correct_artist_qobuz_id') + item = _item('Jorja Smith', '111', 'artist_qobuz_id') + w._verify_artist_id(item, '999', 'Kendrick Lamar') + assert w._corrections == [] + + +def test_qobuz_corrects_on_name_match(): + w = _stub(QobuzWorker, '_correct_artist_qobuz_id') + item = _item('Kendrick Lamar', '111', 'artist_qobuz_id') + w._verify_artist_id(item, '999', 'Kendrick Lamar') + assert w._corrections == [(1, '999')] + + +# -------------------------------------------------------------------------- +# Tidal — _verify_artist_id(item, result_artist_id, result_artist_name). +# -------------------------------------------------------------------------- + +def test_tidal_skips_correction_on_name_mismatch(): + w = _stub(TidalWorker, '_correct_artist_tidal_id') + item = _item('Jorja Smith', '111', 'artist_tidal_id') + w._verify_artist_id(item, '999', 'Kendrick Lamar') + assert w._corrections == [] + + +def test_tidal_corrects_on_name_match(): + w = _stub(TidalWorker, '_correct_artist_tidal_id') + item = _item('Kendrick Lamar', '111', 'artist_tidal_id') + w._verify_artist_id(item, '999', 'Kendrick Lamar') + assert w._corrections == [(1, '999')] + + +# -------------------------------------------------------------------------- +# Shared: a missing result name preserves the old "trust the search" behavior +# (only the workers that pass an id+name — qobuz/tidal — exercise this path). +# -------------------------------------------------------------------------- + +def test_qobuz_missing_result_name_preserves_old_behavior(): + w = _stub(QobuzWorker, '_correct_artist_qobuz_id') + item = _item('Kendrick Lamar', '111', 'artist_qobuz_id') + w._verify_artist_id(item, '999', None) + assert w._corrections == [(1, '999')] diff --git a/tests/test_library_retag_job.py b/tests/test_library_retag_job.py new file mode 100644 index 00000000..d0405827 --- /dev/null +++ b/tests/test_library_retag_job.py @@ -0,0 +1,286 @@ +"""Seam tests for the Library Re-tag job scan + the apply handler. + +Injected fakes only — no metadata APIs, no real tag writes. A temp sqlite db ++ real (empty) track files exercise the orchestration: scan -> detailed +finding, and finding -> per-track write payload. +""" + +import sqlite3 +import sys +import types +from types import SimpleNamespace + +# Stub optional deps so the modules import in the test env. +if 'spotipy' not in sys.modules: + sp = types.ModuleType('spotipy'); oa = types.ModuleType('spotipy.oauth2') + sp.Spotify = type('S', (), {}); oa.SpotifyOAuth = oa.SpotifyClientCredentials = type('O', (), {}) + sp.oauth2 = oa; sys.modules['spotipy'] = sp; sys.modules['spotipy.oauth2'] = oa +if 'config.settings' not in sys.modules: + cm = types.ModuleType('config'); sm = types.ModuleType('config.settings') + + class _Cfg: + def get(self, k, d=None): return d + def get_active_media_server(self): return 'plex' + sm.config_manager = _Cfg(); cm.settings = sm + sys.modules['config'] = cm; sys.modules['config.settings'] = sm + +from core.repair_jobs import library_retag as lr + + +def _db_with_album(path, track_file, current_title='Old Title'): + conn = sqlite3.connect(path) + c = conn.cursor() + c.execute("CREATE TABLE artists (id INTEGER PRIMARY KEY, name TEXT)") + c.execute("""CREATE TABLE albums (id INTEGER PRIMARY KEY, title TEXT, artist_id INTEGER, + spotify_album_id TEXT, itunes_album_id TEXT, deezer_id TEXT, musicbrainz_release_id TEXT)""") + c.execute("""CREATE TABLE tracks (id INTEGER PRIMARY KEY, album_id INTEGER, title TEXT, + track_number INTEGER, disc_number INTEGER, file_path TEXT)""") + c.execute("INSERT INTO artists (id, name) VALUES (1, 'Real Artist')") + c.execute("INSERT INTO albums (id, title, artist_id, spotify_album_id) VALUES (1, 'Real Album', 1, 'sp_alb')") + c.execute("INSERT INTO tracks (id, album_id, title, track_number, disc_number, file_path) VALUES (1, 1, ?, 1, 1, ?)", + (current_title, track_file)) + conn.commit() + return conn + + +def _context(conn, settings): + findings = [] + return SimpleNamespace( + db=SimpleNamespace(_get_connection=lambda: conn), + config_manager=SimpleNamespace(get=lambda k, d=None: {'repair.jobs.library_retag.settings': settings}.get(k, d)), + check_stop=lambda: False, wait_if_paused=lambda: False, + update_progress=lambda *a, **k: None, report_progress=lambda *a, **k: None, + create_finding=lambda **kw: (findings.append(kw) or True), + findings=findings, + ) + + +_ALBUM_META = {'name': 'Real Album', 'artists': [{'name': 'Real Artist'}], + 'year': '2021', 'genres': ['Rock'], 'total_tracks': 1, + 'image_url': 'http://art/cover.jpg'} +_SRC_TRACKS = [{'name': 'Real Title', 'track_number': 1, 'disc_number': 1, 'id': 'sp_trk'}] + + +def _patch_source(monkeypatch, current_tags): + monkeypatch.setattr(lr, 'get_album_for_source', lambda s, i: _ALBUM_META) + monkeypatch.setattr(lr, 'get_album_tracks_for_source', lambda s, i: list(_SRC_TRACKS)) + monkeypatch.setattr(lr, '_read_current_tags', lambda p: dict(current_tags)) + + +def test_scan_creates_detailed_finding(tmp_path, monkeypatch): + track = tmp_path / 'track.flac'; track.write_bytes(b'') + conn = _db_with_album(str(tmp_path / 'm.db'), str(track), current_title='Old Title') + ctx = _context(conn, {'mode': 'overwrite', 'cover_art': 'replace', 'source': 'spotify'}) + _patch_source(monkeypatch, { + 'title': 'Old Title', 'album_artist': 'Real Artist', 'album': 'Real Album', + 'year': '2021', 'genre': 'Rock', 'track_number': 1, 'disc_number': 1, + }) + + result = lr.LibraryRetagJob().scan(ctx) + + assert result.findings_created == 1 + d = ctx.findings[0]['details'] + assert ctx.findings[0]['finding_type'] == 'library_retag' + assert d['source'] == 'spotify' + assert d['cover_action'] == 'replace' + tp = d['tracks'][0] + assert tp['changes']['title'] == {'old': 'Old Title', 'new': 'Real Title'} + # source ids stamped onto the write payload + assert tp['db_data']['spotify_album_id'] == 'sp_alb' + assert tp['db_data']['spotify_track_id'] == 'sp_trk' + assert tp['db_data']['title'] == 'Real Title' + + +def test_scan_dry_run_off_auto_applies_no_finding(tmp_path, monkeypatch): + """dry_run=False: scan applies in place (auto_fixed) and creates NO finding.""" + track = tmp_path / 'track.flac'; track.write_bytes(b'') + conn = _db_with_album(str(tmp_path / 'm.db'), str(track), current_title='Old Title') + ctx = _context(conn, {'mode': 'overwrite', 'cover_art': 'skip', 'source': 'spotify', 'dry_run': False}) + _patch_source(monkeypatch, { + 'title': 'Old Title', 'album_artist': 'Real Artist', 'album': 'Real Album', + 'year': '2021', 'genre': 'Rock', 'track_number': 1, 'disc_number': 1, + }) + writes = [] + monkeypatch.setattr('core.tag_writer.write_tags_to_file', + lambda fp, db_data, **k: writes.append(db_data) or {'success': True}) + + result = lr.LibraryRetagJob().scan(ctx) + + assert ctx.findings == [] # no finding in apply mode + assert result.auto_fixed == 1 + assert writes and writes[0]['title'] == 'Real Title' # actually wrote + + +def test_scan_prefers_configured_cover_art_source(tmp_path, monkeypatch): + """Sokhi's request: when cover-art sources are configured, the re-tag pulls + art from them (select_preferred_art_url) instead of the matched source's + album image — so changing sources + 'replace' re-downloads fresh covers.""" + track = tmp_path / 'track.flac'; track.write_bytes(b'') + conn = _db_with_album(str(tmp_path / 'm.db'), str(track), current_title='Old Title') + ctx = _context(conn, {'mode': 'overwrite', 'cover_art': 'replace', 'source': 'spotify'}) + _patch_source(monkeypatch, { + 'title': 'Old Title', 'album_artist': 'Real Artist', 'album': 'Real Album', + 'year': '2021', 'genre': 'Rock', 'track_number': 1, 'disc_number': 1, + }) + monkeypatch.setattr('core.metadata.art_lookup.select_preferred_art_url', + lambda artist, album, meta, order, **k: 'http://itunes/big-cover.jpg') + + result = lr.LibraryRetagJob().scan(ctx) + + assert result.findings_created == 1 + d = ctx.findings[0]['details'] + assert d['cover_url'] == 'http://itunes/big-cover.jpg' # configured source won + assert d['cover_action'] == 'replace' + + +def test_scan_falls_back_to_source_image_when_no_configured_art(tmp_path, monkeypatch): + """Non-breaking: with no configured cover-art order, keep using the matched + source's album image (select_preferred_art_url returns None).""" + track = tmp_path / 'track.flac'; track.write_bytes(b'') + conn = _db_with_album(str(tmp_path / 'm.db'), str(track), current_title='Old Title') + ctx = _context(conn, {'mode': 'overwrite', 'cover_art': 'replace', 'source': 'spotify'}) + _patch_source(monkeypatch, { + 'title': 'Old Title', 'album_artist': 'Real Artist', 'album': 'Real Album', + 'year': '2021', 'genre': 'Rock', 'track_number': 1, 'disc_number': 1, + }) + monkeypatch.setattr('core.metadata.art_lookup.select_preferred_art_url', + lambda *a, **k: None) + + result = lr.LibraryRetagJob().scan(ctx) + d = ctx.findings[0]['details'] + assert d['cover_url'] == 'http://art/cover.jpg' # _ALBUM_META['image_url'] + + +def test_scan_full_depth_attaches_full_meta_to_finding(tmp_path, monkeypatch): + """depth=full: each track plan carries a full_meta dict (title/album/artist + + source ids) for the enrichment cascade, and details record the depth.""" + track = tmp_path / 'track.flac'; track.write_bytes(b'') + conn = _db_with_album(str(tmp_path / 'm.db'), str(track), current_title='Old Title') + ctx = _context(conn, {'mode': 'overwrite', 'cover_art': 'skip', 'source': 'spotify', 'depth': 'full'}) + _patch_source(monkeypatch, { + 'title': 'Old Title', 'album_artist': 'Real Artist', 'album': 'Real Album', + 'year': '2021', 'genre': 'Rock', 'track_number': 1, 'disc_number': 1, + }) + + result = lr.LibraryRetagJob().scan(ctx) + + assert result.findings_created == 1 + d = ctx.findings[0]['details'] + assert d['depth'] == 'full' + fm = d['tracks'][0]['full_meta'] + assert fm['title'] == 'Real Title' + assert fm['album'] == 'Real Album' + assert fm['album_artist'] == 'Real Artist' + assert fm['spotify_album_id'] == 'sp_alb' + assert fm['spotify_track_id'] == 'sp_trk' + + +def test_scan_full_depth_auto_apply_runs_enrich(tmp_path, monkeypatch): + """depth=full + dry_run off: after the light write, the full enrichment + cascade runs once per written track.""" + track = tmp_path / 'track.flac'; track.write_bytes(b'') + conn = _db_with_album(str(tmp_path / 'm.db'), str(track), current_title='Old Title') + ctx = _context(conn, {'mode': 'overwrite', 'cover_art': 'skip', 'source': 'spotify', + 'depth': 'full', 'dry_run': False}) + _patch_source(monkeypatch, { + 'title': 'Old Title', 'album_artist': 'Real Artist', 'album': 'Real Album', + 'year': '2021', 'genre': 'Rock', 'track_number': 1, 'disc_number': 1, + }) + monkeypatch.setattr('core.tag_writer.write_tags_to_file', + lambda fp, db_data, **k: {'success': True}) + enriched = [] + monkeypatch.setattr(lr, '_run_full_enrich', + lambda fp, meta: enriched.append((fp, meta)) or True) + + result = lr.LibraryRetagJob().scan(ctx) + + assert result.auto_fixed == 1 + assert len(enriched) == 1 + assert enriched[0][1]['spotify_track_id'] == 'sp_trk' + + +def test_scan_light_depth_does_not_run_enrich(tmp_path, monkeypatch): + """depth=light (default): no full_meta, enrichment cascade never invoked.""" + track = tmp_path / 'track.flac'; track.write_bytes(b'') + conn = _db_with_album(str(tmp_path / 'm.db'), str(track), current_title='Old Title') + ctx = _context(conn, {'mode': 'overwrite', 'cover_art': 'skip', 'source': 'spotify', + 'dry_run': False}) # depth defaults to light + _patch_source(monkeypatch, { + 'title': 'Old Title', 'album_artist': 'Real Artist', 'album': 'Real Album', + 'year': '2021', 'genre': 'Rock', 'track_number': 1, 'disc_number': 1, + }) + monkeypatch.setattr('core.tag_writer.write_tags_to_file', + lambda fp, db_data, **k: {'success': True}) + enriched = [] + monkeypatch.setattr(lr, '_run_full_enrich', + lambda fp, meta: enriched.append(fp) or True) + + lr.LibraryRetagJob().scan(ctx) + assert enriched == [] + + +def test_scan_skips_album_already_correct(tmp_path, monkeypatch): + track = tmp_path / 'track.flac'; track.write_bytes(b'') + conn = _db_with_album(str(tmp_path / 'm.db'), str(track), current_title='Real Title') + # cover skipped + tags already match → nothing to do + ctx = _context(conn, {'mode': 'overwrite', 'cover_art': 'skip', 'source': 'spotify'}) + _patch_source(monkeypatch, { + 'title': 'Real Title', 'album_artist': 'Real Artist', 'album': 'Real Album', + 'year': '2021', 'genre': 'Rock', 'track_number': 1, 'disc_number': 1, + }) + + result = lr.LibraryRetagJob().scan(ctx) + assert result.findings_created == 0 + assert ctx.findings == [] + assert result.skipped >= 1 + + +def test_add_source_ids_maps_per_source(): + db = {} + lr._add_source_ids(db, 'spotify', 'AL', {'id': 'TR'}) + assert db == {'spotify_album_id': 'AL', 'spotify_track_id': 'TR'} + db2 = {} + lr._add_source_ids(db2, 'musicbrainz', 'REL', {'id': 'REC'}) + assert db2 == {'musicbrainz_release_id': 'REL', 'musicbrainz_recording_id': 'REC'} + + +# ── apply handler ── + +def test_fix_library_retag_writes_each_track(tmp_path, monkeypatch): + import core.repair_worker as rw + track = tmp_path / 'a.flac'; track.write_bytes(b'') + + worker = rw.RepairWorker.__new__(rw.RepairWorker) + worker.db = SimpleNamespace() + worker._config_manager = SimpleNamespace(get=lambda k, d=None: d) + worker.transfer_folder = str(tmp_path) + + monkeypatch.setattr(rw, '_resolve_file_path', lambda p, *a, **k: p) + writes = [] + monkeypatch.setattr('core.tag_writer.write_tags_to_file', + lambda fp, db_data, **k: writes.append((fp, db_data)) or {'success': True}) + + details = { + 'tracks': [{'file_path': str(track), 'db_data': {'title': 'Real Title', 'spotify_track_id': 'sp_trk'}}], + 'cover_action': None, 'cover_url': None, + } + res = worker._fix_library_retag('album', '1', None, details) + assert res['success'] is True + assert res['written'] == 1 + assert writes[0][1]['title'] == 'Real Title' + assert writes[0][1]['spotify_track_id'] == 'sp_trk' + + +def test_fix_library_retag_counts_unreachable(tmp_path, monkeypatch): + import core.repair_worker as rw + worker = rw.RepairWorker.__new__(rw.RepairWorker) + worker.db = SimpleNamespace() + worker._config_manager = SimpleNamespace(get=lambda k, d=None: d) + worker.transfer_folder = str(tmp_path) + monkeypatch.setattr(rw, '_resolve_file_path', lambda p, *a, **k: p) + monkeypatch.setattr('core.tag_writer.write_tags_to_file', lambda *a, **k: {'success': True}) + + details = {'tracks': [{'file_path': str(tmp_path / 'missing.flac'), 'db_data': {'title': 'x'}}], + 'cover_action': None, 'cover_url': None} + res = worker._fix_library_retag('album', '1', None, details) + assert res['success'] is False # nothing written (file missing) diff --git a/tests/test_lyrics_reembed_from_sidecar.py b/tests/test_lyrics_reembed_from_sidecar.py index 498dda54..64577b1d 100644 --- a/tests/test_lyrics_reembed_from_sidecar.py +++ b/tests/test_lyrics_reembed_from_sidecar.py @@ -168,53 +168,3 @@ def test_no_sidecar_falls_through_to_lrclib(fake_audio_file): assert os.path.exists(lrc) # And USLT was embedded client._embed_lyrics.assert_called_once() - - -# ────────────────────────────────────────────────────────────────────── -# RetagDeps integration — generate_lrc_file is now wired -# ────────────────────────────────────────────────────────────────────── - -def test_retagdeps_accepts_generate_lrc_file_field(): - from core.library.retag import RetagDeps - - # Mock the required + optional deps with do-nothing callables - deps = RetagDeps( - config_manager=MagicMock(), - retag_lock=MagicMock(), - spotify_client=MagicMock(), - get_audio_quality_string=lambda *a: '', - enhance_file_metadata=lambda *a: True, - build_final_path_for_track=lambda *a: ('', ''), - safe_move_file=lambda *a: None, - cleanup_empty_directories=lambda *a: None, - download_cover_art=lambda *a: None, - docker_resolve_path=lambda x: x, - _get_retag_state=lambda: {}, - _set_retag_state=lambda v: None, - get_database=lambda: MagicMock(), - generate_lrc_file=lambda *a: True, - ) - assert callable(deps.generate_lrc_file) - - -def test_retagdeps_generate_lrc_file_optional_for_backward_compat(): - """Tests that built RetagDeps without the new field don't break.""" - from core.library.retag import RetagDeps - - deps = RetagDeps( - config_manager=MagicMock(), - retag_lock=MagicMock(), - spotify_client=MagicMock(), - get_audio_quality_string=lambda *a: '', - enhance_file_metadata=lambda *a: True, - build_final_path_for_track=lambda *a: ('', ''), - safe_move_file=lambda *a: None, - cleanup_empty_directories=lambda *a: None, - download_cover_art=lambda *a: None, - docker_resolve_path=lambda x: x, - _get_retag_state=lambda: {}, - _set_retag_state=lambda v: None, - get_database=lambda: MagicMock(), - ) - # Field defaults to None — no crash on construction. - assert deps.generate_lrc_file is None diff --git a/tests/test_manual_library_match.py b/tests/test_manual_library_match.py index 6329a01c..f964454e 100644 --- a/tests/test_manual_library_match.py +++ b/tests/test_manual_library_match.py @@ -90,6 +90,34 @@ def test_save_upserts_existing(db): assert row["source_title"] == "Updated" +def test_save_persists_library_file_path(db): + """#787 durability: the file path is stored so a manual match can be + re-resolved after a rescan re-keys the track.""" + ok = db.save_manual_library_match(1, "spotify", "track-abc", 42, + library_file_path="/music/Artist/Album/01.flac") + assert ok is True + row = db.get_manual_library_match(1, "spotify", "track-abc") + assert row["library_file_path"] == "/music/Artist/Album/01.flac" + + +def test_find_track_id_by_file_path(db): + """Re-resolution primitive: locate the current tracks.id for a file path + (exact, then basename fallback).""" + with db._get_connection() as conn: + conn.execute("PRAGMA foreign_keys=OFF") + conn.execute( + "INSERT INTO tracks (id, album_id, artist_id, title, file_path) VALUES (?, ?, ?, ?, ?)", + ("7777", 1, 1, "Track", "/music/Artist/Album/01.flac"), + ) + # Exact path + assert db.find_track_id_by_file_path("/music/Artist/Album/01.flac") == "7777" + # Basename fallback (server vs local path shape) + assert db.find_track_id_by_file_path("/different/root/01.flac") == "7777" + # Unknown file → None + assert db.find_track_id_by_file_path("/music/nope.flac") is None + assert db.find_track_id_by_file_path("") is None + + def test_delete_by_id(db): db.save_manual_library_match(1, "spotify", "track-abc", 42) row = db.get_manual_library_match(1, "spotify", "track-abc") diff --git a/tests/test_missing_cover_art.py b/tests/test_missing_cover_art.py index 6c21fe93..2994ad33 100644 --- a/tests/test_missing_cover_art.py +++ b/tests/test_missing_cover_art.py @@ -162,6 +162,37 @@ def test_missing_cover_art_prefers_explicit_source_over_primary(monkeypatch): assert context.findings[0]['details']['found_artwork_url'] == 'https://img/spotify-direct' +def test_missing_cover_art_uses_configured_art_sources(monkeypatch): + """When cover-art sources are configured (album_art_order), the Filler pulls + art from them and skips the metadata source-priority loop — same 'cover art + sources' notion the Re-tag job and post-process embed honor.""" + conn = _make_db((1, 'Album', 1, '', 'sp-album', 'it-album', 'dz-album', 'dg-album', 'hy-album')) + settings = { + 'repair.jobs.missing_cover_art.settings': {}, + 'metadata_enhancement.album_art_order': ['itunes', 'deezer'], + } + findings = [] + context = SimpleNamespace( + db=SimpleNamespace(_get_connection=lambda: conn), + config_manager=SimpleNamespace(get=lambda key, default=None: settings.get(key, default)), + check_stop=lambda: False, wait_if_paused=lambda: False, + update_progress=lambda *a, **k: None, report_progress=lambda *a, **k: None, + create_finding=lambda **kw: (findings.append(kw) or True), + findings=findings, + ) + monkeypatch.setattr(mca, 'get_primary_source', lambda: 'spotify') + consulted = [] + monkeypatch.setattr(mca, 'get_client_for_source', lambda s: consulted.append(s) or _FakeClient()) + monkeypatch.setattr('core.metadata.art_lookup.select_preferred_art_url', + lambda artist, album, meta, order, **k: 'https://configured/art.jpg') + + result = mca.MissingCoverArtJob().scan(context) + + assert result.findings_created == 1 + assert findings[0]['details']['found_artwork_url'] == 'https://configured/art.jpg' + assert consulted == [] # source-priority loop skipped when configured art wins + + def test_missing_cover_art_uses_primary_when_prefer_unset(monkeypatch): conn = _make_db((1, 'Album', 1, '', None, None, None, None, None)) context = _make_context(conn) diff --git a/tests/test_playlist_edit.py b/tests/test_playlist_edit.py index 13a2f041..8cc0e10d 100644 --- a/tests/test_playlist_edit.py +++ b/tests/test_playlist_edit.py @@ -2,7 +2,12 @@ from __future__ import annotations -from core.sync.playlist_edit import plan_playlist_add, remove_one_occurrence +from core.sync.playlist_edit import ( + normalize_sync_mode, + plan_playlist_add, + plan_playlist_reconcile, + remove_one_occurrence, +) # ── plan_playlist_add: link must not duplicate ──────────────────────────── @@ -78,3 +83,72 @@ def test_remove_single_occurrence(): def test_remove_stringifies(): new_ids, removed = remove_one_occurrence([1, 2, 2, 3], 2) assert removed and new_ids == ["1", "2", "3"] + + +# ── plan_playlist_reconcile: in-place delta (#792) ──────────────────────── + +def test_reconcile_adds_new_keeps_existing(): + # 4-track playlist + a 5th in source → add only the 5th, remove nothing. + plan = plan_playlist_reconcile(['a', 'b', 'c', 'd'], ['a', 'b', 'c', 'd', 'e']) + assert plan == {'add': ['e'], 'remove': []} + + +def test_reconcile_removes_gone(): + # Source dropped 'b' → remove it, add nothing. + plan = plan_playlist_reconcile(['a', 'b', 'c'], ['a', 'c']) + assert plan == {'add': [], 'remove': ['b']} + + +def test_reconcile_add_and_remove_together(): + plan = plan_playlist_reconcile(['a', 'b', 'c'], ['a', 'c', 'd', 'e']) + assert plan['add'] == ['d', 'e'] # desired order preserved + assert plan['remove'] == ['b'] + + +def test_reconcile_noop_when_identical(): + assert plan_playlist_reconcile(['a', 'b'], ['a', 'b']) == {'add': [], 'remove': []} + + +def test_reconcile_empty_desired_removes_all(): + assert plan_playlist_reconcile(['a', 'b'], []) == {'add': [], 'remove': ['a', 'b']} + + +def test_reconcile_empty_current_adds_all(): + assert plan_playlist_reconcile([], ['a', 'b']) == {'add': ['a', 'b'], 'remove': []} + + +def test_reconcile_stringifies_ids(): + plan = plan_playlist_reconcile([1, 2], [2, 3]) + assert plan == {'add': ['3'], 'remove': ['1']} + + +def test_reconcile_duplicate_still_desired_not_removed(): + # 'a' appears twice and is still desired → never queued for removal; + # a gone id is listed once even if it had duplicates. + plan = plan_playlist_reconcile(['a', 'a', 'b', 'b'], ['a']) + assert plan == {'add': [], 'remove': ['b']} + + +# ── normalize_sync_mode: reconcile must survive resolution (#792 regression) ── + +def test_normalize_keeps_reconcile_from_config(): + # The bug: a validation list omitting 'reconcile' downgraded the configured + # setting to 'replace'. No per-request override → configured value wins. + assert normalize_sync_mode(None, 'reconcile') == 'reconcile' + assert normalize_sync_mode('', 'reconcile') == 'reconcile' + + +def test_normalize_request_overrides_config(): + assert normalize_sync_mode('append', 'reconcile') == 'append' + assert normalize_sync_mode('reconcile', 'replace') == 'reconcile' + + +def test_normalize_falls_back_for_unknown(): + assert normalize_sync_mode('bogus', 'replace') == 'replace' + assert normalize_sync_mode(None, None) == 'replace' + assert normalize_sync_mode(None, 'also-bogus') == 'replace' + + +def test_normalize_all_real_modes_pass_through(): + for m in ('replace', 'append', 'reconcile'): + assert normalize_sync_mode(m, 'replace') == m diff --git a/tests/test_retag_planner.py b/tests/test_retag_planner.py new file mode 100644 index 00000000..2137c425 --- /dev/null +++ b/tests/test_retag_planner.py @@ -0,0 +1,114 @@ +"""Unit tests for the library re-tag planner (pure match + diff + payload).""" + +from __future__ import annotations + +from core.library import retag_planner as rp + + +# ── track matching ── + +def test_match_by_disc_and_track_number(): + src = [ + {'name': 'A', 'track_number': 1, 'disc_number': 1}, + {'name': 'B', 'track_number': 2, 'disc_number': 1}, + ] + lib = [ + {'title': 'wrong title', 'track_number': 2, 'disc_number': 1}, + {'title': 'whatever', 'track_number': 1, 'disc_number': 1}, + ] + pairs = rp.match_source_tracks(src, lib) + assert pairs[0][1]['name'] == 'B' # lib track #2 → source B + assert pairs[1][1]['name'] == 'A' + + +def test_match_by_title_when_no_track_number(): + src = [{'name': 'Bohemian Rhapsody', 'track_number': 1, 'disc_number': 1}] + lib = [{'title': 'Bohemian Rhapsody (Remastered)', 'track_number': None, 'disc_number': 1}] + pairs = rp.match_source_tracks(src, lib) + assert pairs[0][1]['name'] == 'Bohemian Rhapsody' + + +def test_unmatched_library_track_is_none(): + src = [{'name': 'A', 'track_number': 1, 'disc_number': 1}] + lib = [{'title': 'Completely Different', 'track_number': 9, 'disc_number': 1}] + pairs = rp.match_source_tracks(src, lib) + assert pairs[0][1] is None + + +def test_source_track_consumed_once(): + src = [{'name': 'A', 'track_number': 1, 'disc_number': 1}] + lib = [ + {'title': 'A', 'track_number': 1, 'disc_number': 1}, + {'title': 'A again', 'track_number': 1, 'disc_number': 1}, + ] + pairs = rp.match_source_tracks(src, lib) + assert pairs[0][1] is not None + assert pairs[1][1] is None # the one source track was already used + + +# ── per-track diff (overwrite) ── + +ALBUM = {'name': 'Real Album', 'artists': [{'name': 'Real Artist'}], + 'year': '2021-05-01', 'genres': ['Rock', 'Indie'], 'total_tracks': 10} +SRC = {'name': 'Real Title', 'track_number': 3, 'disc_number': 1, + 'artists': [{'name': 'Real Artist'}]} + + +def test_overwrite_reports_changed_fields_only(): + current = {'title': 'Old Title', 'album_artist': 'Real Artist', + 'album': 'Real Album', 'year': '2021', 'genre': 'Rock, Indie', + 'track_number': 3, 'disc_number': 1} + plan = rp.plan_track(current, SRC, ALBUM, mode=rp.MODE_OVERWRITE) + # Only the title differs; everything else already matches → single change. + assert set(plan['changes']) == {'title'} + assert plan['changes']['title'] == {'old': 'Old Title', 'new': 'Real Title'} + assert plan['db_data'].get('title') == 'Real Title' + # Unchanged fields must NOT be in the write payload. + assert 'album_title' not in plan['db_data'] + + +def test_overwrite_writes_album_artist_via_artist_name_key(): + current = {'title': 'Real Title', 'album_artist': 'WRONG Artist', + 'album': 'Real Album', 'year': '2021', 'genre': 'Rock, Indie', + 'track_number': 3, 'disc_number': 1} + plan = rp.plan_track(current, SRC, ALBUM, mode=rp.MODE_OVERWRITE) + assert plan['changes']['artist'] == {'old': 'WRONG Artist', 'new': 'Real Artist'} + assert plan['db_data']['artist_name'] == 'Real Artist' # writer uses artist_name = album artist + + +def test_track_number_write_carries_track_count(): + current = {'title': 'Real Title', 'album_artist': 'Real Artist', 'album': 'Real Album', + 'year': '2021', 'genre': 'Rock, Indie', 'track_number': 99, 'disc_number': 1} + plan = rp.plan_track(current, SRC, ALBUM, mode=rp.MODE_OVERWRITE) + assert plan['db_data']['track_number'] == 3 + assert plan['db_data']['track_count'] == 10 # carried alongside + + +def test_no_changes_when_everything_matches(): + current = {'title': 'Real Title', 'album_artist': 'Real Artist', 'album': 'Real Album', + 'year': '2021', 'genre': 'Rock, Indie', 'track_number': 3, 'disc_number': 1} + plan = rp.plan_track(current, SRC, ALBUM, mode=rp.MODE_OVERWRITE) + assert plan['changes'] == {} + assert plan['db_data'] == {} + + +def test_source_blank_field_never_written(): + album = {'name': 'Real Album', 'artists': [{'name': 'Real Artist'}]} # no year/genres + current = {'title': 'Real Title', 'album_artist': 'Real Artist', 'album': 'Real Album', + 'year': '', 'genre': '', 'track_number': 3, 'disc_number': 1} + plan = rp.plan_track(current, SRC, album, mode=rp.MODE_OVERWRITE) + assert 'year' not in plan['changes'] and 'year' not in plan['db_data'] + assert 'genres' not in plan['db_data'] + + +# ── fill-missing mode ── + +def test_fill_missing_only_writes_blanks(): + current = {'title': 'Keep My Title', 'album_artist': '', 'album': 'Real Album', + 'year': '', 'genre': 'Rock, Indie', 'track_number': 3, 'disc_number': 1} + plan = rp.plan_track(current, SRC, ALBUM, mode=rp.MODE_FILL_MISSING) + # title is present (kept), artist + year are blank (filled). genre present (kept). + assert set(plan['changes']) == {'artist', 'year'} + assert 'title' not in plan['db_data'] # not overwritten in fill-missing + assert plan['db_data']['artist_name'] == 'Real Artist' + assert plan['db_data']['year'] == '2021' diff --git a/tests/test_soulseek_album_fallback.py b/tests/test_soulseek_album_fallback.py index 5e2cce07..78f97e5b 100644 --- a/tests/test_soulseek_album_fallback.py +++ b/tests/test_soulseek_album_fallback.py @@ -83,7 +83,7 @@ def test_healthy_folder_does_not_fall_back(): # True. Patch the atomic copy to echo the completed paths through. from pathlib import Path completed = [Path("/staged/01.flac"), Path("/staged/02.flac")] - with patch("core.soulseek_client.copy_audio_files_atomically", lambda files, dest: list(files)): + with patch("core.soulseek_client.copy_audio_files_atomically", lambda files, dest, **kw: list(files)): stub = _Stub(completed) with patch("core.soulseek_client.run_async", lambda x: x): result = SoulseekClient.download_album_to_staging( diff --git a/tests/test_spotify_free_metadata.py b/tests/test_spotify_free_metadata.py new file mode 100644 index 00000000..20ddae39 --- /dev/null +++ b/tests/test_spotify_free_metadata.py @@ -0,0 +1,224 @@ +"""Tests for core/spotify_free_metadata.py — the no-creds Spotify fallback. + +Pure-unit only: the live SpotipyFree calls hit the network and can't run in CI, +but the two things that actually need pinning are pure — the activation gate and +the artist normalizer (the one shape SpotipyFree returns raw). Fixtures below +are trimmed from real captured responses (2026-06). +""" + +from __future__ import annotations + +from core.spotify_free_metadata import ( + normalize_artist, + should_block_rate_limited_resume, + should_offer_spotify_metadata, + should_use_free_fallback, + spotify_free_installed, +) + + +# --------------------------------------------------------------------------- +# should_block_rate_limited_resume — the worker resume guard +# --------------------------------------------------------------------------- + +def test_resume_blocked_when_rate_limited_and_nothing_serves(): + # Plain auth, no free: resuming during a ban would just sleep → block. + assert should_block_rate_limited_resume(rate_limited=True, metadata_available=False) is True + + +def test_resume_allowed_when_free_can_bridge(): + # Rate-limited but free is available (metadata_available True during a ban + # because is_spotify_authenticated() is False while banned) → allow resume, + # the worker bridges via free. + assert should_block_rate_limited_resume(rate_limited=True, metadata_available=True) is False + + +def test_resume_never_blocked_when_not_rate_limited(): + assert should_block_rate_limited_resume(rate_limited=False, metadata_available=False) is False + assert should_block_rate_limited_resume(rate_limited=False, metadata_available=True) is False + + +# --------------------------------------------------------------------------- +# should_use_free_fallback — the activation gate +# --------------------------------------------------------------------------- + +def test_gate_open_when_no_auth(): + assert should_use_free_fallback(authenticated=False, rate_limited=False) is True + + +def test_gate_open_when_rate_limited_even_if_authed(): + assert should_use_free_fallback(authenticated=True, rate_limited=True) is True + + +def test_gate_closed_when_authed_and_healthy(): + # The critical guarantee: with working Spotify auth and no rate limit, the + # free source must NEVER activate. + assert should_use_free_fallback(authenticated=True, rate_limited=False) is False + + +def test_gate_open_when_no_auth_and_rate_limited(): + assert should_use_free_fallback(authenticated=False, rate_limited=True) is True + + +# --------------------------------------------------------------------------- +# should_offer_spotify_metadata — the availability gate the callers use +# --------------------------------------------------------------------------- + +def test_offer_when_authed_even_if_no_free(): + assert should_offer_spotify_metadata(authenticated=True, free_available=False) is True + + +def test_offer_when_free_available_even_if_no_auth(): + # The whole point: no auth but free available → Spotify source stays usable. + assert should_offer_spotify_metadata(authenticated=False, free_available=True) is True + + +def test_not_offered_when_neither(): + assert should_offer_spotify_metadata(authenticated=False, free_available=False) is False + + +def test_full_composition_authed_healthy_never_uses_free(): + # The critical no-regression guarantee end to end: authed + healthy → + # offered (official), and the per-request gate stays CLOSED. + assert should_offer_spotify_metadata(authenticated=True, free_available=True) is True + assert should_use_free_fallback(authenticated=True, rate_limited=False) is False + + +def test_spotify_free_installed_returns_bool(): + # Whatever the env, it's a cached bool and doesn't raise / hit network. + assert isinstance(spotify_free_installed(), bool) + + +# --------------------------------------------------------------------------- +# normalize_artist — raw web-player GraphQL → Spotify-compatible artist dict +# --------------------------------------------------------------------------- + +# Trimmed from a real `SpotipyFree.artist()` response. +_RAW_ARTIST = { + '__typename': 'Artist', + 'id': '4Z8W4fKeB5YxbusRsdQVPb', + 'uri': 'spotify:artist:4Z8W4fKeB5YxbusRsdQVPb', + 'profile': {'name': 'Radiohead', 'biography': {'text': '...'}}, + 'stats': {'followers': 16239336, 'monthlyListeners': 45139421}, + 'visuals': { + 'avatarImage': { + 'sources': [ + {'height': 640, 'width': 640, 'url': 'https://i.scdn.co/image/big'}, + {'height': 160, 'width': 160, 'url': 'https://i.scdn.co/image/small'}, + ] + } + }, +} + +# Trimmed from a real `artist_search` item's `data` (no usable image — only +# color swatches under visualIdentity; SoulSync lazy-loads art separately). +_RAW_SEARCH_ARTIST = { + '__typename': 'Artist', + 'uri': 'spotify:artist:1dfeR4HaWDbWqFHLkxsg1d', + 'profile': {'name': 'Queen'}, + 'visualIdentity': {'squareCoverImage': {'extractedColorSet': {}}}, +} + + +def test_normalize_artist_full_shape(): + out = normalize_artist(_RAW_ARTIST) + assert out['id'] == '4Z8W4fKeB5YxbusRsdQVPb' + assert out['name'] == 'Radiohead' + assert out['followers'] == {'total': 16239336} + assert out['images'][0]['url'] == 'https://i.scdn.co/image/big' + assert len(out['images']) == 2 + assert out['external_urls'] == { + 'spotify': 'https://open.spotify.com/artist/4Z8W4fKeB5YxbusRsdQVPb' + } + assert out['genres'] == [] # web player doesn't provide genres + + +def test_normalize_artist_id_from_uri_when_no_id_field(): + out = normalize_artist(_RAW_SEARCH_ARTIST) + assert out['id'] == '1dfeR4HaWDbWqFHLkxsg1d' + assert out['name'] == 'Queen' + assert out['images'] == [] # search items carry no usable image + assert out['external_urls']['spotify'].endswith('1dfeR4HaWDbWqFHLkxsg1d') + + +def test_normalize_artist_handles_empty_and_none(): + for bad in (None, {}, {'profile': None, 'stats': None, 'visuals': None}): + out = normalize_artist(bad) + assert out['name'] == '' + assert out['images'] == [] + assert out['followers'] == {'total': 0} + assert out['external_urls'] == {} + + +def test_normalize_artist_skips_imageless_sources(): + raw = {'uri': 'spotify:artist:x', 'profile': {'name': 'A'}, + 'visuals': {'avatarImage': {'sources': [{'height': 1}, {'url': 'u'}]}}} + out = normalize_artist(raw) + assert out['images'] == [{'url': 'u', 'height': None, 'width': None}] + + +# --------------------------------------------------------------------------- +# SpotifyClient gate model — auto-bridge vs explicit opt-in vs no-surprise +# (constructs the client via __new__ so no network/config init runs) +# --------------------------------------------------------------------------- + +from unittest.mock import patch # noqa: E402 +from core.spotify_client import SpotifyClient # noqa: E402 +import core.spotify_free_metadata as _sfm # noqa: E402 + + +def _gate(authed, has_creds, selected, installed, rate_limited): + c = SpotifyClient.__new__(SpotifyClient) + with patch.object(SpotifyClient, 'is_spotify_authenticated', return_value=authed), \ + patch('core.spotify_client.config_manager') as cm, \ + patch('core.spotify_client._is_globally_rate_limited', return_value=rate_limited), \ + patch.object(_sfm, 'spotify_free_installed', return_value=installed): + cm.get_spotify_config.return_value = ( + {'client_id': 'x', 'client_secret': 'y'} if has_creds else {}) + cm.get.side_effect = lambda k, d=None: selected if k == 'metadata.spotify_free' else d + return c.is_spotify_metadata_available(), c._free_active() + + +def test_connected_healthy_uses_official(): + avail, free = _gate(authed=True, has_creds=True, selected=False, installed=True, rate_limited=False) + assert avail is True and free is False # official; free never opens + + +def test_plain_spotify_ratelimited_does_NOT_bridge(): + # OPT-IN: a user on plain 'Spotify' (didn't pick Spotify Free) waits out a + # rate-limit ban — free does NOT auto-bridge for them. No surprise scraping. + avail, free = _gate(authed=False, has_creds=True, selected=False, installed=True, rate_limited=True) + assert avail is False and free is False + + +def test_spotify_free_user_ratelimited_bridges(): + # A user who DID pick Spotify Free and also connected an account: official + # when healthy, free bridges during a ban. + avail, free = _gate(authed=False, has_creds=True, selected=True, installed=True, rate_limited=True) + assert avail is True and free is True + + +def test_spotify_free_user_healthy_uses_official(): + avail, free = _gate(authed=True, has_creds=True, selected=True, installed=True, rate_limited=False) + assert avail is True and free is False # picked Spotify Free but authed -> official + + +def test_no_auth_picked_spotify_free_serves(): + avail, free = _gate(authed=False, has_creds=False, selected=True, installed=True, rate_limited=False) + assert avail is True and free is True + + +def test_no_auth_not_opted_in_does_nothing(): + # The key no-surprise guarantee: a user who never chose Spotify gets no free. + avail, free = _gate(authed=False, has_creds=False, selected=False, installed=True, rate_limited=False) + assert avail is False and free is False + + +def test_selected_but_package_missing_is_graceful(): + avail, free = _gate(authed=False, has_creds=False, selected=True, installed=False, rate_limited=False) + assert avail is False and free is False + + +def test_connected_ratelimited_but_no_package_no_bridge(): + avail, free = _gate(authed=False, has_creds=True, selected=False, installed=False, rate_limited=True) + assert avail is False and free is False diff --git a/tests/test_tag_writer_placeholder_guard.py b/tests/test_tag_writer_placeholder_guard.py new file mode 100644 index 00000000..d5b63ad6 --- /dev/null +++ b/tests/test_tag_writer_placeholder_guard.py @@ -0,0 +1,143 @@ +"""#800 — Write Tags must never overwrite a real file value with a placeholder. + +A mis-grouped track sits under a 'Various Artists' / '[Unknown Album]' record, +while the file itself is correctly tagged. Previously Write Tags stamped that +junk over the good file (data loss). These pin the guard at both seams: the +preview (build_tag_diff) and the actual write (write_tags_to_file). +""" + +from __future__ import annotations + +import os +import tempfile + +import pytest +from mutagen.flac import FLAC + +from core.tag_writer import ( + build_tag_diff, + guard_placeholder_overwrite, + is_placeholder_meta, + write_tags_to_file, +) + + +# --------------------------------------------------------------------------- +# pure helpers +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize('val', [ + None, '', ' ', 'Various Artists', 'various artists', 'VARIOUS ARTIST', + 'Unknown Artist', 'Unknown Album', '[Unknown Album]', 'Unknown', 'Untitled Album', +]) +def test_placeholder_values_detected(val): + assert is_placeholder_meta(val) is True + + +@pytest.mark.parametrize('val', ['OneRepublic', 'Native (Deluxe)', 'Counting Stars', 'VA Movement']) +def test_real_values_not_placeholder(val): + assert is_placeholder_meta(val) is False + + +def test_guard_blocks_placeholder_over_real(): + assert guard_placeholder_overwrite('Various Artists', 'OneRepublic') is None + assert guard_placeholder_overwrite('[Unknown Album]', 'Native (Deluxe)') is None + + +def test_guard_allows_real_over_anything(): + assert guard_placeholder_overwrite('OneRepublic', 'Old Wrong Name') == 'OneRepublic' + assert guard_placeholder_overwrite('OneRepublic', '') == 'OneRepublic' + + +def test_guard_allows_placeholder_when_file_has_no_real_value(): + # Legit compilation: file album-artist empty → 'Various Artists' still writes. + assert guard_placeholder_overwrite('Various Artists', '') == 'Various Artists' + assert guard_placeholder_overwrite('Various Artists', 'Various Artists') == 'Various Artists' + + +# --------------------------------------------------------------------------- +# build_tag_diff — the preview (screenshot #2 scenario) +# --------------------------------------------------------------------------- + +def test_diff_protects_placeholder_over_real(): + file_tags = {'title': 'Counting Stars', 'artist': 'OneRepublic', + 'album': 'Native (Deluxe)', 'album_artist': 'OneRepublic'} + db_data = {'title': 'Counting Stars', 'artist_name': 'Various Artists', + 'album_title': '[Unknown Album]'} + diff = {d['field']: d for d in build_tag_diff(file_tags, db_data)} + + assert diff['Title']['changed'] is False # already equal + for f in ('Artist', 'Album', 'Album Artist'): + assert diff[f]['changed'] is False, f # held back, not a wrong overwrite + assert diff[f]['protected'] is True, f + # Nothing real to write → no changes overall. + assert not any(d['changed'] for d in build_tag_diff(file_tags, db_data)) + + +def test_diff_real_db_value_still_changes(): + file_tags = {'artist': 'Old Wrong'} + db_data = {'artist_name': 'OneRepublic'} + diff = {d['field']: d for d in build_tag_diff(file_tags, db_data)} + assert diff['Artist']['changed'] is True + assert diff['Artist']['protected'] is False + + +def test_diff_compilation_va_writes_when_file_empty(): + file_tags = {'album_artist': ''} # no real value to protect + db_data = {'artist_name': 'Various Artists'} + diff = {d['field']: d for d in build_tag_diff(file_tags, db_data)} + assert diff['Album Artist']['changed'] is True + assert diff['Album Artist']['protected'] is False + + +# --------------------------------------------------------------------------- +# write_tags_to_file — end-to-end on a real FLAC +# --------------------------------------------------------------------------- + +def _make_minimal_flac(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) + + +@pytest.fixture +def flac_path(): + fd, path = tempfile.mkstemp(suffix='.flac') + os.close(fd) + _make_minimal_flac(path) + yield path + try: + os.remove(path) + except OSError: + pass + + +def test_write_preserves_real_value_against_placeholder(flac_path): + # 1) lay down correct tags (the file is correctly tagged) + write_tags_to_file(flac_path, { + 'title': 'Counting Stars', 'artist_name': 'OneRepublic', + 'album_title': 'Native (Deluxe)', + }, embed_cover=False) + + # 2) attempt to write the mis-grouped DB junk over it + result = write_tags_to_file(flac_path, { + 'title': 'Counting Stars', 'artist_name': 'Various Artists', + 'album_title': '[Unknown Album]', + }, embed_cover=False) + assert result['success'] is True + + audio = FLAC(flac_path) + assert audio.get('artist') == ['OneRepublic'] # preserved, not clobbered + assert audio.get('album') == ['Native (Deluxe)'] # preserved + assert audio.get('albumartist') == ['OneRepublic'] # preserved + + +def test_write_real_value_still_overwrites(flac_path): + write_tags_to_file(flac_path, {'artist_name': 'OneRepublic'}, embed_cover=False) + # A real (non-placeholder) new value must still overwrite normally. + result = write_tags_to_file(flac_path, {'artist_name': 'Coldplay'}, embed_cover=False) + assert result['success'] is True + assert FLAC(flac_path).get('artist') == ['Coldplay'] diff --git a/tests/test_torrent_client_adapters.py b/tests/test_torrent_client_adapters.py index 19f8d423..ba344a51 100644 --- a/tests/test_torrent_client_adapters.py +++ b/tests/test_torrent_client_adapters.py @@ -263,6 +263,58 @@ def test_transmission_normalises_bare_host_to_rpc_path() -> None: assert adapter._url == 'http://host:9091/transmission/rpc' +# --------------------------------------------------------------------------- +# URL scheme normalization (#790) +# --------------------------------------------------------------------------- + + +def test_normalize_client_url_prepends_http_when_scheme_missing() -> None: + from core.torrent_clients.base import normalize_client_url + # The exact shapes users type: bare IP:port, bare DNS name:port, bare host. + assert normalize_client_url('192.168.1.5:8080') == 'http://192.168.1.5:8080' + assert normalize_client_url('qbittorrent.lan:8080') == 'http://qbittorrent.lan:8080' + assert normalize_client_url('myhost') == 'http://myhost' + + +def test_normalize_client_url_preserves_existing_scheme_and_trims() -> None: + from core.torrent_clients.base import normalize_client_url + assert normalize_client_url('http://host:8080') == 'http://host:8080' + assert normalize_client_url('https://host') == 'https://host' + assert normalize_client_url(' http://host:8080/ ') == 'http://host:8080' + assert normalize_client_url('') == '' + assert normalize_client_url(None) == '' + + +def test_qbit_load_config_defaults_scheme_for_bare_host() -> None: + """Regression #790: a bare ``host:port`` config (no scheme) must become an + http:// URL. Otherwise requests can't pick an adapter and raises + 'No connection adapters were found for ...', which surfaced to the user as + a generic 'qbittorrent probe failed'.""" + adapter = QBittorrentAdapter.__new__(QBittorrentAdapter) + import threading + adapter._session = None + adapter._session_lock = threading.Lock() + with patch('core.torrent_clients.qbittorrent.config_manager') as cm: + cm.get.side_effect = lambda key, default='': { + 'torrent_client.url': '192.168.1.5:8080', + }.get(key, default) + adapter._load_config() + assert adapter._url == 'http://192.168.1.5:8080' + + +def test_deluge_load_config_defaults_scheme_for_bare_host() -> None: + adapter = DelugeAdapter.__new__(DelugeAdapter) + import threading + adapter._session = None + adapter._session_lock = threading.Lock() + with patch('core.torrent_clients.deluge.config_manager') as cm: + cm.get.side_effect = lambda key, default='': { + 'torrent_client.url': 'deluge.lan:8112', + }.get(key, default) + adapter._load_config() + assert adapter._url == 'http://deluge.lan:8112' + + def test_transmission_session_id_renegotiation() -> None: """Transmission rejects the first call with 409 and a fresh ``X-Transmission-Session-Id`` header; the adapter must store it diff --git a/tests/test_worker_artist_match_gate.py b/tests/test_worker_artist_match_gate.py new file mode 100644 index 00000000..59810852 --- /dev/null +++ b/tests/test_worker_artist_match_gate.py @@ -0,0 +1,114 @@ +"""Tests for the shared artist-match gate in core/worker_utils.py. + +Two jobs: + * artist_name_matches — a stricter (0.85) gate than the 0.80 used for + album/track titles, so short-name false positives ('ODESZA'/'odessa', + 'Blance'/'Blanke', 'Lady A'/'Lady Gaga') are rejected. + * source_id_conflict / accept_artist_match — refuse to store a source id that + a DIFFERENTLY-named artist already holds, while still allowing a same-named + artist (the same act indexed on two media servers) to share it. +""" + +from __future__ import annotations + +import pytest + +from core import worker_utils as wu +from database.music_database import MusicDatabase + + +@pytest.fixture +def db(tmp_path): + return MusicDatabase(str(tmp_path / "music.db")) + + +def _insert(db, *, artist_id, name, **extra): + cols = ["id", "name", "server_source"] + list(extra.keys()) + vals = [artist_id, name, "plex"] + list(extra.values()) + ph = ",".join("?" for _ in cols) + with db._get_connection() as conn: + conn.execute(f"INSERT INTO artists ({','.join(cols)}) VALUES ({ph})", vals) + conn.commit() + + +# --------------------------------------------------------------------------- +# artist_name_matches — the 0.85 gate +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("a,b", [ + ("ODESZA", "odessa"), + ("Blance", "Blanke"), + ("COLLEGE", "Colle"), + ("Lady A", "Lady Gaga"), + ("M&O", "M.O.P."), +]) +def test_near_name_pairs_rejected_at_085(a, b): + assert wu.artist_name_matches(a, b) is False + + +@pytest.mark.parametrize("a,b", [ + ("Saib", "saib."), # punctuation only → identical + ("-Us.", "Us"), + ("Kendrick Lamar", "KENDRICK LAMAR"), + ("Beyoncé", "Beyonce"), +]) +def test_true_variants_still_match(a, b): + assert wu.artist_name_matches(a, b) is True + + +# --------------------------------------------------------------------------- +# source_id_conflict — different name blocks, same name allowed +# --------------------------------------------------------------------------- + +def test_conflict_when_different_named_artist_holds_id(db): + _insert(db, artist_id="1", name="Kendrick Lamar", deezer_id="525046") + # Trying to give Jorja the same id → conflict reports the holder. + assert wu.source_id_conflict(db, "deezer_id", "525046", "2", "Jorja Smith") == "Kendrick Lamar" + + +def test_no_conflict_for_same_named_artist_two_servers(db): + # Radiohead already on plex with this id; the jellyfin Radiohead (id 2) may + # legitimately share it. + _insert(db, artist_id="1", name="Radiohead", deezer_id="999") + assert wu.source_id_conflict(db, "deezer_id", "999", "2", "Radiohead") is None + + +def test_no_conflict_when_same_row_holds_it(db): + _insert(db, artist_id="1", name="Radiohead", deezer_id="999") + # Re-matching the same artist to its own id is fine. + assert wu.source_id_conflict(db, "deezer_id", "999", "1", "Radiohead") is None + + +def test_no_conflict_when_id_unused(db): + assert wu.source_id_conflict(db, "deezer_id", "12345", "1", "Anyone") is None + + +def test_unknown_column_is_refused(db): + assert wu.source_id_conflict(db, "evil_id", "x", "1", "Anyone") is None + + +# --------------------------------------------------------------------------- +# accept_artist_match — combined gate +# --------------------------------------------------------------------------- + +def test_accept_rejects_name_mismatch(db): + ok, reason = wu.accept_artist_match(db, "deezer_id", "1", "1", "ODESZA", "odessa") + assert ok is False + assert "name mismatch" in reason + + +def test_accept_rejects_id_already_claimed_by_other(db): + _insert(db, artist_id="1", name="Kendrick Lamar", deezer_id="525046") + ok, reason = wu.accept_artist_match( + db, "deezer_id", "525046", "2", "Jorja Smith", "Jorja Smith" + ) + assert ok is False + assert "already claimed" in reason + + +def test_accept_passes_clean_match(db): + ok, reason = wu.accept_artist_match( + db, "deezer_id", "111", "1", "Kendrick Lamar", "Kendrick Lamar" + ) + assert ok is True + assert reason == "" diff --git a/web_server.py b/web_server.py index b107fccf..24fdf547 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.6.6" +_SOULSYNC_BASE_VERSION = "2.6.7" def _build_version_string(): """Append short commit hash to version when available (e.g. 2.35+abc1234).""" @@ -862,19 +862,6 @@ duplicate_cleaner_state = { duplicate_cleaner_lock = threading.Lock() duplicate_cleaner_executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="DuplicateCleaner") -# Retag Tool Globals -retag_state = { - "status": "idle", - "phase": "Ready", - "progress": 0, - "current_track": "", - "total_tracks": 0, - "processed": 0, - "error_message": "", -} -retag_lock = threading.Lock() -retag_executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="RetagWorker") - # Download Missing Tracks Modal State Management # Thread-safe state tracking for modal download functionality. # Shared task/batch state now lives in core.runtime_state. @@ -1709,7 +1696,6 @@ def _shutdown_runtime_components(): (db_update_executor, "db update executor"), (quality_scanner_executor, "quality scanner executor"), (duplicate_cleaner_executor, "duplicate cleaner executor"), - (retag_executor, "retag executor"), (sync_executor, "sync executor"), (missing_download_executor, "missing download executor"), (album_bundle_executor, "album bundle executor"), @@ -2334,7 +2320,7 @@ def get_status(): status_data = { 'metadata_source': metadata_status['metadata_source'], - 'spotify': metadata_status['spotify'], + 'spotify': _spotify_status_with_availability(metadata_status['spotify']), 'media_server': _status_cache['media_server'], 'soulseek': soulseek_data, 'active_media_server': active_server, @@ -3679,10 +3665,23 @@ def settings_config_status_endpoint(): Drives the green/yellow header gradient. No API calls — just config reads. """ try: - return jsonify({ + result = { service: {'configured': _is_service_configured(service)} for service in SERVICE_CONFIG_REGISTRY - }) + } + # Spotify Free: Spotify metadata can be available without credentials + # (opt-in no-creds source). Surface that separately so the search source + # picker offers Spotify, while `configured` (the Connections indicator) + # keeps meaning "has client credentials". + if 'spotify' in result: + try: + meta_avail = bool(spotify_client and spotify_client.is_spotify_metadata_available()) + except Exception: + meta_avail = False + result['spotify']['metadata_available'] = ( + result['spotify']['configured'] or meta_avail + ) + return jsonify(result) except Exception as e: logger.error(f"config-status error: {e}") return jsonify({"error": str(e)}), 500 @@ -5621,6 +5620,7 @@ def start_sync(): # Search route bodies live in core/search/* — these routes are thin handlers. from core.search import basic as _search_basic +from core.search import by_id as _search_by_id from core.search import library_check as _search_library_check from core.search import orchestrator as _search_orchestrator from core.search import stream as _search_stream @@ -5739,6 +5739,34 @@ def enhanced_search(): return jsonify({"error": str(e)}), 500 +@app.route('/api/enhanced-search/by-id', methods=['POST']) +def enhanced_search_by_id(): + """Resolve a pasted metadata link to a single album or track (#775). + + A provider URL (Spotify/Apple/MusicBrainz/Deezer) is looked up directly + on the owning source via its get-by-id — no fuzzy search, no scoring. The + domain pins the source and the path pins album-vs-track, so it's + unambiguous. Returns the same dropdown shape the normal enhanced search + renders, plus the resolving ``source`` so the frontend can route + downloads/imports through the existing flow. + + Body: ``{"query": ""}``. Links only — a + bare ID is rejected with a hint, since it carries no source or type. + """ + data = request.get_json() or {} + raw = (data.get('query') or '').strip() + if not raw: + return jsonify(_search_by_id._empty_result('')) + + try: + deps = _build_search_deps() + result = _search_by_id.resolve_identifier(raw, deps) + return jsonify(result) + except Exception as e: + logger.error(f"Link/ID resolve error: {e}") + return jsonify({"error": str(e)}), 500 + + @app.route('/api/enhanced-search/source/', methods=['POST']) def enhanced_search_source(source_name): """Streaming NDJSON search for one alternate metadata source. @@ -7638,6 +7666,26 @@ def _find_library_artist_for_source(database, source, source_artist_id, artist_n ) +def _resolve_source_artist_name(source, artist_id): + """Resolve a source artist's display name by id, or '' on any failure. + + Reuses the #775 link-resolver's per-source artist fetch so we have one + place that knows each source's get-by-id quirks. Used by the artist-detail + library upgrade to disambiguate a duplicated/corrupt source id by name + when the URL-driven navigation didn't carry a name. + """ + try: + deps = _build_search_deps() + client, _available = _search_orchestrator.resolve_client(source, deps) + if client is None: + return '' + data = _search_by_id._fetch_artist(client, source, artist_id) + return (data or {}).get('name') or '' + except Exception as e: + logger.debug(f"Source artist name resolution failed for {source}:{artist_id}: {e}") + return '' + + def _build_source_only_artist_detail(artist_id, artist_name, source): """Thin wrapper around ``core.artist_source_detail.build_source_only_artist_detail``. @@ -7739,6 +7787,18 @@ def get_artist_detail(artist_id): library_pk = _find_library_artist_for_source( database, source_param, artist_id, artist_name_arg ) + # URL-driven navigation carries no name, so a duplicated/corrupt + # source id (one Deezer id on several artists) can't be matched by + # the id alone — it's ambiguous and the lookup bails. Resolve the + # artist's name from the source and retry so an owned artist still + # gets the rich library view instead of the bare source one. + if not library_pk and not artist_name_arg: + resolved_name = _resolve_source_artist_name(source_param, artist_id) + if resolved_name: + artist_name_arg = resolved_name + library_pk = _find_library_artist_for_source( + database, source_param, artist_id, artist_name_arg + ) if library_pk: logger.info( f"Source-id {source_param}:{artist_id} matched library artist " @@ -9336,6 +9396,13 @@ def _build_library_tag_db_data(track_data, album_genres=None): if artists_list: db_data['artists_list'] = artists_list + # Carry the known source IDs through so they get embedded too (the writer + # only acts on the ones present). These come from t.* on the track row. + for _k in ('spotify_track_id', 'itunes_track_id', 'musicbrainz_recording_id'): + _v = track_data.get(_k) + if _v: + db_data[_k] = _v + return db_data @@ -9761,6 +9828,159 @@ def get_write_tags_batch_status(): return jsonify(state) +# ── Reconcile embedded provider IDs (gap-fill DB from file tags) ── +# +# Files that SoulSync (or MusicBrainz Picard) already tagged carry Spotify / +# iTunes / MusicBrainz / Deezer / Tidal / AudioDB / Genius IDs in their +# metadata. Reading them back and gap-filling the {provider}_id + +# {provider}_match_status='matched' columns lets the enrichment workers skip +# the API lookup entirely — large API savings on an already-tagged library. +# Gap-fill only: an existing id is never overwritten (see +# core/library/embedded_id_reconcile.py). +def _reconcile_library_tracks(conn, track_ids=None, on_progress=None, should_stop=None): + """Run the embedded-ID reconcile with web-server path resolution injected. + + Thin wrapper over core.library.embedded_id_reconcile.reconcile_library that + supplies the read_tags callable (docker/library path resolution + mutagen + read). Used by the manual backfill job and available for any scoped + reconcile. ``track_ids=None`` => whole library. + """ + from core.library.file_tags import read_embedded_tags + from core.library.embedded_id_reconcile import reconcile_library + + def _read_tags(file_path): + resolved = _resolve_library_file_path(file_path) + if not resolved: + return None + info = read_embedded_tags(resolved) + return info.get('tags') if info.get('available') else None + + return reconcile_library(conn, _read_tags, track_ids=track_ids, + on_progress=on_progress, should_stop=should_stop) + + +def _reconcile_after_scan(worker): + """Gap-fill embedded provider IDs for tracks a scan newly inserted. + + Runs after a library scan/deep-scan completes, scoped to the rows the + worker actually INSERTED this run (``worker._new_track_ids``). New files + are written with empty provider-id columns, so this reads their tags and + fills any IDs present — keeping the DB current without a manual backfill. + Best-effort: never raises into the scan flow. + """ + try: + new_ids = list(getattr(worker, '_new_track_ids', None) or []) + if not new_ids: + return + n = len(new_ids) + try: + _db_update_phase_callback( + f"Reading file tags for {n} new track{'s' if n != 1 else ''}…") + except Exception: # noqa: S110 — best-effort UI phase, never block the reconcile + pass + + def _on_progress(totals, title): + try: + pct = (totals.processed / totals.total * 100) if totals.total else 100 + _db_update_progress_callback(title, totals.processed, totals.total, pct) + except Exception: # noqa: S110 — best-effort UI progress tick + pass + + database = get_database() + conn = database._get_connection() + try: + totals = _reconcile_library_tracks(conn, track_ids=new_ids, on_progress=_on_progress) + logger.info( + "[Reconcile] Post-scan: filled %d id(s) across %d row(s) from %d new " + "track(s) (%d unreadable, %d conflicts)", + totals.ids_filled, totals.entities_updated, totals.processed, + totals.unreadable, totals.conflicts, + ) + finally: + conn.close() + except Exception as e: + logger.warning("[Reconcile] Post-scan reconcile failed (non-fatal): %s", e) + + +_reconcile_ids_state = { + 'status': 'idle', # idle | running | done + 'total': 0, + 'processed': 0, + 'entities_updated': 0, # track/album/artist rows written + 'ids_filled': 0, # individual id columns filled + 'conflicts': 0, # embedded id disagreed with a stored id (not applied) + 'unreadable': 0, # files missing / unreadable by mutagen + 'current': '', +} +_reconcile_ids_lock = threading.Lock() + + +@app.route('/api/library/reconcile-embedded-ids', methods=['POST']) +def reconcile_embedded_ids(): + """Scan every library file for embedded provider IDs and gap-fill them + into the DB so enrichment workers skip the API lookup. Runs in the + background; poll the status endpoint for progress.""" + try: + with _reconcile_ids_lock: + if _reconcile_ids_state['status'] == 'running': + return jsonify({"success": False, "error": "A reconcile is already in progress"}), 409 + _reconcile_ids_state.update({ + 'status': 'running', 'total': 0, 'processed': 0, + 'entities_updated': 0, 'ids_filled': 0, 'conflicts': 0, + 'unreadable': 0, 'current': 'Starting…', + }) + + database = get_database() + + def _run(): + conn = None + try: + conn = database._get_connection() + + def _on_progress(totals, title): + with _reconcile_ids_lock: + _reconcile_ids_state.update({ + 'total': totals.total, + 'processed': totals.processed, + 'entities_updated': totals.entities_updated, + 'ids_filled': totals.ids_filled, + 'conflicts': totals.conflicts, + 'unreadable': totals.unreadable, + 'current': title, + }) + + # Whole-library backfill (track_ids=None). Shares the exact + # orchestration used by the on-scan auto-reconcile hook. + _reconcile_library_tracks(conn, on_progress=_on_progress) + except Exception as e: + logger.error(f"Reconcile embedded IDs background error: {e}") + finally: + if conn is not None: + try: + conn.close() + except Exception: # noqa: S110 — connection cleanup, nothing to recover + pass + with _reconcile_ids_lock: + _reconcile_ids_state['status'] = 'done' + _reconcile_ids_state['current'] = '' + + thread = threading.Thread(target=_run, daemon=True, name="ReconcileEmbeddedIds") + thread.start() + return jsonify({"success": True, "message": "Reconcile started"}) + + except Exception as e: + logger.error(f"Reconcile embedded IDs kickoff error: {e}") + with _reconcile_ids_lock: + _reconcile_ids_state['status'] = 'idle' + return jsonify({"success": False, "error": str(e)}), 500 + + +@app.route('/api/library/reconcile-embedded-ids/status', methods=['GET']) +def get_reconcile_embedded_ids_status(): + """Poll the status of the embedded-ID reconcile job.""" + with _reconcile_ids_lock: + return jsonify(dict(_reconcile_ids_state)) + # ── ReplayGain Analysis endpoints ── @@ -11096,6 +11316,18 @@ def library_manual_match(): if cursor.rowcount == 0: return jsonify({"success": False, "error": "Entity not found"}), 404 + # #758 — a manual ALBUM match also pins (and LOCKS) the canonical album + # version to the chosen release, so the auto resolve job and every tool + # that reads the canonical pin (track-number repair, reorganize, + # missing-tracks) honor the user's edition instead of re-resolving back + # to the deluxe. The lock survives future enrichment/resolution cycles. + try: + from core.metadata.canonical_version import should_pin_manual_canonical + if should_pin_manual_canonical(entity_type, service): + database.set_album_canonical(entity_id, service, service_id, 1.0, locked=True) + except Exception as e: + logger.warning("Manual canonical pin failed for album %s: %s", entity_id, e) + # Re-fetch fresh data artist_id = data.get('artist_id', entity_id) if entity_type != 'artist': @@ -14267,45 +14499,6 @@ _download_retry_attempts = {} # {context_key: {'count': N, 'first_attempt': tim _download_retry_max = 10 # Max retries before giving up (10 seconds with 1s poll interval) _download_retry_lock = threading.Lock() -# Retag worker logic lives in core/library/retag.py. -from core.library import retag as _library_retag - - -def _build_retag_deps(): - """Build the RetagDeps bundle from web_server.py globals on each call.""" - from database.music_database import get_database as _get_db - - def _get_state(): - return retag_state - - def _set_state(value): - global retag_state - retag_state = value - - from core.metadata.lyrics import generate_lrc_file as _generate_lrc_file - - return _library_retag.RetagDeps( - config_manager=config_manager, - retag_lock=retag_lock, - spotify_client=spotify_client, - get_audio_quality_string=_get_audio_quality_string, - enhance_file_metadata=_enhance_file_metadata, - build_final_path_for_track=_build_final_path_for_track, - safe_move_file=_safe_move_file, - cleanup_empty_directories=_cleanup_empty_directories, - download_cover_art=_download_cover_art, - docker_resolve_path=docker_resolve_path, - _get_retag_state=_get_state, - _set_retag_state=_set_state, - get_database=_get_db, - generate_lrc_file=_generate_lrc_file, - ) - - -def _execute_retag(group_id, album_id): - return _library_retag.execute_retag(group_id, album_id, _build_retag_deps()) - - def _automatic_wishlist_cleanup_after_db_update(): """Automatic wishlist cleanup that runs after database updates.""" return _cleanup_wishlist_after_db_update(logger=logger) @@ -15035,6 +15228,11 @@ def _run_db_update_task(full_refresh, server_type): db_update_worker.connect_callback('finished', _db_update_finished_callback) db_update_worker.connect_callback('error', _db_update_error_callback) + # Auto-reconcile runs as the FINAL scan phase (inside run(), before the + # 'finished' signal) so status stays 'running' through it — automations, + # the dashboard card, and the Tools page all treat it as part of the scan. + db_update_worker.post_scan_hook = _reconcile_after_scan + # This is a blocking call that runs the worker logic db_update_worker.run() @@ -15083,6 +15281,9 @@ def _run_deep_scan_task(server_type): db_update_worker.connect_callback('finished', _db_update_finished_callback) db_update_worker.connect_callback('error', _db_update_error_callback) + # Auto-reconcile runs as the final scan phase (see _run_database_update_task). + db_update_worker.post_scan_hook = _reconcile_after_scan + # Run deep scan instead of normal run() db_update_worker.run_deep_scan() @@ -16454,115 +16655,6 @@ def stop_duplicate_cleaner(): # == RETAG TOOL ENDPOINTS == # =============================== -@app.route('/api/retag/stats', methods=['GET']) -def get_retag_stats(): - """Get retag tool statistics for the dashboard card.""" - from database.music_database import get_database - db = get_database() - stats = db.get_retag_stats() - return jsonify({"success": True, **stats}) - -@app.route('/api/retag/groups', methods=['GET']) -def get_retag_groups(): - """Get all retag groups sorted by artist name.""" - from database.music_database import get_database - db = get_database() - groups = db.get_retag_groups() - return jsonify({"success": True, "groups": groups}) - -@app.route('/api/retag/groups//tracks', methods=['GET']) -def get_retag_group_tracks(group_id): - """Get tracks for a specific retag group.""" - from database.music_database import get_database - db = get_database() - tracks = db.get_retag_tracks(group_id) - return jsonify({"success": True, "tracks": tracks}) - -@app.route('/api/retag/search', methods=['GET']) -def search_retag_albums(): - """Search for albums to use for retagging (uses Spotify/iTunes fallback).""" - query = request.args.get('q', '').strip() - if not query: - return jsonify({"success": False, "error": "Query parameter 'q' is required"}), 400 - - limit = min(int(request.args.get('limit', 12)), 50) - try: - results = spotify_client.search_albums(query, limit=limit) - albums = [] - for a in results: - albums.append({ - 'id': str(a.id), - 'name': a.name, - 'artist': ', '.join(a.artists) if a.artists else 'Unknown Artist', - 'release_date': a.release_date or '', - 'total_tracks': a.total_tracks, - 'image_url': a.image_url, - 'album_type': a.album_type or 'album' - }) - return jsonify({"success": True, "albums": albums}) - except Exception as e: - logger.error(f"[Retag] Album search error: {e}") - return jsonify({"success": False, "error": str(e)}), 500 - -@app.route('/api/retag/execute', methods=['POST']) -def execute_retag(): - """Start a retag operation for a group with a new album match.""" - data = request.get_json() - if not data: - return jsonify({"success": False, "error": "JSON body required"}), 400 - - group_id = data.get('group_id') - album_id = data.get('album_id') - if not group_id or not album_id: - return jsonify({"success": False, "error": "group_id and album_id are required"}), 400 - - with retag_lock: - if retag_state["status"] == "running": - return jsonify({"success": False, "error": "A retag operation is already running"}), 409 - - retag_executor.submit(_execute_retag, group_id, str(album_id)) - return jsonify({"success": True, "message": "Retag operation started"}) - -@app.route('/api/retag/status', methods=['GET']) -def get_retag_status(): - """Get the current retag operation status.""" - with retag_lock: - return jsonify(dict(retag_state)) - -@app.route('/api/retag/groups/', methods=['DELETE']) -def delete_retag_group(group_id): - """Delete a retag group (files are NOT deleted).""" - from database.music_database import get_database - db = get_database() - success = db.delete_retag_group(group_id) - if success: - return jsonify({"success": True}) - else: - return jsonify({"success": False, "error": "Group not found"}), 404 - -@app.route('/api/retag/groups/delete-batch', methods=['POST']) -def delete_retag_groups_batch(): - """Delete multiple retag groups at once.""" - from database.music_database import get_database - data = request.get_json() or {} - group_ids = data.get('group_ids', []) - if not group_ids: - return jsonify({"success": False, "error": "No group IDs provided"}), 400 - db = get_database() - removed = 0 - for gid in group_ids: - if db.delete_retag_group(int(gid)): - removed += 1 - return jsonify({"success": True, "removed": removed}) - -@app.route('/api/retag/groups/clear-all', methods=['POST']) -def clear_all_retag_groups(): - """Delete all retag groups.""" - from database.music_database import get_database - db = get_database() - count = db.delete_all_retag_groups() - return jsonify({"success": True, "removed": count}) - # =============================== # == DOWNLOAD MISSING TRACKS == # =============================== @@ -18227,13 +18319,26 @@ def get_server_playlist_tracks(playlist_id): # exact/fuzzy passes entirely. Stale-cache safe — if the cached # server track no longer exists, the override is silently # skipped and normal matching runs. - from core.sync.match_overrides import resolve_match_overrides + from core.sync.match_overrides import resolve_durable_match_server_id, resolve_match_overrides _db_for_overrides = get_database() - _override_pairs = resolve_match_overrides( - source_tracks, - server_tracks, - lambda src_id: ((_db_for_overrides.read_sync_match_cache(src_id, active_server) or {}).get('server_track_id')), - ) + # Set of server track ids currently in this playlist — used to validate + # a re-resolved durable match actually exists before pairing it. + _server_ids = {str(t.get('id')) for t in server_tracks if isinstance(t, dict) and t.get('id') is not None} + _ov_profile = get_current_profile_id() + + def _override_lookup(src_id): + # 1) Fast override cache (cleared on every rescan). + cached = _db_for_overrides.read_sync_match_cache(src_id, active_server) or {} + if cached.get('server_track_id'): + return cached['server_track_id'] + # 2) Durable manual library match — survives a rescan (#787), so a + # Find & Add / manual match keeps pairing after a DB scan. Re- + # resolves a stale id via the stored file path when needed. + return resolve_durable_match_server_id( + _db_for_overrides, _ov_profile, src_id, active_server, _server_ids + ) + + _override_pairs = resolve_match_overrides(source_tracks, server_tracks, _override_lookup) combined = reconcile_playlist(source_tracks, server_tracks, _override_pairs) @@ -18349,27 +18454,55 @@ def server_playlist_replace_track(playlist_id): return jsonify({"success": False, "error": str(e)}), 500 -def _persist_find_and_add_match(source_track_id, server_source, server_track_id, server_track_title, source_title, source_artist): - """Wrap match-override persistence with the active DB. No-op when - source_track_id is missing (e.g. add to a non-mirrored playlist).""" +def _persist_find_and_add_match(source_track_id, server_source, server_track_id, server_track_title, source_title, source_artist, source='spotify'): + """Persist a Find & Add selection two ways. No-op when source_track_id is + missing (e.g. add to a non-mirrored playlist). + + 1. ``sync_match_cache`` override — fast auto-match on the next sync, but + wiped on every library rescan. + 2. A DURABLE manual library match (#787) — survives a rescan, so the + source→library pairing sticks in the sync display AND the download loop + stops re-fetching it. This is the unification the issue asked for: + Find & Add now also records a manual library match (one-way; the manual + match tool has no playlist to act on, so it doesn't reverse-create).""" if not source_track_id: return + db = get_database() try: from core.sync.match_overrides import record_manual_match ok = record_manual_match( - get_database(), + db, source_track_id=source_track_id, server_source=server_source, server_track_id=server_track_id, - server_track_title=server_track_title, source_title=source_title, source_artist=source_artist, + server_track_title=server_track_title, ) if ok: logger.info(f"[ServerPlaylist] Persisted Find & Add override: {source_track_id} → {server_track_id} ({server_source})") except Exception as e: logger.warning(f"[ServerPlaylist] Failed to persist Find & Add override: {e}") + # Durable manual library match — survives a rescan (the override above does not). + try: + from core.library import manual_library_match as _mlm + file_path = '' + try: + _rows = db.api_get_tracks_by_ids([server_track_id]) + if _rows: + file_path = _rows[0].get('file_path', '') or '' + except Exception as _fp_err: + logger.debug(f"[ServerPlaylist] file_path lookup for manual match failed: {_fp_err}") + _mlm.save_match( + db, get_current_profile_id(), (source or 'spotify'), str(source_track_id), str(server_track_id), + source_title=source_title, source_artist=source_artist, + server_source=server_source, library_file_path=file_path, + ) + logger.info(f"[ServerPlaylist] Recorded durable manual library match: {source_track_id} → {server_track_id}") + except Exception as e: + logger.warning(f"[ServerPlaylist] Failed to record durable manual match: {e}") + @app.route('/api/server/playlist//add-track', methods=['POST']) def server_playlist_add_track(playlist_id): @@ -18391,6 +18524,9 @@ def server_playlist_add_track(playlist_id): source_title = data.get('source_title') or '' source_artist = data.get('source_artist') or '' server_track_title = data.get('server_track_title') or '' + # Provider of the source track (spotify/deezer/...) for the durable + # manual match. Retrieval is source-agnostic, so this is metadata. + source_provider = data.get('source') or 'spotify' if not track_id: return jsonify({"success": False, "error": "track_id required"}), 400 @@ -18431,7 +18567,7 @@ def server_playlist_add_track(playlist_id): except Exception: _existing = set() if str(track_id) in _existing: - _persist_find_and_add_match(source_track_id, active_server, track_id, server_track_title or new_item.title, source_title, source_artist) + _persist_find_and_add_match(source_track_id, active_server, track_id, server_track_title or new_item.title, source_title, source_artist, source_provider) return jsonify({"success": True, "message": "Track linked"}) logger.info(f"[ServerPlaylist] Adding track: '{new_item.title}' (ratingKey={new_item.ratingKey}) to playlist '{playlist_name}'") @@ -18455,7 +18591,7 @@ def server_playlist_add_track(playlist_id): new_id = str(raw_playlist.ratingKey) logger.info(f"[ServerPlaylist] Added track to playlist, playlist ID: {new_id}") - _persist_find_and_add_match(source_track_id, active_server, track_id, server_track_title or new_item.title, source_title, source_artist) + _persist_find_and_add_match(source_track_id, active_server, track_id, server_track_title or new_item.title, source_title, source_artist, source_provider) return jsonify({"success": True, "message": "Track added", "new_playlist_id": new_id}) elif active_server == 'jellyfin' and media_server_engine.client('jellyfin'): @@ -18468,7 +18604,7 @@ def server_playlist_add_track(playlist_id): if plan['should_insert']: new_track_objs = [type('T', (), {'ratingKey': tid, 'title': ''})() for tid in plan['new_ids']] media_server_engine.client('jellyfin').update_playlist(playlist_name, new_track_objs) - _persist_find_and_add_match(source_track_id, active_server, track_id, server_track_title, source_title, source_artist) + _persist_find_and_add_match(source_track_id, active_server, track_id, server_track_title, source_title, source_artist, source_provider) return jsonify({"success": True, "message": "Track linked" if not plan['should_insert'] else "Track added"}) elif active_server == 'navidrome' and media_server_engine.client('navidrome'): @@ -18481,7 +18617,7 @@ def server_playlist_add_track(playlist_id): if plan['should_insert']: new_track_objs = [type('T', (), {'ratingKey': tid, 'title': ''})() for tid in plan['new_ids']] media_server_engine.client('navidrome').create_playlist(playlist_name, new_track_objs, playlist_id=playlist_id) - _persist_find_and_add_match(source_track_id, active_server, track_id, server_track_title, source_title, source_artist) + _persist_find_and_add_match(source_track_id, active_server, track_id, server_track_title, source_title, source_artist, source_provider) return jsonify({"success": True, "message": "Track linked" if not plan['should_insert'] else "Track added"}) return jsonify({"success": False, "error": f"Unsupported server: {active_server}"}), 400 @@ -18685,8 +18821,12 @@ def mlm_save(): db = get_database() profile_id = get_current_profile_id() # Validate library track exists before saving - if not db.api_get_tracks_by_ids([library_track_id]): + _lib_rows = db.api_get_tracks_by_ids([library_track_id]) + if not _lib_rows: return jsonify({"success": False, "error": "Library track not found — it may have been removed"}), 400 + # Store the file path so this match re-resolves after a rescan re-keys + # the track (#787) — same durability the Find & Add path gets. + _lib_file_path = (_lib_rows[0].get('file_path') or '') if _lib_rows else '' ok = mlm.save_match( db, profile_id, source, source_track_id, library_track_id, source_title=data.get('source_title', ''), @@ -18694,6 +18834,7 @@ def mlm_save(): source_album=data.get('source_album', ''), source_context_json=data.get('source_context_json', ''), server_source=data.get('server_source', ''), + library_file_path=_lib_file_path, ) if not ok: return jsonify({"success": False, "error": "Failed to save match"}), 500 @@ -18739,6 +18880,10 @@ def start_missing_tracks_process(playlist_id): is_album_download = data.get('is_album_download', False) album_context = data.get('album_context', None) artist_context = data.get('artist_context', None) + # Issue #797 — per-request "Skip AcoustID verification" toggle from the + # album-download modal. Stored on the batch and propagated per-track by + # the master worker so AcoustID never quarantines this request's files. + skip_acoustid = bool(data.get('skip_acoustid', False)) if not tracks: return jsonify({"success": False, "error": "No tracks provided"}), 400 @@ -18806,6 +18951,7 @@ def start_missing_tracks_process(playlist_id): 'album_context': album_context, 'artist_context': artist_context, 'wing_it': wing_it, + 'skip_acoustid': skip_acoustid, # #797 per-request AcoustID bypass 'batch_source': _downloads_history.detect_sync_source(playlist_id), } @@ -21159,10 +21305,17 @@ def _get_source_playlist_states(states, error_label, info_log_label=None): def _submit_sync_task(sync_playlist_id, playlist_name, spotify_tracks, playlist_image_url): """Submit a sync to the shared executor (closes over sync_executor / _run_sync_task / get_current_profile_id so the lifted start_sync helper - stays free of those globals).""" + stays free of those globals). + + Used by ALL per-source discovery syncs (Spotify-Public/Tidal/Deezer/Qobuz/ + YouTube/iTunes-link/ListenBrainz/Beatport). These have no per-request mode + selector, so honor the configured default (Settings > Playlist sync mode) — + otherwise they always ran 'replace' regardless of the setting (#792).""" + from core.sync.playlist_edit import normalize_sync_mode + _mode = normalize_sync_mode(None, config_manager.get('playlist_sync.mode', 'replace')) return sync_executor.submit( _run_sync_task, sync_playlist_id, playlist_name, spotify_tracks, - None, get_current_profile_id(), playlist_image_url, + None, get_current_profile_id(), playlist_image_url, _mode, ) @@ -23260,6 +23413,12 @@ def update_youtube_discovery_match(): 'confidence': 1.0, 'matched_data': matched_data, 'manual_match': True, + # extra_data is MERGED on save, so explicitly clear + # any stale stub/removal flags from before this fix — + # otherwise a leftover wing_it_fallback would make the + # pipeline re-discover and revert this manual pick. + 'wing_it_fallback': False, + 'unmatched_by_user': False, } db.update_mirrored_track_extra_data(db_track_id, extra_data) result['matched_data'] = matched_data @@ -23652,9 +23811,11 @@ def start_playlist_sync(): # playlist — only adds tracks that aren't there yet. Per-server clients # implement append via native add APIs (Plex addItems, Jellyfin POST # /Playlists//Items, Navidrome updatePlaylist?songIdToAdd=...). - sync_mode = data.get('sync_mode', 'replace') - if sync_mode not in ('replace', 'append'): - sync_mode = 'replace' + # Per-request sync_mode wins; otherwise use the configured default + # (Settings > Playlist sync mode). Default 'replace' keeps today's behavior. + from core.sync.playlist_edit import normalize_sync_mode + sync_mode = normalize_sync_mode(data.get('sync_mode'), + config_manager.get('playlist_sync.mode', 'replace')) if not all([playlist_id, playlist_name, tracks_json]): return jsonify({"success": False, "error": "Missing playlist_id, name, or tracks."}), 400 @@ -34450,6 +34611,26 @@ def import_staging_suggestions(): # WEBSOCKET (SOCKET.IO) EVENT HANDLERS AND BACKGROUND EMITTERS # ================================================================================================ +def _spotify_status_with_availability(spotify_status): + """Augment the spotify status dict with the Spotify-Free availability flags + the UI needs: ``metadata_available`` (search picker) and ``free_installed`` + (Settings source selector — 'Spotify Free' is selectable when the package is + installed, since selecting it is the opt-in). Used by BOTH the /status + endpoint and the WebSocket status push so they never drift.""" + out = dict(spotify_status or {}) + try: + out['metadata_available'] = bool( + spotify_client and spotify_client.is_spotify_metadata_available()) + except Exception: + out['metadata_available'] = bool(out.get('authenticated')) + try: + from core.spotify_free_metadata import spotify_free_installed + out['free_installed'] = spotify_free_installed() + except Exception: + out['free_installed'] = False + return out + + def _build_status_payload(): """Build the same status payload used by GET /status, reading from the cache.""" download_mode = config_manager.get('download_source.mode', 'hybrid') @@ -34469,7 +34650,7 @@ def _build_status_payload(): return { 'metadata_source': metadata_status['metadata_source'], - 'spotify': metadata_status['spotify'], + 'spotify': _spotify_status_with_availability(metadata_status['spotify']), 'media_server': _status_cache.get('media_server', {}), 'soulseek': soulseek_data, 'active_media_server': config_manager.get_active_media_server(), @@ -34714,12 +34895,24 @@ from core.enrichment.services import ( def _spotify_resume_pre_check(): - """Mirror the inline Spotify rate-limit guard from the legacy - ``/api/spotify-enrichment/resume`` route. Returns - ``(429, message)`` to short-circuit when banned, ``None`` when ok.""" + """Guard the Spotify enrichment worker's resume button against a rate-limit + ban. Returns ``(429, message)`` to short-circuit when banned, ``None`` ok. + + Mirrors the worker's own loop: if the opt-in Spotify-Free fallback can + serve enrichment, allow resume and let the worker bridge via the no-creds + source during the ban. Block only when rate-limited AND nothing can serve + (plain auth, no free) — where resuming would just sleep. + """ try: if _spotify_rate_limited(): - return (429, 'Cannot resume while Spotify is rate limited') + from core.spotify_free_metadata import should_block_rate_limited_resume + try: + metadata_available = bool(spotify_client.is_spotify_metadata_available()) + except Exception as e: + logger.debug("spotify free-availability check failed: %s", e) + metadata_available = False + if should_block_rate_limited_resume(True, metadata_available): + return (429, 'Cannot resume while Spotify is rate limited') except Exception as e: logger.debug("spotify rate-limit pre-check failed: %s", e) return None @@ -34961,12 +35154,6 @@ def _emit_tool_progress_loop(): socketio.emit('tool:duplicate-cleaner', state_copy) except Exception as e: logger.debug(f"Error emitting duplicate cleaner status: {e}") - # Retag - try: - with retag_lock: - socketio.emit('tool:retag', dict(retag_state)) - except Exception as e: - logger.debug(f"Error emitting retag status: {e}") # DB Update try: with db_update_lock: diff --git a/webui/index.html b/webui/index.html index f9d3204c..a7a68330 100644 --- a/webui/index.html +++ b/webui/index.html @@ -2201,6 +2201,19 @@ + +
+ 🔗 + + +
+ @@ -6112,6 +6132,18 @@

Playlist Sync Settings

+
+ + +
+ "Replace" deletes and recreates the server playlist every sync, which wipes its custom image/description. "Reconcile" updates the same playlist in place (adds new tracks, removes ones no longer in the source) so your custom image and description survive. "Append" only ever adds. +
+
+
-
-
-

Metadata Updater

- -
- -
- - -
-
-

Current Artist: Not - running

-
-
-
-
-

0 / 0 artists (0.0%) -

-
-
-

Quality Scanner

@@ -6590,6 +6591,43 @@
+
+
+

Import IDs from File Tags

+ +
+

Read provider IDs (Spotify, MusicBrainz, iTunes, Deezer…) already embedded in your files and fill them into the database — lets enrichment workers skip redundant API lookups. Only fills blanks; never overwrites an existing match.

+
+
+ IDs Filled: + 0 +
+
+ Rows Updated: + 0 +
+
+ Conflicts: + 0 +
+
+ Unreadable: + 0 +
+
+
+ +
+
+

Ready to scan

+
+
+
+

0 / 0 files scanned (0.0%)

+
+
+

Duplicate Cleaner

@@ -6667,6 +6705,37 @@

Metadata & Cache

+
+
+

Metadata Updater

+ +
+ +
+ + +
+
+

Current Artist: Not + running

+
+
+
+
+

0 / 0 artists (0.0%) +

+
+
+
@@ -6707,45 +6776,6 @@
- -
-
-

Retag Tool

- -
-

Fix metadata on previously downloaded albums & singles

-
-
- Groups: - 0 -
-
- Tracks: - 0 -
-
- Artists: - 0 -
-
- Status: - Idle -
-
-
- -
-
-

Ready

-
-
-
-
-

0 / 0 tracks (0.0%)

-
-
-
@@ -7788,40 +7818,6 @@ -
-
-
-
-

Retag Tool

-
-
- - -
-
- -
-
Loading downloads...
-
-
-
- - -
-
-
-

Search for Correct Album

- -
-
- -
-
-
-
diff --git a/webui/static/api-monitor.js b/webui/static/api-monitor.js index 9e1c8e88..1b4215b1 100644 --- a/webui/static/api-monitor.js +++ b/webui/static/api-monitor.js @@ -65,6 +65,14 @@ function _handleRateMonitorUpdate(data) { const grid = document.getElementById('rate-monitor-grid'); if (!grid) return; + // Skip DOM writes while the equalizer is off-screen (you're on another page). + // All pages stay mounted, so updating a hidden grid still fires every + // MutationObserver on the document — including password-manager extensions + // that re-scan the WHOLE DOM on each mutation — for zero visible benefit. + // offsetParent is null when an ancestor is display:none. The next update that + // arrives while the dashboard is visible renders normally. + if (grid.offsetParent === null) return; + // The dashboard rate monitor uses the equalizer-bar visual — a // vertical-bar VU-meter row that fits any service count without // an orphan grid cell. Detail page / mobile breakpoints keep the diff --git a/webui/static/core.js b/webui/static/core.js index 48843c3e..44681920 100644 --- a/webui/static/core.js +++ b/webui/static/core.js @@ -487,7 +487,6 @@ function initializeWebSocket() { socket.on('tool:stream', (data) => updateStreamStatusFromData(data)); socket.on('tool:quality-scanner', (data) => updateQualityScanProgressFromData(data)); socket.on('tool:duplicate-cleaner', (data) => updateDuplicateCleanProgressFromData(data)); - socket.on('tool:retag', (data) => updateRetagStatusFromData(data)); socket.on('tool:db-update', (data) => updateDbProgressFromData(data)); socket.on('tool:metadata', (data) => updateMetadataStatusFromData(data)); socket.on('tool:logs', (data) => updateLogsFromData(data)); diff --git a/webui/static/downloads.js b/webui/static/downloads.js index 67df1945..85c0f944 100644 --- a/webui/static/downloads.js +++ b/webui/static/downloads.js @@ -2451,6 +2451,11 @@ async function startMissingTracksProcess(playlistId) { const forceDownloadCheckbox = document.getElementById(`force-download-all-${playlistId}`); const forceDownloadAll = forceDownloadCheckbox ? forceDownloadCheckbox.checked : false; + // Issue #797 — per-request "Skip AcoustID verification" toggle. Absent + // checkbox (other call sites) → false, so behavior is unchanged there. + const skipAcoustidCheckbox = document.getElementById(`skip-acoustid-${playlistId}`); + const skipAcoustid = skipAcoustidCheckbox ? skipAcoustidCheckbox.checked : false; + // Check if playlist folder mode toggle is enabled (only for sync page playlists) const playlistFolderMode = typeof isPlaylistOrganizeEnabled === 'function' ? isPlaylistOrganizeEnabled(playlistId) @@ -2495,6 +2500,7 @@ async function startMissingTracksProcess(playlistId) { force_download_all: forceDownloadAll || isWingIt, ignore_manual_matches: forceDownloadAll, wing_it: isWingIt, + skip_acoustid: skipAcoustid, }; // If this is an artist album download, use album name and include full context @@ -4401,12 +4407,15 @@ async function startPlaylistSync(playlistId, syncModeOverride = null) { let syncMode = syncModeOverride; if (!syncMode) { const modeSelect = document.getElementById(`sync-mode-${playlistId}`); - syncMode = (modeSelect && modeSelect.value) || 'replace'; + // Empty value = "use the Settings default" — send nothing so the + // backend falls back to playlist_sync.mode (#792). Don't hardcode + // 'replace' here or it shadows the global setting. + syncMode = (modeSelect && modeSelect.value) || ''; } - if (syncMode !== 'replace' && syncMode !== 'append') { - syncMode = 'replace'; + if (syncMode && !['replace', 'reconcile', 'append'].includes(syncMode)) { + syncMode = ''; } - console.log(`🚀 [${new Date().toTimeString().split(' ')[0]}] Starting sync for playlist: ${playlistId} (mode: ${syncMode})`); + console.log(`🚀 [${new Date().toTimeString().split(' ')[0]}] Starting sync for playlist: ${playlistId} (mode: ${syncMode || 'default(setting)'})`); const playlist = spotifyPlaylists.find(p => p.id === playlistId); if (!playlist) { console.error(`❌ Could not find playlist data for ID: ${playlistId}`); diff --git a/webui/static/enrichment-manager.js b/webui/static/enrichment-manager.js index 9f8b0c28..a6dea8af 100644 --- a/webui/static/enrichment-manager.js +++ b/webui/static/enrichment-manager.js @@ -274,6 +274,9 @@ async function _emPollSelected() { function _emStatusInfo(status) { if (!status || !status.enabled) return { cls: 'disabled', label: 'Disabled' }; + // Rate-limited on the real API but still matching via the no-creds Spotify + // Free source — show as running, not stuck (#798 bridge). + if (status.using_free) return { cls: 'running', label: 'Running (Spotify Free)' }; if (status.rate_limited) return { cls: 'ratelimited', label: 'Rate-limited' }; if (status.paused) return { cls: 'paused', label: 'Paused' }; if (status.idle) return { cls: 'idle', label: 'Idle' }; @@ -709,6 +712,9 @@ function _emUpdateHeaderLive() { cls += ' em-banner--warn'; html = '⚙️ This source isn’t configured — add its credentials in Settings. ' + 'Browsing works, but matches and retries won’t run until it’s set up.'; + } else if (status && status.using_free) { + // Real API banned but bridging via the no-creds Spotify Free source. + html = '✓ Spotify is rate-limited — matching via Spotify Free until the ban lifts.'; } else if (status && status.rate_limited) { cls += ' em-banner--warn'; const rl = status.rate_limit || {}; diff --git a/webui/static/enrichment.js b/webui/static/enrichment.js index efdfbb29..649920e5 100644 --- a/webui/static/enrichment.js +++ b/webui/static/enrichment.js @@ -457,6 +457,10 @@ function updateSpotifyEnrichmentStatusFromData(data) { const notAuthenticated = data.authenticated === false; const isRateLimited = data.rate_limited === true; + // The real API is banned but the worker is still matching via the no-creds + // Spotify Free source — treat it as running, not stuck (#798 bridge). + const bridgingFree = data.using_free === true; + const rateLimitedStuck = isRateLimited && !bridgingFree; const budgetExhausted = data.daily_budget && data.daily_budget.exhausted; button.classList.remove('active', 'paused', 'complete', 'no-auth'); @@ -464,7 +468,7 @@ function updateSpotifyEnrichmentStatusFromData(data) { button.classList.add('paused'); } else if (notAuthenticated) { button.classList.add('no-auth'); - } else if (isRateLimited || budgetExhausted) { + } else if (rateLimitedStuck || budgetExhausted) { button.classList.add('paused'); } else if (data.idle) { button.classList.add('complete'); @@ -479,7 +483,8 @@ function updateSpotifyEnrichmentStatusFromData(data) { if (tooltipStatus) { if (data.paused) { tooltipStatus.textContent = 'Paused'; } else if (notAuthenticated) { tooltipStatus.textContent = 'Not Authenticated'; } - else if (isRateLimited) { tooltipStatus.textContent = 'Rate Limited'; } + else if (rateLimitedStuck) { tooltipStatus.textContent = 'Rate Limited'; } + else if (bridgingFree) { tooltipStatus.textContent = 'Running (Spotify Free)'; } else if (budgetExhausted) { tooltipStatus.textContent = 'Daily Limit Reached'; } else if (data.idle) { tooltipStatus.textContent = 'Complete'; } else if (data.running) { tooltipStatus.textContent = 'Running'; } @@ -491,10 +496,12 @@ function updateSpotifyEnrichmentStatusFromData(data) { tooltipCurrent.textContent = notAuthenticated ? 'Connect Spotify in Settings to enrich' : 'Click to resume'; } else if (notAuthenticated) { tooltipCurrent.textContent = 'Connect Spotify in Settings to enrich'; - } else if (isRateLimited) { + } else if (rateLimitedStuck) { const info = data.rate_limit || {}; const remaining = info.remaining_seconds || 0; tooltipCurrent.textContent = remaining > 0 ? `Waiting ${Math.ceil(remaining / 60)}m for rate limit to clear` : 'Waiting for rate limit to clear'; + } else if (bridgingFree && data.current_item && data.current_item.name) { + tooltipCurrent.textContent = `Now: ${data.current_item.name} (via Spotify Free)`; } else if (budgetExhausted) { const resets = data.daily_budget.resets_in_seconds || 0; const hours = Math.floor(resets / 3600); @@ -2725,7 +2732,7 @@ async function loadRepairFindings() { duplicate_tracks: 'Duplicate', incomplete_album: 'Incomplete', path_mismatch: 'Path Mismatch', metadata_gap: 'Missing Metadata', missing_cover_art: 'Missing Art', track_number_mismatch: 'Track Number', - missing_lossy_copy: 'No Lossy Copy' + missing_lossy_copy: 'No Lossy Copy', library_retag: 'Re-tag' }; // Finding types that have an automated fix action @@ -2740,6 +2747,7 @@ async function loadRepairFindings() { missing_lossy_copy: 'Convert', acoustid_mismatch: 'Fix', missing_discography_track: 'Add to Wishlist', + library_retag: 'Apply Tags', }; container.innerHTML = items.map(f => { @@ -2905,6 +2913,40 @@ function _renderFindingDetail(f) { if (f.file_path) rows.push(['Full Path', f.file_path, 'path']); return _gridRows(rows) + _renderPlayButton(f); + case 'library_retag': { + const tracks = Array.isArray(d.tracks) ? d.tracks : []; + const changed = tracks.filter(t => t.changes && Object.keys(t.changes).length); + const meta = []; + if (d.source) meta.push(`Source: ${d.source}`); + if (d.mode) meta.push(`Mode: ${d.mode}`); + if (d.cover_action) meta.push(`Cover: ${d.cover_action}`); + let html = ''; + if (meta.length) { + html += `
${_escFinding(meta.join(' · '))}
`; + } + if (!changed.length && d.cover_action) { + html += `
Tags already correct — this would refresh cover art only.
`; + } + // Per-track old → new diff (cap the rendered list so huge albums stay sane). + changed.slice(0, 40).forEach(t => { + const label = t.title || (t.file_path || '').split(/[\\/]/).pop(); + const rows = Object.entries(t.changes).map(([field, c]) => [ + field.replace(/_/g, ' '), + `${(c.old === '' || c.old == null) ? '∅' : c.old} → ${c.new}`, + 'highlight', + ]); + html += `
${_escFinding(label)}
`; + html += _gridRows(rows); + }); + if (changed.length > 40) { + html += `
…and ${changed.length - 40} more track(s)
`; + } + if (Array.isArray(d.unmatched) && d.unmatched.length) { + html += `
Unmatched (left untouched): ${_escFinding(d.unmatched.join(', '))}
`; + } + return html || '
No changes.
'; + } + case 'acoustid_mismatch': { let html = media + '
'; html += _renderScoreBar(d.fingerprint_score, 'Fingerprint'); diff --git a/webui/static/helper.js b/webui/static/helper.js index 7c79cde7..16a57350 100644 --- a/webui/static/helper.js +++ b/webui/static/helper.js @@ -3413,6 +3413,27 @@ function closeHelperSearch() { // projects that span multiple commits before shipping. Strip the flag at // release time and add a real `date:` line at the top of the version block. const WHATS_NEW = { + '2.6.7': [ + { date: 'June 5, 2026 — 2.6.7 release' }, + { title: 'Spotify Free — Spotify metadata with no credentials, and a rate-limit bridge (#798)', desc: 'a new "Spotify Free (no credentials)" metadata source. Pick it in Settings → Metadata to get Spotify search + enrichment without connecting a Spotify account (it uses the public web-player data via the optional spotapi package). It also works as a rate-limit bridge for connected users: if your real Spotify auth gets rate-limited, enrichment automatically falls through to the free source instead of stalling, then returns to your real auth once the ban lifts. The worker can be resumed mid-ban, the dashboard now shows "Running (Spotify Free)" instead of looking stuck, and the daily-API budget no longer blocks free work (it only ever capped real-API calls). Opt-in — nothing changes unless you select it.', page: 'settings' }, + { title: 'Import IDs from File Tags — recover the provider IDs already in your files', desc: 'a new "Import IDs from File Tags" tool (Tools → Database & Scanning) reads the Spotify / iTunes / MusicBrainz / Deezer / Tidal / AudioDB / Genius / Last.fm IDs that SoulSync (or MusicBrainz Picard) already embedded in your files and fills them into the database. The media-server API never exposes these IDs, so the only way to get them is from the files themselves — and once they\'re in the DB, the enrichment workers skip every entity that already has an ID, saving a large amount of API traffic on an already-tagged library. Gap-fill only: it never overwrites an existing match, and it\'s atomically guarded so it can\'t clobber a match a worker makes at the same moment. New tracks also get this automatically as the final phase of every library scan, so it stays current without re-running the tool.', page: 'dashboard' }, + { title: 'Library Re-tag — rewrite your library\'s tags from the source, safely', desc: 'a proper Library Re-tag job replaces the old Retag tool. It matches each file to its source tracklist and rewrites the tags — title, artist, album, track / disc numbers, cover art, and the embedded source IDs — and shows you a per-track old→new diff before anything is written. Standard dry-run pattern (you see exactly what would change and opt in to apply), Light / Full depth settings, and it pulls cover art + metadata from your configured source order.', page: 'dashboard' }, + { title: 'Paste a metadata link to open an artist, album, or track (#775)', desc: 'the Search page now accepts a pasted metadata link. Paste a Spotify / iTunes / Deezer / etc. artist, album, or track URL and SoulSync resolves it and opens that exact item instead of running a name search — handy when a name search is ambiguous or you already have the link. Bare IDs are rejected as ambiguous (a link carries the entity type), and a clear not-found hint shows when nothing resolves.', page: 'search' }, + { title: 'Mobile: a full responsive pass across the app (#793, #795)', desc: 'a comprehensive small-screen pass. The artist-detail page, enhanced track table, music player and Now Playing modal, sync buttons, discover carousels, the downloads page, the notification panel, and the mini-player all lay out cleanly on phones now, plus scroll-render and password-manager-extension compatibility improvements.' }, + { title: 'Playlist sync: new "Reconcile" mode that edits in place (#792)', desc: 'playlist sync gains a "Reconcile" mode alongside Replace and Append. Replace deletes and recreates the server playlist every sync (which wipes its custom image / description); Reconcile updates the same playlist in place — adds new tracks, removes ones no longer in the source — so your custom image and description survive. Choose it per playlist in the sync-mode setting.', page: 'sync' }, + { title: 'Manual album match now locks the edition it\'s pinned to (#758)', desc: 'manually matching an album now pins AND locks that exact edition. Previously the auto canonical resolver could drag a manually-matched regular edition back to the deluxe on the next cycle — reporting missing songs or renumbering tracks. The manual choice is now the authority every downstream tool reads (track-number repair, reorganize, missing-tracks), and the auto resolver won\'t override it. A new manual match still wins if you change your mind.', page: 'library' }, + { title: 'Write Tags won\'t overwrite a correct file with placeholder data (#800)', desc: 'Write Tags will no longer stamp a correctly-tagged file with placeholder database values like "Various Artists" or "[Unknown Album]". If your file already holds a real value and the database only has a placeholder, the file\'s value is preserved instead of being destroyed. A legitimate value (including a genuine compilation\'s "Various Artists") still writes normally.', page: 'library' }, + { title: 'AcoustID stops quarantining correct downloads of non-English artists (#797)', desc: 'AcoustID verification no longer false-quarantines correct downloads from non-English artists. When the fingerprint database returns the artist/title in its original script (e.g. Japanese kanji for Joe Hisaishi) and your metadata is romanized, the title can\'t match across scripts — so a correct file used to get quarantined. When the artist is confirmed across scripts via MusicBrainz aliases, the file is now kept instead of quarantined. A per-request "Skip AcoustID verification" toggle was also added to the download flow.', page: 'downloads' }, + { title: 'Fix: wrong artist on the artist-detail page when a source ID was duplicated', desc: 'fixed a class of bugs where the artist-detail page could show the wrong artist, and where one enrichment worker could stamp a single source ID onto several different artists. Artist matching is tightened (a 0.85 confidence gate + a shared uniqueness guard), a one-time startup repair de-duplicates any source IDs shared across multiple artists, and the library view is kept when a source ID is ambiguous instead of jumping to the wrong artist.', page: 'library' }, + { title: 'Fix: manual playlist fixes reverted on the next mirrored-sync run (#799)', desc: 'a manual fix on a mirrored playlist no longer reverts to "Wing It" on the next discovery / sync run — the manual match is checked first and its flag is cleared correctly. Also stopped the wide "Server Playlists" sync tab from stretching full-width.', page: 'sync' }, + { title: 'Find & Add: manual matches now survive a library rescan (#787)', desc: 'a match made via Find & Add (and via the manual-match tool) now records a durable manual match plus the file path, so it survives a library rescan instead of being lost and re-flagged.' }, + { title: 'Fix: streamed tracks played with no sound', desc: 'fixed streamed tracks occasionally playing silently — the browser\'s Web Audio context could be left suspended. It\'s now resumed on the play event across every play path.', page: 'dashboard' }, + { title: 'Navidrome: respect the selected music library + survive renames (#789)', desc: 'Navidrome now respects the music library you selected (it previously ignored the selection and imported all libraries), and pins that selection by id rather than name so it survives a library rename.', page: 'settings' }, + { title: 'Fix: file / CSV playlists failed to match raw "Artist - Title" titles (#785)', desc: 'file and CSV playlists whose rows are raw "Artist - Title" strings now match correctly — the discovery worker also searches the canonical title form.', page: 'sync' }, + { title: 'Fix: torrent client URL without http:// failed to connect (#790)', desc: 'a torrent client URL entered without an http:// or https:// scheme now connects instead of failing the connection probe.', page: 'settings' }, + { title: 'Fix: Soulseek album bundle left completed files in the slskd folder (#796)', desc: 'Soulseek album-bundle downloads no longer leave their completed copies behind in the slskd download folder after the bundle finishes.', page: 'downloads' }, + { title: 'Cover Art Filler + Library Re-tag honor your configured cover-art sources', desc: 'both the Cover Art Filler and the new Library Re-tag job now pull artwork from your configured cover-art source order instead of a fixed path, so the cover you get matches your source preferences.', page: 'dashboard' }, + ], '2.6.6': [ { date: 'June 3, 2026 — 2.6.6 release' }, { title: 'Fix: qBittorrent 5.2.0+ would not connect (HTTP 204 login)', desc: 'qBittorrent 5.2.0 changed its /api/v2/auth/login endpoint to answer a successful login with HTTP 204 (No Content) instead of the old HTTP 200 + "Ok." body. SoulSync required the literal "Ok." response, so on 5.2.0+ every login failed with "HTTP 204 body=" — the connection probe and all torrent actions were dead even though qBittorrent itself logged a successful login. Login is now accepted on the SID auth cookie and/or a success response (the old "Ok." or the new empty 204), while bad credentials (which qBittorrent reports as HTTP 200 + "Fails.") are still rejected. Covers 5.2.0 / 5.2.1; no more whitelist-bypass workaround needed.', page: 'settings' }, @@ -3572,6 +3593,63 @@ const WHATS_NEW = { // Section shape: { title, description, features: [bullet strings], // usage_note?: 'optional hint shown at the bottom' } const VERSION_MODAL_SECTIONS = [ + { + title: "Spotify Free — metadata with no credentials (#798)", + description: "a new \"Spotify Free\" metadata source that uses Spotify's public web-player data, so you can get Spotify search + enrichment without connecting an account. For connected users it also bridges rate-limit bans automatically — when your real auth is rate-limited, enrichment keeps running through the free source instead of stalling, then returns to your real auth once the ban lifts.", + features: [ + "pick \"Spotify Free (no credentials)\" in Settings → Metadata to use it without a Spotify account", + "automatic rate-limit bridge for connected users — no more enrichment stalling out during a ban", + "the worker resumes mid-ban, the dashboard shows \"Running (Spotify Free)\" instead of looking stuck", + "the daily-API budget no longer blocks free work — it only ever capped real-API calls", + "opt-in; requires the optional spotapi package", + ], + usage_note: "Settings → Metadata → Spotify Free", + }, + { + title: "Import IDs from File Tags", + description: "your files often already carry the Spotify / MusicBrainz / iTunes / Deezer IDs that SoulSync (or MusicBrainz Picard) embedded when they were tagged — but the media-server scan can't see them. This tool reads them straight from the files into the database, so the enrichment workers can skip those lookups entirely. On an already-tagged library that's a large amount of saved API traffic.", + features: [ + "reads embedded Spotify / iTunes / MusicBrainz / Deezer / Tidal / AudioDB / Genius / Last.fm IDs into the DB", + "gap-fill only — never overwrites an existing match, and atomically guarded against races", + "new tracks also get it automatically as the final phase of every library scan", + "enrichment workers skip any entity that already has an ID, so this directly cuts API calls", + ], + usage_note: "Tools → Database & Scanning → Import IDs from File Tags", + }, + { + title: "Library Re-tag", + description: "a proper Library Re-tag job (replacing the old Retag tool): it matches each file to its source tracklist and rewrites the tags — title, artist, album, track / disc numbers, cover art, and the embedded source IDs — and shows a per-track old→new diff so you can review before anything is written.", + features: [ + "per-track old→new diff in the finding card before you apply", + "standard dry-run pattern — see what would change, then opt in to apply", + "Light / Full depth settings; cover art + metadata pulled from your configured source order", + "writes the embedded source IDs too, so re-tagged files survive future scans", + ], + usage_note: "Dashboard → Manage Workers → Library Re-tag", + }, + { + title: "Paste a metadata link to open it (#775)", + description: "the Search page now takes a pasted metadata link. Drop in a Spotify / iTunes / Deezer artist, album, or track URL and SoulSync opens that exact item instead of running a name search — perfect when a name search is ambiguous or you already have the link.", + features: [ + "paste an artist / album / track URL → opens the exact item", + "bare IDs are rejected as ambiguous (a link carries the entity type)", + "clear not-found hint when nothing resolves", + ], + usage_note: "Search page", + }, + { + title: "Recent Fixes & Polish (2.6.7)", + description: "a stack of fixes and refinements that shipped alongside the headline features.", + features: [ + "Manual album match now LOCKS the edition it's pinned to (#758) — the auto resolver can't drag it back to the deluxe", + "Write Tags won't overwrite a correct file with placeholder data like \"Various Artists\" / \"[Unknown Album]\" (#800)", + "AcoustID stops false-quarantining correct downloads of non-English artists (#797)", + "full mobile / small-screen responsive pass across the app (#793, #795)", + "new \"Reconcile\" playlist sync mode that edits in place and keeps your custom image / description (#792)", + "fixes: wrong artist on duplicated source IDs, manual fixes reverting on mirrored sync (#799), Find & Add matches surviving rescans (#787), Navidrome library selection (#789), silent streamed tracks, torrent URL without scheme (#790), file/CSV playlist matching (#785)", + ], + usage_note: "browse the What's New panel for the full 2.6.7 changelog", + }, { title: "Artist Map, Reimagined", description: "the Discover artist map got a full rework — it now reads like a living constellation of your library instead of a flat blob. Explore one genre island at a time, watch bubbles bloom into place, and open a side panel with everything about the artist under your cursor.", diff --git a/webui/static/media-player.js b/webui/static/media-player.js index 0eb2ee3e..2408ed3f 100644 --- a/webui/static/media-player.js +++ b/webui/static/media-player.js @@ -23,6 +23,11 @@ function initializeMediaPlayer() { audioPlayer.addEventListener('error', onAudioError); audioPlayer.addEventListener('loadstart', onAudioLoadStart); audioPlayer.addEventListener('canplay', onAudioCanPlay); + // Universal: once the visualizer routes this element through + // npAudioContext, the element is silent while that context is + // suspended. The 'play' event fires on EVERY playback start (every + // code path, incl. ones that bypass setPlayingState), so resume here. + audioPlayer.addEventListener('play', npEnsureAudioContextRunning); // Set initial volume — restore the saved level (Spotify-style), else 70%. const _savedVol = npLoadSavedVolume(); @@ -2661,6 +2666,20 @@ function getNpAlbumArtUrl() { // WEB AUDIO VISUALIZER // =============================== +// Once createMediaElementSource() captures the