Merge pull request #803 from Nezreka/dev

2.6.7
This commit is contained in:
BoulderBadgeDad 2026-06-05 23:48:10 -07:00 committed by GitHub
commit 04de4b61c4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
102 changed files with 7889 additions and 1384 deletions

View file

@ -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:

View file

@ -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"

View file

@ -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 = (

View file

@ -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(

View file

@ -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

View file

@ -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}")

View file

@ -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')

View file

@ -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:

View file

@ -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

View file

@ -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 "<artist> - " 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

View file

@ -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}")

View file

@ -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

View file

@ -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}")

View file

@ -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

View file

@ -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')

View file

@ -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)

View file

@ -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')

View file

@ -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():

View file

@ -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, planapply 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

View file

@ -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)
})

View file

@ -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}

View file

@ -0,0 +1 @@
"""Maintenance / one-off data-repair helpers."""

View file

@ -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

View file

@ -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 AZ / az 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)

View file

@ -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",
]

View file

@ -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()

View file

@ -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')

View file

@ -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():

View file

@ -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

View file

@ -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')

View file

@ -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',
]

View file

@ -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

View file

@ -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:

View file

@ -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')

371
core/search/by_id.py Normal file
View file

@ -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=<track-id>; 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)

View file

@ -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':

View file

@ -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

View file

@ -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}")

View file

@ -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 []

View file

@ -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})")

View file

@ -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)

View file

@ -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",
]

View file

@ -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

View file

@ -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')

View file

@ -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.

View file

@ -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 = (

View file

@ -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'

View file

@ -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.

View file

@ -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.

View file

@ -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."""

View file

@ -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 (

View file

@ -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()

View file

@ -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)

View file

@ -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)

View file

@ -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

View file

@ -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()

View file

@ -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"},

View file

@ -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

View file

@ -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'

View file

@ -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")

View file

@ -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

View file

@ -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',

View file

@ -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."""

View file

@ -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
(albumtrack), 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'] == []

View file

@ -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)

View file

@ -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

View file

@ -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']

View file

@ -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

View file

@ -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
# ---------------------------------------------------------------------------

View file

@ -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

View file

@ -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]

View file

@ -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()

View file

@ -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')]

View file

@ -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

View file

@ -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')]

View file

@ -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)

View file

@ -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

View file

@ -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")

View file

@ -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)

View file

@ -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

114
tests/test_retag_planner.py Normal file
View file

@ -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'

View file

@ -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(

View file

@ -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

View file

@ -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']

View file

@ -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

View file

@ -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 == ""

View file

@ -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": "<provider link or spotify: URI>"}``. 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/<source_name>', 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/<int:group_id>/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/<int:group_id>', 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
sourcelibrary 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/<playlist_id>/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/<id>/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:

View file

@ -2201,6 +2201,19 @@
</div>
</div>
<!-- Link lookup (#775): paste a provider link and resolve
it directly on the owning source (Spotify / Apple Music
/ MusicBrainz / Deezer) — no fuzzy search. Results render
in the dropdown below and reuse the normal download/
import flow. Links only: a bare ID is ambiguous. -->
<div class="enh-id-lookup">
<span class="enh-id-lookup-icon">🔗</span>
<input type="text" id="enh-id-input" class="enh-id-input"
placeholder="…or paste a Spotify / Apple Music / MusicBrainz / Deezer link"
autocomplete="off" spellcheck="false">
<button id="enh-id-btn" class="enh-id-btn" type="button">Look up</button>
</div>
<!-- Enhanced Search Dropdown (Overlay Panel) -->
<div id="enhanced-dropdown" class="enhanced-dropdown hidden">
<div class="enhanced-dropdown-content">
@ -3825,6 +3838,7 @@
<label>Primary metadata source:</label>
<select id="metadata-fallback-source">
<option value="spotify">Spotify</option>
<option value="spotify_free">Spotify Free (no credentials)</option>
<option value="itunes">iTunes / Apple Music</option>
<option value="deezer">Deezer</option>
<option value="discogs">Discogs</option>
@ -3832,7 +3846,13 @@
</select>
</div>
<div class="callback-info">
<div class="callback-help">Choose the primary source for artist, album, and track metadata. Spotify can only be selected while an active Spotify session exists. Discogs requires a personal token. MusicBrainz is always available but rate-limited to 1 req/sec.</div>
<div class="callback-help">Where artist, album, and track metadata comes from:<br>
<strong>Spotify</strong> — official Spotify; connect your account in the Spotify section below.<br>
<strong>Spotify Free</strong> — the same Spotify catalog with no account needed. It's unofficial and best-effort (may break if Spotify changes its site), can't search albums by name (album results come from iTunes/Deezer instead), and can't read your personal library or playlists.<br>
<strong>Deezer</strong> / <strong>iTunes</strong> — free, no account.<br>
<strong>MusicBrainz</strong> — free, but limited to 1 request/sec.<br>
<strong>Discogs</strong> — needs a personal access token.<br><br>
Tip: if you pick <strong>Spotify Free</strong> and later connect a real Spotify account, it uses the official account normally and only falls back to free while Spotify is rate-limited — then switches back automatically.</div>
</div>
</div>
@ -6112,6 +6132,18 @@
<div class="settings-group" data-stg="library">
<h3>Playlist Sync Settings</h3>
<div class="form-group">
<label>Sync mode:</label>
<select id="playlist-sync-mode" class="form-select">
<option value="replace">Replace — recreate the playlist each sync (default)</option>
<option value="reconcile">Reconcile — edit in place, keep image &amp; description</option>
<option value="append">Append — only add new tracks, never remove</option>
</select>
<div class="help-text">
"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.
</div>
</div>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" id="create-backup" checked>
@ -6516,37 +6548,6 @@
</div>
</div>
<div class="tool-card" id="metadata-updater-card">
<div class="tool-card-header">
<h4 class="tool-card-title">Metadata Updater</h4>
<button class="tool-help-button" data-tool="metadata-updater"
title="Learn more about this tool">?</button>
</div>
<p class="metadata-updater-description tool-card-info">Updates artist photos, genres,
and album art from Spotify.</p>
<div class="tool-card-controls">
<select id="metadata-refresh-interval">
<option value="180">6 months</option>
<option value="90">3 months</option>
<option value="30" selected>1 month</option>
<option value="14">2 weeks</option>
<option value="7">1 week</option>
<option value="0">Full refresh</option>
</select>
<button id="metadata-update-button">Begin Update</button>
</div>
<div class="tool-card-progress-section">
<p class="progress-phase-label" id="metadata-phase-label">Current Artist: Not
running</p>
<div class="progress-bar-container">
<div class="progress-bar-fill" id="metadata-progress-bar" style="width: 0%;">
</div>
</div>
<p class="progress-details-label" id="metadata-progress-label">0 / 0 artists (0.0%)
</p>
</div>
</div>
<div class="tool-card" id="quality-scanner-card">
<div class="tool-card-header">
<h4 class="tool-card-title">Quality Scanner</h4>
@ -6590,6 +6591,43 @@
</div>
</div>
<div class="tool-card" id="reconcile-ids-card">
<div class="tool-card-header">
<h4 class="tool-card-title">Import IDs from File Tags</h4>
<button class="tool-help-button" data-tool="reconcile-ids"
title="Learn more about this tool">?</button>
</div>
<p class="tool-card-info">Read provider IDs (Spotify, MusicBrainz, iTunes, Deezer&hellip;) already embedded in your files and fill them into the database &mdash; lets enrichment workers skip redundant API lookups. Only fills blanks; never overwrites an existing match.</p>
<div class="tool-card-stats">
<div class="stat-item">
<span class="stat-item-label">IDs Filled:</span>
<span class="stat-item-value" id="reconcile-stat-filled">0</span>
</div>
<div class="stat-item">
<span class="stat-item-label">Rows Updated:</span>
<span class="stat-item-value" id="reconcile-stat-updated">0</span>
</div>
<div class="stat-item">
<span class="stat-item-label">Conflicts:</span>
<span class="stat-item-value" id="reconcile-stat-conflicts">0</span>
</div>
<div class="stat-item">
<span class="stat-item-label">Unreadable:</span>
<span class="stat-item-value" id="reconcile-stat-unreadable">0</span>
</div>
</div>
<div class="tool-card-controls">
<button id="reconcile-ids-button">Scan Library</button>
</div>
<div class="tool-card-progress-section">
<p class="progress-phase-label" id="reconcile-phase-label">Ready to scan</p>
<div class="progress-bar-container">
<div class="progress-bar-fill" id="reconcile-progress-bar" style="width: 0%;"></div>
</div>
<p class="progress-details-label" id="reconcile-progress-label">0 / 0 files scanned (0.0%)</p>
</div>
</div>
<div class="tool-card" id="duplicate-cleaner-card">
<div class="tool-card-header">
<h4 class="tool-card-title">Duplicate Cleaner</h4>
@ -6667,6 +6705,37 @@
<div class="tools-section">
<h3 class="tools-section-title">Metadata &amp; Cache</h3>
<div class="tools-grid">
<div class="tool-card" id="metadata-updater-card">
<div class="tool-card-header">
<h4 class="tool-card-title">Metadata Updater</h4>
<button class="tool-help-button" data-tool="metadata-updater"
title="Learn more about this tool">?</button>
</div>
<p class="metadata-updater-description tool-card-info">Updates artist photos, genres,
and album art from Spotify.</p>
<div class="tool-card-controls">
<select id="metadata-refresh-interval">
<option value="180">6 months</option>
<option value="90">3 months</option>
<option value="30" selected>1 month</option>
<option value="14">2 weeks</option>
<option value="7">1 week</option>
<option value="0">Full refresh</option>
</select>
<button id="metadata-update-button">Begin Update</button>
</div>
<div class="tool-card-progress-section">
<p class="progress-phase-label" id="metadata-phase-label">Current Artist: Not
running</p>
<div class="progress-bar-container">
<div class="progress-bar-fill" id="metadata-progress-bar" style="width: 0%;">
</div>
</div>
<p class="progress-details-label" id="metadata-progress-label">0 / 0 artists (0.0%)
</p>
</div>
</div>
<div class="tool-card" id="discovery-pool-card">
<div class="tool-card-header">
@ -6707,45 +6776,6 @@
<button onclick="openManualLibraryMatchTool()">Open Library Match</button>
</div>
</div>
<div class="tool-card" id="retag-tool-card">
<div class="tool-card-header">
<h4 class="tool-card-title">Retag Tool</h4>
<button class="tool-help-button" data-tool="retag-tool"
title="Learn more about this tool">?</button>
</div>
<p class="tool-card-info">Fix metadata on previously downloaded albums &amp; singles</p>
<div class="tool-card-stats">
<div class="stat-item">
<span class="stat-item-label">Groups:</span>
<span class="stat-item-value" id="retag-stat-groups">0</span>
</div>
<div class="stat-item">
<span class="stat-item-label">Tracks:</span>
<span class="stat-item-value" id="retag-stat-tracks">0</span>
</div>
<div class="stat-item">
<span class="stat-item-label">Artists:</span>
<span class="stat-item-value" id="retag-stat-artists">0</span>
</div>
<div class="stat-item">
<span class="stat-item-label">Status:</span>
<span class="stat-item-value" id="retag-stat-status">Idle</span>
</div>
</div>
<div class="tool-card-controls">
<button id="retag-open-button">Open Retag Tool</button>
</div>
<div class="tool-card-progress-section">
<p class="progress-phase-label" id="retag-phase-label">Ready</p>
<div class="progress-bar-container">
<div class="progress-bar-fill" id="retag-progress-bar" style="width: 0%;">
</div>
</div>
<p class="progress-details-label" id="retag-progress-label">0 / 0 tracks (0.0%)</p>
</div>
</div>
</div></div>
<!-- ── Management ── -->
@ -7788,40 +7818,6 @@
<!-- Tool Help Modal -->
<!-- Retag Tool Modal -->
<div class="retag-modal-overlay" id="retag-modal">
<div class="retag-modal-container">
<div class="retag-modal-header">
<div class="retag-modal-header-left">
<h2 class="retag-modal-title">Retag Tool</h2>
</div>
<div class="retag-header-actions">
<button class="retag-clear-all-btn" id="retag-clear-all-btn" onclick="clearAllRetagGroups(this)">Clear All</button>
<button class="retag-modal-close" onclick="closeRetagModal()">&times;</button>
</div>
</div>
<div class="retag-batch-bar" id="retag-batch-bar" style="display: none;">
<span class="retag-batch-count" id="retag-batch-count">0 selected</span>
<button class="retag-batch-remove-btn" onclick="batchRemoveRetagGroups()">Remove Selected</button>
</div>
<div class="retag-modal-body" id="retag-modal-body">
<div class="retag-loading">Loading downloads...</div>
</div>
</div>
</div>
<!-- Retag Search Sub-Modal -->
<div class="retag-search-overlay" id="retag-search-modal">
<div class="retag-search-container">
<div class="retag-search-header">
<h3 class="retag-search-title" id="retag-search-title">Search for Correct Album</h3>
<button class="retag-search-close" onclick="closeRetagSearch()">&times;</button>
</div>
<div class="retag-search-input-section">
<input type="text" id="retag-search-input" placeholder="Search albums..." autocomplete="off">
</div>
<div class="retag-search-results" id="retag-search-results"></div>
</div>
</div>
<div class="tool-help-modal" id="tool-help-modal" onclick="if(event.target===this)closeToolHelpModal()">
<div class="tool-help-modal-content">

View file

@ -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

View file

@ -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));

View file

@ -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}`);

View file

@ -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 isnt configured — add its credentials in Settings. '
+ 'Browsing works, but matches and retries wont run until its 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 || {};

View file

@ -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 += `<div class="repair-finding-meta" style="margin-bottom:8px">${_escFinding(meta.join(' · '))}</div>`;
}
if (!changed.length && d.cover_action) {
html += `<div class="repair-finding-desc">Tags already correct — this would refresh cover art only.</div>`;
}
// 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 += `<div style="margin:8px 0 2px;font-weight:600">${_escFinding(label)}</div>`;
html += _gridRows(rows);
});
if (changed.length > 40) {
html += `<div class="repair-finding-meta" style="margin-top:6px">…and ${changed.length - 40} more track(s)</div>`;
}
if (Array.isArray(d.unmatched) && d.unmatched.length) {
html += `<div class="repair-finding-meta" style="margin-top:8px">Unmatched (left untouched): ${_escFinding(d.unmatched.join(', '))}</div>`;
}
return html || '<div class="repair-finding-desc">No changes.</div>';
}
case 'acoustid_mismatch': {
let html = media + '<div style="margin-bottom:8px">';
html += _renderScoreBar(d.fingerprint_score, 'Fingerprint');

View file

@ -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.",

View file

@ -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 <audio> element, ALL of its
// output is routed through this AudioContext — so if the context is suspended
// (browsers create it suspended under the autoplay policy, and we init it from
// an async play().then callback that's outside the gesture), the track plays
// but no sound reaches the speakers. Resuming must happen on every play start,
// not only when the visualizer loop runs. Safe to call anytime.
function npEnsureAudioContextRunning() {
try {
if (npAudioContext && npAudioContext.state === 'suspended') {
npAudioContext.resume().catch(() => {});
}
} catch (_) { /* no-op */ }
}
function npInitVisualizer() {
if (npVizInitialized || !audioPlayer) return;
try {
@ -2676,6 +2695,9 @@ function npInitVisualizer() {
npAnalyser.connect(npAudioContext.destination);
}
npVizInitialized = true;
// Freshly created contexts start suspended — resume so the rerouted
// element is actually audible (this is the no-sound-on-play bug).
npEnsureAudioContextRunning();
} catch (e) {
console.warn('Web Audio visualizer init failed, using CSS fallback:', e.message);
// Mark as CSS fallback

View file

@ -109,7 +109,8 @@
}
#artist-hero-section #artist-detail-image {
width: 100% !important
width: 100% !important;
max-width: none !important;
}
#media-player {
@ -127,6 +128,7 @@
width: 100%;
flex-direction: row;
flex-wrap: wrap;
justify-content: center;
}
.enh-compact-item.artist-card {
@ -1039,22 +1041,16 @@
gap: 16px;
}
.artist-image-container {
width: 100px;
height: 100px;
}
.artist-image,
.artist-image-fallback {
width: 100px;
height: 100px;
}
.artist-info h1,
.artist-name {
font-size: 24px !important;
white-space: normal;
.artist-hero-section .artist-name {
font-size: 1.6em;
text-align: center;
display: block;
}
.artist-hero-badges {
@ -1076,7 +1072,7 @@
.artist-hero-bio {
font-size: 12px;
-webkit-line-clamp: 3;
max-height: 54px;
max-height: fit-content;
}
.artist-hero-numbers {
@ -1186,7 +1182,7 @@
}
.discover-card {
width: 160px;
width: 45%;
}
/* Horizontal scroll tabs */
@ -3079,10 +3075,14 @@
/* Notification panel */
.notif-panel {
width: calc(100vw - 24px);
max-width: 100%;
right: 12px;
bottom: 62px;
/* JS positions this inline from the bell's rect (right/bottom), which on
mobile pushes it off-screen (the bell isn't flush right). Override the
inline styles with !important and anchor both sides so it always fits. */
left: 12px !important;
right: 12px !important;
width: auto !important;
max-width: none !important;
bottom: 62px !important;
max-height: 55vh;
border-radius: 14px;
}
@ -3190,9 +3190,10 @@
}
.notif-panel {
right: 8px;
width: calc(100vw - 16px);
bottom: 54px;
left: 8px !important;
right: 8px !important;
width: auto !important;
bottom: 54px !important;
}
}
@ -3310,3 +3311,225 @@
padding: 9px 12px;
}
}
/* ============================================================================
Mobile fixes (v2) phone-only, all inside max-width:768px. Desktop untouched.
============================================================================ */
@media (max-width: 768px) {
/* Center the artist hero action buttons + match-status chips on phone
(moved here from the base rule so desktop stays left-aligned). */
.artist-hero-actions { justify-content: center; }
.enhanced-match-status-row { justify-content: center; }
/* #8 Page-container heroes carry desktop-sized padding/margins that waste
space and look off on a phone. Tighten them up. */
.tools-maintenance-hero,
.watchlist-artist-config-hero,
.watchlist-detail-hero,
.discover-hero {
padding: 14px 14px;
margin-bottom: 16px;
}
/* #9 Sync page header: title + the three action buttons (Auto-Sync / Library
Match / Sync History) don't fit side-by-side. Stack the header and let the
buttons wrap so all three are reachable. */
.sync-header-row {
flex-direction: column;
align-items: stretch;
gap: 12px;
}
.sync-header-row > div:last-child {
flex-wrap: wrap;
}
.sync-header-row .sync-history-btn {
flex: 1 1 auto;
}
}
@media (max-width: 768px) {
/* #6 Enhanced-view track table: a 6+ column table can't fit a phone and the
panel clips the overflow, so only the first column showed. Drop table
layout entirely and make each row a single flex line: play · title (takes
the space) · duration · the actions button. Secondary columns hidden. */
.enhanced-track-table,
.enhanced-track-table thead,
.enhanced-track-table tbody,
.enhanced-track-table tr,
.enhanced-track-table td,
.enhanced-track-table th {
display: block;
width: auto;
}
.enhanced-track-table thead { display: none; }
.enhanced-track-table tr {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 6px;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
}
.enhanced-track-table td,
.enhanced-track-table th {
padding: 0;
border: none;
white-space: normal;
}
.enhanced-track-table .col-play {
flex: 0 0 auto;
width: auto;
}
.enhanced-track-table .col-title {
flex: 1 1 auto;
min-width: 0;
word-break: break-word;
font-size: 13px;
}
.enhanced-track-table .col-duration {
flex: 0 0 auto;
width: auto;
font-size: 11px;
opacity: 0.65;
}
.enhanced-track-table .col-mobile-actions {
display: block;
flex: 0 0 auto;
width: auto;
text-align: right;
}
/* Everything else folds into the ⋯ actions sheet — hide on the row. */
.enhanced-track-table .col-num,
.enhanced-track-table .col-disc,
.enhanced-track-table .col-format,
.enhanced-track-table .col-bitrate,
.enhanced-track-table .col-bpm,
.enhanced-track-table .col-path,
.enhanced-track-table .col-match,
.enhanced-track-table .col-queue,
.enhanced-track-table .col-writetag,
.enhanced-track-table .col-delete,
.enhanced-track-table .col-report,
.enhanced-track-table tr > th:first-child {
display: none;
}
}
@media (max-width: 768px) {
/* #7 Mini media player: on desktop it's a 340px widget pinned bottom-right
(right:132px) that overflows a phone and overlaps the bottom search.
Make it a full-width bar that sits just ABOVE the global search bar
(which is at bottom:16px, height:38px on mobile). */
.media-player {
left: 10px;
right: 10px;
width: auto;
bottom: 62px;
margin: 0;
}
}
@media (max-width: 768px) {
/* Now Playing modal full responsive pass
Desktop is a two-column (art | controls) layout with heavy 40-48px
padding. Existing mobile rules made it full-screen + stacked the body but
left the panels' desktop padding and the controls/queue/lyrics untouched. */
.np-body {
padding: 24px 16px 28px;
gap: 20px;
}
.np-left,
.np-right {
width: 100%;
}
/* Album art scales with the screen instead of a fixed 220px. */
.np-album-art-container {
width: min(220px, 66vw);
height: auto;
aspect-ratio: 1 / 1;
margin: 0 auto;
}
.np-track-info { text-align: center; }
.np-action-buttons { justify-content: center; }
.np-util-row { justify-content: center; flex-wrap: wrap; }
/* Playback controls: tighten the gap so the row fits a phone. */
.np-controls-row { gap: 12px; justify-content: center; }
/* Queue + lyrics panels: drop the 40px desktop side padding that crushed
content on a narrow screen; give them a bit more vertical room. */
.np-queue-panel { padding: 0 16px 16px; }
.np-lyrics-panel { padding: 0 16px; }
.np-queue-body { max-height: 240px; }
.np-lyrics-body { max-height: 300px; }
}
/* When the expanded Now Playing modal is open, hide the floating mini-player
it has a higher z-index (99998) than the modal overlay (10001), so it would
otherwise float on top of the modal's content. Both are body-level siblings
(#media-player follows #np-modal-overlay), and the overlay drops its .hidden
class when open. Not width-scoped applies on desktop too. */
.np-modal-overlay:not(.hidden) ~ #media-player {
display: none !important;
}
@media (max-width: 768px) {
/* Recommended-artist carousel cards 2-up wrap on mobile. The base rule
pins them with flex:0 0 160px, which overrides plain width so override
the flex-basis too for the 45% to take. */
.recommended-card--carousel {
flex: 0 0 45%;
width: 45%;
}
}
@media (max-width: 768px) {
/* Downloads page (.adl-*)
Had ZERO mobile rules. Desktop is a two-column layout: the main list
(.adl-main, flex:1) + a fixed 366px batch panel with a left border that
scrolls independently. On a phone that doesn't fit stack it. */
.adl-layout {
flex-direction: column;
gap: 16px;
}
.adl-batch-panel {
width: 100%;
flex-shrink: 1;
border-left: none;
border-top: 1px solid rgba(255, 255, 255, 0.06);
padding: 14px 4px 0;
overflow-y: visible; /* grows in the page flow instead of own scroll */
}
/* Header: title + controls sat in one row; stack so the pills get full width. */
.adl-header {
flex-direction: column;
align-items: stretch;
gap: 10px;
}
.adl-title { font-size: 1.3rem; }
.adl-controls {
flex-direction: column;
align-items: stretch;
gap: 8px;
}
/* Filter pills (All / Active / Queued / Completed / Failed) + count +
Cancel All / Clear Completed: wrap instead of overflowing off-screen. */
.adl-filter-pills {
flex-wrap: wrap;
justify-content: flex-start;
}
.adl-pill { padding: 6px 12px; }
/* Action buttons full-width-friendly so they're tappable. */
.adl-cancel-all-btn,
.adl-clear-btn { flex: 1 1 auto; }
}
@media (max-width: 768px) {
/* Sidebar visualizer is pinned at the (desktop) sidebar's right edge, so on
mobile it floats over the page content when the off-canvas sidebar drawer
is closed. Show it ONLY while the drawer is open. The .active/.viz-* rules
set display with 2-class specificity, so this 3-class + !important selector
is needed to beat them; when the drawer IS open it doesn't match, so the
visualizer's normal display rules take over again. */
.sidebar:not(.mobile-open) ~ .sidebar-visualizer {
display: none !important;
}
}

View file

@ -1066,18 +1066,24 @@ function explorerFitToView() {
});
}
// Scroll wheel zoom (no modifier needed inside viewport)
document.addEventListener('wheel', (e) => {
// Scroll wheel zoom (no modifier needed inside viewport).
// IMPORTANT: attach to the viewport element, NOT document. A non-passive wheel
// listener on document disables the browser's compositor (async) scrolling for
// the ENTIRE app — every wheel/trackpad scroll then runs through the main thread.
// Scoping it to the viewport keeps zoom working while the rest of the app keeps
// smooth compositor scrolling.
(function attachExplorerWheelZoom() {
const viewport = document.getElementById('explorer-viewport');
if (!viewport || !viewport.contains(e.target)) return;
// Check if we're on the explorer page
const page = document.getElementById('playlist-explorer-page');
if (!page || !page.classList.contains('active')) return;
if (!viewport) return;
viewport.addEventListener('wheel', (e) => {
const page = document.getElementById('playlist-explorer-page');
if (!page || !page.classList.contains('active')) return;
e.preventDefault();
const step = e.deltaY > 0 ? -0.08 : 0.08;
explorerZoom(step);
}, { passive: false });
e.preventDefault();
const step = e.deltaY > 0 ? -0.08 : 0.08;
explorerZoom(step);
}, { passive: false });
})();
// Middle-click / right-click drag to pan
document.addEventListener('mousedown', (e) => {
@ -1836,6 +1842,9 @@ async function _serverSelectTrack(trackIndex, mode, newTrackId, el) {
source_track_id: srcTrack.source_track_id || '',
source_title: srcTrack.name || '',
source_artist: srcTrack.artist || '',
// Provider of the source track, so the durable manual match
// (#787) records the right source. Retrieval is source-agnostic.
source: srcTrack.source || _serverEditorState.mirroredPlaylist?.source || '',
})
});
}

View file

@ -271,6 +271,79 @@ function initializeSearchModeToggle() {
});
}
// ── Link / ID lookup (#775) ──────────────────────────────────────
// Paste a provider link or bare ID; the backend resolves it directly
// on the owning source (no fuzzy search) and returns the single hit
// plus that source. We adopt the resolved source as the active one so
// the existing album re-fetch + download/import flow routes correctly,
// then render through the same dropdown path as a normal search.
const idInput = document.getElementById('enh-id-input');
const idBtn = document.getElementById('enh-id-btn');
async function submitIdLookup() {
if (!idInput) return;
const raw = idInput.value.trim();
if (!raw) return;
// Show the controller's loading UI while resolving.
if (loadingState) {
loadingState.classList.remove('hidden');
const loadingText = document.getElementById('enhanced-loading-text');
if (loadingText) loadingText.textContent = 'Resolving link…';
}
if (emptyState) emptyState.classList.add('hidden');
if (resultsContainer) resultsContainer.classList.add('hidden');
showDropdown();
let data;
try {
const resp = await fetch('/api/enhanced-search/by-id', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query: raw }),
});
data = await resp.json();
} catch (err) {
console.error('Link/ID lookup failed:', err);
if (loadingState) loadingState.classList.add('hidden');
if (typeof showToast === 'function') showToast('Link/ID lookup failed.', 'error');
return;
}
if (data && data.available && data.source) {
// Adopt the resolving source as active, seed its cache with the
// single hit, and render via the shared state-driven path.
searchController.state.activeSource = data.source;
searchController.state.query = raw;
searchController.state.sources[data.source] = {
db_artists: [],
artists: data.artists || [],
albums: data.albums || [],
tracks: data.tracks || [],
};
if (typeof searchController.renderSourceRow === 'function') {
searchController.renderSourceRow();
}
_renderFromState(searchController.state);
} else {
// Not a link, or nothing resolved — surface the backend's hint
// via a toast (non-destructive) and show the empty state.
if (loadingState) loadingState.classList.add('hidden');
if (resultsContainer) resultsContainer.classList.add('hidden');
if (emptyState) emptyState.classList.remove('hidden');
showDropdown();
const msg = (data && data.message) || 'No match for that link.';
if (typeof showToast === 'function') showToast(msg, 'warning');
}
}
if (idBtn) idBtn.addEventListener('click', submitIdLookup);
if (idInput) {
idInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') submitIdLookup();
});
}
// Close button inside dropdown (mobile)
const dropdownCloseBtn = document.getElementById('enhanced-dropdown-close');
if (dropdownCloseBtn) {

View file

@ -64,8 +64,14 @@ function syncMetadataSourceSelection(source) {
function _isMetadataSourceSelectable(source) {
if (source === 'spotify') {
// Official Spotify needs a connected session.
return _lastStatusPayload?.spotify?.authenticated === true;
}
if (source === 'spotify_free') {
// No-creds Spotify only needs the SpotipyFree package installed —
// selecting it IS the opt-in, so it must NOT depend on having selected it.
return _lastStatusPayload?.spotify?.free_installed === true;
}
if (source === 'discogs') {
const token = document.getElementById('discogs-token');
return !!token?.value?.trim();
@ -143,6 +149,25 @@ function handleMetadataSourceChange(event) {
}
let _settingsInitialized = false;
// Tell password-manager extensions (Bitwarden / 1Password / LastPass) to ignore
// this app's credential inputs. The settings page is full of API-key / token /
// secret fields; password managers treat them as login forms and re-scan the
// whole (large, constantly-mutating) DOM on every change, which can peg the main
// thread for seconds. These attributes make them skip the fields entirely.
function _markCredentialFieldsNoAutofill(root) {
const scope = root || document;
scope.querySelectorAll('input, textarea').forEach((el) => {
if (el.dataset.bwignore !== undefined) return; // already tagged
el.setAttribute('data-bwignore', ''); // Bitwarden
el.setAttribute('data-1p-ignore', ''); // 1Password
el.setAttribute('data-lpignore', 'true'); // LastPass
if (!el.getAttribute('autocomplete')) el.setAttribute('autocomplete', 'off');
});
}
// Run once on load (inputs exist from page load — all pages are mounted).
if (document.readyState !== 'loading') _markCredentialFieldsNoAutofill();
else document.addEventListener('DOMContentLoaded', () => _markCredentialFieldsNoAutofill());
function initializeSettings() {
// This function is called when the settings page is loaded.
// It attaches event listeners to all interactive elements on the page.
@ -151,6 +176,9 @@ function initializeSettings() {
if (_settingsInitialized) return;
_settingsInitialized = true;
// Re-tag in case any inputs were added dynamically since page load.
_markCredentialFieldsNoAutofill(document.getElementById('settings-page'));
// Accent color listeners (live preview + custom picker toggle)
initAccentColorListeners();
@ -1021,8 +1049,13 @@ async function loadSettingsData() {
// Populate Discogs settings
document.getElementById('discogs-token').value = settings.discogs?.token || '';
// Populate Metadata source setting
document.getElementById('metadata-fallback-source').value = settings.metadata?.fallback_source || 'deezer';
// Populate Metadata source setting. 'Spotify Free' is stored as
// fallback_source='spotify' + spotify_free=true (so all downstream
// 'spotify' routing is unchanged) — map it back to the dropdown value.
const _fbSrc = settings.metadata?.fallback_source || 'deezer';
const _metaSel = (_fbSrc === 'spotify' && settings.metadata?.spotify_free === true)
? 'spotify_free' : _fbSrc;
document.getElementById('metadata-fallback-source').value = _metaSel;
// Populate Hydrabase settings
const hbConfig = settings.hydrabase || {};
@ -1175,6 +1208,8 @@ async function loadSettingsData() {
// Populate Playlist Sync settings
document.getElementById('create-backup').checked = settings.playlist_sync?.create_backup !== false;
const _syncModeEl = document.getElementById('playlist-sync-mode');
if (_syncModeEl) _syncModeEl.value = settings.playlist_sync?.mode || 'replace';
// Populate Post-Download Conversion settings
document.getElementById('downsample-hires').checked = settings.lossy_copy?.downsample_hires === true;
@ -2731,12 +2766,19 @@ async function saveSettings(quiet = false) {
const discogsTokenPresent = !!discogsTokenInput?.value?.trim();
let metadataSource = metadataSourceSelect?.value || 'deezer';
const spotifySessionActive = _lastStatusPayload?.spotify?.authenticated === true;
const spotifyFreeInstalled = _lastStatusPayload?.spotify?.free_installed === true;
if (metadataSource === 'spotify' && !spotifySessionActive) {
metadataSource = _metadataSourceFallback('spotify');
if (metadataSourceSelect) metadataSourceSelect.value = metadataSource;
if (!quiet) {
showToast('Spotify is disconnected, so the primary metadata source was switched.', 'warning');
}
} else if (metadataSource === 'spotify_free' && !spotifyFreeInstalled) {
metadataSource = _metadataSourceFallback('spotify_free');
if (metadataSourceSelect) metadataSourceSelect.value = metadataSource;
if (!quiet) {
showToast('Spotify Free needs the SpotipyFree package installed.', 'warning');
}
} else if (metadataSource === 'discogs' && !discogsTokenPresent) {
metadataSource = _metadataSourceFallback('discogs');
if (metadataSourceSelect) metadataSourceSelect.value = metadataSource;
@ -2818,7 +2860,10 @@ async function saveSettings(quiet = false) {
token: document.getElementById('discogs-token').value,
},
metadata: {
fallback_source: metadataSource
// 'Spotify Free' is stored as the spotify source + a flag, so all
// downstream 'spotify' routing is unchanged.
fallback_source: metadataSource === 'spotify_free' ? 'spotify' : metadataSource,
spotify_free: metadataSource === 'spotify_free'
},
hydrabase: {
url: document.getElementById('hydrabase-url').value,
@ -2938,7 +2983,8 @@ async function saveSettings(quiet = false) {
allow_duplicate_tracks: document.getElementById('allow-duplicate-tracks').checked
},
playlist_sync: {
create_backup: document.getElementById('create-backup').checked
create_backup: document.getElementById('create-backup').checked,
mode: document.getElementById('playlist-sync-mode')?.value || 'replace'
},
content_filter: {
allow_explicit: document.getElementById('allow-explicit').checked

View file

@ -110,6 +110,10 @@ async function fetchSourceConfiguredMap() {
for (const src of SOURCE_ORDER) {
if (_ALWAYS_CONFIGURED_SOURCES.has(src)) {
map[src] = true;
} else if (src === 'spotify') {
// Spotify Free: available without credentials when the
// opt-in no-creds source is on (metadata_available).
map[src] = !!(data[src] && (data[src].configured || data[src].metadata_available));
} else {
map[src] = !!(data[src] && data[src].configured);
}
@ -1265,8 +1269,10 @@ function playlistModalDownloadSyncFooterHtml(playlistId, options = {}) {
}
return `${downloadBtns}
<select id="sync-mode-${playlistId}" class="playlist-modal-sync-mode" title="Replace overwrites the server playlist; Append only adds new tracks (preserves user-added)">
<option value="replace" selected>Replace</option>
<select id="sync-mode-${playlistId}" class="playlist-modal-sync-mode" title="Default uses your Settings > Playlist sync mode. Replace overwrites the server playlist; Reconcile edits it in place (keeps custom image/description); Append only adds new tracks.">
<option value="" selected>Sync mode: default</option>
<option value="replace">Replace</option>
<option value="reconcile">Reconcile (keep image/desc)</option>
<option value="append">Append only</option>
</select>
<button id="sync-btn-${playlistId}" class="playlist-modal-btn playlist-modal-btn-primary" onclick="startPlaylistSync('${playlistId}')" ${isSyncing ? 'disabled' : ''}>${isSyncing ? '⏳ Syncing...' : 'Sync Playlist'}</button>`;
@ -1477,6 +1483,10 @@ async function openDownloadMissingModalForArtistAlbum(virtualPlaylistId, playlis
<input type="checkbox" id="force-download-all-${virtualPlaylistId}">
<span>Force Download All</span>
</label>
<label class="force-download-toggle" title="Skip the AcoustID fingerprint check for this request. Useful for non-English artists whose original-language metadata AcoustID can't match against the romanized request (issue #797).">
<input type="checkbox" id="skip-acoustid-${virtualPlaylistId}">
<span>Skip AcoustID verification</span>
</label>
${contextType === 'playlist' ? `
<label class="force-download-toggle">
<input type="checkbox" id="playlist-folder-mode-${virtualPlaylistId}">

Some files were not shown because too many files have changed in this diff Show more