commit
04de4b61c4
102 changed files with 7889 additions and 1384 deletions
4
.github/workflows/docker-publish.yml
vendored
4
.github/workflows/docker-publish.yml
vendored
|
|
@ -9,9 +9,9 @@ on:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
version_tag:
|
version_tag:
|
||||||
description: 'Version tag (e.g. 2.6.4)'
|
description: 'Version tag (e.g. 2.6.7)'
|
||||||
required: true
|
required: true
|
||||||
default: '2.6.4'
|
default: '2.6.7'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-and-push:
|
build-and-push:
|
||||||
|
|
|
||||||
|
|
@ -620,7 +620,13 @@ class ConfigManager:
|
||||||
"embed_tags": True
|
"embed_tags": True
|
||||||
},
|
},
|
||||||
"playlist_sync": {
|
"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": {
|
"settings": {
|
||||||
"audio_quality": "flac"
|
"audio_quality": "flac"
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ from utils.logging_config import get_logger
|
||||||
from core.acoustid_client import AcoustIDClient
|
from core.acoustid_client import AcoustIDClient
|
||||||
from core.matching_engine import MusicMatchingEngine
|
from core.matching_engine import MusicMatchingEngine
|
||||||
from core.matching.version_mismatch import is_acceptable_version_mismatch
|
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
|
from core.musicbrainz_client import MusicBrainzClient
|
||||||
|
|
||||||
logger = get_logger("acoustid.verification")
|
logger = get_logger("acoustid.verification")
|
||||||
|
|
@ -655,10 +656,32 @@ class AcoustIDVerification:
|
||||||
and title_sim >= 0.80
|
and title_sim >= 0.80
|
||||||
and artist_sim >= ARTIST_MATCH_THRESHOLD
|
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 = (
|
reason = (
|
||||||
"likely same song in different language/script"
|
"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"
|
else "title/artist match within tolerance"
|
||||||
)
|
)
|
||||||
msg = (
|
msg = (
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,11 @@ def find_library_artist_for_source(
|
||||||
|
|
||||||
Lookup order:
|
Lookup order:
|
||||||
1. Direct match on the source-specific ID column (server-agnostic — any
|
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
|
2. Case-insensitive name match within ``active_server`` (defaults to the
|
||||||
active media server when not provided), so we don't jump the user
|
active media server when not provided), so we don't jump the user
|
||||||
across server contexts on a name collision.
|
across server contexts on a name collision.
|
||||||
|
|
@ -67,13 +71,23 @@ def find_library_artist_for_source(
|
||||||
try:
|
try:
|
||||||
with database._get_connection() as conn:
|
with database._get_connection() as conn:
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
|
# LIMIT 2 so we can tell a unique match from an ambiguous one.
|
||||||
cursor.execute(
|
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),),
|
(str(source_artist_id),),
|
||||||
)
|
)
|
||||||
row = cursor.fetchone()
|
rows = cursor.fetchall()
|
||||||
if row:
|
if len(rows) == 1:
|
||||||
return row[0]
|
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:
|
if artist_name and active_server:
|
||||||
cursor.execute(
|
cursor.execute(
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ from datetime import datetime, timedelta
|
||||||
from utils.logging_config import get_logger
|
from utils.logging_config import get_logger
|
||||||
from database.music_database import MusicDatabase
|
from database.music_database import MusicDatabase
|
||||||
from core.audiodb_client import AudioDBClient
|
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")
|
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:
|
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.
|
"""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),
|
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')
|
parent_audiodb_id = item.get('artist_audiodb_id')
|
||||||
if not parent_audiodb_id:
|
if not parent_audiodb_id:
|
||||||
return True
|
return True
|
||||||
|
|
@ -284,6 +289,18 @@ class AudioDBWorker:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
if str(result_artist_id) != str(parent_audiodb_id):
|
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(
|
logger.info(
|
||||||
f"Artist ID correction from {item['type']} '{item['name']}': "
|
f"Artist ID correction from {item['type']} '{item['name']}': "
|
||||||
f"updating parent artist AudioDB ID from {parent_audiodb_id} to {result_artist_id}"
|
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)
|
result = self.client.search_artist(item_name)
|
||||||
if result:
|
if result:
|
||||||
result_name = result.get('strArtist', '')
|
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._update_artist(item_id, result)
|
||||||
self.stats['matched'] += 1
|
self.stats['matched'] += 1
|
||||||
logger.info(f"Matched artist '{item_name}' -> AudioDB ID: {result.get('idArtist')}")
|
logger.info(f"Matched artist '{item_name}' -> AudioDB ID: {result.get('idArtist')}")
|
||||||
else:
|
else:
|
||||||
self._mark_status('artist', item_id, 'not_found')
|
self._mark_status('artist', item_id, 'not_found')
|
||||||
self.stats['not_found'] += 1
|
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:
|
else:
|
||||||
self._mark_status('artist', item_id, 'not_found')
|
self._mark_status('artist', item_id, 'not_found')
|
||||||
self.stats['not_found'] += 1
|
self.stats['not_found'] += 1
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,19 @@ class DatabaseUpdateWorker:
|
||||||
self.database_path = database_path
|
self.database_path = database_path
|
||||||
self.full_refresh = full_refresh
|
self.full_refresh = full_refresh
|
||||||
self.should_stop = False
|
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
|
# Statistics tracking
|
||||||
self.processed_artists = 0
|
self.processed_artists = 0
|
||||||
self.processed_albums = 0
|
self.processed_albums = 0
|
||||||
|
|
@ -79,7 +91,26 @@ class DatabaseUpdateWorker:
|
||||||
callback(*args)
|
callback(*args)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error in callback for {signal_name}: {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):
|
def connect_callback(self, signal_name: str, callback: Callable):
|
||||||
"""Connect a callback for progress notifications."""
|
"""Connect a callback for progress notifications."""
|
||||||
self.callbacks.setdefault(signal_name, []).append(callback)
|
self.callbacks.setdefault(signal_name, []).append(callback)
|
||||||
|
|
@ -146,7 +177,7 @@ class DatabaseUpdateWorker:
|
||||||
logger.info(f"Merged {merged} duplicate artists")
|
logger.info(f"Merged {merged} duplicate artists")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Could not merge duplicate artists: {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
|
return
|
||||||
logger.info(f"Incremental update: Found {len(artists_to_process)} artists to process")
|
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
|
self.removed_tracks = removal.get('tracks_removed', 0) if removal else 0
|
||||||
|
|
||||||
# Emit final results
|
# Emit final results
|
||||||
self._emit_signal('finished',
|
self._emit_finished(
|
||||||
self.processed_artists,
|
self.processed_artists,
|
||||||
self.processed_albums,
|
self.processed_albums,
|
||||||
self.processed_tracks,
|
self.processed_tracks,
|
||||||
|
|
@ -331,7 +362,7 @@ class DatabaseUpdateWorker:
|
||||||
f"{self.processed_albums} albums, {self.processed_tracks} new tracks, "
|
f"{self.processed_albums} albums, {self.processed_tracks} new tracks, "
|
||||||
f"{stale_removed} stale tracks removed")
|
f"{stale_removed} stale tracks removed")
|
||||||
|
|
||||||
self._emit_signal('finished',
|
self._emit_finished(
|
||||||
self.processed_artists,
|
self.processed_artists,
|
||||||
self.processed_albums,
|
self.processed_albums,
|
||||||
self.processed_tracks,
|
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)
|
track_success = self.database.insert_or_update_media_track(track, album_id, artist_id, server_source=self.server_type)
|
||||||
if track_success:
|
if track_success:
|
||||||
total_processed_tracks += 1
|
total_processed_tracks += 1
|
||||||
|
if track_success == 'inserted':
|
||||||
|
self._new_track_ids.add(str(track.ratingKey))
|
||||||
logger.debug(f"Processed new track: {track.title}")
|
logger.debug(f"Processed new track: {track.title}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Failed to process track '{getattr(track, 'title', 'Unknown')}': {e}")
|
logger.warning(f"Failed to process track '{getattr(track, 'title', 'Unknown')}': {e}")
|
||||||
|
|
@ -1344,6 +1377,8 @@ class DatabaseUpdateWorker:
|
||||||
skipped_count += 1
|
skipped_count += 1
|
||||||
elif track_success:
|
elif track_success:
|
||||||
track_count += 1
|
track_count += 1
|
||||||
|
if track_success == 'inserted':
|
||||||
|
self._new_track_ids.add(track_id_str)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Failed to process track '{getattr(track, 'title', 'Unknown')}': {e}")
|
logger.warning(f"Failed to process track '{getattr(track, 'title', 'Unknown')}': {e}")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ from datetime import datetime, timedelta
|
||||||
from utils.logging_config import get_logger
|
from utils.logging_config import get_logger
|
||||||
from database.music_database import MusicDatabase
|
from database.music_database import MusicDatabase
|
||||||
from core.deezer_client import DeezerClient
|
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
|
from core.enrichment.manual_match_honoring import honor_stored_match
|
||||||
|
|
||||||
logger = get_logger("deezer_worker")
|
logger = get_logger("deezer_worker")
|
||||||
|
|
@ -278,10 +278,19 @@ class DeezerWorker:
|
||||||
logger.debug(f"Name similarity: '{query_name}' vs '{result_name}' = {similarity:.2f}")
|
logger.debug(f"Name similarity: '{query_name}' vs '{result_name}' = {similarity:.2f}")
|
||||||
return similarity >= self.name_similarity_threshold
|
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.
|
"""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),
|
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')
|
parent_deezer_id = item.get('artist_deezer_id')
|
||||||
if not parent_deezer_id:
|
if not parent_deezer_id:
|
||||||
return True
|
return True
|
||||||
|
|
@ -290,6 +299,20 @@ class DeezerWorker:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
if str(result_artist_id) != str(parent_deezer_id):
|
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(
|
logger.info(
|
||||||
f"Artist ID correction from {item['type']} '{item['name']}': "
|
f"Artist ID correction from {item['type']} '{item['name']}': "
|
||||||
f"updating parent artist Deezer ID from {parent_deezer_id} to {result_artist_id}"
|
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)
|
result = self.client.search_artist(artist_name)
|
||||||
if result:
|
if result:
|
||||||
result_name = result.get('name', '')
|
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._update_artist(artist_id, result)
|
||||||
self.stats['matched'] += 1
|
self.stats['matched'] += 1
|
||||||
logger.info(f"Matched artist '{artist_name}' -> Deezer ID: {result.get('id')}")
|
logger.info(f"Matched artist '{artist_name}' -> Deezer ID: {result.get('id')}")
|
||||||
else:
|
else:
|
||||||
self._mark_status('artist', artist_id, 'not_found')
|
self._mark_status('artist', artist_id, 'not_found')
|
||||||
self.stats['not_found'] += 1
|
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:
|
else:
|
||||||
self._mark_status('artist', artist_id, 'not_found')
|
self._mark_status('artist', artist_id, 'not_found')
|
||||||
self.stats['not_found'] += 1
|
self.stats['not_found'] += 1
|
||||||
|
|
@ -430,7 +457,8 @@ class DeezerWorker:
|
||||||
# Verify artist ID
|
# Verify artist ID
|
||||||
result_artist = result.get('artist', {})
|
result_artist = result.get('artist', {})
|
||||||
result_artist_id = result_artist.get('id') if result_artist else None
|
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
|
# Fetch full album details for label, genres, explicit
|
||||||
deezer_album_id = result.get('id')
|
deezer_album_id = result.get('id')
|
||||||
|
|
@ -481,7 +509,8 @@ class DeezerWorker:
|
||||||
# Verify artist ID
|
# Verify artist ID
|
||||||
result_artist = result.get('artist', {})
|
result_artist = result.get('artist', {})
|
||||||
result_artist_id = result_artist.get('id') if result_artist else None
|
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
|
# Fetch full track details for BPM
|
||||||
deezer_track_id = result.get('id')
|
deezer_track_id = result.get('id')
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ from datetime import datetime, timedelta
|
||||||
from utils.logging_config import get_logger
|
from utils.logging_config import get_logger
|
||||||
from database.music_database import MusicDatabase
|
from database.music_database import MusicDatabase
|
||||||
from core.discogs_client import DiscogsClient
|
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")
|
logger = get_logger("discogs_worker")
|
||||||
|
|
||||||
|
|
@ -332,9 +332,13 @@ class DiscogsWorker:
|
||||||
self.stats['not_found'] += 1
|
self.stats['not_found'] += 1
|
||||||
return
|
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:
|
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)
|
# Fetch full artist detail (uses cache)
|
||||||
data = self.client._fetch_and_cache_artist(result.id)
|
data = self.client._fetch_and_cache_artist(result.id)
|
||||||
if data:
|
if data:
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,10 @@ to test in isolation:
|
||||||
overwrites the user's deliberate pick with whatever the auto-search
|
overwrites the user's deliberate pick with whatever the auto-search
|
||||||
ranks first, so manual matches are exempt regardless of provider
|
ranks first, so manual matches are exempt regardless of provider
|
||||||
drift. `is_drifted_for_redo` encapsulates the decision.
|
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
|
from __future__ import annotations
|
||||||
|
|
@ -68,3 +72,49 @@ def is_drifted_for_redo(
|
||||||
return False
|
return False
|
||||||
cached_provider = extra_data.get('provider', 'spotify')
|
cached_provider = extra_data.get('provider', 'spotify')
|
||||||
return cached_provider != active_provider
|
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
|
||||||
|
|
|
||||||
|
|
@ -37,9 +37,35 @@ import time
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any, Callable
|
from typing import Any, Callable
|
||||||
|
|
||||||
|
from core.discovery.manual_match import should_rediscover
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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
|
@dataclass
|
||||||
class PlaylistDiscoveryDeps:
|
class PlaylistDiscoveryDeps:
|
||||||
"""Bundle of cross-cutting deps the playlist discovery worker needs."""
|
"""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']
|
existing_extra = json.loads(track['extra_data']) if isinstance(track['extra_data'], str) else track['extra_data']
|
||||||
except (json.JSONDecodeError, TypeError):
|
except (json.JSONDecodeError, TypeError):
|
||||||
pass
|
pass
|
||||||
if existing_extra.get('discovered'):
|
# `should_rediscover` is the single source of truth for this
|
||||||
if existing_extra.get('wing_it_fallback'):
|
# gate (manual match checked FIRST so a stale Wing It flag can't
|
||||||
# Wing It stub — always re-attempt to find a real match
|
# revert a user's deliberate fix — see its docstring).
|
||||||
undiscovered_tracks.append(track)
|
if should_rediscover(existing_extra):
|
||||||
elif existing_extra.get('manual_match'):
|
undiscovered_tracks.append(track)
|
||||||
# User explicitly picked this match via the Fix popup.
|
else:
|
||||||
# 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
|
|
||||||
pl_skipped += 1
|
pl_skipped += 1
|
||||||
total_skipped += 1
|
total_skipped += 1
|
||||||
else:
|
|
||||||
undiscovered_tracks.append(track)
|
|
||||||
|
|
||||||
if pl_skipped > 0:
|
if pl_skipped > 0:
|
||||||
deps.update_automation_progress(automation_id,
|
deps.update_automation_progress(automation_id,
|
||||||
|
|
@ -223,6 +219,20 @@ def run_playlist_discovery_worker(playlists, automation_id=None, deps: PlaylistD
|
||||||
except Exception:
|
except Exception:
|
||||||
search_queries = [f"{artist_name} {track_name}", track_name]
|
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
|
# Step 3: Search and score
|
||||||
best_match = None
|
best_match = None
|
||||||
best_confidence = 0.0
|
best_confidence = 0.0
|
||||||
|
|
@ -237,8 +247,8 @@ def run_playlist_discovery_worker(playlists, automation_id=None, deps: PlaylistD
|
||||||
if not results:
|
if not results:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
match, confidence, _ = deps.discovery_score_candidates(
|
match, confidence = _canonical_best_score(
|
||||||
track_name, artist_name, duration_ms, results
|
deps, track_name, artist_name, duration_ms, results
|
||||||
)
|
)
|
||||||
|
|
||||||
if match and confidence > best_confidence:
|
if match and confidence > best_confidence:
|
||||||
|
|
@ -259,8 +269,8 @@ def run_playlist_discovery_worker(playlists, automation_id=None, deps: PlaylistD
|
||||||
else:
|
else:
|
||||||
extended = itunes_client_instance.search_tracks(query, limit=50)
|
extended = itunes_client_instance.search_tracks(query, limit=50)
|
||||||
if extended:
|
if extended:
|
||||||
match, confidence, _ = deps.discovery_score_candidates(
|
match, confidence = _canonical_best_score(
|
||||||
track_name, artist_name, duration_ms, extended
|
deps, track_name, artist_name, duration_ms, extended
|
||||||
)
|
)
|
||||||
if match and confidence > best_confidence:
|
if match and confidence > best_confidence:
|
||||||
best_confidence = confidence
|
best_confidence = confidence
|
||||||
|
|
|
||||||
|
|
@ -188,6 +188,22 @@ async def _database_only_find_track(spotify_track, candidate_pool=None):
|
||||||
server_source=active_server
|
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:
|
if db_track and confidence >= 0.80:
|
||||||
logger.info(f"Database match: '{db_track.title}' (confidence: {confidence:.2f})")
|
logger.info(f"Database match: '{db_track.title}' (confidence: {confidence:.2f})")
|
||||||
if spotify_id:
|
if spotify_id:
|
||||||
|
|
@ -508,7 +524,13 @@ def run_sync_task(
|
||||||
# don't want persisted to app.log.
|
# don't want persisted to app.log.
|
||||||
_synced = getattr(result, 'synced_tracks', 0)
|
_synced = getattr(result, 'synced_tracks', 0)
|
||||||
logger.info(f"[PLAYLIST IMAGE] has_image={bool(playlist_image_url)}, synced_tracks={_synced}")
|
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:
|
try:
|
||||||
active_server = deps.config_manager.get_active_media_server()
|
active_server = deps.config_manager.get_active_media_server()
|
||||||
logger.info(f"[PLAYLIST IMAGE] active_server={active_server}")
|
logger.info(f"[PLAYLIST IMAGE] active_server={active_server}")
|
||||||
|
|
|
||||||
|
|
@ -706,13 +706,19 @@ def resolve_reported_save_path(
|
||||||
|
|
||||||
|
|
||||||
def copy_audio_files_atomically(
|
def copy_audio_files_atomically(
|
||||||
sources: Iterable[Path], staging_dir: Path,
|
sources: Iterable[Path], staging_dir: Path, remove_source: bool = False,
|
||||||
) -> list:
|
) -> list:
|
||||||
"""Convenience wrapper: pick a non-colliding staging path for
|
"""Convenience wrapper: pick a non-colliding staging path for
|
||||||
each source, copy via ``atomic_copy_to_staging``. Returns the
|
each source, copy via ``atomic_copy_to_staging``. Returns the
|
||||||
list of final destination paths (as strings). Files that fail
|
list of final destination paths (as strings). Files that fail
|
||||||
to copy are logged and skipped; the caller decides what to do
|
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)
|
staging_dir.mkdir(parents=True, exist_ok=True)
|
||||||
out: list = []
|
out: list = []
|
||||||
for src in sources:
|
for src in sources:
|
||||||
|
|
@ -720,6 +726,14 @@ def copy_audio_files_atomically(
|
||||||
try:
|
try:
|
||||||
atomic_copy_to_staging(src, dest)
|
atomic_copy_to_staging(src, dest)
|
||||||
out.append(str(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:
|
except Exception as e:
|
||||||
logger.warning("[album_bundle] Failed to stage %s -> %s: %s", src, dest, e)
|
logger.warning("[album_bundle] Failed to stage %s -> %s: %s", src, dest, e)
|
||||||
return out
|
return out
|
||||||
|
|
|
||||||
|
|
@ -326,6 +326,17 @@ def attempt_download_with_candidates(task_id, candidates, track, batch_id=None,
|
||||||
"task=%s username=%s filename=%s",
|
"task=%s username=%s filename=%s",
|
||||||
task_id, username, os.path.basename(filename),
|
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.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}")
|
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}")
|
||||||
|
|
|
||||||
|
|
@ -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_name = 'Unknown Playlist'
|
||||||
batch_playlist_id = playlist_id
|
batch_playlist_id = playlist_id
|
||||||
batch_source_playlist_ref = ''
|
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:
|
with tasks_lock:
|
||||||
if batch_id in download_batches:
|
if batch_id in download_batches:
|
||||||
force_download_all = download_batches[batch_id].get('force_download_all', False)
|
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 = (
|
batch_source_playlist_ref = (
|
||||||
download_batches[batch_id].get('source_playlist_ref') or ''
|
download_batches[batch_id].get('source_playlist_ref') or ''
|
||||||
).strip()
|
).strip()
|
||||||
|
batch_skip_acoustid = bool(download_batches[batch_id].get('skip_acoustid', False))
|
||||||
|
|
||||||
from core.downloads.playlist_folder import (
|
from core.downloads.playlist_folder import (
|
||||||
resolve_playlist_folder_mode_for_batch,
|
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']}'")
|
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
|
# Add playlist folder mode flag for sync page playlists and wishlist
|
||||||
# tracks tied to a mirrored playlist with organize_by_playlist enabled.
|
# tracks tied to a mirrored playlist with organize_by_playlist enabled.
|
||||||
task_pl_folder_mode = batch_playlist_folder_mode
|
task_pl_folder_mode = batch_playlist_folder_mode
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,6 @@ from core.imports.side_effects import (
|
||||||
emit_track_downloaded,
|
emit_track_downloaded,
|
||||||
record_download_provenance,
|
record_download_provenance,
|
||||||
record_library_history_download,
|
record_library_history_download,
|
||||||
record_retag_download,
|
|
||||||
record_soulsync_library_entry,
|
record_soulsync_library_entry,
|
||||||
)
|
)
|
||||||
from core.wishlist.resolution import check_and_remove_from_wishlist
|
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_download_provenance(context)
|
||||||
record_soulsync_library_entry(context, artist_context, album_info)
|
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:
|
try:
|
||||||
completed_path = context.get('_final_processed_path', final_path)
|
completed_path = context.get('_final_processed_path', final_path)
|
||||||
batch_id_for_repair = context.get('batch_id')
|
batch_id_for_repair = context.get('batch_id')
|
||||||
|
|
|
||||||
|
|
@ -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)
|
logger.info("[SoulSync Library] Added: %s / %s / %s", artist_name, album_name, track_name)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error("[SoulSync Library] Could not record library entry: %s", 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)
|
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ from datetime import datetime, timedelta
|
||||||
from utils.logging_config import get_logger
|
from utils.logging_config import get_logger
|
||||||
from database.music_database import MusicDatabase
|
from database.music_database import MusicDatabase
|
||||||
from core.itunes_client import iTunesClient
|
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
|
from core.enrichment.manual_match_honoring import honor_stored_match
|
||||||
|
|
||||||
logger = get_logger("itunes_worker")
|
logger = get_logger("itunes_worker")
|
||||||
|
|
@ -393,7 +393,11 @@ class iTunesWorker:
|
||||||
return
|
return
|
||||||
|
|
||||||
for artist_obj in results:
|
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):
|
if not self._is_itunes_id(artist_obj.id):
|
||||||
logger.warning(f"Rejecting non-iTunes ID '{artist_obj.id}' for artist '{artist_name}'")
|
logger.warning(f"Rejecting non-iTunes ID '{artist_obj.id}' for artist '{artist_name}'")
|
||||||
self._mark_status('artist', artist_id, 'error')
|
self._mark_status('artist', artist_id, 'error')
|
||||||
|
|
|
||||||
|
|
@ -1605,6 +1605,80 @@ class JellyfinClient(MediaServerClient):
|
||||||
logger.error(f"Error appending to Jellyfin playlist '{playlist_name}': {e}")
|
logger.error(f"Error appending to Jellyfin playlist '{playlist_name}': {e}")
|
||||||
return False
|
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:
|
def update_playlist(self, playlist_name: str, tracks) -> bool:
|
||||||
"""Update an existing playlist or create it if it doesn't exist"""
|
"""Update an existing playlist or create it if it doesn't exist"""
|
||||||
if not self.ensure_connection():
|
if not self.ensure_connection():
|
||||||
|
|
|
||||||
415
core/library/embedded_id_reconcile.py
Normal file
415
core/library/embedded_id_reconcile.py
Normal 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, plan→apply chaining,
|
||||||
|
keeping the in-memory parent maps fresh for sibling tracks) is testable
|
||||||
|
without the Flask job. ``embedded_tags`` is the ``tags`` dict from
|
||||||
|
``read_embedded_tags`` (``None`` => unreadable file).
|
||||||
|
|
||||||
|
``album_map`` / ``artist_map`` map entity-id -> current column dict; this
|
||||||
|
function UPDATES them in place with any fills it applies so a later track
|
||||||
|
on the same album/artist sees the value and doesn't re-plan it. (DB safety
|
||||||
|
is the guarded UPDATE in apply, never these maps.)
|
||||||
|
"""
|
||||||
|
if not embedded_tags:
|
||||||
|
return TrackReconcileResult(ReconcileApplied(), 0, readable=False)
|
||||||
|
|
||||||
|
album_id = str(track_row['album_id']) if track_row.get('album_id') is not None else None
|
||||||
|
artist_id = str(track_row['artist_id']) if track_row.get('artist_id') is not None else None
|
||||||
|
|
||||||
|
plan = plan_reconcile(embedded_tags, {
|
||||||
|
'track': track_row,
|
||||||
|
'album': album_map.get(album_id, {}) if album_id else {},
|
||||||
|
'artist': artist_map.get(artist_id, {}) if artist_id else {},
|
||||||
|
})
|
||||||
|
applied = apply_reconcile_plan(cursor, {
|
||||||
|
'track': track_row.get('id'), 'album': album_id, 'artist': artist_id,
|
||||||
|
}, plan)
|
||||||
|
|
||||||
|
if album_id:
|
||||||
|
for f in plan.fills_for('album'):
|
||||||
|
album_map.setdefault(album_id, {})[f.id_column] = f.value
|
||||||
|
if artist_id:
|
||||||
|
for f in plan.fills_for('artist'):
|
||||||
|
artist_map.setdefault(artist_id, {})[f.id_column] = f.value
|
||||||
|
|
||||||
|
return TrackReconcileResult(applied, len(plan.conflicts), readable=True)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ReconcileTotals:
|
||||||
|
"""Accumulated counts over a reconcile run."""
|
||||||
|
total: int = 0
|
||||||
|
processed: int = 0
|
||||||
|
ids_filled: int = 0
|
||||||
|
entities_updated: int = 0
|
||||||
|
conflicts: int = 0
|
||||||
|
unreadable: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
def _load_missing_rows(cursor, ids, table, target: Dict[str, Dict[str, Any]]) -> None:
|
||||||
|
"""Load any not-yet-cached entity rows for ``ids`` into ``target`` in place.
|
||||||
|
|
||||||
|
Ids with no row get an empty dict so they're never re-queried. Chunked to
|
||||||
|
keep the IN clause bounded.
|
||||||
|
"""
|
||||||
|
missing = [i for i in {x for x in ids if x} if i not in target]
|
||||||
|
for start in range(0, len(missing), 500):
|
||||||
|
chunk = missing[start:start + 500]
|
||||||
|
ph = ','.join('?' * len(chunk))
|
||||||
|
cursor.execute(f"SELECT * FROM {table} WHERE id IN ({ph})", chunk)
|
||||||
|
for r in cursor.fetchall():
|
||||||
|
target[str(r['id'])] = dict(r)
|
||||||
|
for i in missing:
|
||||||
|
target.setdefault(i, {}) # mark absent → don't re-query
|
||||||
|
|
||||||
|
|
||||||
|
def reconcile_library(
|
||||||
|
conn,
|
||||||
|
read_tags,
|
||||||
|
track_ids=None,
|
||||||
|
page_size: int = 500,
|
||||||
|
on_progress=None,
|
||||||
|
should_stop=None,
|
||||||
|
) -> ReconcileTotals:
|
||||||
|
"""Gap-fill embedded provider IDs into the DB for a set of tracks.
|
||||||
|
|
||||||
|
Shared orchestration used by both the manual backfill job and the
|
||||||
|
auto-reconcile hook on library scans. Pages the track list (bounded
|
||||||
|
memory), lazily loads only the parent album/artist rows actually
|
||||||
|
referenced (cheap when scoped to a handful of new tracks), and commits
|
||||||
|
per page so concurrent enrichment workers aren't starved of the write
|
||||||
|
lock.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
conn: open DB connection; this function commits per page.
|
||||||
|
read_tags: callable ``(file_path) -> tags dict | None``. The caller
|
||||||
|
injects path resolution + ``read_embedded_tags`` so this module
|
||||||
|
stays free of Flask / docker-path concerns. ``None`` => unreadable.
|
||||||
|
track_ids: iterable of track ids to reconcile, or ``None`` for every
|
||||||
|
track that has a ``file_path``.
|
||||||
|
page_size: rows materialised per page.
|
||||||
|
on_progress: optional ``(totals, current_title) -> None`` after each
|
||||||
|
track (for live UI).
|
||||||
|
should_stop: optional ``() -> bool`` checked between tracks/pages to
|
||||||
|
abort early.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
:class:`ReconcileTotals`.
|
||||||
|
"""
|
||||||
|
from utils.logging_config import get_logger
|
||||||
|
logger = get_logger("library.reconcile")
|
||||||
|
|
||||||
|
totals = ReconcileTotals()
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
if track_ids is None:
|
||||||
|
cur.execute("SELECT id FROM tracks WHERE file_path IS NOT NULL AND TRIM(file_path) != ''")
|
||||||
|
ids = [str(r[0]) for r in cur.fetchall()]
|
||||||
|
else:
|
||||||
|
ids = [str(t) for t in track_ids if t is not None]
|
||||||
|
totals.total = len(ids)
|
||||||
|
|
||||||
|
album_map: Dict[str, Dict[str, Any]] = {}
|
||||||
|
artist_map: Dict[str, Dict[str, Any]] = {}
|
||||||
|
|
||||||
|
for start in range(0, len(ids), page_size):
|
||||||
|
if should_stop and should_stop():
|
||||||
|
break
|
||||||
|
page = ids[start:start + page_size]
|
||||||
|
ph = ','.join('?' * len(page))
|
||||||
|
cur.execute(f"SELECT * FROM tracks WHERE id IN ({ph})", page)
|
||||||
|
rows = [dict(r) for r in cur.fetchall()]
|
||||||
|
|
||||||
|
_load_missing_rows(cur, [str(r['album_id']) for r in rows if r.get('album_id') is not None],
|
||||||
|
'albums', album_map)
|
||||||
|
_load_missing_rows(cur, [str(r['artist_id']) for r in rows if r.get('artist_id') is not None],
|
||||||
|
'artists', artist_map)
|
||||||
|
|
||||||
|
for tr in rows:
|
||||||
|
if should_stop and should_stop():
|
||||||
|
break
|
||||||
|
title = tr.get('title') or '?'
|
||||||
|
try:
|
||||||
|
tags = read_tags(tr.get('file_path'))
|
||||||
|
result = reconcile_track_row(cur, tr, album_map, artist_map, tags)
|
||||||
|
if not result.readable:
|
||||||
|
totals.unreadable += 1
|
||||||
|
else:
|
||||||
|
totals.ids_filled += result.applied.ids_filled
|
||||||
|
totals.entities_updated += result.applied.rows_updated
|
||||||
|
totals.conflicts += result.conflicts
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug("reconcile: skipped track %s: %s", tr.get('id'), e)
|
||||||
|
totals.unreadable += 1
|
||||||
|
finally:
|
||||||
|
totals.processed += 1
|
||||||
|
if on_progress:
|
||||||
|
on_progress(totals, title)
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
return totals
|
||||||
|
|
||||||
|
|
||||||
|
def _existing_columns(cursor, table: str) -> set:
|
||||||
|
"""Return the set of column names on ``table`` (migration-safe guard)."""
|
||||||
|
cursor.execute(f"PRAGMA table_info({table})")
|
||||||
|
return {r[1] for r in cursor.fetchall()}
|
||||||
|
|
||||||
|
|
||||||
|
def apply_reconcile_plan(cursor, entity_ids: Dict[str, Any], plan: ReconcilePlan) -> ReconcileApplied:
|
||||||
|
"""Apply a :class:`ReconcilePlan` to the DB via ``cursor``.
|
||||||
|
|
||||||
|
Each fill is a single guarded ``UPDATE``:
|
||||||
|
|
||||||
|
UPDATE {table} SET {id}=?, {status}='matched', {attempted}=now
|
||||||
|
WHERE id=? AND ({id} IS NULL OR {id}='')
|
||||||
|
|
||||||
|
The ``id IS NULL OR id=''`` guard makes the gap-fill atomic: if the
|
||||||
|
column became non-empty between the plan's read and now (an enrichment
|
||||||
|
worker matched it concurrently), the UPDATE affects 0 rows and the
|
||||||
|
worker's value is preserved. Only columns that exist on the table are
|
||||||
|
written (introspected + cached per call), so a schema missing a
|
||||||
|
provider's columns is silently skipped.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
cursor: an open DB cursor (caller owns the transaction/commit).
|
||||||
|
entity_ids: ``{'track': id, 'album': id, 'artist': id}``. An entity
|
||||||
|
with no id is skipped.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A :class:`ReconcileApplied` with counts derived from real rowcounts.
|
||||||
|
"""
|
||||||
|
result = ReconcileApplied()
|
||||||
|
touched: set = set()
|
||||||
|
col_cache: Dict[str, set] = {}
|
||||||
|
|
||||||
|
for fill in plan.fills:
|
||||||
|
ent_id = entity_ids.get(fill.entity)
|
||||||
|
if ent_id is None or ent_id == '':
|
||||||
|
continue
|
||||||
|
table = _ENTITY_TABLE[fill.entity]
|
||||||
|
if table not in col_cache:
|
||||||
|
col_cache[table] = _existing_columns(cursor, table)
|
||||||
|
cols = col_cache[table]
|
||||||
|
if fill.id_column not in cols:
|
||||||
|
continue
|
||||||
|
|
||||||
|
assignments = [f"{fill.id_column} = ?"]
|
||||||
|
values: List[Any] = [fill.value]
|
||||||
|
if fill.status_column in cols:
|
||||||
|
assignments.append(f"{fill.status_column} = ?")
|
||||||
|
values.append('matched')
|
||||||
|
attempted = fill.status_column.replace('_match_status', '_last_attempted')
|
||||||
|
if attempted in cols:
|
||||||
|
assignments.append(f"{attempted} = CURRENT_TIMESTAMP")
|
||||||
|
|
||||||
|
cursor.execute(
|
||||||
|
f"UPDATE {table} SET {', '.join(assignments)} "
|
||||||
|
f"WHERE id = ? AND ({fill.id_column} IS NULL OR {fill.id_column} = '')",
|
||||||
|
values + [str(ent_id)],
|
||||||
|
)
|
||||||
|
if cursor.rowcount:
|
||||||
|
result.ids_filled += 1
|
||||||
|
touched.add((fill.entity, str(ent_id)))
|
||||||
|
|
||||||
|
result.rows_updated = len(touched)
|
||||||
|
return result
|
||||||
|
|
@ -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)
|
|
||||||
})
|
|
||||||
218
core/library/retag_planner.py
Normal file
218
core/library/retag_planner.py
Normal 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}
|
||||||
1
core/maintenance/__init__.py
Normal file
1
core/maintenance/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
"""Maintenance / one-off data-repair helpers."""
|
||||||
138
core/maintenance/dedupe_source_ids.py
Normal file
138
core/maintenance/dedupe_source_ids.py
Normal 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
|
||||||
96
core/matching/script_compat.py
Normal file
96
core/matching/script_compat.py
Normal 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 A–Z / a–z letter."""
|
||||||
|
if not text:
|
||||||
|
return False
|
||||||
|
return any(('a' <= c <= 'z') or ('A' <= c <= 'Z') for c in text)
|
||||||
|
|
||||||
|
|
||||||
|
def is_cross_script_mismatch(a: str, b: str) -> bool:
|
||||||
|
"""True when ``a`` and ``b`` are written in different scripts.
|
||||||
|
|
||||||
|
Specifically: exactly one side uses a non-Latin writing system while
|
||||||
|
the other is genuine Latin text. This is the signal that a raw
|
||||||
|
similarity score between the two is meaningless (a romanized name vs
|
||||||
|
its native-script form), NOT that they name different things.
|
||||||
|
|
||||||
|
Symmetric. Returns False when:
|
||||||
|
- both sides are Latin (ordinary English-vs-English comparison),
|
||||||
|
- both sides are non-Latin (same-script comparison still works),
|
||||||
|
- either side is empty / has no comparable letters.
|
||||||
|
"""
|
||||||
|
a_nonlatin = has_strong_nonlatin(a)
|
||||||
|
b_nonlatin = has_strong_nonlatin(b)
|
||||||
|
if a_nonlatin == b_nonlatin:
|
||||||
|
# Same script class on both sides (or neither has non-Latin) —
|
||||||
|
# similarity comparison is meaningful, no script bridge needed.
|
||||||
|
return False
|
||||||
|
# Exactly one side is non-Latin. It's only a true cross-script case
|
||||||
|
# if the OTHER side is real Latin text (not punctuation / digits).
|
||||||
|
if a_nonlatin:
|
||||||
|
return _has_latin_letter(b)
|
||||||
|
return _has_latin_letter(a)
|
||||||
|
|
@ -207,4 +207,24 @@ def pick_canonical_release(
|
||||||
return best, best_score
|
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",
|
||||||
|
]
|
||||||
|
|
|
||||||
|
|
@ -1122,6 +1122,60 @@ def extract_source_metadata(context: dict, artist: dict, album_info: dict) -> di
|
||||||
return metadata
|
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):
|
def embed_source_ids(audio_file, metadata: dict, context: dict = None, runtime=None):
|
||||||
cfg = get_config_manager()
|
cfg = get_config_manager()
|
||||||
symbols = get_mutagen_symbols()
|
symbols = get_mutagen_symbols()
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ from datetime import datetime, timedelta
|
||||||
from utils.logging_config import get_logger
|
from utils.logging_config import get_logger
|
||||||
from database.music_database import MusicDatabase
|
from database.music_database import MusicDatabase
|
||||||
from core.musicbrainz_service import MusicBrainzService
|
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")
|
logger = get_logger("musicbrainz_worker")
|
||||||
|
|
||||||
|
|
@ -367,8 +367,17 @@ class MusicBrainzWorker:
|
||||||
|
|
||||||
if item_type == 'artist':
|
if item_type == 'artist':
|
||||||
result = self.mb_service.match_artist(item_name)
|
result = self.mb_service.match_artist(item_name)
|
||||||
if result and result.get('mbid'):
|
mbid = result.get('mbid') if result else None
|
||||||
self.mb_service.update_artist_mbid(item_id, result['mbid'], 'matched')
|
# 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
|
# Issue #442 — pull alternate-spelling aliases (Japanese
|
||||||
# kanji, Cyrillic, etc.) so the verifier can recognise
|
# kanji, Cyrillic, etc.) so the verifier can recognise
|
||||||
# cross-script artist names without re-querying MB on
|
# cross-script artist names without re-querying MB on
|
||||||
|
|
@ -377,7 +386,7 @@ class MusicBrainzWorker:
|
||||||
# empty list) so a transient MB outage never regresses
|
# empty list) so a transient MB outage never regresses
|
||||||
# the enrichment outcome.
|
# the enrichment outcome.
|
||||||
try:
|
try:
|
||||||
aliases = self.mb_service.fetch_artist_aliases(result['mbid'])
|
aliases = self.mb_service.fetch_artist_aliases(mbid)
|
||||||
if aliases:
|
if aliases:
|
||||||
self.mb_service.update_artist_aliases(item_id, aliases)
|
self.mb_service.update_artist_aliases(item_id, aliases)
|
||||||
logger.debug(
|
logger.debug(
|
||||||
|
|
@ -388,11 +397,17 @@ class MusicBrainzWorker:
|
||||||
"Alias enrichment failed for artist '%s': %s", item_name, alias_err,
|
"Alias enrichment failed for artist '%s': %s", item_name, alias_err,
|
||||||
)
|
)
|
||||||
self.stats['matched'] += 1
|
self.stats['matched'] += 1
|
||||||
logger.info(f"Matched artist '{item_name}' → MBID: {result['mbid']}")
|
logger.info(f"Matched artist '{item_name}' → MBID: {mbid}")
|
||||||
else:
|
else:
|
||||||
self.mb_service.update_artist_mbid(item_id, None, 'not_found')
|
self.mb_service.update_artist_mbid(item_id, None, 'not_found')
|
||||||
self.stats['not_found'] += 1
|
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':
|
elif item_type == 'album':
|
||||||
artist_name = item.get('artist')
|
artist_name = item.get('artist')
|
||||||
|
|
|
||||||
|
|
@ -192,6 +192,14 @@ class NavidromeClient(MediaServerClient):
|
||||||
"""Get available music folders from Navidrome."""
|
"""Get available music folders from Navidrome."""
|
||||||
if not self.ensure_connection():
|
if not self.ensure_connection():
|
||||||
return []
|
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:
|
try:
|
||||||
response = self._make_request('getMusicFolders')
|
response = self._make_request('getMusicFolders')
|
||||||
if not response:
|
if not response:
|
||||||
|
|
@ -216,7 +224,10 @@ class NavidromeClient(MediaServerClient):
|
||||||
logger.info(f"Set music folder to: {folder_name} (ID: {self.music_folder_id})")
|
logger.info(f"Set music folder to: {folder_name} (ID: {self.music_folder_id})")
|
||||||
from database.music_database import MusicDatabase
|
from database.music_database import MusicDatabase
|
||||||
db = 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', folder_name)
|
||||||
|
db.set_preference('navidrome_music_folder_id', folder['key'])
|
||||||
return True
|
return True
|
||||||
# If folder_name is empty, clear the selection
|
# If folder_name is empty, clear the selection
|
||||||
if not folder_name:
|
if not folder_name:
|
||||||
|
|
@ -225,6 +236,7 @@ class NavidromeClient(MediaServerClient):
|
||||||
from database.music_database import MusicDatabase
|
from database.music_database import MusicDatabase
|
||||||
db = MusicDatabase()
|
db = MusicDatabase()
|
||||||
db.set_preference('navidrome_music_folder', '')
|
db.set_preference('navidrome_music_folder', '')
|
||||||
|
db.set_preference('navidrome_music_folder_id', '')
|
||||||
logger.info("Cleared music folder selection — will use all libraries")
|
logger.info("Cleared music folder selection — will use all libraries")
|
||||||
return True
|
return True
|
||||||
logger.warning(f"Music folder '{folder_name}' not found")
|
logger.warning(f"Music folder '{folder_name}' not found")
|
||||||
|
|
@ -276,14 +288,35 @@ class NavidromeClient(MediaServerClient):
|
||||||
try:
|
try:
|
||||||
from database.music_database import MusicDatabase
|
from database.music_database import MusicDatabase
|
||||||
db = MusicDatabase()
|
db = MusicDatabase()
|
||||||
|
saved_id = db.get_preference('navidrome_music_folder_id')
|
||||||
saved_folder = db.get_preference('navidrome_music_folder')
|
saved_folder = db.get_preference('navidrome_music_folder')
|
||||||
if saved_folder:
|
if saved_id or saved_folder:
|
||||||
folders = self.get_music_folders()
|
# Use the non-reentrant fetch: we're still inside
|
||||||
for folder in folders:
|
# ensure_connection() here (_is_connecting=True), so the
|
||||||
if folder['title'] == saved_folder:
|
# public get_music_folders() would re-enter the guard and
|
||||||
self.music_folder_id = folder['key']
|
# return [], silently dropping the saved selection.
|
||||||
logger.info(f"Restored music folder preference: {saved_folder} (ID: {self.music_folder_id})")
|
folders = self._fetch_music_folders()
|
||||||
break
|
# 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:
|
except Exception as e:
|
||||||
logger.warning(f"Could not restore music folder preference: {e}")
|
logger.warning(f"Could not restore music folder preference: {e}")
|
||||||
else:
|
else:
|
||||||
|
|
@ -1091,6 +1124,60 @@ class NavidromeClient(MediaServerClient):
|
||||||
logger.error(f"Error appending to Navidrome playlist '{playlist_name}': {e}")
|
logger.error(f"Error appending to Navidrome playlist '{playlist_name}': {e}")
|
||||||
return False
|
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:
|
def update_playlist(self, playlist_name: str, tracks) -> bool:
|
||||||
"""Update an existing playlist or create it if it doesn't exist. Handles duplicates."""
|
"""Update an existing playlist or create it if it doesn't exist. Handles duplicates."""
|
||||||
if not self.ensure_connection():
|
if not self.ensure_connection():
|
||||||
|
|
|
||||||
|
|
@ -667,6 +667,53 @@ class PlexClient(MediaServerClient):
|
||||||
logger.error(f"Error appending to Plex playlist '{playlist_name}': {e}")
|
logger.error(f"Error appending to Plex playlist '{playlist_name}': {e}")
|
||||||
return False
|
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:
|
def update_playlist(self, playlist_name: str, tracks: List[TrackInfo]) -> bool:
|
||||||
if not self.ensure_connection():
|
if not self.ensure_connection():
|
||||||
return False
|
return False
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ from datetime import datetime, timedelta
|
||||||
from utils.logging_config import get_logger
|
from utils.logging_config import get_logger
|
||||||
from database.music_database import MusicDatabase
|
from database.music_database import MusicDatabase
|
||||||
from core.qobuz_client import _qobuz_is_rate_limited
|
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
|
from core.enrichment.manual_match_honoring import honor_stored_match
|
||||||
|
|
||||||
logger = get_logger("qobuz_worker")
|
logger = get_logger("qobuz_worker")
|
||||||
|
|
@ -301,13 +301,29 @@ class QobuzWorker:
|
||||||
logger.debug(f"Name similarity: '{query_name}' vs '{result_name}' = {similarity:.2f}")
|
logger.debug(f"Name similarity: '{query_name}' vs '{result_name}' = {similarity:.2f}")
|
||||||
return similarity >= self.name_similarity_threshold
|
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,
|
||||||
"""Verify/correct parent artist's Qobuz ID based on album/track match"""
|
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')
|
parent_qobuz_id = item.get('artist_qobuz_id')
|
||||||
if not parent_qobuz_id or not result_artist_id:
|
if not parent_qobuz_id or not result_artist_id:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
if str(result_artist_id) != str(parent_qobuz_id):
|
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(
|
logger.info(
|
||||||
f"Artist ID correction from {item['type']} '{item['name']}': "
|
f"Artist ID correction from {item['type']} '{item['name']}': "
|
||||||
f"updating parent artist Qobuz ID from {parent_qobuz_id} to {result_artist_id}"
|
f"updating parent artist Qobuz ID from {parent_qobuz_id} to {result_artist_id}"
|
||||||
|
|
@ -407,14 +423,19 @@ class QobuzWorker:
|
||||||
|
|
||||||
if result:
|
if result:
|
||||||
result_name = result.get('name', '')
|
result_name = result.get('name', '')
|
||||||
if self._name_matches(artist_name, result_name):
|
qobuz_artist_id = result.get('id')
|
||||||
qobuz_artist_id = result.get('id')
|
ok, reason = accept_artist_match(
|
||||||
if not qobuz_artist_id:
|
self.db, 'qobuz_id', qobuz_artist_id, artist_id, artist_name, result_name,
|
||||||
self._mark_status('artist', artist_id, 'error')
|
)
|
||||||
self.stats['errors'] += 1
|
if not ok:
|
||||||
logger.warning(f"Qobuz search result for '{artist_name}' has no ID")
|
self._mark_status('artist', artist_id, 'not_found')
|
||||||
return
|
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
|
# Fetch full artist details
|
||||||
full_artist = None
|
full_artist = None
|
||||||
try:
|
try:
|
||||||
|
|
@ -425,10 +446,6 @@ class QobuzWorker:
|
||||||
self._update_artist(artist_id, result, full_artist)
|
self._update_artist(artist_id, result, full_artist)
|
||||||
self.stats['matched'] += 1
|
self.stats['matched'] += 1
|
||||||
logger.info(f"Matched artist '{artist_name}' -> Qobuz ID: {qobuz_artist_id}")
|
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:
|
else:
|
||||||
if _qobuz_is_rate_limited():
|
if _qobuz_is_rate_limited():
|
||||||
logger.warning(f"Rate limited while searching artist '{artist_name}', will retry")
|
logger.warning(f"Rate limited while searching artist '{artist_name}', will retry")
|
||||||
|
|
@ -467,7 +484,8 @@ class QobuzWorker:
|
||||||
# Verify artist ID
|
# Verify artist ID
|
||||||
result_artist = result.get('artist', {})
|
result_artist = result.get('artist', {})
|
||||||
result_artist_id = result_artist.get('id') if result_artist else None
|
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
|
# Fetch full album details
|
||||||
qobuz_album_id = result.get('id')
|
qobuz_album_id = result.get('id')
|
||||||
|
|
@ -528,7 +546,8 @@ class QobuzWorker:
|
||||||
# Verify artist ID
|
# Verify artist ID
|
||||||
result_artist = result.get('artist', result.get('performer', {}))
|
result_artist = result.get('artist', result.get('performer', {}))
|
||||||
result_artist_id = result_artist.get('id') if result_artist else None
|
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
|
# Fetch full track details
|
||||||
qobuz_track_id = result.get('id')
|
qobuz_track_id = result.get('id')
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,7 @@ _JOB_MODULES = [
|
||||||
'core.repair_jobs.unknown_artist_fixer',
|
'core.repair_jobs.unknown_artist_fixer',
|
||||||
'core.repair_jobs.discography_backfill',
|
'core.repair_jobs.discography_backfill',
|
||||||
'core.repair_jobs.canonical_version_resolve',
|
'core.repair_jobs.canonical_version_resolve',
|
||||||
|
'core.repair_jobs.library_retag',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
477
core/repair_jobs/library_retag.py
Normal file
477
core/repair_jobs/library_retag.py
Normal 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
|
||||||
|
|
@ -46,9 +46,10 @@ class MissingCoverArtJob(RepairJob):
|
||||||
description = 'Finds albums missing artwork and locates art from metadata sources'
|
description = 'Finds albums missing artwork and locates art from metadata sources'
|
||||||
help_text = (
|
help_text = (
|
||||||
'Scans your library for albums that have no cover art stored in the database. '
|
'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 '
|
'For each missing cover, it searches for matching artwork using the album name '
|
||||||
'album name and artist to find matching artwork. If Prefer Source is set, that '
|
'and artist. If you have configured cover-art sources (Settings > metadata '
|
||||||
'source is tried first; otherwise the primary metadata source is used.\n\n'
|
'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 '
|
'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'
|
'and apply it. The job does not download or embed artwork automatically.\n\n'
|
||||||
'Settings:\n'
|
'Settings:\n'
|
||||||
|
|
@ -66,6 +67,10 @@ class MissingCoverArtJob(RepairJob):
|
||||||
settings = self._get_settings(context)
|
settings = self._get_settings(context)
|
||||||
primary_source = get_primary_source()
|
primary_source = get_primary_source()
|
||||||
source_priority = get_source_priority(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')
|
prefer_source = settings.get('prefer_source')
|
||||||
if prefer_source and prefer_source != primary_source and prefer_source in source_priority:
|
if prefer_source and prefer_source != primary_source and prefer_source in source_priority:
|
||||||
source_priority.remove(prefer_source)
|
source_priority.remove(prefer_source)
|
||||||
|
|
@ -170,11 +175,27 @@ class MissingCoverArtJob(RepairJob):
|
||||||
|
|
||||||
artwork_url = None
|
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.
|
# Try source-specific IDs first, then title/artist search, in priority order.
|
||||||
for source in source_priority:
|
if not artwork_url:
|
||||||
artwork_url = self._try_source(source, source_album_ids.get(source), title, artist_name)
|
for source in source_priority:
|
||||||
if artwork_url:
|
artwork_url = self._try_source(source, source_album_ids.get(source), title, artist_name)
|
||||||
break
|
if artwork_url:
|
||||||
|
break
|
||||||
|
|
||||||
if artwork_url:
|
if artwork_url:
|
||||||
if context.report_progress:
|
if context.report_progress:
|
||||||
|
|
|
||||||
|
|
@ -976,6 +976,7 @@ class RepairWorker:
|
||||||
'unknown_artist': self._fix_unknown_artist,
|
'unknown_artist': self._fix_unknown_artist,
|
||||||
'acoustid_mismatch': self._fix_acoustid_mismatch,
|
'acoustid_mismatch': self._fix_acoustid_mismatch,
|
||||||
'missing_discography_track': self._fix_discography_backfill,
|
'missing_discography_track': self._fix_discography_backfill,
|
||||||
|
'library_retag': self._fix_library_retag,
|
||||||
}
|
}
|
||||||
handler = handlers.get(finding_type)
|
handler = handlers.get(finding_type)
|
||||||
if not handler:
|
if not handler:
|
||||||
|
|
@ -1356,6 +1357,43 @@ class RepairWorker:
|
||||||
msg = 'Updated database thumbnail, but could not write art to files (read-only?)'
|
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}
|
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):
|
def _fix_metadata_gap(self, entity_type, entity_id, file_path, details):
|
||||||
"""Apply found metadata fields to the track."""
|
"""Apply found metadata fields to the track."""
|
||||||
found_fields = details.get('found_fields')
|
found_fields = details.get('found_fields')
|
||||||
|
|
|
||||||
371
core/search/by_id.py
Normal file
371
core/search/by_id.py
Normal 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)
|
||||||
|
|
@ -65,7 +65,9 @@ class SearchDeps:
|
||||||
def resolve_client(source_name: str, deps: SearchDeps) -> tuple[Any, bool]:
|
def resolve_client(source_name: str, deps: SearchDeps) -> tuple[Any, bool]:
|
||||||
"""Return (client, is_available) for an explicit metadata source request."""
|
"""Return (client, is_available) for an explicit metadata source request."""
|
||||||
if source_name == 'spotify':
|
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 deps.spotify_client, True
|
||||||
return None, False
|
return None, False
|
||||||
if source_name == 'itunes':
|
if source_name == 'itunes':
|
||||||
|
|
|
||||||
|
|
@ -1613,7 +1613,10 @@ class SoulseekClient(DownloadSourcePlugin):
|
||||||
return result
|
return result
|
||||||
|
|
||||||
_emit('staging', release=getattr(picked, 'album_title', folder_path) if picked else folder_path)
|
_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:
|
if not copied:
|
||||||
result['error'] = 'No Soulseek album files copied to staging'
|
result['error'] = 'No Soulseek album files copied to staging'
|
||||||
return result
|
return result
|
||||||
|
|
|
||||||
|
|
@ -569,6 +569,72 @@ class SpotifyClient:
|
||||||
return self._itunes # Fall back to iTunes if no Discogs token
|
return self._itunes # Fall back to iTunes if no Discogs token
|
||||||
return self._itunes
|
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):
|
def reload_config(self):
|
||||||
"""Reload configuration and re-initialize client"""
|
"""Reload configuration and re-initialize client"""
|
||||||
self._invalidate_auth_cache()
|
self._invalidate_auth_cache()
|
||||||
|
|
@ -1276,6 +1342,17 @@ class SpotifyClient:
|
||||||
logger.error(f"Error searching tracks via Spotify: {e}")
|
logger.error(f"Error searching tracks via Spotify: {e}")
|
||||||
# Fall through to fallback
|
# 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)
|
# Fallback (iTunes or Deezer — configured in settings)
|
||||||
if allow_fallback:
|
if allow_fallback:
|
||||||
logger.debug(f"Using {self._fallback_source} fallback for track search: {query}")
|
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}")
|
logger.error(f"Error searching artists via Spotify: {e}")
|
||||||
# Fall through to iTunes fallback
|
# 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)
|
# Fallback (iTunes or Deezer)
|
||||||
if allow_fallback:
|
if allow_fallback:
|
||||||
logger.debug(f"Using {self._fallback_source} fallback for artist search: {query}")
|
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}")
|
logger.error(f"Error fetching track details via Spotify: {e}")
|
||||||
# Fall through to iTunes fallback
|
# 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)
|
# Fallback - only if ID is numeric (non-Spotify format)
|
||||||
if allow_fallback and self._is_itunes_id(track_id):
|
if allow_fallback and self._is_itunes_id(track_id):
|
||||||
logger.debug(f"Using {fallback_src} fallback for track details: {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}")
|
logger.error(f"Error fetching album via Spotify: {e}")
|
||||||
# Fall through to fallback
|
# 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)
|
# Fallback - only if ID is numeric (non-Spotify format)
|
||||||
if allow_fallback and self._is_itunes_id(album_id):
|
if allow_fallback and self._is_itunes_id(album_id):
|
||||||
logger.debug(f"Using {fallback_src} fallback for album: {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}")
|
logger.error(f"Error fetching album tracks via Spotify: {e}")
|
||||||
# Fall through to iTunes fallback
|
# 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)
|
# Fallback - only if ID is numeric (non-Spotify format)
|
||||||
if allow_fallback and self._is_itunes_id(album_id):
|
if allow_fallback and self._is_itunes_id(album_id):
|
||||||
logger.debug(f"Using {fallback_src} fallback for album tracks: {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}")
|
logger.error(f"Error fetching artist albums via Spotify: {e}")
|
||||||
# Fall through to iTunes fallback
|
# 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)
|
# Fallback - only if ID is numeric (non-Spotify format)
|
||||||
if allow_fallback and self._is_itunes_id(artist_id):
|
if allow_fallback and self._is_itunes_id(artist_id):
|
||||||
logger.debug(f"Using {fallback_src} fallback for artist albums: {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}")
|
logger.error(f"Error fetching artist via Spotify: {e}")
|
||||||
# Fall through to iTunes fallback
|
# 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)
|
# Fallback - only if ID is numeric (non-Spotify format)
|
||||||
if allow_fallback and self._is_itunes_id(artist_id):
|
if allow_fallback and self._is_itunes_id(artist_id):
|
||||||
logger.debug(f"Using {fallback_src} fallback for artist: {artist_id}")
|
logger.debug(f"Using {fallback_src} fallback for artist: {artist_id}")
|
||||||
|
|
|
||||||
224
core/spotify_free_metadata.py
Normal file
224
core/spotify_free_metadata.py
Normal 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 []
|
||||||
|
|
@ -9,7 +9,12 @@ from datetime import datetime, date, timedelta
|
||||||
from utils.logging_config import get_logger
|
from utils.logging_config import get_logger
|
||||||
from database.music_database import MusicDatabase
|
from database.music_database import MusicDatabase
|
||||||
from core.spotify_client import SpotifyClient, SpotifyRateLimitError
|
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
|
from core.enrichment.manual_match_honoring import honor_stored_match
|
||||||
|
|
||||||
logger = get_logger("spotify_worker")
|
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
|
rate_limit_info = self.client.get_rate_limit_info() if rate_limited else None
|
||||||
in_cooldown = self.client.get_post_ban_cooldown_remaining() > 0
|
in_cooldown = self.client.get_post_ban_cooldown_remaining() > 0
|
||||||
authenticated = self.client.sp is not None
|
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:
|
except Exception:
|
||||||
authenticated = False
|
authenticated = False
|
||||||
rate_limited = False
|
rate_limited = False
|
||||||
rate_limit_info = None
|
rate_limit_info = None
|
||||||
in_cooldown = False
|
in_cooldown = False
|
||||||
|
using_free = False
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'enabled': True,
|
'enabled': True,
|
||||||
|
|
@ -134,6 +147,7 @@ class SpotifyWorker:
|
||||||
'idle': is_idle,
|
'idle': is_idle,
|
||||||
'authenticated': authenticated,
|
'authenticated': authenticated,
|
||||||
'rate_limited': rate_limited,
|
'rate_limited': rate_limited,
|
||||||
|
'using_free': using_free,
|
||||||
'rate_limit': rate_limit_info,
|
'rate_limit': rate_limit_info,
|
||||||
'daily_budget': self._get_daily_budget_info(),
|
'daily_budget': self._get_daily_budget_info(),
|
||||||
'current_item': self.current_item,
|
'current_item': self.current_item,
|
||||||
|
|
@ -181,16 +195,33 @@ class SpotifyWorker:
|
||||||
interruptible_sleep(self._stop_event, 1)
|
interruptible_sleep(self._stop_event, 1)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Rate limit guard — if globally rate limited, sleep until ban expires
|
# Rate limit guard — if globally rate limited, sleep until ban
|
||||||
if self.client.is_rate_limited():
|
# 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()
|
info = self.client.get_rate_limit_info()
|
||||||
remaining = info['remaining_seconds'] if info else 60
|
remaining = info['remaining_seconds'] if info else 60
|
||||||
logger.debug(f"Spotify globally rate limited, sleeping {remaining}s...")
|
logger.debug(f"Spotify globally rate limited, sleeping {remaining}s...")
|
||||||
interruptible_sleep(self._stop_event, min(remaining, 60)) # Check again every 60s max
|
interruptible_sleep(self._stop_event, min(remaining, 60)) # Check again every 60s max
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Daily budget guard — worker-only cap to avoid saturating Spotify rate limits
|
# Is the worker serving via the no-creds Spotify Free source this
|
||||||
if self._is_daily_budget_exhausted():
|
# 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()
|
budget = self._get_daily_budget_info()
|
||||||
resets_in = budget['resets_in_seconds']
|
resets_in = budget['resets_in_seconds']
|
||||||
logger.info(f"Daily enrichment budget exhausted ({budget['used']}/{budget['limit']}), "
|
logger.info(f"Daily enrichment budget exhausted ({budget['used']}/{budget['limit']}), "
|
||||||
|
|
@ -199,8 +230,9 @@ class SpotifyWorker:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Post-ban cooldown guard — after ban expires, wait before resuming
|
# Post-ban cooldown guard — after ban expires, wait before resuming
|
||||||
# to avoid immediately re-triggering the rate limit
|
# to avoid immediately re-triggering the rate limit. Only matters
|
||||||
cooldown = self.client.get_post_ban_cooldown_remaining()
|
# 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:
|
if cooldown > 0:
|
||||||
logger.debug(f"Post-ban cooldown active ({cooldown}s left), sleeping...")
|
logger.debug(f"Post-ban cooldown active ({cooldown}s left), sleeping...")
|
||||||
interruptible_sleep(self._stop_event, min(cooldown, 60))
|
interruptible_sleep(self._stop_event, min(cooldown, 60))
|
||||||
|
|
@ -210,10 +242,12 @@ class SpotifyWorker:
|
||||||
# We intentionally avoid calling is_spotify_authenticated() here
|
# We intentionally avoid calling is_spotify_authenticated() here
|
||||||
# because it makes an API probe that can re-trigger rate limits
|
# because it makes an API probe that can re-trigger rate limits
|
||||||
# and lock users in an infinite rate-limit loop.
|
# 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()
|
self.client.reload_config()
|
||||||
if not self.client.is_spotify_authenticated():
|
if not self.client.is_spotify_metadata_available():
|
||||||
logger.debug("Spotify not authenticated, sleeping 30s...")
|
logger.debug("Spotify metadata unavailable, sleeping 30s...")
|
||||||
interruptible_sleep(self._stop_event, 30)
|
interruptible_sleep(self._stop_event, 30)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|
@ -239,7 +273,10 @@ class SpotifyWorker:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
self._process_item(item)
|
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)
|
interruptible_sleep(self._stop_event, self.inter_item_sleep)
|
||||||
|
|
||||||
except SpotifyRateLimitError:
|
except SpotifyRateLimitError:
|
||||||
|
|
@ -483,12 +520,14 @@ class SpotifyWorker:
|
||||||
logger.debug(f"No Spotify results for artist '{artist_name}'")
|
logger.debug(f"No Spotify results for artist '{artist_name}'")
|
||||||
return
|
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_obj = None
|
||||||
best_score = 0
|
best_score = 0
|
||||||
for artist_obj in results:
|
for artist_obj in results:
|
||||||
score = self._name_similarity(artist_name, artist_obj.name)
|
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_obj = artist_obj
|
||||||
best_score = score
|
best_score = score
|
||||||
|
|
||||||
|
|
@ -498,6 +537,19 @@ class SpotifyWorker:
|
||||||
self._mark_status('artist', artist_id, 'error')
|
self._mark_status('artist', artist_id, 'error')
|
||||||
self.stats['errors'] += 1
|
self.stats['errors'] += 1
|
||||||
return
|
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._update_artist(artist_id, best_obj)
|
||||||
self.stats['matched'] += 1
|
self.stats['matched'] += 1
|
||||||
logger.info(f"Matched artist '{artist_name}' -> Spotify ID: {best_obj.id} (score: {best_score:.2f})")
|
logger.info(f"Matched artist '{artist_name}' -> Spotify ID: {best_obj.id} (score: {best_score:.2f})")
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,10 @@ from __future__ import annotations
|
||||||
|
|
||||||
from typing import Any, Callable, Dict, List, Optional
|
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(
|
def resolve_match_overrides(
|
||||||
source_tracks: List[Dict[str, Any]],
|
source_tracks: List[Dict[str, Any]],
|
||||||
|
|
@ -117,3 +121,76 @@ def record_manual_match(
|
||||||
))
|
))
|
||||||
except Exception:
|
except Exception:
|
||||||
return False
|
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)
|
||||||
|
|
|
||||||
|
|
@ -71,4 +71,64 @@ def remove_one_occurrence(
|
||||||
return ids, False
|
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",
|
||||||
|
]
|
||||||
|
|
|
||||||
|
|
@ -136,10 +136,49 @@ def read_file_tags(file_path: str) -> Dict[str, Any]:
|
||||||
return result
|
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]]:
|
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:
|
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 = [
|
fields = [
|
||||||
('title', 'title', 'Title'),
|
('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
|
# 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)
|
# (writer skips fields where DB value is empty, so don't show them as diffs)
|
||||||
changed = bool(db_str) and file_str != db_str
|
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({
|
diffs.append({
|
||||||
'field': label,
|
'field': label,
|
||||||
'file_key': file_key,
|
'file_key': file_key,
|
||||||
'file_value': str(file_val) if file_val is not None else '',
|
'file_value': str(file_val) if file_val is not None else '',
|
||||||
'db_value': str(db_val) if db_val is not None else '',
|
'db_value': str(db_val) if db_val is not None else '',
|
||||||
'changed': changed,
|
'changed': changed,
|
||||||
|
'protected': protected,
|
||||||
})
|
})
|
||||||
|
|
||||||
# Cover art — special row
|
# 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
|
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 = db_data.get('album_title')
|
||||||
album_artist = db_data.get('artist_name') # Album artist stays as the album-level artist
|
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')
|
year = db_data.get('year')
|
||||||
genres = db_data.get('genres')
|
genres = db_data.get('genres')
|
||||||
track_num = db_data.get('track_number')
|
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,
|
year, genre_str, track_num, total_tracks,
|
||||||
disc_num, bpm, artists_list=artists_list)
|
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
|
# Embed cover art if requested
|
||||||
if embed_cover:
|
if embed_cover:
|
||||||
art_ok = False
|
art_ok = False
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ from datetime import datetime, timedelta
|
||||||
from utils.logging_config import get_logger
|
from utils.logging_config import get_logger
|
||||||
from database.music_database import MusicDatabase
|
from database.music_database import MusicDatabase
|
||||||
from core.tidal_client import TidalClient
|
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
|
from core.enrichment.manual_match_honoring import honor_stored_match
|
||||||
|
|
||||||
logger = get_logger("tidal_worker")
|
logger = get_logger("tidal_worker")
|
||||||
|
|
@ -313,13 +313,29 @@ class TidalWorker:
|
||||||
logger.debug(f"Name similarity: '{query_name}' vs '{result_name}' = {similarity:.2f}")
|
logger.debug(f"Name similarity: '{query_name}' vs '{result_name}' = {similarity:.2f}")
|
||||||
return similarity >= self.name_similarity_threshold
|
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,
|
||||||
"""Verify/correct parent artist's Tidal ID based on album/track match"""
|
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')
|
parent_tidal_id = item.get('artist_tidal_id')
|
||||||
if not parent_tidal_id or not result_artist_id:
|
if not parent_tidal_id or not result_artist_id:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
if str(result_artist_id) != str(parent_tidal_id):
|
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(
|
logger.info(
|
||||||
f"Artist ID correction from {item['type']} '{item['name']}': "
|
f"Artist ID correction from {item['type']} '{item['name']}': "
|
||||||
f"updating parent artist Tidal ID from {parent_tidal_id} to {result_artist_id}"
|
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)
|
result = self.client.search_artist(artist_name)
|
||||||
if result:
|
if result:
|
||||||
result_name = result.get('name', '')
|
result_name = result.get('name', '')
|
||||||
if self._name_matches(artist_name, result_name):
|
tidal_artist_id = result.get('id')
|
||||||
tidal_artist_id = result.get('id')
|
ok, reason = accept_artist_match(
|
||||||
if not tidal_artist_id:
|
self.db, 'tidal_id', tidal_artist_id, artist_id, artist_name, result_name,
|
||||||
self._mark_status('artist', artist_id, 'error')
|
)
|
||||||
self.stats['errors'] += 1
|
if not ok:
|
||||||
logger.warning(f"Tidal search result for '{artist_name}' has no ID")
|
self._mark_status('artist', artist_id, 'not_found')
|
||||||
return
|
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
|
# Fetch full artist details for image
|
||||||
full_artist = None
|
full_artist = None
|
||||||
try:
|
try:
|
||||||
|
|
@ -437,10 +458,6 @@ class TidalWorker:
|
||||||
self._update_artist(artist_id, result, full_artist)
|
self._update_artist(artist_id, result, full_artist)
|
||||||
self.stats['matched'] += 1
|
self.stats['matched'] += 1
|
||||||
logger.info(f"Matched artist '{artist_name}' -> Tidal ID: {tidal_artist_id}")
|
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:
|
else:
|
||||||
self._mark_status('artist', artist_id, 'not_found')
|
self._mark_status('artist', artist_id, 'not_found')
|
||||||
self.stats['not_found'] += 1
|
self.stats['not_found'] += 1
|
||||||
|
|
@ -479,7 +496,8 @@ class TidalWorker:
|
||||||
# Verify artist ID
|
# Verify artist ID
|
||||||
result_artist = result.get('artist', {})
|
result_artist = result.get('artist', {})
|
||||||
result_artist_id = result_artist.get('id') if result_artist else None
|
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
|
# Fetch full album details
|
||||||
tidal_album_id = result.get('id')
|
tidal_album_id = result.get('id')
|
||||||
|
|
@ -533,7 +551,8 @@ class TidalWorker:
|
||||||
# Verify artist ID
|
# Verify artist ID
|
||||||
result_artist = result.get('artist', {})
|
result_artist = result.get('artist', {})
|
||||||
result_artist_id = result_artist.get('id') if result_artist else None
|
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
|
# Fetch full track details
|
||||||
tidal_track_id = result.get('id')
|
tidal_track_id = result.get('id')
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,24 @@ from dataclasses import dataclass
|
||||||
from typing import List, Optional, Protocol, runtime_checkable
|
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
|
@dataclass
|
||||||
class TorrentStatus:
|
class TorrentStatus:
|
||||||
"""Adapter-uniform view of one torrent's live state.
|
"""Adapter-uniform view of one torrent's live state.
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ from typing import Any, List, Optional
|
||||||
import requests as http_requests
|
import requests as http_requests
|
||||||
|
|
||||||
from config.settings import config_manager
|
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
|
from utils.logging_config import get_logger
|
||||||
|
|
||||||
logger = get_logger("torrent.deluge")
|
logger = get_logger("torrent.deluge")
|
||||||
|
|
@ -67,7 +67,7 @@ class DelugeAdapter:
|
||||||
self._load_config()
|
self._load_config()
|
||||||
|
|
||||||
def _load_config(self) -> None:
|
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.
|
# Deluge's WebUI auth uses a single password, not username+password.
|
||||||
# We accept whichever field the user filled in — keeps the UI uniform.
|
# We accept whichever field the user filled in — keeps the UI uniform.
|
||||||
self._password = (
|
self._password = (
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ from typing import List, Optional
|
||||||
import requests as http_requests
|
import requests as http_requests
|
||||||
|
|
||||||
from config.settings import config_manager
|
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
|
from utils.logging_config import get_logger
|
||||||
|
|
||||||
logger = get_logger("torrent.qbittorrent")
|
logger = get_logger("torrent.qbittorrent")
|
||||||
|
|
@ -64,7 +64,7 @@ class QBittorrentAdapter:
|
||||||
self._load_config()
|
self._load_config()
|
||||||
|
|
||||||
def _load_config(self) -> None:
|
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._username = config_manager.get('torrent_client.username', '') or ''
|
||||||
self._password = config_manager.get('torrent_client.password', '') or ''
|
self._password = config_manager.get('torrent_client.password', '') or ''
|
||||||
self._category = config_manager.get('torrent_client.category', 'soulsync') or 'soulsync'
|
self._category = config_manager.get('torrent_client.category', 'soulsync') or 'soulsync'
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ from typing import List, Optional
|
||||||
import requests as http_requests
|
import requests as http_requests
|
||||||
|
|
||||||
from config.settings import config_manager
|
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
|
from utils.logging_config import get_logger
|
||||||
|
|
||||||
logger = get_logger("torrent.transmission")
|
logger = get_logger("torrent.transmission")
|
||||||
|
|
@ -57,7 +57,7 @@ class TransmissionAdapter:
|
||||||
self._load_config()
|
self._load_config()
|
||||||
|
|
||||||
def _load_config(self) -> None:
|
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
|
# Transmission's RPC endpoint is always /transmission/rpc — if the
|
||||||
# user pasted a bare host URL, append it. If they pasted the full
|
# user pasted a bare host URL, append it. If they pasted the full
|
||||||
# /transmission/rpc URL, leave it.
|
# /transmission/rpc URL, leave it.
|
||||||
|
|
|
||||||
|
|
@ -489,12 +489,16 @@ class WatchlistScanner:
|
||||||
self._spotify_disabled_reason = reason
|
self._spotify_disabled_reason = reason
|
||||||
|
|
||||||
def _spotify_available_for_run(self) -> bool:
|
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:
|
if self._spotify_disabled_for_run:
|
||||||
return False
|
return False
|
||||||
if not self.spotify_client:
|
if not self.spotify_client:
|
||||||
return False
|
return False
|
||||||
return self.spotify_client.is_spotify_authenticated()
|
return self.spotify_client.is_spotify_metadata_available()
|
||||||
|
|
||||||
def _spotify_is_primary_source(self) -> bool:
|
def _spotify_is_primary_source(self) -> bool:
|
||||||
"""Check if Spotify is both authenticated and the configured primary metadata source.
|
"""Check if Spotify is both authenticated and the configured primary metadata source.
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,105 @@
|
||||||
"""Shared helpers for background workers."""
|
"""Shared helpers for background workers."""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
import threading
|
import threading
|
||||||
|
from difflib import SequenceMatcher
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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:
|
def interruptible_sleep(stop_event: threading.Event, seconds: float, step: float = 0.5) -> bool:
|
||||||
"""Sleep in chunks so shutdown can interrupt long waits."""
|
"""Sleep in chunks so shutdown can interrupt long waits."""
|
||||||
|
|
|
||||||
|
|
@ -790,6 +790,55 @@ class MusicDatabase:
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug("Failed to purge cached tracks/albums with junk artist names: %s", 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
|
# HiFi API instances table
|
||||||
cursor.execute("""
|
cursor.execute("""
|
||||||
CREATE TABLE IF NOT EXISTS hifi_instances (
|
CREATE TABLE IF NOT EXISTS hifi_instances (
|
||||||
|
|
@ -970,6 +1019,10 @@ class MusicDatabase:
|
||||||
'canonical_album_id': 'TEXT DEFAULT NULL',
|
'canonical_album_id': 'TEXT DEFAULT NULL',
|
||||||
'canonical_score': 'REAL DEFAULT NULL',
|
'canonical_score': 'REAL DEFAULT NULL',
|
||||||
'canonical_resolved_at': 'TIMESTAMP 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():
|
for _col, _typedef in _canonical_cols.items():
|
||||||
if album_cols and _col not in album_cols:
|
if album_cols and _col not in album_cols:
|
||||||
|
|
@ -978,17 +1031,27 @@ class MusicDatabase:
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Error repairing core media schema columns: %s", 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
|
"""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()
|
conn = self._get_connection()
|
||||||
try:
|
try:
|
||||||
cursor = conn.cursor()
|
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(
|
cursor.execute(
|
||||||
"UPDATE albums SET canonical_source = ?, canonical_album_id = ?, "
|
"UPDATE albums SET canonical_source = ?, canonical_album_id = ?, "
|
||||||
"canonical_score = ?, canonical_resolved_at = CURRENT_TIMESTAMP "
|
"canonical_score = ?, canonical_locked = ?, "
|
||||||
"WHERE id = ?",
|
"canonical_resolved_at = CURRENT_TIMESTAMP "
|
||||||
(source, str(canonical_album_id), float(score), album_id),
|
f"WHERE id = ?{guard}",
|
||||||
|
(source, str(canonical_album_id), float(score), 1 if locked else 0, album_id),
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
return cursor.rowcount > 0
|
return cursor.rowcount > 0
|
||||||
|
|
@ -999,15 +1062,16 @@ class MusicDatabase:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
def get_album_canonical(self, album_id) -> Optional[dict]:
|
def get_album_canonical(self, album_id) -> Optional[dict]:
|
||||||
"""Return ``{'source','album_id','score','resolved_at'}`` for an album's
|
"""Return ``{'source','album_id','score','resolved_at','locked'}`` for an
|
||||||
pinned canonical release, or ``None`` when unresolved (#765 Stage 2).
|
album's pinned canonical release, or ``None`` when unresolved (#765 Stage
|
||||||
Consumers treat ``None`` as 'fall back to today's behavior'."""
|
2). ``locked`` is True for a manual pin (#758). Consumers treat ``None``
|
||||||
|
as 'fall back to today's behavior'."""
|
||||||
conn = self._get_connection()
|
conn = self._get_connection()
|
||||||
try:
|
try:
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
cursor.execute(
|
cursor.execute(
|
||||||
"SELECT canonical_source, canonical_album_id, canonical_score, "
|
"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,),
|
(album_id,),
|
||||||
)
|
)
|
||||||
row = cursor.fetchone()
|
row = cursor.fetchone()
|
||||||
|
|
@ -1018,6 +1082,7 @@ class MusicDatabase:
|
||||||
'album_id': row[1],
|
'album_id': row[1],
|
||||||
'score': row[2],
|
'score': row[2],
|
||||||
'resolved_at': row[3],
|
'resolved_at': row[3],
|
||||||
|
'locked': bool(row[4]),
|
||||||
}
|
}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Error reading album canonical for %s: %s", album_id, 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
|
CREATE INDEX IF NOT EXISTS idx_mltm_lib_track
|
||||||
ON manual_library_track_matches (library_track_id)
|
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:
|
except Exception as e:
|
||||||
logger.error(f"Error creating manual_library_track_matches table: {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,
|
def save_manual_library_match(self, profile_id: int, source: str, source_track_id: str,
|
||||||
library_track_id: str, **meta) -> bool:
|
library_track_id: str, **meta) -> bool:
|
||||||
"""Insert or replace a manual match. meta keys: source_title, source_artist,
|
"""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:
|
try:
|
||||||
with self._get_connection() as conn:
|
with self._get_connection() as conn:
|
||||||
conn.execute("""
|
conn.execute("""
|
||||||
INSERT INTO manual_library_track_matches
|
INSERT INTO manual_library_track_matches
|
||||||
(profile_id, source, source_track_id, library_track_id,
|
(profile_id, source, source_track_id, library_track_id,
|
||||||
source_title, source_artist, source_album,
|
source_title, source_artist, source_album,
|
||||||
source_context_json, server_source, updated_at)
|
source_context_json, server_source, library_file_path, updated_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||||
ON CONFLICT(profile_id, source, source_track_id, server_source)
|
ON CONFLICT(profile_id, source, source_track_id, server_source)
|
||||||
DO UPDATE SET
|
DO UPDATE SET
|
||||||
library_track_id = excluded.library_track_id,
|
library_track_id = excluded.library_track_id,
|
||||||
|
|
@ -4643,18 +4716,46 @@ class MusicDatabase:
|
||||||
source_artist = excluded.source_artist,
|
source_artist = excluded.source_artist,
|
||||||
source_album = excluded.source_album,
|
source_album = excluded.source_album,
|
||||||
source_context_json = excluded.source_context_json,
|
source_context_json = excluded.source_context_json,
|
||||||
|
library_file_path = excluded.library_file_path,
|
||||||
updated_at = CURRENT_TIMESTAMP
|
updated_at = CURRENT_TIMESTAMP
|
||||||
""", (
|
""", (
|
||||||
profile_id, source, source_track_id, library_track_id,
|
profile_id, source, source_track_id, library_track_id,
|
||||||
meta.get('source_title'), meta.get('source_artist'),
|
meta.get('source_title'), meta.get('source_artist'),
|
||||||
meta.get('source_album'), meta.get('source_context_json'),
|
meta.get('source_album'), meta.get('source_context_json'),
|
||||||
meta.get('server_source', ''),
|
meta.get('server_source', ''), meta.get('library_file_path'),
|
||||||
))
|
))
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"save_manual_library_match error: {e}")
|
logger.error(f"save_manual_library_match error: {e}")
|
||||||
return False
|
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,
|
def get_manual_library_match(self, profile_id: int, source: str,
|
||||||
source_track_id: str, server_source: str = '') -> Optional[Dict[str, Any]]:
|
source_track_id: str, server_source: str = '') -> Optional[Dict[str, Any]]:
|
||||||
"""Return match row dict or None."""
|
"""Return match row dict or None."""
|
||||||
|
|
@ -5948,8 +6049,12 @@ class MusicDatabase:
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug("history logging: %s", 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:
|
except Exception as e:
|
||||||
error_text = str(e).lower()
|
error_text = str(e).lower()
|
||||||
if (
|
if (
|
||||||
|
|
|
||||||
59
scripts/dedupe_source_ids.py
Normal file
59
scripts/dedupe_source_ids.py
Normal 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()
|
||||||
|
|
@ -195,6 +195,26 @@ class PlaylistSyncService:
|
||||||
failed_tracks=failed_tracks
|
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:
|
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
|
self._active_profile_id = profile_id
|
||||||
# Check if THIS specific playlist is already syncing
|
# Check if THIS specific playlist is already syncing
|
||||||
|
|
@ -378,6 +398,8 @@ class PlaylistSyncService:
|
||||||
)
|
)
|
||||||
if sync_mode == 'append':
|
if sync_mode == 'append':
|
||||||
sync_success = media_client.append_to_playlist(playlist.name, valid_tracks)
|
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:
|
else:
|
||||||
sync_success = media_client.update_playlist(playlist.name, valid_tracks)
|
sync_success = media_client.update_playlist(playlist.name, valid_tracks)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -496,3 +496,45 @@ def test_multi_playlist_aggregates_grand_total():
|
||||||
|
|
||||||
# All 3 tracks discovered → 3 extra_data writes
|
# All 3 tracks discovered → 3 extra_data writes
|
||||||
assert len(deps._db.extra_data_writes) == 3
|
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)
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ AST-parsing the route file).
|
||||||
from core.discovery.manual_match import (
|
from core.discovery.manual_match import (
|
||||||
derive_manual_match_provider,
|
derive_manual_match_provider,
|
||||||
is_drifted_for_redo,
|
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
|
extra = {'discovered': True} # no provider field
|
||||||
assert is_drifted_for_redo(extra, 'spotify') is False
|
assert is_drifted_for_redo(extra, 'spotify') is False
|
||||||
assert is_drifted_for_redo(extra, 'musicbrainz') is True
|
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
|
||||||
|
|
|
||||||
|
|
@ -461,6 +461,45 @@ def test_auto_search_pick_does_not_inject_acoustid_bypass():
|
||||||
assert "_user_manual_pick" not in ctx
|
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():
|
def test_equal_confidence_candidates_prefer_better_peer_quality():
|
||||||
"""Equal-confidence Soulseek candidates use peer quality as the tiebreaker."""
|
"""Equal-confidence Soulseek candidates use peer quality as the tiebreaker."""
|
||||||
deps = _build_deps()
|
deps = _build_deps()
|
||||||
|
|
|
||||||
|
|
@ -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, "record_soulsync_library_entry", _record_library)
|
||||||
monkeypatch.setattr(import_pipeline, "check_and_remove_from_wishlist", lambda *args, **kwargs: None)
|
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 = {
|
context = {
|
||||||
"track_info": {"_playlist_folder_mode": True, "_playlist_name": "Playlist"},
|
"track_info": {"_playlist_folder_mode": True, "_playlist_name": "Playlist"},
|
||||||
|
|
|
||||||
363
tests/library/test_embedded_id_reconcile.py
Normal file
363
tests/library/test_embedded_id_reconcile.py
Normal 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
|
||||||
|
|
@ -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'
|
|
||||||
|
|
@ -270,6 +270,7 @@ class TestWorkerAliasEnrichment:
|
||||||
|
|
||||||
worker = MusicBrainzWorker.__new__(MusicBrainzWorker)
|
worker = MusicBrainzWorker.__new__(MusicBrainzWorker)
|
||||||
worker.database = temp_db
|
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 = MagicMock()
|
||||||
worker.mb_service.match_artist.return_value = {
|
worker.mb_service.match_artist.return_value = {
|
||||||
'mbid': '60d2ea34-1912-425f-bf9c-fc544e4448cd', 'name': 'Hiroyuki Sawano',
|
'mbid': '60d2ea34-1912-425f-bf9c-fc544e4448cd', 'name': 'Hiroyuki Sawano',
|
||||||
|
|
@ -300,6 +301,7 @@ class TestWorkerAliasEnrichment:
|
||||||
|
|
||||||
worker = MusicBrainzWorker.__new__(MusicBrainzWorker)
|
worker = MusicBrainzWorker.__new__(MusicBrainzWorker)
|
||||||
worker.database = temp_db
|
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 = MagicMock()
|
||||||
worker.mb_service.match_artist.return_value = None
|
worker.mb_service.match_artist.return_value = None
|
||||||
worker.stats = {'matched': 0, 'not_found': 0, 'errors': 0}
|
worker.stats = {'matched': 0, 'not_found': 0, 'errors': 0}
|
||||||
|
|
@ -392,6 +394,7 @@ class TestWorkerAliasEnrichment:
|
||||||
|
|
||||||
worker = MusicBrainzWorker.__new__(MusicBrainzWorker)
|
worker = MusicBrainzWorker.__new__(MusicBrainzWorker)
|
||||||
worker.database = temp_db
|
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 = MagicMock()
|
||||||
worker.mb_service.match_artist.return_value = {'mbid': 'mb-x', 'name': 'X'}
|
worker.mb_service.match_artist.return_value = {'mbid': 'mb-x', 'name': 'X'}
|
||||||
worker.mb_service.fetch_artist_aliases.side_effect = Exception("boom")
|
worker.mb_service.fetch_artist_aliases.side_effect = Exception("boom")
|
||||||
|
|
|
||||||
79
tests/matching/test_script_compat.py
Normal file
79
tests/matching/test_script_compat.py
Normal 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
|
||||||
|
|
@ -79,6 +79,82 @@ def test_get_all_album_ids_returns_set(nav_client):
|
||||||
assert result == {'nav-1', 'nav-2'}
|
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):
|
def test_navidrome_album_exposes_cover_art_url(nav_client):
|
||||||
album = NavidromeAlbum({
|
album = NavidromeAlbum({
|
||||||
'id': 'album-1',
|
'id': 'album-1',
|
||||||
|
|
|
||||||
|
|
@ -164,6 +164,45 @@ class TestFindLibraryArtistForSource:
|
||||||
)
|
)
|
||||||
assert result is None
|
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):
|
def test_id_match_wins_over_name_match(self, db):
|
||||||
"""If both a source-id match and a name match exist, the id match
|
"""If both a source-id match and a name match exist, the id match
|
||||||
should take priority — it's the more reliable signal."""
|
should take priority — it's the more reliable signal."""
|
||||||
|
|
|
||||||
451
tests/search/test_search_by_id.py
Normal file
451
tests/search/test_search_by_id.py
Normal 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
|
||||||
|
(album→track), source fan-out, and the not-found regression.
|
||||||
|
|
||||||
|
Clients are injected via ``client_resolver`` so nothing touches the network
|
||||||
|
or real config.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from core.search import by_id
|
||||||
|
from core.search.by_id import LookupTarget
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Fakes — a client exposing only the get-by-id methods the resolver calls.
|
||||||
|
# Each "source" can return album/track dicts in its native shape.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class _FakeClient:
|
||||||
|
def __init__(self, album=None, track=None, artist=None, name='fake'):
|
||||||
|
self._album = album
|
||||||
|
self._track = track
|
||||||
|
self._artist = artist
|
||||||
|
self._name = name
|
||||||
|
self.album_calls: list[str] = []
|
||||||
|
self.track_calls: list[str] = []
|
||||||
|
self.artist_calls: list[str] = []
|
||||||
|
|
||||||
|
# Spotify / iTunes / MusicBrainz album-by-id
|
||||||
|
def get_album(self, identifier, include_tracks=True):
|
||||||
|
self.album_calls.append(identifier)
|
||||||
|
return self._album
|
||||||
|
|
||||||
|
# Deezer album-by-id (different method name)
|
||||||
|
def get_album_metadata(self, identifier, include_tracks=True):
|
||||||
|
self.album_calls.append(identifier)
|
||||||
|
return self._album
|
||||||
|
|
||||||
|
# Uniform track-by-id
|
||||||
|
def get_track_details(self, identifier):
|
||||||
|
self.track_calls.append(identifier)
|
||||||
|
return self._track
|
||||||
|
|
||||||
|
# Spotify / iTunes / MusicBrainz artist-by-id
|
||||||
|
def get_artist(self, identifier):
|
||||||
|
self.artist_calls.append(identifier)
|
||||||
|
return self._artist
|
||||||
|
|
||||||
|
# Deezer artist-by-id (different method name)
|
||||||
|
def get_artist_info(self, identifier):
|
||||||
|
self.artist_calls.append(identifier)
|
||||||
|
return self._artist
|
||||||
|
|
||||||
|
|
||||||
|
def _resolver_from(mapping):
|
||||||
|
"""Build a client_resolver from {source: client}."""
|
||||||
|
return lambda source: mapping.get(source)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# parse_metadata_identifier — URLs
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_parse_spotify_album_url():
|
||||||
|
out = by_id.parse_metadata_identifier(
|
||||||
|
'https://open.spotify.com/album/4aawyAB9vmqN3uQ7FjRGTy'
|
||||||
|
)
|
||||||
|
assert out == [LookupTarget('spotify', 'album', '4aawyAB9vmqN3uQ7FjRGTy')]
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_spotify_track_url_with_intl_prefix():
|
||||||
|
out = by_id.parse_metadata_identifier(
|
||||||
|
'https://open.spotify.com/intl-de/track/11dFghVXANMlKmJXsNCbNl'
|
||||||
|
)
|
||||||
|
assert out == [LookupTarget('spotify', 'track', '11dFghVXANMlKmJXsNCbNl')]
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_spotify_uri():
|
||||||
|
assert by_id.parse_metadata_identifier('spotify:album:ABC') == [
|
||||||
|
LookupTarget('spotify', 'album', 'ABC')
|
||||||
|
]
|
||||||
|
assert by_id.parse_metadata_identifier('spotify:track:XYZ') == [
|
||||||
|
LookupTarget('spotify', 'track', 'XYZ')
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_apple_album_url():
|
||||||
|
out = by_id.parse_metadata_identifier(
|
||||||
|
'https://music.apple.com/us/album/in-rainbows/1109714933'
|
||||||
|
)
|
||||||
|
assert out == [LookupTarget('itunes', 'album', '1109714933')]
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_apple_track_url_uses_i_param():
|
||||||
|
out = by_id.parse_metadata_identifier(
|
||||||
|
'https://music.apple.com/us/album/in-rainbows/1109714933?i=1109714934'
|
||||||
|
)
|
||||||
|
assert out == [LookupTarget('itunes', 'track', '1109714934')]
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_apple_song_url():
|
||||||
|
out = by_id.parse_metadata_identifier(
|
||||||
|
'https://music.apple.com/us/song/15-step/1109714938'
|
||||||
|
)
|
||||||
|
assert out == [LookupTarget('itunes', 'track', '1109714938')]
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_musicbrainz_release_group_url():
|
||||||
|
mbid = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'
|
||||||
|
out = by_id.parse_metadata_identifier(
|
||||||
|
f'https://musicbrainz.org/release-group/{mbid}'
|
||||||
|
)
|
||||||
|
assert out == [LookupTarget('musicbrainz', 'album', mbid)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_musicbrainz_recording_url_is_track():
|
||||||
|
mbid = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'
|
||||||
|
out = by_id.parse_metadata_identifier(
|
||||||
|
f'https://musicbrainz.org/recording/{mbid}'
|
||||||
|
)
|
||||||
|
assert out == [LookupTarget('musicbrainz', 'track', mbid)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_deezer_album_url_with_locale():
|
||||||
|
out = by_id.parse_metadata_identifier('https://www.deezer.com/en/album/302127')
|
||||||
|
assert out == [LookupTarget('deezer', 'album', '302127')]
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_deezer_track_url_no_scheme():
|
||||||
|
out = by_id.parse_metadata_identifier('www.deezer.com/track/3135556')
|
||||||
|
assert out == [LookupTarget('deezer', 'track', '3135556')]
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_spotify_url_without_scheme_or_www():
|
||||||
|
# Known host detected even without a scheme.
|
||||||
|
out = by_id.parse_metadata_identifier('open.spotify.com/album/4aawyAB9vmqN3uQ7FjRGTy')
|
||||||
|
assert out == [LookupTarget('spotify', 'album', '4aawyAB9vmqN3uQ7FjRGTy')]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# parse_metadata_identifier — artist links
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_parse_spotify_artist_url():
|
||||||
|
out = by_id.parse_metadata_identifier(
|
||||||
|
'https://open.spotify.com/artist/3TVXtAsR1Inumwj472S9r4'
|
||||||
|
)
|
||||||
|
assert out == [LookupTarget('spotify', 'artist', '3TVXtAsR1Inumwj472S9r4')]
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_spotify_artist_uri():
|
||||||
|
assert by_id.parse_metadata_identifier('spotify:artist:ABC') == [
|
||||||
|
LookupTarget('spotify', 'artist', 'ABC')
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_apple_artist_url():
|
||||||
|
out = by_id.parse_metadata_identifier(
|
||||||
|
'https://music.apple.com/us/artist/kendrick-lamar/368183298'
|
||||||
|
)
|
||||||
|
assert out == [LookupTarget('itunes', 'artist', '368183298')]
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_musicbrainz_artist_url():
|
||||||
|
mbid = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'
|
||||||
|
out = by_id.parse_metadata_identifier(f'https://musicbrainz.org/artist/{mbid}')
|
||||||
|
assert out == [LookupTarget('musicbrainz', 'artist', mbid)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_deezer_artist_url():
|
||||||
|
out = by_id.parse_metadata_identifier('https://www.deezer.com/artist/13')
|
||||||
|
assert out == [LookupTarget('deezer', 'artist', '13')]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# parse_metadata_identifier — bare IDs are rejected (links only)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_parse_bare_numeric_id_rejected():
|
||||||
|
# The footgun case (#775 follow-up): a bare number has no source/type, so
|
||||||
|
# it must NOT resolve to whatever album happens to own that id.
|
||||||
|
assert by_id.parse_metadata_identifier('525046') == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_bare_uuid_rejected():
|
||||||
|
assert by_id.parse_metadata_identifier(
|
||||||
|
'a1b2c3d4-e5f6-7890-abcd-ef1234567890'
|
||||||
|
) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_bare_base62_rejected():
|
||||||
|
assert by_id.parse_metadata_identifier('4aawyAB9vmqN3uQ7FjRGTy') == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_empty_and_garbage_return_empty():
|
||||||
|
assert by_id.parse_metadata_identifier('') == []
|
||||||
|
assert by_id.parse_metadata_identifier(' ') == []
|
||||||
|
assert by_id.parse_metadata_identifier('not an id!!') == []
|
||||||
|
# Unknown domain → no targets.
|
||||||
|
assert by_id.parse_metadata_identifier('https://example.com/album/1') == []
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Shaping adapters
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_album_card_from_spotify_shaped_dict():
|
||||||
|
d = {
|
||||||
|
'id': 'abc',
|
||||||
|
'name': 'OK Computer',
|
||||||
|
'artists': [{'name': 'Radiohead', 'id': 'r1'}],
|
||||||
|
'images': [{'url': 'http://img/big.jpg', 'height': 640, 'width': 640}],
|
||||||
|
'release_date': '1997-05-21',
|
||||||
|
'total_tracks': 12,
|
||||||
|
'album_type': 'album',
|
||||||
|
'external_urls': {'spotify': 'http://spot/abc'},
|
||||||
|
}
|
||||||
|
card = by_id.album_dict_to_card(d)
|
||||||
|
assert card['id'] == 'abc'
|
||||||
|
assert card['name'] == 'OK Computer'
|
||||||
|
assert card['artist'] == 'Radiohead'
|
||||||
|
assert card['image_url'] == 'http://img/big.jpg'
|
||||||
|
assert card['total_tracks'] == 12
|
||||||
|
assert card['external_urls'] == {'spotify': 'http://spot/abc'}
|
||||||
|
|
||||||
|
|
||||||
|
def test_album_card_carries_optional_musicbrainz_fields():
|
||||||
|
d = {
|
||||||
|
'id': 'mbid', 'name': 'Kid A', 'artists': [{'name': 'Radiohead'}],
|
||||||
|
'images': [], 'release_date': '2000', 'total_tracks': 10,
|
||||||
|
'album_type': 'album', 'country': 'GB', 'label': 'Parlophone',
|
||||||
|
'release_group_id': 'rg1', 'external_urls': {},
|
||||||
|
}
|
||||||
|
card = by_id.album_dict_to_card(d)
|
||||||
|
assert card['country'] == 'GB'
|
||||||
|
assert card['label'] == 'Parlophone'
|
||||||
|
assert card['release_group_id'] == 'rg1'
|
||||||
|
|
||||||
|
|
||||||
|
def test_track_card_handles_list_of_string_artists():
|
||||||
|
# Spotify/iTunes shape: artists is a list of plain strings.
|
||||||
|
d = {
|
||||||
|
'id': 't1', 'name': 'Paranoid Android',
|
||||||
|
'artists': ['Radiohead'],
|
||||||
|
'album': {'name': 'OK Computer', 'release_date': '1997'},
|
||||||
|
'duration_ms': 387000,
|
||||||
|
}
|
||||||
|
card = by_id.track_dict_to_card(d)
|
||||||
|
assert card['artist'] == 'Radiohead'
|
||||||
|
assert card['album'] == 'OK Computer'
|
||||||
|
assert card['duration_ms'] == 387000
|
||||||
|
assert card['release_date'] == '1997'
|
||||||
|
|
||||||
|
|
||||||
|
def test_track_card_handles_list_of_dict_artists_and_album_image():
|
||||||
|
# MusicBrainz shape: artists is a list of dicts; album carries images.
|
||||||
|
d = {
|
||||||
|
'id': 't2', 'name': 'Idioteque',
|
||||||
|
'artists': [{'name': 'Radiohead', 'id': ''}],
|
||||||
|
'album': {
|
||||||
|
'name': 'Kid A',
|
||||||
|
'images': [{'url': 'http://img/kida.jpg', 'height': 250, 'width': 250}],
|
||||||
|
'release_date': '2000',
|
||||||
|
},
|
||||||
|
'duration_ms': 300000,
|
||||||
|
'external_urls': {'musicbrainz': 'http://mb/t2'},
|
||||||
|
}
|
||||||
|
card = by_id.track_dict_to_card(d)
|
||||||
|
assert card['artist'] == 'Radiohead'
|
||||||
|
assert card['album'] == 'Kid A'
|
||||||
|
assert card['image_url'] == 'http://img/kida.jpg'
|
||||||
|
assert card['external_urls'] == {'musicbrainz': 'http://mb/t2'}
|
||||||
|
|
||||||
|
|
||||||
|
def test_join_artists_empty_is_unknown():
|
||||||
|
assert by_id._join_artists([]) == 'Unknown Artist'
|
||||||
|
assert by_id._join_artists(None) == 'Unknown Artist'
|
||||||
|
|
||||||
|
|
||||||
|
def test_artist_card_from_spotify_shaped_dict():
|
||||||
|
d = {
|
||||||
|
'id': 'a1', 'name': 'Radiohead',
|
||||||
|
'images': [{'url': 'http://img/rh.jpg', 'height': 640, 'width': 640}],
|
||||||
|
'external_urls': {'spotify': 'http://spot/a1'},
|
||||||
|
'genres': ['rock'], 'popularity': 80,
|
||||||
|
}
|
||||||
|
card = by_id.artist_dict_to_card(d)
|
||||||
|
assert card == {
|
||||||
|
'id': 'a1',
|
||||||
|
'name': 'Radiohead',
|
||||||
|
'image_url': 'http://img/rh.jpg',
|
||||||
|
'external_urls': {'spotify': 'http://spot/a1'},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# resolve_identifier — end-to-end with fake clients
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_SPOTIFY_ALBUM = {
|
||||||
|
'id': 'abc', 'name': 'OK Computer',
|
||||||
|
'artists': [{'name': 'Radiohead'}],
|
||||||
|
'images': [{'url': 'http://i/a.jpg'}],
|
||||||
|
'release_date': '1997', 'total_tracks': 12, 'album_type': 'album',
|
||||||
|
'external_urls': {'spotify': 'http://s/abc'},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_spotify_album_link():
|
||||||
|
client = _FakeClient(album=_SPOTIFY_ALBUM)
|
||||||
|
res = by_id.resolve_identifier(
|
||||||
|
'https://open.spotify.com/album/abc', deps=None,
|
||||||
|
client_resolver=_resolver_from({'spotify': client}),
|
||||||
|
)
|
||||||
|
assert res['available'] is True
|
||||||
|
assert res['source'] == 'spotify'
|
||||||
|
assert len(res['albums']) == 1
|
||||||
|
assert res['albums'][0]['name'] == 'OK Computer'
|
||||||
|
assert res['tracks'] == []
|
||||||
|
assert client.album_calls == ['abc']
|
||||||
|
assert client.track_calls == [] # kind pinned to album — no track probe
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_deezer_album_uses_get_album_metadata():
|
||||||
|
client = _FakeClient(album={'id': '302127', 'name': 'Discovery',
|
||||||
|
'artists': [{'name': 'Daft Punk'}], 'images': [],
|
||||||
|
'release_date': '2001', 'total_tracks': 14,
|
||||||
|
'album_type': 'album', 'external_urls': {}})
|
||||||
|
res = by_id.resolve_identifier(
|
||||||
|
'https://www.deezer.com/album/302127', deps=None,
|
||||||
|
client_resolver=_resolver_from({'deezer': client}),
|
||||||
|
)
|
||||||
|
assert res['available'] is True
|
||||||
|
assert res['source'] == 'deezer'
|
||||||
|
assert res['albums'][0]['name'] == 'Discovery'
|
||||||
|
assert client.album_calls == ['302127']
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_track_link():
|
||||||
|
# A track URL pins kind=track — only get_track_details is called.
|
||||||
|
client = _FakeClient(album=None, track={
|
||||||
|
'id': 't1', 'name': 'Creep', 'artists': ['Radiohead'],
|
||||||
|
'album': {'name': 'Pablo Honey'}, 'duration_ms': 238000,
|
||||||
|
})
|
||||||
|
res = by_id.resolve_identifier(
|
||||||
|
'https://open.spotify.com/track/t1', deps=None,
|
||||||
|
client_resolver=_resolver_from({'spotify': client}),
|
||||||
|
)
|
||||||
|
assert res['available'] is True
|
||||||
|
assert res['tracks'][0]['name'] == 'Creep'
|
||||||
|
assert res['albums'] == []
|
||||||
|
assert client.album_calls == [] # kind pinned to track — no album probe
|
||||||
|
assert client.track_calls == ['t1']
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_artist_link():
|
||||||
|
# An artist URL pins kind=artist — only get_artist is called.
|
||||||
|
client = _FakeClient(artist={
|
||||||
|
'id': 'a1', 'name': 'Radiohead',
|
||||||
|
'images': [{'url': 'http://i/rh.jpg'}],
|
||||||
|
'external_urls': {'spotify': 'http://s/a1'},
|
||||||
|
})
|
||||||
|
res = by_id.resolve_identifier(
|
||||||
|
'https://open.spotify.com/artist/a1', deps=None,
|
||||||
|
client_resolver=_resolver_from({'spotify': client}),
|
||||||
|
)
|
||||||
|
assert res['available'] is True
|
||||||
|
assert res['source'] == 'spotify'
|
||||||
|
assert res['artists'][0]['name'] == 'Radiohead'
|
||||||
|
assert res['albums'] == [] and res['tracks'] == []
|
||||||
|
assert client.artist_calls == ['a1']
|
||||||
|
assert client.album_calls == [] and client.track_calls == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_deezer_artist_uses_get_artist_info():
|
||||||
|
client = _FakeClient(artist={'id': '13', 'name': 'Daft Punk',
|
||||||
|
'images': [], 'external_urls': {}})
|
||||||
|
res = by_id.resolve_identifier(
|
||||||
|
'https://www.deezer.com/artist/13', deps=None,
|
||||||
|
client_resolver=_resolver_from({'deezer': client}),
|
||||||
|
)
|
||||||
|
assert res['available'] is True
|
||||||
|
assert res['source'] == 'deezer'
|
||||||
|
assert res['artists'][0]['name'] == 'Daft Punk'
|
||||||
|
assert client.artist_calls == ['13']
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_bare_id_rejected_with_hint():
|
||||||
|
# The #775 follow-up regression: a bare number must not resolve; it
|
||||||
|
# returns not-found with a link hint instead of an unrelated album.
|
||||||
|
called = []
|
||||||
|
res = by_id.resolve_identifier(
|
||||||
|
'525046', deps=None,
|
||||||
|
client_resolver=lambda s: called.append(s), # must never be invoked
|
||||||
|
)
|
||||||
|
assert res['available'] is False
|
||||||
|
assert res['albums'] == [] and res['tracks'] == []
|
||||||
|
assert 'link' in res['message'].lower()
|
||||||
|
assert called == [] # no source was even probed
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_unavailable_client_is_skipped():
|
||||||
|
# Spotify client is None (unauthed) — resolver returns not-found, no crash.
|
||||||
|
res = by_id.resolve_identifier(
|
||||||
|
'https://open.spotify.com/album/abc', deps=None,
|
||||||
|
client_resolver=_resolver_from({'spotify': None}),
|
||||||
|
)
|
||||||
|
assert res['available'] is False
|
||||||
|
assert res['albums'] == [] and res['tracks'] == []
|
||||||
|
# The source we tried is reported even on miss.
|
||||||
|
assert res['source'] == 'spotify'
|
||||||
|
assert res['message']
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_client_exception_does_not_propagate():
|
||||||
|
def boom(_source):
|
||||||
|
raise RuntimeError('client init failed')
|
||||||
|
res = by_id.resolve_identifier(
|
||||||
|
'https://open.spotify.com/album/abc', deps=None, client_resolver=boom,
|
||||||
|
)
|
||||||
|
assert res['available'] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_unrecognized_identifier_returns_empty():
|
||||||
|
res = by_id.resolve_identifier(
|
||||||
|
'definitely not a link', deps=None,
|
||||||
|
client_resolver=_resolver_from({}),
|
||||||
|
)
|
||||||
|
assert res['available'] is False
|
||||||
|
assert res['query'] == 'definitely not a link'
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_get_album_returning_none_yields_not_found():
|
||||||
|
# Regression: a pinned-kind link whose lookup returns None must report
|
||||||
|
# not-found, not raise or fabricate a card.
|
||||||
|
client = _FakeClient(album=None)
|
||||||
|
res = by_id.resolve_identifier(
|
||||||
|
'https://open.spotify.com/album/missing', deps=None,
|
||||||
|
client_resolver=_resolver_from({'spotify': client}),
|
||||||
|
)
|
||||||
|
assert res['available'] is False
|
||||||
|
assert res['albums'] == []
|
||||||
|
|
@ -50,7 +50,7 @@ class _Track:
|
||||||
|
|
||||||
class _Client:
|
class _Client:
|
||||||
def __init__(self, *, name='fake', artists=None, albums=None, tracks=None,
|
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.name = name
|
||||||
self._artists = artists or []
|
self._artists = artists or []
|
||||||
self._albums = albums or []
|
self._albums = albums or []
|
||||||
|
|
@ -58,6 +58,10 @@ class _Client:
|
||||||
self._fail = fail_search
|
self._fail = fail_search
|
||||||
self._authed = authed
|
self._authed = authed
|
||||||
self._connected = connected
|
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):
|
def search_artists(self, q, limit=10):
|
||||||
if self._fail:
|
if self._fail:
|
||||||
|
|
@ -77,6 +81,9 @@ class _Client:
|
||||||
def is_spotify_authenticated(self):
|
def is_spotify_authenticated(self):
|
||||||
return self._authed
|
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):
|
def is_connected(self):
|
||||||
return self._connected
|
return self._connected
|
||||||
|
|
||||||
|
|
@ -157,12 +164,22 @@ def test_resolve_spotify_authed_returns_client():
|
||||||
|
|
||||||
|
|
||||||
def test_resolve_spotify_unauthed_returns_none():
|
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)
|
client, ok = orchestrator.resolve_client('spotify', deps)
|
||||||
assert client is None
|
assert client is None
|
||||||
assert ok is False
|
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():
|
def test_resolve_spotify_missing_returns_none():
|
||||||
deps = _build_deps(spotify_client=None)
|
deps = _build_deps(spotify_client=None)
|
||||||
client, ok = orchestrator.resolve_client('spotify', deps)
|
client, ok = orchestrator.resolve_client('spotify', deps)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,10 @@
|
||||||
from unittest.mock import MagicMock
|
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_title"] == ""
|
||||||
assert kwargs["normalized_artist"] == ""
|
assert kwargs["normalized_artist"] == ""
|
||||||
assert kwargs["server_track_title"] == ""
|
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
|
||||||
|
|
|
||||||
67
tests/sync/test_reconcile_or_replace.py
Normal file
67
tests/sync/test_reconcile_or_replace.py
Normal 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']
|
||||||
|
|
@ -185,6 +185,80 @@ def test_high_score_but_artist_mismatch_no_longer_skipped(verifier):
|
||||||
assert result == VerificationResult.FAIL
|
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):
|
def test_old_loose_threshold_no_longer_fires_for_unrelated_titles(verifier):
|
||||||
"""Pin the negative case for the old loose threshold (title_sim
|
"""Pin the negative case for the old loose threshold (title_sim
|
||||||
>= 0.55). 'Crown' vs 'Crown of Thorns' had similarity around 0.6
|
>= 0.55). 'Crown' vs 'Crown of Thorns' had similarity around 0.6
|
||||||
|
|
|
||||||
|
|
@ -360,6 +360,40 @@ def test_copy_audio_files_atomically_creates_staging_dir(tmp_path: Path) -> None
|
||||||
assert staging.exists()
|
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
|
# Config-driven poll cadence
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
99
tests/test_canonical_manual_lock.py
Normal file
99
tests/test_canonical_manual_lock.py
Normal 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
|
||||||
66
tests/test_database_update_reconcile_hook.py
Normal file
66
tests/test_database_update_reconcile_hook.py
Normal 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]
|
||||||
168
tests/test_dedupe_source_ids.py
Normal file
168
tests/test_dedupe_source_ids.py
Normal 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()
|
||||||
76
tests/test_deezer_worker_artist_id_guard.py
Normal file
76
tests/test_deezer_worker_artist_id_guard.py
Normal 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')]
|
||||||
69
tests/test_embed_known_source_ids.py
Normal file
69
tests/test_embed_known_source_ids.py
Normal 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
|
||||||
100
tests/test_enrichment_artist_id_guard.py
Normal file
100
tests/test_enrichment_artist_id_guard.py
Normal 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')]
|
||||||
286
tests/test_library_retag_job.py
Normal file
286
tests/test_library_retag_job.py
Normal 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)
|
||||||
|
|
@ -168,53 +168,3 @@ def test_no_sidecar_falls_through_to_lrclib(fake_audio_file):
|
||||||
assert os.path.exists(lrc)
|
assert os.path.exists(lrc)
|
||||||
# And USLT was embedded
|
# And USLT was embedded
|
||||||
client._embed_lyrics.assert_called_once()
|
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
|
|
||||||
|
|
|
||||||
|
|
@ -90,6 +90,34 @@ def test_save_upserts_existing(db):
|
||||||
assert row["source_title"] == "Updated"
|
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):
|
def test_delete_by_id(db):
|
||||||
db.save_manual_library_match(1, "spotify", "track-abc", 42)
|
db.save_manual_library_match(1, "spotify", "track-abc", 42)
|
||||||
row = db.get_manual_library_match(1, "spotify", "track-abc")
|
row = db.get_manual_library_match(1, "spotify", "track-abc")
|
||||||
|
|
|
||||||
|
|
@ -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'
|
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):
|
def test_missing_cover_art_uses_primary_when_prefer_unset(monkeypatch):
|
||||||
conn = _make_db((1, 'Album', 1, '', None, None, None, None, None))
|
conn = _make_db((1, 'Album', 1, '', None, None, None, None, None))
|
||||||
context = _make_context(conn)
|
context = _make_context(conn)
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,12 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
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 ────────────────────────────
|
# ── plan_playlist_add: link must not duplicate ────────────────────────────
|
||||||
|
|
@ -78,3 +83,72 @@ def test_remove_single_occurrence():
|
||||||
def test_remove_stringifies():
|
def test_remove_stringifies():
|
||||||
new_ids, removed = remove_one_occurrence([1, 2, 2, 3], 2)
|
new_ids, removed = remove_one_occurrence([1, 2, 2, 3], 2)
|
||||||
assert removed and new_ids == ["1", "2", "3"]
|
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
114
tests/test_retag_planner.py
Normal 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'
|
||||||
|
|
@ -83,7 +83,7 @@ def test_healthy_folder_does_not_fall_back():
|
||||||
# True. Patch the atomic copy to echo the completed paths through.
|
# True. Patch the atomic copy to echo the completed paths through.
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
completed = [Path("/staged/01.flac"), Path("/staged/02.flac")]
|
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)
|
stub = _Stub(completed)
|
||||||
with patch("core.soulseek_client.run_async", lambda x: x):
|
with patch("core.soulseek_client.run_async", lambda x: x):
|
||||||
result = SoulseekClient.download_album_to_staging(
|
result = SoulseekClient.download_album_to_staging(
|
||||||
|
|
|
||||||
224
tests/test_spotify_free_metadata.py
Normal file
224
tests/test_spotify_free_metadata.py
Normal 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
|
||||||
143
tests/test_tag_writer_placeholder_guard.py
Normal file
143
tests/test_tag_writer_placeholder_guard.py
Normal 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']
|
||||||
|
|
@ -263,6 +263,58 @@ def test_transmission_normalises_bare_host_to_rpc_path() -> None:
|
||||||
assert adapter._url == 'http://host:9091/transmission/rpc'
|
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:
|
def test_transmission_session_id_renegotiation() -> None:
|
||||||
"""Transmission rejects the first call with 409 and a fresh
|
"""Transmission rejects the first call with 409 and a fresh
|
||||||
``X-Transmission-Session-Id`` header; the adapter must store it
|
``X-Transmission-Session-Id`` header; the adapter must store it
|
||||||
|
|
|
||||||
114
tests/test_worker_artist_match_gate.py
Normal file
114
tests/test_worker_artist_match_gate.py
Normal 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 == ""
|
||||||
583
web_server.py
583
web_server.py
|
|
@ -40,7 +40,7 @@ logger = setup_logging(_log_level, _log_path)
|
||||||
|
|
||||||
# App version — single source of truth for backup metadata, system-info, update check, etc.
|
# App version — single source of truth for backup metadata, system-info, update check, etc.
|
||||||
# Semver: MAJOR.MINOR.PATCH. Bump at each dev→main release.
|
# 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():
|
def _build_version_string():
|
||||||
"""Append short commit hash to version when available (e.g. 2.35+abc1234)."""
|
"""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_lock = threading.Lock()
|
||||||
duplicate_cleaner_executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="DuplicateCleaner")
|
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
|
# Download Missing Tracks Modal State Management
|
||||||
# Thread-safe state tracking for modal download functionality.
|
# Thread-safe state tracking for modal download functionality.
|
||||||
# Shared task/batch state now lives in core.runtime_state.
|
# Shared task/batch state now lives in core.runtime_state.
|
||||||
|
|
@ -1709,7 +1696,6 @@ def _shutdown_runtime_components():
|
||||||
(db_update_executor, "db update executor"),
|
(db_update_executor, "db update executor"),
|
||||||
(quality_scanner_executor, "quality scanner executor"),
|
(quality_scanner_executor, "quality scanner executor"),
|
||||||
(duplicate_cleaner_executor, "duplicate cleaner executor"),
|
(duplicate_cleaner_executor, "duplicate cleaner executor"),
|
||||||
(retag_executor, "retag executor"),
|
|
||||||
(sync_executor, "sync executor"),
|
(sync_executor, "sync executor"),
|
||||||
(missing_download_executor, "missing download executor"),
|
(missing_download_executor, "missing download executor"),
|
||||||
(album_bundle_executor, "album bundle executor"),
|
(album_bundle_executor, "album bundle executor"),
|
||||||
|
|
@ -2334,7 +2320,7 @@ def get_status():
|
||||||
|
|
||||||
status_data = {
|
status_data = {
|
||||||
'metadata_source': metadata_status['metadata_source'],
|
'metadata_source': metadata_status['metadata_source'],
|
||||||
'spotify': metadata_status['spotify'],
|
'spotify': _spotify_status_with_availability(metadata_status['spotify']),
|
||||||
'media_server': _status_cache['media_server'],
|
'media_server': _status_cache['media_server'],
|
||||||
'soulseek': soulseek_data,
|
'soulseek': soulseek_data,
|
||||||
'active_media_server': active_server,
|
'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.
|
Drives the green/yellow header gradient. No API calls — just config reads.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
return jsonify({
|
result = {
|
||||||
service: {'configured': _is_service_configured(service)}
|
service: {'configured': _is_service_configured(service)}
|
||||||
for service in SERVICE_CONFIG_REGISTRY
|
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:
|
except Exception as e:
|
||||||
logger.error(f"config-status error: {e}")
|
logger.error(f"config-status error: {e}")
|
||||||
return jsonify({"error": str(e)}), 500
|
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.
|
# Search route bodies live in core/search/* — these routes are thin handlers.
|
||||||
from core.search import basic as _search_basic
|
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 library_check as _search_library_check
|
||||||
from core.search import orchestrator as _search_orchestrator
|
from core.search import orchestrator as _search_orchestrator
|
||||||
from core.search import stream as _search_stream
|
from core.search import stream as _search_stream
|
||||||
|
|
@ -5739,6 +5739,34 @@ def enhanced_search():
|
||||||
return jsonify({"error": str(e)}), 500
|
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'])
|
@app.route('/api/enhanced-search/source/<source_name>', methods=['POST'])
|
||||||
def enhanced_search_source(source_name):
|
def enhanced_search_source(source_name):
|
||||||
"""Streaming NDJSON search for one alternate metadata source.
|
"""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):
|
def _build_source_only_artist_detail(artist_id, artist_name, source):
|
||||||
"""Thin wrapper around ``core.artist_source_detail.build_source_only_artist_detail``.
|
"""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(
|
library_pk = _find_library_artist_for_source(
|
||||||
database, source_param, artist_id, artist_name_arg
|
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:
|
if library_pk:
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Source-id {source_param}:{artist_id} matched library artist "
|
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:
|
if artists_list:
|
||||||
db_data['artists_list'] = 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
|
return db_data
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -9761,6 +9828,159 @@ def get_write_tags_batch_status():
|
||||||
return jsonify(state)
|
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 ──
|
# ── ReplayGain Analysis endpoints ──
|
||||||
|
|
||||||
|
|
@ -11096,6 +11316,18 @@ def library_manual_match():
|
||||||
if cursor.rowcount == 0:
|
if cursor.rowcount == 0:
|
||||||
return jsonify({"success": False, "error": "Entity not found"}), 404
|
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
|
# Re-fetch fresh data
|
||||||
artist_id = data.get('artist_id', entity_id)
|
artist_id = data.get('artist_id', entity_id)
|
||||||
if entity_type != 'artist':
|
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_max = 10 # Max retries before giving up (10 seconds with 1s poll interval)
|
||||||
_download_retry_lock = threading.Lock()
|
_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():
|
def _automatic_wishlist_cleanup_after_db_update():
|
||||||
"""Automatic wishlist cleanup that runs after database updates."""
|
"""Automatic wishlist cleanup that runs after database updates."""
|
||||||
return _cleanup_wishlist_after_db_update(logger=logger)
|
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('finished', _db_update_finished_callback)
|
||||||
db_update_worker.connect_callback('error', _db_update_error_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
|
# This is a blocking call that runs the worker logic
|
||||||
db_update_worker.run()
|
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('finished', _db_update_finished_callback)
|
||||||
db_update_worker.connect_callback('error', _db_update_error_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()
|
# Run deep scan instead of normal run()
|
||||||
db_update_worker.run_deep_scan()
|
db_update_worker.run_deep_scan()
|
||||||
|
|
||||||
|
|
@ -16454,115 +16655,6 @@ def stop_duplicate_cleaner():
|
||||||
# == RETAG TOOL ENDPOINTS ==
|
# == 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 ==
|
# == DOWNLOAD MISSING TRACKS ==
|
||||||
# ===============================
|
# ===============================
|
||||||
|
|
@ -18227,13 +18319,26 @@ def get_server_playlist_tracks(playlist_id):
|
||||||
# exact/fuzzy passes entirely. Stale-cache safe — if the cached
|
# exact/fuzzy passes entirely. Stale-cache safe — if the cached
|
||||||
# server track no longer exists, the override is silently
|
# server track no longer exists, the override is silently
|
||||||
# skipped and normal matching runs.
|
# 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()
|
_db_for_overrides = get_database()
|
||||||
_override_pairs = resolve_match_overrides(
|
# Set of server track ids currently in this playlist — used to validate
|
||||||
source_tracks,
|
# a re-resolved durable match actually exists before pairing it.
|
||||||
server_tracks,
|
_server_ids = {str(t.get('id')) for t in server_tracks if isinstance(t, dict) and t.get('id') is not None}
|
||||||
lambda src_id: ((_db_for_overrides.read_sync_match_cache(src_id, active_server) or {}).get('server_track_id')),
|
_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)
|
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
|
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):
|
def _persist_find_and_add_match(source_track_id, server_source, server_track_id, server_track_title, source_title, source_artist, source='spotify'):
|
||||||
"""Wrap match-override persistence with the active DB. No-op when
|
"""Persist a Find & Add selection two ways. No-op when source_track_id is
|
||||||
source_track_id is missing (e.g. add to a non-mirrored playlist)."""
|
missing (e.g. add to a non-mirrored playlist).
|
||||||
|
|
||||||
|
1. ``sync_match_cache`` override — fast auto-match on the next sync, but
|
||||||
|
wiped on every library rescan.
|
||||||
|
2. A DURABLE manual library match (#787) — survives a rescan, so the
|
||||||
|
source→library pairing sticks in the sync display AND the download loop
|
||||||
|
stops re-fetching it. This is the unification the issue asked for:
|
||||||
|
Find & Add now also records a manual library match (one-way; the manual
|
||||||
|
match tool has no playlist to act on, so it doesn't reverse-create)."""
|
||||||
if not source_track_id:
|
if not source_track_id:
|
||||||
return
|
return
|
||||||
|
db = get_database()
|
||||||
try:
|
try:
|
||||||
from core.sync.match_overrides import record_manual_match
|
from core.sync.match_overrides import record_manual_match
|
||||||
ok = record_manual_match(
|
ok = record_manual_match(
|
||||||
get_database(),
|
db,
|
||||||
source_track_id=source_track_id,
|
source_track_id=source_track_id,
|
||||||
server_source=server_source,
|
server_source=server_source,
|
||||||
server_track_id=server_track_id,
|
server_track_id=server_track_id,
|
||||||
server_track_title=server_track_title,
|
|
||||||
source_title=source_title,
|
source_title=source_title,
|
||||||
source_artist=source_artist,
|
source_artist=source_artist,
|
||||||
|
server_track_title=server_track_title,
|
||||||
)
|
)
|
||||||
if ok:
|
if ok:
|
||||||
logger.info(f"[ServerPlaylist] Persisted Find & Add override: {source_track_id} → {server_track_id} ({server_source})")
|
logger.info(f"[ServerPlaylist] Persisted Find & Add override: {source_track_id} → {server_track_id} ({server_source})")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"[ServerPlaylist] Failed to persist Find & Add override: {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'])
|
@app.route('/api/server/playlist/<playlist_id>/add-track', methods=['POST'])
|
||||||
def server_playlist_add_track(playlist_id):
|
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_title = data.get('source_title') or ''
|
||||||
source_artist = data.get('source_artist') or ''
|
source_artist = data.get('source_artist') or ''
|
||||||
server_track_title = data.get('server_track_title') 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:
|
if not track_id:
|
||||||
return jsonify({"success": False, "error": "track_id required"}), 400
|
return jsonify({"success": False, "error": "track_id required"}), 400
|
||||||
|
|
@ -18431,7 +18567,7 @@ def server_playlist_add_track(playlist_id):
|
||||||
except Exception:
|
except Exception:
|
||||||
_existing = set()
|
_existing = set()
|
||||||
if str(track_id) in _existing:
|
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"})
|
return jsonify({"success": True, "message": "Track linked"})
|
||||||
|
|
||||||
logger.info(f"[ServerPlaylist] Adding track: '{new_item.title}' (ratingKey={new_item.ratingKey}) to playlist '{playlist_name}'")
|
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)
|
new_id = str(raw_playlist.ratingKey)
|
||||||
logger.info(f"[ServerPlaylist] Added track to playlist, playlist ID: {new_id}")
|
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})
|
return jsonify({"success": True, "message": "Track added", "new_playlist_id": new_id})
|
||||||
|
|
||||||
elif active_server == 'jellyfin' and media_server_engine.client('jellyfin'):
|
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']:
|
if plan['should_insert']:
|
||||||
new_track_objs = [type('T', (), {'ratingKey': tid, 'title': ''})() for tid in plan['new_ids']]
|
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)
|
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"})
|
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'):
|
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']:
|
if plan['should_insert']:
|
||||||
new_track_objs = [type('T', (), {'ratingKey': tid, 'title': ''})() for tid in plan['new_ids']]
|
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)
|
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": True, "message": "Track linked" if not plan['should_insert'] else "Track added"})
|
||||||
|
|
||||||
return jsonify({"success": False, "error": f"Unsupported server: {active_server}"}), 400
|
return jsonify({"success": False, "error": f"Unsupported server: {active_server}"}), 400
|
||||||
|
|
@ -18685,8 +18821,12 @@ def mlm_save():
|
||||||
db = get_database()
|
db = get_database()
|
||||||
profile_id = get_current_profile_id()
|
profile_id = get_current_profile_id()
|
||||||
# Validate library track exists before saving
|
# 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
|
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(
|
ok = mlm.save_match(
|
||||||
db, profile_id, source, source_track_id, library_track_id,
|
db, profile_id, source, source_track_id, library_track_id,
|
||||||
source_title=data.get('source_title', ''),
|
source_title=data.get('source_title', ''),
|
||||||
|
|
@ -18694,6 +18834,7 @@ def mlm_save():
|
||||||
source_album=data.get('source_album', ''),
|
source_album=data.get('source_album', ''),
|
||||||
source_context_json=data.get('source_context_json', ''),
|
source_context_json=data.get('source_context_json', ''),
|
||||||
server_source=data.get('server_source', ''),
|
server_source=data.get('server_source', ''),
|
||||||
|
library_file_path=_lib_file_path,
|
||||||
)
|
)
|
||||||
if not ok:
|
if not ok:
|
||||||
return jsonify({"success": False, "error": "Failed to save match"}), 500
|
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)
|
is_album_download = data.get('is_album_download', False)
|
||||||
album_context = data.get('album_context', None)
|
album_context = data.get('album_context', None)
|
||||||
artist_context = data.get('artist_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:
|
if not tracks:
|
||||||
return jsonify({"success": False, "error": "No tracks provided"}), 400
|
return jsonify({"success": False, "error": "No tracks provided"}), 400
|
||||||
|
|
@ -18806,6 +18951,7 @@ def start_missing_tracks_process(playlist_id):
|
||||||
'album_context': album_context,
|
'album_context': album_context,
|
||||||
'artist_context': artist_context,
|
'artist_context': artist_context,
|
||||||
'wing_it': wing_it,
|
'wing_it': wing_it,
|
||||||
|
'skip_acoustid': skip_acoustid, # #797 per-request AcoustID bypass
|
||||||
'batch_source': _downloads_history.detect_sync_source(playlist_id),
|
'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):
|
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 /
|
"""Submit a sync to the shared executor (closes over sync_executor /
|
||||||
_run_sync_task / get_current_profile_id so the lifted start_sync helper
|
_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(
|
return sync_executor.submit(
|
||||||
_run_sync_task, sync_playlist_id, playlist_name, spotify_tracks,
|
_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,
|
'confidence': 1.0,
|
||||||
'matched_data': matched_data,
|
'matched_data': matched_data,
|
||||||
'manual_match': True,
|
'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)
|
db.update_mirrored_track_extra_data(db_track_id, extra_data)
|
||||||
result['matched_data'] = matched_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
|
# playlist — only adds tracks that aren't there yet. Per-server clients
|
||||||
# implement append via native add APIs (Plex addItems, Jellyfin POST
|
# implement append via native add APIs (Plex addItems, Jellyfin POST
|
||||||
# /Playlists/<id>/Items, Navidrome updatePlaylist?songIdToAdd=...).
|
# /Playlists/<id>/Items, Navidrome updatePlaylist?songIdToAdd=...).
|
||||||
sync_mode = data.get('sync_mode', 'replace')
|
# Per-request sync_mode wins; otherwise use the configured default
|
||||||
if sync_mode not in ('replace', 'append'):
|
# (Settings > Playlist sync mode). Default 'replace' keeps today's behavior.
|
||||||
sync_mode = 'replace'
|
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]):
|
if not all([playlist_id, playlist_name, tracks_json]):
|
||||||
return jsonify({"success": False, "error": "Missing playlist_id, name, or tracks."}), 400
|
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
|
# 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():
|
def _build_status_payload():
|
||||||
"""Build the same status payload used by GET /status, reading from the cache."""
|
"""Build the same status payload used by GET /status, reading from the cache."""
|
||||||
download_mode = config_manager.get('download_source.mode', 'hybrid')
|
download_mode = config_manager.get('download_source.mode', 'hybrid')
|
||||||
|
|
@ -34469,7 +34650,7 @@ def _build_status_payload():
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'metadata_source': metadata_status['metadata_source'],
|
'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', {}),
|
'media_server': _status_cache.get('media_server', {}),
|
||||||
'soulseek': soulseek_data,
|
'soulseek': soulseek_data,
|
||||||
'active_media_server': config_manager.get_active_media_server(),
|
'active_media_server': config_manager.get_active_media_server(),
|
||||||
|
|
@ -34714,12 +34895,24 @@ from core.enrichment.services import (
|
||||||
|
|
||||||
|
|
||||||
def _spotify_resume_pre_check():
|
def _spotify_resume_pre_check():
|
||||||
"""Mirror the inline Spotify rate-limit guard from the legacy
|
"""Guard the Spotify enrichment worker's resume button against a rate-limit
|
||||||
``/api/spotify-enrichment/resume`` route. Returns
|
ban. Returns ``(429, message)`` to short-circuit when banned, ``None`` ok.
|
||||||
``(429, message)`` to short-circuit when banned, ``None`` when 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:
|
try:
|
||||||
if _spotify_rate_limited():
|
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:
|
except Exception as e:
|
||||||
logger.debug("spotify rate-limit pre-check failed: %s", e)
|
logger.debug("spotify rate-limit pre-check failed: %s", e)
|
||||||
return None
|
return None
|
||||||
|
|
@ -34961,12 +35154,6 @@ def _emit_tool_progress_loop():
|
||||||
socketio.emit('tool:duplicate-cleaner', state_copy)
|
socketio.emit('tool:duplicate-cleaner', state_copy)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(f"Error emitting duplicate cleaner status: {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
|
# DB Update
|
||||||
try:
|
try:
|
||||||
with db_update_lock:
|
with db_update_lock:
|
||||||
|
|
|
||||||
206
webui/index.html
206
webui/index.html
|
|
@ -2201,6 +2201,19 @@
|
||||||
</div>
|
</div>
|
||||||
</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) -->
|
<!-- Enhanced Search Dropdown (Overlay Panel) -->
|
||||||
<div id="enhanced-dropdown" class="enhanced-dropdown hidden">
|
<div id="enhanced-dropdown" class="enhanced-dropdown hidden">
|
||||||
<div class="enhanced-dropdown-content">
|
<div class="enhanced-dropdown-content">
|
||||||
|
|
@ -3825,6 +3838,7 @@
|
||||||
<label>Primary metadata source:</label>
|
<label>Primary metadata source:</label>
|
||||||
<select id="metadata-fallback-source">
|
<select id="metadata-fallback-source">
|
||||||
<option value="spotify">Spotify</option>
|
<option value="spotify">Spotify</option>
|
||||||
|
<option value="spotify_free">Spotify Free (no credentials)</option>
|
||||||
<option value="itunes">iTunes / Apple Music</option>
|
<option value="itunes">iTunes / Apple Music</option>
|
||||||
<option value="deezer">Deezer</option>
|
<option value="deezer">Deezer</option>
|
||||||
<option value="discogs">Discogs</option>
|
<option value="discogs">Discogs</option>
|
||||||
|
|
@ -3832,7 +3846,13 @@
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="callback-info">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -6112,6 +6132,18 @@
|
||||||
<div class="settings-group" data-stg="library">
|
<div class="settings-group" data-stg="library">
|
||||||
<h3>Playlist Sync Settings</h3>
|
<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 & 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">
|
<div class="form-group">
|
||||||
<label class="checkbox-label">
|
<label class="checkbox-label">
|
||||||
<input type="checkbox" id="create-backup" checked>
|
<input type="checkbox" id="create-backup" checked>
|
||||||
|
|
@ -6516,37 +6548,6 @@
|
||||||
</div>
|
</div>
|
||||||
</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" id="quality-scanner-card">
|
||||||
<div class="tool-card-header">
|
<div class="tool-card-header">
|
||||||
<h4 class="tool-card-title">Quality Scanner</h4>
|
<h4 class="tool-card-title">Quality Scanner</h4>
|
||||||
|
|
@ -6590,6 +6591,43 @@
|
||||||
</div>
|
</div>
|
||||||
</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…) already embedded in your files and fill them into the database — lets enrichment workers skip redundant API lookups. Only fills blanks; never overwrites an existing match.</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" id="duplicate-cleaner-card">
|
||||||
<div class="tool-card-header">
|
<div class="tool-card-header">
|
||||||
<h4 class="tool-card-title">Duplicate Cleaner</h4>
|
<h4 class="tool-card-title">Duplicate Cleaner</h4>
|
||||||
|
|
@ -6667,6 +6705,37 @@
|
||||||
<div class="tools-section">
|
<div class="tools-section">
|
||||||
<h3 class="tools-section-title">Metadata & Cache</h3>
|
<h3 class="tools-section-title">Metadata & Cache</h3>
|
||||||
<div class="tools-grid">
|
<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" id="discovery-pool-card">
|
||||||
<div class="tool-card-header">
|
<div class="tool-card-header">
|
||||||
|
|
@ -6707,45 +6776,6 @@
|
||||||
<button onclick="openManualLibraryMatchTool()">Open Library Match</button>
|
<button onclick="openManualLibraryMatchTool()">Open Library Match</button>
|
||||||
</div>
|
</div>
|
||||||
</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 & 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>
|
</div></div>
|
||||||
|
|
||||||
<!-- ── Management ── -->
|
<!-- ── Management ── -->
|
||||||
|
|
@ -7788,40 +7818,6 @@
|
||||||
|
|
||||||
<!-- Tool Help Modal -->
|
<!-- Tool Help Modal -->
|
||||||
<!-- Retag Tool 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()">×</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()">×</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" id="tool-help-modal" onclick="if(event.target===this)closeToolHelpModal()">
|
||||||
<div class="tool-help-modal-content">
|
<div class="tool-help-modal-content">
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,14 @@ function _handleRateMonitorUpdate(data) {
|
||||||
const grid = document.getElementById('rate-monitor-grid');
|
const grid = document.getElementById('rate-monitor-grid');
|
||||||
if (!grid) return;
|
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
|
// The dashboard rate monitor uses the equalizer-bar visual — a
|
||||||
// vertical-bar VU-meter row that fits any service count without
|
// vertical-bar VU-meter row that fits any service count without
|
||||||
// an orphan grid cell. Detail page / mobile breakpoints keep the
|
// an orphan grid cell. Detail page / mobile breakpoints keep the
|
||||||
|
|
|
||||||
|
|
@ -487,7 +487,6 @@ function initializeWebSocket() {
|
||||||
socket.on('tool:stream', (data) => updateStreamStatusFromData(data));
|
socket.on('tool:stream', (data) => updateStreamStatusFromData(data));
|
||||||
socket.on('tool:quality-scanner', (data) => updateQualityScanProgressFromData(data));
|
socket.on('tool:quality-scanner', (data) => updateQualityScanProgressFromData(data));
|
||||||
socket.on('tool:duplicate-cleaner', (data) => updateDuplicateCleanProgressFromData(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:db-update', (data) => updateDbProgressFromData(data));
|
||||||
socket.on('tool:metadata', (data) => updateMetadataStatusFromData(data));
|
socket.on('tool:metadata', (data) => updateMetadataStatusFromData(data));
|
||||||
socket.on('tool:logs', (data) => updateLogsFromData(data));
|
socket.on('tool:logs', (data) => updateLogsFromData(data));
|
||||||
|
|
|
||||||
|
|
@ -2451,6 +2451,11 @@ async function startMissingTracksProcess(playlistId) {
|
||||||
const forceDownloadCheckbox = document.getElementById(`force-download-all-${playlistId}`);
|
const forceDownloadCheckbox = document.getElementById(`force-download-all-${playlistId}`);
|
||||||
const forceDownloadAll = forceDownloadCheckbox ? forceDownloadCheckbox.checked : false;
|
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)
|
// Check if playlist folder mode toggle is enabled (only for sync page playlists)
|
||||||
const playlistFolderMode = typeof isPlaylistOrganizeEnabled === 'function'
|
const playlistFolderMode = typeof isPlaylistOrganizeEnabled === 'function'
|
||||||
? isPlaylistOrganizeEnabled(playlistId)
|
? isPlaylistOrganizeEnabled(playlistId)
|
||||||
|
|
@ -2495,6 +2500,7 @@ async function startMissingTracksProcess(playlistId) {
|
||||||
force_download_all: forceDownloadAll || isWingIt,
|
force_download_all: forceDownloadAll || isWingIt,
|
||||||
ignore_manual_matches: forceDownloadAll,
|
ignore_manual_matches: forceDownloadAll,
|
||||||
wing_it: isWingIt,
|
wing_it: isWingIt,
|
||||||
|
skip_acoustid: skipAcoustid,
|
||||||
};
|
};
|
||||||
|
|
||||||
// If this is an artist album download, use album name and include full context
|
// 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;
|
let syncMode = syncModeOverride;
|
||||||
if (!syncMode) {
|
if (!syncMode) {
|
||||||
const modeSelect = document.getElementById(`sync-mode-${playlistId}`);
|
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') {
|
if (syncMode && !['replace', 'reconcile', 'append'].includes(syncMode)) {
|
||||||
syncMode = 'replace';
|
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);
|
const playlist = spotifyPlaylists.find(p => p.id === playlistId);
|
||||||
if (!playlist) {
|
if (!playlist) {
|
||||||
console.error(`❌ Could not find playlist data for ID: ${playlistId}`);
|
console.error(`❌ Could not find playlist data for ID: ${playlistId}`);
|
||||||
|
|
|
||||||
|
|
@ -274,6 +274,9 @@ async function _emPollSelected() {
|
||||||
|
|
||||||
function _emStatusInfo(status) {
|
function _emStatusInfo(status) {
|
||||||
if (!status || !status.enabled) return { cls: 'disabled', label: 'Disabled' };
|
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.rate_limited) return { cls: 'ratelimited', label: 'Rate-limited' };
|
||||||
if (status.paused) return { cls: 'paused', label: 'Paused' };
|
if (status.paused) return { cls: 'paused', label: 'Paused' };
|
||||||
if (status.idle) return { cls: 'idle', label: 'Idle' };
|
if (status.idle) return { cls: 'idle', label: 'Idle' };
|
||||||
|
|
@ -709,6 +712,9 @@ function _emUpdateHeaderLive() {
|
||||||
cls += ' em-banner--warn';
|
cls += ' em-banner--warn';
|
||||||
html = '⚙️ This source isn’t configured — add its credentials in Settings. '
|
html = '⚙️ This source isn’t configured — add its credentials in Settings. '
|
||||||
+ 'Browsing works, but matches and retries won’t run until it’s set up.';
|
+ 'Browsing works, but matches and retries won’t run until it’s set up.';
|
||||||
|
} else if (status && status.using_free) {
|
||||||
|
// Real API banned but bridging via the no-creds Spotify Free source.
|
||||||
|
html = '✓ Spotify is rate-limited — matching via Spotify Free until the ban lifts.';
|
||||||
} else if (status && status.rate_limited) {
|
} else if (status && status.rate_limited) {
|
||||||
cls += ' em-banner--warn';
|
cls += ' em-banner--warn';
|
||||||
const rl = status.rate_limit || {};
|
const rl = status.rate_limit || {};
|
||||||
|
|
|
||||||
|
|
@ -457,6 +457,10 @@ function updateSpotifyEnrichmentStatusFromData(data) {
|
||||||
|
|
||||||
const notAuthenticated = data.authenticated === false;
|
const notAuthenticated = data.authenticated === false;
|
||||||
const isRateLimited = data.rate_limited === true;
|
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;
|
const budgetExhausted = data.daily_budget && data.daily_budget.exhausted;
|
||||||
|
|
||||||
button.classList.remove('active', 'paused', 'complete', 'no-auth');
|
button.classList.remove('active', 'paused', 'complete', 'no-auth');
|
||||||
|
|
@ -464,7 +468,7 @@ function updateSpotifyEnrichmentStatusFromData(data) {
|
||||||
button.classList.add('paused');
|
button.classList.add('paused');
|
||||||
} else if (notAuthenticated) {
|
} else if (notAuthenticated) {
|
||||||
button.classList.add('no-auth');
|
button.classList.add('no-auth');
|
||||||
} else if (isRateLimited || budgetExhausted) {
|
} else if (rateLimitedStuck || budgetExhausted) {
|
||||||
button.classList.add('paused');
|
button.classList.add('paused');
|
||||||
} else if (data.idle) {
|
} else if (data.idle) {
|
||||||
button.classList.add('complete');
|
button.classList.add('complete');
|
||||||
|
|
@ -479,7 +483,8 @@ function updateSpotifyEnrichmentStatusFromData(data) {
|
||||||
if (tooltipStatus) {
|
if (tooltipStatus) {
|
||||||
if (data.paused) { tooltipStatus.textContent = 'Paused'; }
|
if (data.paused) { tooltipStatus.textContent = 'Paused'; }
|
||||||
else if (notAuthenticated) { tooltipStatus.textContent = 'Not Authenticated'; }
|
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 (budgetExhausted) { tooltipStatus.textContent = 'Daily Limit Reached'; }
|
||||||
else if (data.idle) { tooltipStatus.textContent = 'Complete'; }
|
else if (data.idle) { tooltipStatus.textContent = 'Complete'; }
|
||||||
else if (data.running) { tooltipStatus.textContent = 'Running'; }
|
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';
|
tooltipCurrent.textContent = notAuthenticated ? 'Connect Spotify in Settings to enrich' : 'Click to resume';
|
||||||
} else if (notAuthenticated) {
|
} else if (notAuthenticated) {
|
||||||
tooltipCurrent.textContent = 'Connect Spotify in Settings to enrich';
|
tooltipCurrent.textContent = 'Connect Spotify in Settings to enrich';
|
||||||
} else if (isRateLimited) {
|
} else if (rateLimitedStuck) {
|
||||||
const info = data.rate_limit || {};
|
const info = data.rate_limit || {};
|
||||||
const remaining = info.remaining_seconds || 0;
|
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';
|
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) {
|
} else if (budgetExhausted) {
|
||||||
const resets = data.daily_budget.resets_in_seconds || 0;
|
const resets = data.daily_budget.resets_in_seconds || 0;
|
||||||
const hours = Math.floor(resets / 3600);
|
const hours = Math.floor(resets / 3600);
|
||||||
|
|
@ -2725,7 +2732,7 @@ async function loadRepairFindings() {
|
||||||
duplicate_tracks: 'Duplicate', incomplete_album: 'Incomplete',
|
duplicate_tracks: 'Duplicate', incomplete_album: 'Incomplete',
|
||||||
path_mismatch: 'Path Mismatch', metadata_gap: 'Missing Metadata',
|
path_mismatch: 'Path Mismatch', metadata_gap: 'Missing Metadata',
|
||||||
missing_cover_art: 'Missing Art', track_number_mismatch: 'Track Number',
|
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
|
// Finding types that have an automated fix action
|
||||||
|
|
@ -2740,6 +2747,7 @@ async function loadRepairFindings() {
|
||||||
missing_lossy_copy: 'Convert',
|
missing_lossy_copy: 'Convert',
|
||||||
acoustid_mismatch: 'Fix',
|
acoustid_mismatch: 'Fix',
|
||||||
missing_discography_track: 'Add to Wishlist',
|
missing_discography_track: 'Add to Wishlist',
|
||||||
|
library_retag: 'Apply Tags',
|
||||||
};
|
};
|
||||||
|
|
||||||
container.innerHTML = items.map(f => {
|
container.innerHTML = items.map(f => {
|
||||||
|
|
@ -2905,6 +2913,40 @@ function _renderFindingDetail(f) {
|
||||||
if (f.file_path) rows.push(['Full Path', f.file_path, 'path']);
|
if (f.file_path) rows.push(['Full Path', f.file_path, 'path']);
|
||||||
return _gridRows(rows) + _renderPlayButton(f);
|
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': {
|
case 'acoustid_mismatch': {
|
||||||
let html = media + '<div style="margin-bottom:8px">';
|
let html = media + '<div style="margin-bottom:8px">';
|
||||||
html += _renderScoreBar(d.fingerprint_score, 'Fingerprint');
|
html += _renderScoreBar(d.fingerprint_score, 'Fingerprint');
|
||||||
|
|
|
||||||
|
|
@ -3413,6 +3413,27 @@ function closeHelperSearch() {
|
||||||
// projects that span multiple commits before shipping. Strip the flag at
|
// 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.
|
// release time and add a real `date:` line at the top of the version block.
|
||||||
const WHATS_NEW = {
|
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': [
|
'2.6.6': [
|
||||||
{ date: 'June 3, 2026 — 2.6.6 release' },
|
{ 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' },
|
{ 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],
|
// Section shape: { title, description, features: [bullet strings],
|
||||||
// usage_note?: 'optional hint shown at the bottom' }
|
// usage_note?: 'optional hint shown at the bottom' }
|
||||||
const VERSION_MODAL_SECTIONS = [
|
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",
|
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.",
|
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.",
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,11 @@ function initializeMediaPlayer() {
|
||||||
audioPlayer.addEventListener('error', onAudioError);
|
audioPlayer.addEventListener('error', onAudioError);
|
||||||
audioPlayer.addEventListener('loadstart', onAudioLoadStart);
|
audioPlayer.addEventListener('loadstart', onAudioLoadStart);
|
||||||
audioPlayer.addEventListener('canplay', onAudioCanPlay);
|
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%.
|
// Set initial volume — restore the saved level (Spotify-style), else 70%.
|
||||||
const _savedVol = npLoadSavedVolume();
|
const _savedVol = npLoadSavedVolume();
|
||||||
|
|
@ -2661,6 +2666,20 @@ function getNpAlbumArtUrl() {
|
||||||
// WEB AUDIO VISUALIZER
|
// 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() {
|
function npInitVisualizer() {
|
||||||
if (npVizInitialized || !audioPlayer) return;
|
if (npVizInitialized || !audioPlayer) return;
|
||||||
try {
|
try {
|
||||||
|
|
@ -2676,6 +2695,9 @@ function npInitVisualizer() {
|
||||||
npAnalyser.connect(npAudioContext.destination);
|
npAnalyser.connect(npAudioContext.destination);
|
||||||
}
|
}
|
||||||
npVizInitialized = true;
|
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) {
|
} catch (e) {
|
||||||
console.warn('Web Audio visualizer init failed, using CSS fallback:', e.message);
|
console.warn('Web Audio visualizer init failed, using CSS fallback:', e.message);
|
||||||
// Mark as CSS fallback
|
// Mark as CSS fallback
|
||||||
|
|
|
||||||
|
|
@ -109,7 +109,8 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
#artist-hero-section #artist-detail-image {
|
#artist-hero-section #artist-detail-image {
|
||||||
width: 100% !important
|
width: 100% !important;
|
||||||
|
max-width: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
#media-player {
|
#media-player {
|
||||||
|
|
@ -127,6 +128,7 @@
|
||||||
width: 100%;
|
width: 100%;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.enh-compact-item.artist-card {
|
.enh-compact-item.artist-card {
|
||||||
|
|
@ -1039,22 +1041,16 @@
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.artist-image-container {
|
|
||||||
width: 100px;
|
|
||||||
height: 100px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.artist-image,
|
.artist-image,
|
||||||
.artist-image-fallback {
|
.artist-image-fallback {
|
||||||
width: 100px;
|
width: 100px;
|
||||||
height: 100px;
|
height: 100px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.artist-info h1,
|
.artist-hero-section .artist-name {
|
||||||
.artist-name {
|
font-size: 1.6em;
|
||||||
font-size: 24px !important;
|
|
||||||
white-space: normal;
|
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.artist-hero-badges {
|
.artist-hero-badges {
|
||||||
|
|
@ -1076,7 +1072,7 @@
|
||||||
.artist-hero-bio {
|
.artist-hero-bio {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
-webkit-line-clamp: 3;
|
-webkit-line-clamp: 3;
|
||||||
max-height: 54px;
|
max-height: fit-content;
|
||||||
}
|
}
|
||||||
|
|
||||||
.artist-hero-numbers {
|
.artist-hero-numbers {
|
||||||
|
|
@ -1186,7 +1182,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.discover-card {
|
.discover-card {
|
||||||
width: 160px;
|
width: 45%;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Horizontal scroll tabs */
|
/* Horizontal scroll tabs */
|
||||||
|
|
@ -3079,10 +3075,14 @@
|
||||||
|
|
||||||
/* Notification panel */
|
/* Notification panel */
|
||||||
.notif-panel {
|
.notif-panel {
|
||||||
width: calc(100vw - 24px);
|
/* JS positions this inline from the bell's rect (right/bottom), which on
|
||||||
max-width: 100%;
|
mobile pushes it off-screen (the bell isn't flush right). Override the
|
||||||
right: 12px;
|
inline styles with !important and anchor both sides so it always fits. */
|
||||||
bottom: 62px;
|
left: 12px !important;
|
||||||
|
right: 12px !important;
|
||||||
|
width: auto !important;
|
||||||
|
max-width: none !important;
|
||||||
|
bottom: 62px !important;
|
||||||
max-height: 55vh;
|
max-height: 55vh;
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
}
|
}
|
||||||
|
|
@ -3190,9 +3190,10 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.notif-panel {
|
.notif-panel {
|
||||||
right: 8px;
|
left: 8px !important;
|
||||||
width: calc(100vw - 16px);
|
right: 8px !important;
|
||||||
bottom: 54px;
|
width: auto !important;
|
||||||
|
bottom: 54px !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -3310,3 +3311,225 @@
|
||||||
padding: 9px 12px;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1066,18 +1066,24 @@ function explorerFitToView() {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scroll wheel zoom (no modifier needed inside viewport)
|
// Scroll wheel zoom (no modifier needed inside viewport).
|
||||||
document.addEventListener('wheel', (e) => {
|
// 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');
|
const viewport = document.getElementById('explorer-viewport');
|
||||||
if (!viewport || !viewport.contains(e.target)) return;
|
if (!viewport) return;
|
||||||
// Check if we're on the explorer page
|
viewport.addEventListener('wheel', (e) => {
|
||||||
const page = document.getElementById('playlist-explorer-page');
|
const page = document.getElementById('playlist-explorer-page');
|
||||||
if (!page || !page.classList.contains('active')) return;
|
if (!page || !page.classList.contains('active')) return;
|
||||||
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const step = e.deltaY > 0 ? -0.08 : 0.08;
|
const step = e.deltaY > 0 ? -0.08 : 0.08;
|
||||||
explorerZoom(step);
|
explorerZoom(step);
|
||||||
}, { passive: false });
|
}, { passive: false });
|
||||||
|
})();
|
||||||
|
|
||||||
// Middle-click / right-click drag to pan
|
// Middle-click / right-click drag to pan
|
||||||
document.addEventListener('mousedown', (e) => {
|
document.addEventListener('mousedown', (e) => {
|
||||||
|
|
@ -1836,6 +1842,9 @@ async function _serverSelectTrack(trackIndex, mode, newTrackId, el) {
|
||||||
source_track_id: srcTrack.source_track_id || '',
|
source_track_id: srcTrack.source_track_id || '',
|
||||||
source_title: srcTrack.name || '',
|
source_title: srcTrack.name || '',
|
||||||
source_artist: srcTrack.artist || '',
|
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 || '',
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
// Close button inside dropdown (mobile)
|
||||||
const dropdownCloseBtn = document.getElementById('enhanced-dropdown-close');
|
const dropdownCloseBtn = document.getElementById('enhanced-dropdown-close');
|
||||||
if (dropdownCloseBtn) {
|
if (dropdownCloseBtn) {
|
||||||
|
|
|
||||||
|
|
@ -64,8 +64,14 @@ function syncMetadataSourceSelection(source) {
|
||||||
|
|
||||||
function _isMetadataSourceSelectable(source) {
|
function _isMetadataSourceSelectable(source) {
|
||||||
if (source === 'spotify') {
|
if (source === 'spotify') {
|
||||||
|
// Official Spotify needs a connected session.
|
||||||
return _lastStatusPayload?.spotify?.authenticated === true;
|
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') {
|
if (source === 'discogs') {
|
||||||
const token = document.getElementById('discogs-token');
|
const token = document.getElementById('discogs-token');
|
||||||
return !!token?.value?.trim();
|
return !!token?.value?.trim();
|
||||||
|
|
@ -143,6 +149,25 @@ function handleMetadataSourceChange(event) {
|
||||||
}
|
}
|
||||||
|
|
||||||
let _settingsInitialized = false;
|
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() {
|
function initializeSettings() {
|
||||||
// This function is called when the settings page is loaded.
|
// This function is called when the settings page is loaded.
|
||||||
// It attaches event listeners to all interactive elements on the page.
|
// It attaches event listeners to all interactive elements on the page.
|
||||||
|
|
@ -151,6 +176,9 @@ function initializeSettings() {
|
||||||
if (_settingsInitialized) return;
|
if (_settingsInitialized) return;
|
||||||
_settingsInitialized = true;
|
_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)
|
// Accent color listeners (live preview + custom picker toggle)
|
||||||
initAccentColorListeners();
|
initAccentColorListeners();
|
||||||
|
|
||||||
|
|
@ -1021,8 +1049,13 @@ async function loadSettingsData() {
|
||||||
// Populate Discogs settings
|
// Populate Discogs settings
|
||||||
document.getElementById('discogs-token').value = settings.discogs?.token || '';
|
document.getElementById('discogs-token').value = settings.discogs?.token || '';
|
||||||
|
|
||||||
// Populate Metadata source setting
|
// Populate Metadata source setting. 'Spotify Free' is stored as
|
||||||
document.getElementById('metadata-fallback-source').value = settings.metadata?.fallback_source || 'deezer';
|
// 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
|
// Populate Hydrabase settings
|
||||||
const hbConfig = settings.hydrabase || {};
|
const hbConfig = settings.hydrabase || {};
|
||||||
|
|
@ -1175,6 +1208,8 @@ async function loadSettingsData() {
|
||||||
|
|
||||||
// Populate Playlist Sync settings
|
// Populate Playlist Sync settings
|
||||||
document.getElementById('create-backup').checked = settings.playlist_sync?.create_backup !== false;
|
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
|
// Populate Post-Download Conversion settings
|
||||||
document.getElementById('downsample-hires').checked = settings.lossy_copy?.downsample_hires === true;
|
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();
|
const discogsTokenPresent = !!discogsTokenInput?.value?.trim();
|
||||||
let metadataSource = metadataSourceSelect?.value || 'deezer';
|
let metadataSource = metadataSourceSelect?.value || 'deezer';
|
||||||
const spotifySessionActive = _lastStatusPayload?.spotify?.authenticated === true;
|
const spotifySessionActive = _lastStatusPayload?.spotify?.authenticated === true;
|
||||||
|
const spotifyFreeInstalled = _lastStatusPayload?.spotify?.free_installed === true;
|
||||||
if (metadataSource === 'spotify' && !spotifySessionActive) {
|
if (metadataSource === 'spotify' && !spotifySessionActive) {
|
||||||
metadataSource = _metadataSourceFallback('spotify');
|
metadataSource = _metadataSourceFallback('spotify');
|
||||||
if (metadataSourceSelect) metadataSourceSelect.value = metadataSource;
|
if (metadataSourceSelect) metadataSourceSelect.value = metadataSource;
|
||||||
if (!quiet) {
|
if (!quiet) {
|
||||||
showToast('Spotify is disconnected, so the primary metadata source was switched.', 'warning');
|
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) {
|
} else if (metadataSource === 'discogs' && !discogsTokenPresent) {
|
||||||
metadataSource = _metadataSourceFallback('discogs');
|
metadataSource = _metadataSourceFallback('discogs');
|
||||||
if (metadataSourceSelect) metadataSourceSelect.value = metadataSource;
|
if (metadataSourceSelect) metadataSourceSelect.value = metadataSource;
|
||||||
|
|
@ -2818,7 +2860,10 @@ async function saveSettings(quiet = false) {
|
||||||
token: document.getElementById('discogs-token').value,
|
token: document.getElementById('discogs-token').value,
|
||||||
},
|
},
|
||||||
metadata: {
|
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: {
|
hydrabase: {
|
||||||
url: document.getElementById('hydrabase-url').value,
|
url: document.getElementById('hydrabase-url').value,
|
||||||
|
|
@ -2938,7 +2983,8 @@ async function saveSettings(quiet = false) {
|
||||||
allow_duplicate_tracks: document.getElementById('allow-duplicate-tracks').checked
|
allow_duplicate_tracks: document.getElementById('allow-duplicate-tracks').checked
|
||||||
},
|
},
|
||||||
playlist_sync: {
|
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: {
|
content_filter: {
|
||||||
allow_explicit: document.getElementById('allow-explicit').checked
|
allow_explicit: document.getElementById('allow-explicit').checked
|
||||||
|
|
|
||||||
|
|
@ -110,6 +110,10 @@ async function fetchSourceConfiguredMap() {
|
||||||
for (const src of SOURCE_ORDER) {
|
for (const src of SOURCE_ORDER) {
|
||||||
if (_ALWAYS_CONFIGURED_SOURCES.has(src)) {
|
if (_ALWAYS_CONFIGURED_SOURCES.has(src)) {
|
||||||
map[src] = true;
|
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 {
|
} else {
|
||||||
map[src] = !!(data[src] && data[src].configured);
|
map[src] = !!(data[src] && data[src].configured);
|
||||||
}
|
}
|
||||||
|
|
@ -1265,8 +1269,10 @@ function playlistModalDownloadSyncFooterHtml(playlistId, options = {}) {
|
||||||
}
|
}
|
||||||
|
|
||||||
return `${downloadBtns}
|
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)">
|
<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="replace" selected>Replace</option>
|
<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>
|
<option value="append">Append only</option>
|
||||||
</select>
|
</select>
|
||||||
<button id="sync-btn-${playlistId}" class="playlist-modal-btn playlist-modal-btn-primary" onclick="startPlaylistSync('${playlistId}')" ${isSyncing ? 'disabled' : ''}>${isSyncing ? '⏳ Syncing...' : 'Sync Playlist'}</button>`;
|
<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}">
|
<input type="checkbox" id="force-download-all-${virtualPlaylistId}">
|
||||||
<span>Force Download All</span>
|
<span>Force Download All</span>
|
||||||
</label>
|
</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' ? `
|
${contextType === 'playlist' ? `
|
||||||
<label class="force-download-toggle">
|
<label class="force-download-toggle">
|
||||||
<input type="checkbox" id="playlist-folder-mode-${virtualPlaylistId}">
|
<input type="checkbox" id="playlist-folder-mode-${virtualPlaylistId}">
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue