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:
|
||||
inputs:
|
||||
version_tag:
|
||||
description: 'Version tag (e.g. 2.6.4)'
|
||||
description: 'Version tag (e.g. 2.6.7)'
|
||||
required: true
|
||||
default: '2.6.4'
|
||||
default: '2.6.7'
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
|
|
|
|||
|
|
@ -620,7 +620,13 @@ class ConfigManager:
|
|||
"embed_tags": True
|
||||
},
|
||||
"playlist_sync": {
|
||||
"create_backup": True
|
||||
"create_backup": True,
|
||||
# How a re-sync writes to the server playlist:
|
||||
# replace — delete + recreate (default; today's behavior)
|
||||
# reconcile — edit in place (add/remove delta), preserving the
|
||||
# playlist's custom image, description, and identity (#792)
|
||||
# append — only add new tracks, never remove
|
||||
"mode": "replace"
|
||||
},
|
||||
"settings": {
|
||||
"audio_quality": "flac"
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ from utils.logging_config import get_logger
|
|||
from core.acoustid_client import AcoustIDClient
|
||||
from core.matching_engine import MusicMatchingEngine
|
||||
from core.matching.version_mismatch import is_acceptable_version_mismatch
|
||||
from core.matching.script_compat import is_cross_script_mismatch
|
||||
from core.musicbrainz_client import MusicBrainzClient
|
||||
|
||||
logger = get_logger("acoustid.verification")
|
||||
|
|
@ -655,10 +656,32 @@ class AcoustIDVerification:
|
|||
and title_sim >= 0.80
|
||||
and artist_sim >= ARTIST_MATCH_THRESHOLD
|
||||
)
|
||||
if language_script_skip or high_confidence_strong_match_skip:
|
||||
# Issue #797 — the EXPECTED artist and the AcoustID-matched
|
||||
# artist are written in different scripts (e.g. "Joe Hisaishi"
|
||||
# vs "久石譲") yet the alias-aware comparison still confirmed
|
||||
# them as the same artist (artist_sim >= threshold, bridged via
|
||||
# MusicBrainz aliases). When the artist itself spans scripts the
|
||||
# title almost always does too — and a romanized-vs-native title
|
||||
# comparison is meaningless, so it can't be evidence the file is
|
||||
# wrong. Trust the confirmed artist + the fingerprint (already
|
||||
# >= MIN_ACOUSTID_SCORE to reach here) and SKIP rather than
|
||||
# quarantine a correct download of a non-English artist.
|
||||
#
|
||||
# Deliberately narrow (the "tight" scope): keyed on the ARTIST
|
||||
# spanning scripts AND being confirmed. A same-script artist
|
||||
# with only a cross-script TITLE (romaji artist + kanji title)
|
||||
# is NOT covered — that case keeps the stricter 0.95 floor
|
||||
# above, preserving the #607 wrong-file protection.
|
||||
cross_script_artist_skip = (
|
||||
best_score >= MIN_ACOUSTID_SCORE
|
||||
and artist_sim >= ARTIST_MATCH_THRESHOLD
|
||||
and is_cross_script_mismatch(expected_artist_name, display_artist)
|
||||
)
|
||||
if (language_script_skip or high_confidence_strong_match_skip
|
||||
or cross_script_artist_skip):
|
||||
reason = (
|
||||
"likely same song in different language/script"
|
||||
if language_script_skip
|
||||
if (language_script_skip or cross_script_artist_skip)
|
||||
else "title/artist match within tolerance"
|
||||
)
|
||||
msg = (
|
||||
|
|
|
|||
|
|
@ -53,7 +53,11 @@ def find_library_artist_for_source(
|
|||
|
||||
Lookup order:
|
||||
1. Direct match on the source-specific ID column (server-agnostic — any
|
||||
library record with the right external ID is a hit).
|
||||
library record with the right external ID is a hit). If that id is
|
||||
stamped on MORE than one library artist, the mapping is corrupt /
|
||||
ambiguous (e.g. an enrichment bug wrote one Deezer id onto several
|
||||
artists) — we refuse to guess and fall through, so the caller can
|
||||
show the source artist directly instead of an arbitrary wrong one.
|
||||
2. Case-insensitive name match within ``active_server`` (defaults to the
|
||||
active media server when not provided), so we don't jump the user
|
||||
across server contexts on a name collision.
|
||||
|
|
@ -67,13 +71,23 @@ def find_library_artist_for_source(
|
|||
try:
|
||||
with database._get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
# LIMIT 2 so we can tell a unique match from an ambiguous one.
|
||||
cursor.execute(
|
||||
f"SELECT id, name FROM artists WHERE {column} = ? LIMIT 1",
|
||||
f"SELECT id FROM artists WHERE {column} = ? LIMIT 2",
|
||||
(str(source_artist_id),),
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
if row:
|
||||
return row[0]
|
||||
rows = cursor.fetchall()
|
||||
if len(rows) == 1:
|
||||
return rows[0][0]
|
||||
if len(rows) > 1:
|
||||
# Same source id on multiple artists — corrupt mapping. Don't
|
||||
# upgrade on the id; fall through to the name match (and, if
|
||||
# that misses, let the caller render the source artist).
|
||||
logger.warning(
|
||||
f"Source id {source}:{source_artist_id} maps to "
|
||||
f"{len(rows)}+ library artists — ambiguous, skipping "
|
||||
f"id-based library upgrade"
|
||||
)
|
||||
|
||||
if artist_name and active_server:
|
||||
cursor.execute(
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ from datetime import datetime, timedelta
|
|||
from utils.logging_config import get_logger
|
||||
from database.music_database import MusicDatabase
|
||||
from core.audiodb_client import AudioDBClient
|
||||
from core.worker_utils import interruptible_sleep
|
||||
from core.worker_utils import accept_artist_match, interruptible_sleep
|
||||
|
||||
logger = get_logger("audiodb_worker")
|
||||
|
||||
|
|
@ -273,8 +273,13 @@ class AudioDBWorker:
|
|||
|
||||
def _verify_artist_id(self, item: Dict[str, Any], result: Dict[str, Any]) -> bool:
|
||||
"""Verify that the result's artist ID matches the parent artist's stored AudioDB ID.
|
||||
|
||||
If mismatched, the album/track search is more specific (uses artist+title),
|
||||
so we trust it and correct the parent artist's audiodb_id."""
|
||||
so we trust it and correct the parent artist's audiodb_id — BUT only when
|
||||
the result's artist *name* matches our parent artist. Without that guard,
|
||||
a collaboration/compilation (a track our library credits to one artist
|
||||
that lives on another artist's album) would stamp the wrong AudioDB id
|
||||
onto our artist. See the Deezer fix for the full write-up."""
|
||||
parent_audiodb_id = item.get('artist_audiodb_id')
|
||||
if not parent_audiodb_id:
|
||||
return True
|
||||
|
|
@ -284,6 +289,18 @@ class AudioDBWorker:
|
|||
return True
|
||||
|
||||
if str(result_artist_id) != str(parent_audiodb_id):
|
||||
parent_name = item.get('artist') or ''
|
||||
result_artist_name = result.get('strArtist') or ''
|
||||
if (result_artist_name and parent_name
|
||||
and not self._name_matches(parent_name, result_artist_name)):
|
||||
logger.info(
|
||||
f"Skipping artist-ID correction from {item['type']} "
|
||||
f"'{item['name']}': result artist '{result_artist_name}' "
|
||||
f"≠ parent '{parent_name}' (collab/compilation, not a "
|
||||
f"correction)"
|
||||
)
|
||||
return True
|
||||
|
||||
logger.info(
|
||||
f"Artist ID correction from {item['type']} '{item['name']}': "
|
||||
f"updating parent artist AudioDB ID from {parent_audiodb_id} to {result_artist_id}"
|
||||
|
|
@ -409,14 +426,18 @@ class AudioDBWorker:
|
|||
result = self.client.search_artist(item_name)
|
||||
if result:
|
||||
result_name = result.get('strArtist', '')
|
||||
if self._name_matches(item_name, result_name):
|
||||
ok, reason = accept_artist_match(
|
||||
self.db, 'audiodb_id', result.get('idArtist'), item_id,
|
||||
item_name, result_name,
|
||||
)
|
||||
if ok:
|
||||
self._update_artist(item_id, result)
|
||||
self.stats['matched'] += 1
|
||||
logger.info(f"Matched artist '{item_name}' -> AudioDB ID: {result.get('idArtist')}")
|
||||
else:
|
||||
self._mark_status('artist', item_id, 'not_found')
|
||||
self.stats['not_found'] += 1
|
||||
logger.debug(f"Name mismatch for artist '{item_name}' (got '{result_name}')")
|
||||
logger.debug(f"Artist '{item_name}' not matched: {reason}")
|
||||
else:
|
||||
self._mark_status('artist', item_id, 'not_found')
|
||||
self.stats['not_found'] += 1
|
||||
|
|
|
|||
|
|
@ -41,7 +41,19 @@ class DatabaseUpdateWorker:
|
|||
self.database_path = database_path
|
||||
self.full_refresh = full_refresh
|
||||
self.should_stop = False
|
||||
|
||||
|
||||
# Track ids of rows newly INSERTED this run (not updates). The web
|
||||
# layer reads this to gap-fill embedded provider IDs for the new files
|
||||
# (auto-reconcile), so newly-added music contributes its
|
||||
# Spotify/MusicBrainz/etc. ids without a manual backfill.
|
||||
self._new_track_ids = set()
|
||||
|
||||
# Optional callback(worker) run as the FINAL scan phase, immediately
|
||||
# before the 'finished' signal — so the auto-reconcile is inside the
|
||||
# scan's running window (automations/UI treat it as a normal phase and
|
||||
# wait for it). Injected by the web layer (which owns path resolution).
|
||||
self.post_scan_hook = None
|
||||
|
||||
# Statistics tracking
|
||||
self.processed_artists = 0
|
||||
self.processed_albums = 0
|
||||
|
|
@ -79,7 +91,26 @@ class DatabaseUpdateWorker:
|
|||
callback(*args)
|
||||
except Exception as e:
|
||||
logger.error(f"Error in callback for {signal_name}: {e}")
|
||||
|
||||
|
||||
def _emit_finished(self, *args):
|
||||
"""Run the post-scan hook (auto-reconcile) as the final phase, THEN
|
||||
emit 'finished'.
|
||||
|
||||
Running the hook before 'finished' keeps the scan's status at
|
||||
'running' through the reconcile, so every caller (automations that
|
||||
poll for completion, the dashboard card, the Tools page) treats it as
|
||||
a normal scan phase and waits for it — rather than seeing 'finished'
|
||||
and missing the tail. Best-effort: a hook failure never blocks the
|
||||
completion signal.
|
||||
"""
|
||||
if self.post_scan_hook:
|
||||
try:
|
||||
self.post_scan_hook(self)
|
||||
except Exception as e:
|
||||
logger.warning(f"post-scan hook failed (non-fatal): {e}")
|
||||
self._emit_signal('finished', *args)
|
||||
|
||||
|
||||
def connect_callback(self, signal_name: str, callback: Callable):
|
||||
"""Connect a callback for progress notifications."""
|
||||
self.callbacks.setdefault(signal_name, []).append(callback)
|
||||
|
|
@ -146,7 +177,7 @@ class DatabaseUpdateWorker:
|
|||
logger.info(f"Merged {merged} duplicate artists")
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not merge duplicate artists: {e}")
|
||||
self._emit_signal('finished', 0, 0, 0, 0, 0)
|
||||
self._emit_finished(0, 0, 0, 0, 0)
|
||||
return
|
||||
logger.info(f"Incremental update: Found {len(artists_to_process)} artists to process")
|
||||
|
||||
|
|
@ -230,7 +261,7 @@ class DatabaseUpdateWorker:
|
|||
self.removed_tracks = removal.get('tracks_removed', 0) if removal else 0
|
||||
|
||||
# Emit final results
|
||||
self._emit_signal('finished',
|
||||
self._emit_finished(
|
||||
self.processed_artists,
|
||||
self.processed_albums,
|
||||
self.processed_tracks,
|
||||
|
|
@ -331,7 +362,7 @@ class DatabaseUpdateWorker:
|
|||
f"{self.processed_albums} albums, {self.processed_tracks} new tracks, "
|
||||
f"{stale_removed} stale tracks removed")
|
||||
|
||||
self._emit_signal('finished',
|
||||
self._emit_finished(
|
||||
self.processed_artists,
|
||||
self.processed_albums,
|
||||
self.processed_tracks,
|
||||
|
|
@ -880,6 +911,8 @@ class DatabaseUpdateWorker:
|
|||
track_success = self.database.insert_or_update_media_track(track, album_id, artist_id, server_source=self.server_type)
|
||||
if track_success:
|
||||
total_processed_tracks += 1
|
||||
if track_success == 'inserted':
|
||||
self._new_track_ids.add(str(track.ratingKey))
|
||||
logger.debug(f"Processed new track: {track.title}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to process track '{getattr(track, 'title', 'Unknown')}': {e}")
|
||||
|
|
@ -1344,6 +1377,8 @@ class DatabaseUpdateWorker:
|
|||
skipped_count += 1
|
||||
elif track_success:
|
||||
track_count += 1
|
||||
if track_success == 'inserted':
|
||||
self._new_track_ids.add(track_id_str)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to process track '{getattr(track, 'title', 'Unknown')}': {e}")
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ from datetime import datetime, timedelta
|
|||
from utils.logging_config import get_logger
|
||||
from database.music_database import MusicDatabase
|
||||
from core.deezer_client import DeezerClient
|
||||
from core.worker_utils import interruptible_sleep, set_album_api_track_count
|
||||
from core.worker_utils import accept_artist_match, interruptible_sleep, set_album_api_track_count
|
||||
from core.enrichment.manual_match_honoring import honor_stored_match
|
||||
|
||||
logger = get_logger("deezer_worker")
|
||||
|
|
@ -278,10 +278,19 @@ class DeezerWorker:
|
|||
logger.debug(f"Name similarity: '{query_name}' vs '{result_name}' = {similarity:.2f}")
|
||||
return similarity >= self.name_similarity_threshold
|
||||
|
||||
def _verify_artist_id(self, item: Dict[str, Any], result_artist_id) -> bool:
|
||||
def _verify_artist_id(self, item: Dict[str, Any], result_artist_id,
|
||||
result_artist_name: Optional[str] = None) -> bool:
|
||||
"""Verify that the result's artist ID matches the parent artist's stored Deezer ID.
|
||||
|
||||
If mismatched, the album/track search is more specific (uses artist+title),
|
||||
so we trust it and correct the parent artist's deezer_id."""
|
||||
so we trust it and correct the parent artist's deezer_id — BUT only when
|
||||
the result's artist *name* actually matches our parent artist. Without
|
||||
that guard, a collaboration or compilation track (e.g. a track our
|
||||
library credits to Jorja Smith that lives on Kendrick Lamar's curated
|
||||
"Black Panther" album) would search up to an album whose Deezer primary
|
||||
artist is someone else (Kendrick), and we'd stamp that wrong Deezer ID
|
||||
onto our artist — corrupting it (and causing duplicate ids shared across
|
||||
unrelated artists)."""
|
||||
parent_deezer_id = item.get('artist_deezer_id')
|
||||
if not parent_deezer_id:
|
||||
return True
|
||||
|
|
@ -290,6 +299,20 @@ class DeezerWorker:
|
|||
return True
|
||||
|
||||
if str(result_artist_id) != str(parent_deezer_id):
|
||||
# Guard: only correct when the album/track's primary artist is the
|
||||
# SAME artist by name. A mismatch means it's a collab/compilation,
|
||||
# not a stale-id correction.
|
||||
parent_name = item.get('artist') or ''
|
||||
if (result_artist_name and parent_name
|
||||
and not self._name_matches(parent_name, result_artist_name)):
|
||||
logger.info(
|
||||
f"Skipping artist-ID correction from {item['type']} "
|
||||
f"'{item['name']}': result artist '{result_artist_name}' "
|
||||
f"≠ parent '{parent_name}' (collab/compilation, not a "
|
||||
f"correction)"
|
||||
)
|
||||
return True
|
||||
|
||||
logger.info(
|
||||
f"Artist ID correction from {item['type']} '{item['name']}': "
|
||||
f"updating parent artist Deezer ID from {parent_deezer_id} to {result_artist_id}"
|
||||
|
|
@ -381,14 +404,18 @@ class DeezerWorker:
|
|||
result = self.client.search_artist(artist_name)
|
||||
if result:
|
||||
result_name = result.get('name', '')
|
||||
if self._name_matches(artist_name, result_name):
|
||||
ok, reason = accept_artist_match(
|
||||
self.db, 'deezer_id', result.get('id'), artist_id,
|
||||
artist_name, result_name,
|
||||
)
|
||||
if ok:
|
||||
self._update_artist(artist_id, result)
|
||||
self.stats['matched'] += 1
|
||||
logger.info(f"Matched artist '{artist_name}' -> Deezer ID: {result.get('id')}")
|
||||
else:
|
||||
self._mark_status('artist', artist_id, 'not_found')
|
||||
self.stats['not_found'] += 1
|
||||
logger.debug(f"Name mismatch for artist '{artist_name}' (got '{result_name}')")
|
||||
logger.debug(f"Artist '{artist_name}' not matched: {reason}")
|
||||
else:
|
||||
self._mark_status('artist', artist_id, 'not_found')
|
||||
self.stats['not_found'] += 1
|
||||
|
|
@ -430,7 +457,8 @@ class DeezerWorker:
|
|||
# Verify artist ID
|
||||
result_artist = result.get('artist', {})
|
||||
result_artist_id = result_artist.get('id') if result_artist else None
|
||||
self._verify_artist_id(item, result_artist_id)
|
||||
result_artist_name = result_artist.get('name') if result_artist else None
|
||||
self._verify_artist_id(item, result_artist_id, result_artist_name)
|
||||
|
||||
# Fetch full album details for label, genres, explicit
|
||||
deezer_album_id = result.get('id')
|
||||
|
|
@ -481,7 +509,8 @@ class DeezerWorker:
|
|||
# Verify artist ID
|
||||
result_artist = result.get('artist', {})
|
||||
result_artist_id = result_artist.get('id') if result_artist else None
|
||||
self._verify_artist_id(item, result_artist_id)
|
||||
result_artist_name = result_artist.get('name') if result_artist else None
|
||||
self._verify_artist_id(item, result_artist_id, result_artist_name)
|
||||
|
||||
# Fetch full track details for BPM
|
||||
deezer_track_id = result.get('id')
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ from datetime import datetime, timedelta
|
|||
from utils.logging_config import get_logger
|
||||
from database.music_database import MusicDatabase
|
||||
from core.discogs_client import DiscogsClient
|
||||
from core.worker_utils import interruptible_sleep, set_album_api_track_count
|
||||
from core.worker_utils import accept_artist_match, interruptible_sleep, set_album_api_track_count
|
||||
|
||||
logger = get_logger("discogs_worker")
|
||||
|
||||
|
|
@ -332,9 +332,13 @@ class DiscogsWorker:
|
|||
self.stats['not_found'] += 1
|
||||
return
|
||||
|
||||
# Find best match by name similarity
|
||||
# Find best match by name similarity (skipping ids already claimed by
|
||||
# a differently-named artist, so we don't create a shared/duplicate id).
|
||||
for result in results:
|
||||
if self._name_matches(artist_name, result.name):
|
||||
ok, reason = accept_artist_match(
|
||||
self.db, 'discogs_id', result.id, artist_id, artist_name, result.name,
|
||||
)
|
||||
if ok:
|
||||
# Fetch full artist detail (uses cache)
|
||||
data = self.client._fetch_and_cache_artist(result.id)
|
||||
if data:
|
||||
|
|
|
|||
|
|
@ -16,6 +16,10 @@ to test in isolation:
|
|||
overwrites the user's deliberate pick with whatever the auto-search
|
||||
ranks first, so manual matches are exempt regardless of provider
|
||||
drift. `is_drifted_for_redo` encapsulates the decision.
|
||||
|
||||
3. *Should the Playlist Pipeline pre-scan (re)discover this track at all?*
|
||||
— `should_rediscover` encapsulates that gate, with the manual match
|
||||
checked FIRST so a leftover Wing It flag can't override the user's pick.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -68,3 +72,49 @@ def is_drifted_for_redo(
|
|||
return False
|
||||
cached_provider = extra_data.get('provider', 'spotify')
|
||||
return cached_provider != active_provider
|
||||
|
||||
|
||||
def should_rediscover(extra_data: Optional[Dict[str, Any]]) -> bool:
|
||||
"""Return True when a mirrored track needs (re)discovery, False to skip it.
|
||||
|
||||
This is the gate the Playlist Pipeline pre-scan runs over every mirrored
|
||||
track before discovering. The **ordering is the fix**: a manual match is
|
||||
authoritative and is checked FIRST.
|
||||
|
||||
``extra_data`` is *merged* on save (see ``update_mirrored_track_extra_data``),
|
||||
so a track that was a Wing It stub and is then manually fixed still carries
|
||||
``wing_it_fallback: True`` alongside the new ``manual_match: True``. The old
|
||||
pre-scan tested ``wing_it_fallback`` before ``manual_match``, so the stale
|
||||
flag won and the pipeline re-discovered the track — silently reverting the
|
||||
user's pick to Wing It. Checking ``manual_match`` first makes the fix stick.
|
||||
|
||||
Decision order:
|
||||
* manual_match -> skip (authoritative; never re-discover)
|
||||
* wing_it_fallback -> redo (stub — keep trying for a real match)
|
||||
* discovered + complete -> skip (full metadata already stored)
|
||||
* discovered + incomplete -> redo (backfill track_number / album fields)
|
||||
* unmatched_by_user -> skip (user deliberately removed the match)
|
||||
* never discovered -> redo (first-time discovery)
|
||||
"""
|
||||
extra = extra_data if isinstance(extra_data, dict) else {}
|
||||
|
||||
if extra.get('discovered'):
|
||||
if extra.get('manual_match'):
|
||||
return False
|
||||
if extra.get('wing_it_fallback'):
|
||||
return True
|
||||
# Otherwise re-discover only when the stored match is missing the
|
||||
# enriched fields (track_number + release_date/album.id) that older
|
||||
# discoveries dropped via the Track dataclass.
|
||||
matched = extra.get('matched_data')
|
||||
matched = matched if isinstance(matched, dict) else {}
|
||||
album = matched.get('album')
|
||||
album = album if isinstance(album, dict) else {}
|
||||
has_track_num = matched.get('track_number')
|
||||
has_release = album.get('release_date')
|
||||
has_album_id = album.get('id')
|
||||
return not (has_track_num and (has_release or has_album_id))
|
||||
|
||||
if extra.get('unmatched_by_user'):
|
||||
return False
|
||||
return True
|
||||
|
|
|
|||
|
|
@ -37,9 +37,35 @@ import time
|
|||
from dataclasses import dataclass
|
||||
from typing import Any, Callable
|
||||
|
||||
from core.discovery.manual_match import should_rediscover
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _canonical_best_score(deps, title, artist, duration_ms, results):
|
||||
"""Score search results against the source track, trying the canonicalized
|
||||
title/artist too and keeping the better confidence (#785).
|
||||
|
||||
YouTube playlists have their "Artist - Title" / channel decoration stripped
|
||||
at ingest, but file/CSV-imported playlists keep raw titles — so a track
|
||||
titled "Arctic Monkeys - Do I Wanna Know?" scored verbatim against the
|
||||
library's "Do I Wanna Know?" never matched. canonical_source_track is
|
||||
conservative (only strips an "<artist> - " prefix when it equals the
|
||||
artist), so this can only ADD a better candidate, never weaken a match.
|
||||
Returns (match, confidence)."""
|
||||
match, confidence, _ = deps.discovery_score_candidates(title, artist, duration_ms, results)
|
||||
try:
|
||||
from core.text.source_title import canonical_source_track
|
||||
canon_title, canon_artist = canonical_source_track(title or '', artist or '')
|
||||
except Exception:
|
||||
return match, confidence
|
||||
if (canon_title, canon_artist) != (title, artist):
|
||||
alt_match, alt_conf, _ = deps.discovery_score_candidates(canon_title, canon_artist, duration_ms, results)
|
||||
if alt_match and alt_conf > confidence:
|
||||
return alt_match, alt_conf
|
||||
return match, confidence
|
||||
|
||||
|
||||
@dataclass
|
||||
class PlaylistDiscoveryDeps:
|
||||
"""Bundle of cross-cutting deps the playlist discovery worker needs."""
|
||||
|
|
@ -121,44 +147,14 @@ def run_playlist_discovery_worker(playlists, automation_id=None, deps: PlaylistD
|
|||
existing_extra = json.loads(track['extra_data']) if isinstance(track['extra_data'], str) else track['extra_data']
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
if existing_extra.get('discovered'):
|
||||
if existing_extra.get('wing_it_fallback'):
|
||||
# Wing It stub — always re-attempt to find a real match
|
||||
undiscovered_tracks.append(track)
|
||||
elif existing_extra.get('manual_match'):
|
||||
# User explicitly picked this match via the Fix popup.
|
||||
# Manual fixes are authoritative: they may lack
|
||||
# track_number / album.id / release_date (the Fix-popup
|
||||
# save shape is intentionally lean — search-result rows
|
||||
# don't include track_number, and the MBID-lookup flat
|
||||
# shape doesn't carry album.id), but re-running discovery
|
||||
# against the active source would overwrite the user's
|
||||
# deliberate pick with whatever the auto-search ranks
|
||||
# first. Skip — pipeline only re-discovers when the user
|
||||
# has cleared the match.
|
||||
pl_skipped += 1
|
||||
total_skipped += 1
|
||||
else:
|
||||
# Check if matched_data is complete — old discoveries may be missing
|
||||
# track_number/release_date due to the Track dataclass stripping them.
|
||||
# Re-discover these so the enriched pipeline fills in the gaps.
|
||||
md = existing_extra.get('matched_data', {})
|
||||
album = md.get('album', {})
|
||||
has_track_num = md.get('track_number')
|
||||
has_release = album.get('release_date') if isinstance(album, dict) else None
|
||||
has_album_id = album.get('id') if isinstance(album, dict) else None
|
||||
if has_track_num and (has_release or has_album_id):
|
||||
pl_skipped += 1
|
||||
total_skipped += 1
|
||||
else:
|
||||
# Incomplete discovery — re-discover to get full metadata
|
||||
undiscovered_tracks.append(track)
|
||||
elif existing_extra.get('unmatched_by_user'):
|
||||
# User explicitly removed this match — respect their choice
|
||||
# `should_rediscover` is the single source of truth for this
|
||||
# gate (manual match checked FIRST so a stale Wing It flag can't
|
||||
# revert a user's deliberate fix — see its docstring).
|
||||
if should_rediscover(existing_extra):
|
||||
undiscovered_tracks.append(track)
|
||||
else:
|
||||
pl_skipped += 1
|
||||
total_skipped += 1
|
||||
else:
|
||||
undiscovered_tracks.append(track)
|
||||
|
||||
if pl_skipped > 0:
|
||||
deps.update_automation_progress(automation_id,
|
||||
|
|
@ -223,6 +219,20 @@ def run_playlist_discovery_worker(playlists, automation_id=None, deps: PlaylistD
|
|||
except Exception:
|
||||
search_queries = [f"{artist_name} {track_name}", track_name]
|
||||
|
||||
# #785: file/CSV playlists keep raw "Artist - Title" titles, so the
|
||||
# queries above search for the artist prefix too. Also search the
|
||||
# canonicalized title so the right candidates are actually returned
|
||||
# (the scorer best-of then matches them).
|
||||
try:
|
||||
from core.text.source_title import canonical_source_track
|
||||
_cq_title, _cq_artist = canonical_source_track(track_name, artist_name)
|
||||
if (_cq_title, _cq_artist) != (track_name, artist_name):
|
||||
for _q in (f"{_cq_artist} {_cq_title}", _cq_title):
|
||||
if _q not in search_queries:
|
||||
search_queries.append(_q)
|
||||
except Exception as _cq_err:
|
||||
logger.debug("canonical search-query add failed: %s", _cq_err)
|
||||
|
||||
# Step 3: Search and score
|
||||
best_match = None
|
||||
best_confidence = 0.0
|
||||
|
|
@ -237,8 +247,8 @@ def run_playlist_discovery_worker(playlists, automation_id=None, deps: PlaylistD
|
|||
if not results:
|
||||
continue
|
||||
|
||||
match, confidence, _ = deps.discovery_score_candidates(
|
||||
track_name, artist_name, duration_ms, results
|
||||
match, confidence = _canonical_best_score(
|
||||
deps, track_name, artist_name, duration_ms, results
|
||||
)
|
||||
|
||||
if match and confidence > best_confidence:
|
||||
|
|
@ -259,8 +269,8 @@ def run_playlist_discovery_worker(playlists, automation_id=None, deps: PlaylistD
|
|||
else:
|
||||
extended = itunes_client_instance.search_tracks(query, limit=50)
|
||||
if extended:
|
||||
match, confidence, _ = deps.discovery_score_candidates(
|
||||
track_name, artist_name, duration_ms, extended
|
||||
match, confidence = _canonical_best_score(
|
||||
deps, track_name, artist_name, duration_ms, extended
|
||||
)
|
||||
if match and confidence > best_confidence:
|
||||
best_confidence = confidence
|
||||
|
|
|
|||
|
|
@ -188,6 +188,22 @@ async def _database_only_find_track(spotify_track, candidate_pool=None):
|
|||
server_source=active_server
|
||||
)
|
||||
|
||||
if not (db_track and confidence >= 0.80):
|
||||
# #785: file/CSV playlists keep raw "Artist - Title" titles (unlike
|
||||
# YouTube, cleaned at ingest), which don't match the clean library
|
||||
# title. Retry with the canonical form (best-of, conservative).
|
||||
try:
|
||||
from core.text.source_title import canonical_source_track
|
||||
_canon_title, _canon_artist = canonical_source_track(original_title, artist_name)
|
||||
if (_canon_title, _canon_artist) != (original_title, artist_name):
|
||||
_alt_track, _alt_conf = db.check_track_exists(
|
||||
_canon_title, _canon_artist,
|
||||
confidence_threshold=0.80, server_source=active_server)
|
||||
if _alt_track and _alt_conf > confidence:
|
||||
db_track, confidence = _alt_track, _alt_conf
|
||||
except Exception as _canon_err:
|
||||
logger.debug("canonical retry failed: %s", _canon_err)
|
||||
|
||||
if db_track and confidence >= 0.80:
|
||||
logger.info(f"Database match: '{db_track.title}' (confidence: {confidence:.2f})")
|
||||
if spotify_id:
|
||||
|
|
@ -508,7 +524,13 @@ def run_sync_task(
|
|||
# don't want persisted to app.log.
|
||||
_synced = getattr(result, 'synced_tracks', 0)
|
||||
logger.info(f"[PLAYLIST IMAGE] has_image={bool(playlist_image_url)}, synced_tracks={_synced}")
|
||||
if playlist_image_url and _synced > 0:
|
||||
# In reconcile mode the whole point (#792) is to NOT touch the playlist's
|
||||
# existing image — pushing the source image here would re-clobber a
|
||||
# user's custom poster every sync, the exact bug reconcile fixes. So skip
|
||||
# the auto image push for reconcile (the playlist keeps its own art).
|
||||
if sync_mode == 'reconcile':
|
||||
logger.info("[PLAYLIST IMAGE] reconcile mode — preserving existing playlist image")
|
||||
elif playlist_image_url and _synced > 0:
|
||||
try:
|
||||
active_server = deps.config_manager.get_active_media_server()
|
||||
logger.info(f"[PLAYLIST IMAGE] active_server={active_server}")
|
||||
|
|
|
|||
|
|
@ -706,13 +706,19 @@ def resolve_reported_save_path(
|
|||
|
||||
|
||||
def copy_audio_files_atomically(
|
||||
sources: Iterable[Path], staging_dir: Path,
|
||||
sources: Iterable[Path], staging_dir: Path, remove_source: bool = False,
|
||||
) -> list:
|
||||
"""Convenience wrapper: pick a non-colliding staging path for
|
||||
each source, copy via ``atomic_copy_to_staging``. Returns the
|
||||
list of final destination paths (as strings). Files that fail
|
||||
to copy are logged and skipped; the caller decides what to do
|
||||
with a partial result."""
|
||||
with a partial result.
|
||||
|
||||
``remove_source=True`` deletes each source AFTER it copies
|
||||
successfully — used by the Soulseek bundle path so slskd's
|
||||
completed downloads don't pile up in its download folder (#796).
|
||||
Kept False for torrent/usenet, whose clients must retain the
|
||||
originals (seeding / client-managed)."""
|
||||
staging_dir.mkdir(parents=True, exist_ok=True)
|
||||
out: list = []
|
||||
for src in sources:
|
||||
|
|
@ -720,6 +726,14 @@ def copy_audio_files_atomically(
|
|||
try:
|
||||
atomic_copy_to_staging(src, dest)
|
||||
out.append(str(dest))
|
||||
if remove_source:
|
||||
# Only after a verified copy — never lose data on a failed stage.
|
||||
try:
|
||||
Path(src).unlink()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug("[album_bundle] Could not remove staged source %s: %s", src, e)
|
||||
except Exception as e:
|
||||
logger.warning("[album_bundle] Failed to stage %s -> %s: %s", src, dest, e)
|
||||
return out
|
||||
|
|
|
|||
|
|
@ -326,6 +326,17 @@ def attempt_download_with_candidates(task_id, candidates, track, batch_id=None,
|
|||
"task=%s username=%s filename=%s",
|
||||
task_id, username, os.path.basename(filename),
|
||||
)
|
||||
elif track_info and track_info.get('_skip_acoustid'):
|
||||
# Issue #797 — the album-download request had the
|
||||
# per-request "Skip AcoustID verification" toggle on.
|
||||
# Bypass only the AcoustID gate (same as a manual
|
||||
# pick); integrity + bit-depth still run.
|
||||
matched_downloads_context[context_key]['_skip_quarantine_check'] = 'acoustid'
|
||||
logger.info(
|
||||
"[Context] Skip-AcoustID toggle — bypassing AcoustID for "
|
||||
"task=%s filename=%s",
|
||||
task_id, os.path.basename(filename),
|
||||
)
|
||||
|
||||
logger.info(f"[Context] Set is_album_download: {is_album_context} (has clean data: {has_clean_spotify_data})")
|
||||
logger.debug(f"[Debug] Context creation - track_info: {track_info is not None}, playlist_folder_mode: {track_info.get('_playlist_folder_mode', False) if track_info else False}")
|
||||
|
|
|
|||
|
|
@ -347,6 +347,13 @@ def run_full_missing_tracks_process(batch_id, playlist_id, tracks_json, deps: Ma
|
|||
batch_playlist_name = 'Unknown Playlist'
|
||||
batch_playlist_id = playlist_id
|
||||
batch_source_playlist_ref = ''
|
||||
# Issue #797 — per-request "Skip AcoustID verification" toggle from
|
||||
# the album-download modal. When set, every track in this batch
|
||||
# bypasses the AcoustID quarantine gate (the user has chosen to
|
||||
# trust the metadata over fingerprint disagreement — useful for
|
||||
# non-English artists whose native-script metadata AcoustID can't
|
||||
# reconcile with the romanized request).
|
||||
batch_skip_acoustid = False
|
||||
with tasks_lock:
|
||||
if batch_id in download_batches:
|
||||
force_download_all = download_batches[batch_id].get('force_download_all', False)
|
||||
|
|
@ -362,6 +369,7 @@ def run_full_missing_tracks_process(batch_id, playlist_id, tracks_json, deps: Ma
|
|||
batch_source_playlist_ref = (
|
||||
download_batches[batch_id].get('source_playlist_ref') or ''
|
||||
).strip()
|
||||
batch_skip_acoustid = bool(download_batches[batch_id].get('skip_acoustid', False))
|
||||
|
||||
from core.downloads.playlist_folder import (
|
||||
resolve_playlist_folder_mode_for_batch,
|
||||
|
|
@ -1031,6 +1039,14 @@ def run_full_missing_tracks_process(batch_id, playlist_id, tracks_json, deps: Ma
|
|||
logger.info(f"[Wishlist] Added album context for: '{track_info.get('name')}' -> '{album_ctx['name']}'")
|
||||
|
||||
|
||||
# Issue #797 — propagate the batch-level "skip AcoustID"
|
||||
# toggle onto each track so the per-track download context
|
||||
# (built in core/downloads/candidates.py) can set the
|
||||
# AcoustID quarantine bypass. Mirrors the _playlist_folder_mode
|
||||
# threading pattern below.
|
||||
if batch_skip_acoustid:
|
||||
track_info['_skip_acoustid'] = True
|
||||
|
||||
# Add playlist folder mode flag for sync page playlists and wishlist
|
||||
# tracks tied to a mirrored playlist with organize_by_playlist enabled.
|
||||
task_pl_folder_mode = batch_playlist_folder_mode
|
||||
|
|
|
|||
|
|
@ -40,7 +40,6 @@ from core.imports.side_effects import (
|
|||
emit_track_downloaded,
|
||||
record_download_provenance,
|
||||
record_library_history_download,
|
||||
record_retag_download,
|
||||
record_soulsync_library_entry,
|
||||
)
|
||||
from core.wishlist.resolution import check_and_remove_from_wishlist
|
||||
|
|
@ -892,13 +891,6 @@ def post_process_matched_download(context_key, context, file_path, runtime, meta
|
|||
record_download_provenance(context)
|
||||
record_soulsync_library_entry(context, artist_context, album_info)
|
||||
|
||||
try:
|
||||
if not playlist_folder_mode:
|
||||
completed_path = context.get('_final_processed_path', final_path)
|
||||
record_retag_download(context, artist_context, album_info, completed_path)
|
||||
except Exception as retag_err:
|
||||
logger.error(f"[Post-Process] Retag data capture failed (non-fatal): {retag_err}")
|
||||
|
||||
try:
|
||||
completed_path = context.get('_final_processed_path', final_path)
|
||||
batch_id_for_repair = context.get('batch_id')
|
||||
|
|
|
|||
|
|
@ -676,88 +676,3 @@ def record_soulsync_library_entry(context: Dict[str, Any], artist_context: Dict[
|
|||
logger.info("[SoulSync Library] Added: %s / %s / %s", artist_name, album_name, track_name)
|
||||
except Exception as exc:
|
||||
logger.error("[SoulSync Library] Could not record library entry: %s", exc)
|
||||
|
||||
|
||||
def record_retag_download(context: Dict[str, Any], artist_context: Dict[str, Any], album_info: Dict[str, Any], final_path: str) -> None:
|
||||
"""Record a completed download for later re-tagging."""
|
||||
try:
|
||||
db = get_database()
|
||||
|
||||
context = normalize_import_context(context)
|
||||
artist_context = get_import_context_artist(context) or (artist_context if isinstance(artist_context, dict) else {})
|
||||
album_context = get_import_context_album(context)
|
||||
track_info = get_import_track_info(context)
|
||||
original_search = get_import_original_search(context)
|
||||
source = get_import_source(context)
|
||||
source_ids = get_import_source_ids(context)
|
||||
|
||||
artist_name = extract_artist_name(artist_context) or get_import_clean_artist(context, default="Unknown Artist")
|
||||
is_album = album_info and album_info.get("is_album", False)
|
||||
group_type = "album" if is_album else "single"
|
||||
album_name = album_info.get("album_name", "") if album_info else get_import_clean_album(context, default=original_search.get("album", "Unknown"))
|
||||
|
||||
image_url = album_info.get("album_image_url") if album_info else None
|
||||
if not image_url:
|
||||
image_url = album_context.get("image_url", "")
|
||||
if not image_url and album_context.get("images"):
|
||||
images = album_context.get("images", [])
|
||||
if images and isinstance(images[0], dict):
|
||||
image_url = images[0].get("url", "")
|
||||
|
||||
total_tracks = album_context.get("total_tracks", 1) if album_context else 1
|
||||
release_date = album_context.get("release_date", "") if album_context else ""
|
||||
|
||||
spotify_album_id = None
|
||||
itunes_album_id = None
|
||||
if source == "spotify":
|
||||
spotify_album_id = source_ids.get("album_id", "") or None
|
||||
elif source == "itunes":
|
||||
itunes_album_id = source_ids.get("album_id", "") or None
|
||||
|
||||
group_id = db.find_retag_group(artist_name, album_name)
|
||||
if group_id is None:
|
||||
group_id = db.add_retag_group(
|
||||
group_type=group_type,
|
||||
artist_name=artist_name,
|
||||
album_name=album_name,
|
||||
image_url=image_url,
|
||||
spotify_album_id=spotify_album_id,
|
||||
itunes_album_id=itunes_album_id,
|
||||
total_tracks=total_tracks,
|
||||
release_date=release_date,
|
||||
)
|
||||
if group_id is None:
|
||||
return
|
||||
|
||||
track_number = album_info.get("track_number", 1) if album_info else (track_info.get("track_number", 1) or 1)
|
||||
disc_number = original_search.get("disc_number") or (album_info.get("disc_number", 1) if album_info else track_info.get("disc_number", 1) or 1)
|
||||
title = get_import_clean_title(
|
||||
context,
|
||||
album_info=album_info,
|
||||
default=album_info.get("clean_track_name", "Unknown Track") if album_info else "Unknown Track",
|
||||
)
|
||||
file_format = os.path.splitext(str(final_path))[1].lstrip(".").lower()
|
||||
|
||||
source_track_id = None
|
||||
itunes_track_id = None
|
||||
if source == "spotify":
|
||||
source_track_id = source_ids.get("track_id", "") or None
|
||||
elif source == "itunes":
|
||||
itunes_track_id = source_ids.get("track_id", "") or None
|
||||
|
||||
if not db.retag_track_exists(group_id, str(final_path)):
|
||||
db.add_retag_track(
|
||||
group_id=group_id,
|
||||
track_number=track_number,
|
||||
disc_number=disc_number,
|
||||
title=title,
|
||||
file_path=str(final_path),
|
||||
file_format=file_format,
|
||||
spotify_track_id=source_track_id,
|
||||
itunes_track_id=itunes_track_id,
|
||||
)
|
||||
logger.info("[Retag] Recorded track for retag: '%s' in '%s'", title, album_name)
|
||||
|
||||
db.trim_retag_groups(100)
|
||||
except Exception as exc:
|
||||
logger.error("[Retag] Could not record track for retag: %s", exc)
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ from datetime import datetime, timedelta
|
|||
from utils.logging_config import get_logger
|
||||
from database.music_database import MusicDatabase
|
||||
from core.itunes_client import iTunesClient
|
||||
from core.worker_utils import interruptible_sleep, set_album_api_track_count
|
||||
from core.worker_utils import accept_artist_match, interruptible_sleep, set_album_api_track_count
|
||||
from core.enrichment.manual_match_honoring import honor_stored_match
|
||||
|
||||
logger = get_logger("itunes_worker")
|
||||
|
|
@ -393,7 +393,11 @@ class iTunesWorker:
|
|||
return
|
||||
|
||||
for artist_obj in results:
|
||||
if self._name_matches(artist_name, artist_obj.name):
|
||||
ok, reason = accept_artist_match(
|
||||
self.db, 'itunes_artist_id', artist_obj.id, artist_id,
|
||||
artist_name, artist_obj.name,
|
||||
)
|
||||
if ok:
|
||||
if not self._is_itunes_id(artist_obj.id):
|
||||
logger.warning(f"Rejecting non-iTunes ID '{artist_obj.id}' for artist '{artist_name}'")
|
||||
self._mark_status('artist', artist_id, 'error')
|
||||
|
|
|
|||
|
|
@ -1605,6 +1605,80 @@ class JellyfinClient(MediaServerClient):
|
|||
logger.error(f"Error appending to Jellyfin playlist '{playlist_name}': {e}")
|
||||
return False
|
||||
|
||||
def reconcile_playlist(self, playlist_name: str, tracks) -> bool:
|
||||
"""In-place reconcile (#792): add missing + remove gone on the existing
|
||||
playlist (POST/DELETE /Playlists/{id}/Items) so its poster, name, and Id
|
||||
survive — no delete/recreate. Removal needs each entry's PlaylistItemId,
|
||||
which we fetch from /Playlists/{id}/Items. Creates the playlist if
|
||||
missing. Returns False so the caller can fall back to replace."""
|
||||
if not self.ensure_connection():
|
||||
return False
|
||||
try:
|
||||
import requests
|
||||
from core.sync.playlist_edit import plan_playlist_reconcile
|
||||
existing = self.get_playlist_by_name(playlist_name)
|
||||
if not existing:
|
||||
logger.info(f"Jellyfin reconcile: '{playlist_name}' doesn't exist — creating")
|
||||
return self.create_playlist(playlist_name, tracks)
|
||||
|
||||
playlist_id = existing.id
|
||||
# Entries carry both the track Id and the PlaylistItemId (entry id);
|
||||
# removal is by EntryIds, not track Ids.
|
||||
entries = [] # list of (track_id, entry_id) in playlist order
|
||||
resp = self._make_request(f'/Playlists/{playlist_id}/Items', {'UserId': self.user_id})
|
||||
if resp:
|
||||
for item in resp.get('Items', []):
|
||||
tid = str(item.get('Id') or '')
|
||||
eid = str(item.get('PlaylistItemId') or '')
|
||||
if tid:
|
||||
entries.append((tid, eid))
|
||||
|
||||
current_ids = [tid for tid, _ in entries]
|
||||
desired_ids = []
|
||||
for t in tracks:
|
||||
tid = (str(t.id) if hasattr(t, 'id') and t.id
|
||||
else str(t.get('Id') or t.get('id') or '') if isinstance(t, dict) else '')
|
||||
if tid and self._is_valid_guid(tid):
|
||||
desired_ids.append(tid)
|
||||
|
||||
plan = plan_playlist_reconcile(current_ids, desired_ids)
|
||||
hdr = {'X-Emby-Token': self.api_key}
|
||||
|
||||
if plan['add']:
|
||||
for i in range(0, len(plan['add']), 100):
|
||||
batch = plan['add'][i:i + 100]
|
||||
r = requests.post(
|
||||
f"{self.base_url}/Playlists/{playlist_id}/Items",
|
||||
params={'Ids': ','.join(batch), 'UserId': self.user_id},
|
||||
headers=hdr, timeout=30,
|
||||
)
|
||||
if r.status_code not in (200, 204):
|
||||
logger.error(f"Jellyfin reconcile add failed: HTTP {r.status_code}")
|
||||
return False
|
||||
|
||||
if plan['remove']:
|
||||
remove_set = set(plan['remove'])
|
||||
entry_ids = [eid for tid, eid in entries if tid in remove_set and eid]
|
||||
for i in range(0, len(entry_ids), 100):
|
||||
batch = entry_ids[i:i + 100]
|
||||
r = requests.delete(
|
||||
f"{self.base_url}/Playlists/{playlist_id}/Items",
|
||||
params={'EntryIds': ','.join(batch)},
|
||||
headers=hdr, timeout=30,
|
||||
)
|
||||
if r.status_code not in (200, 204):
|
||||
logger.error(f"Jellyfin reconcile remove failed: HTTP {r.status_code}")
|
||||
return False
|
||||
|
||||
logger.info(
|
||||
f"Jellyfin reconcile '{playlist_name}': +{len(plan['add'])} / "
|
||||
f"-{len(plan['remove'])} (playlist preserved)"
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error reconciling Jellyfin playlist '{playlist_name}': {e}")
|
||||
return False
|
||||
|
||||
def update_playlist(self, playlist_name: str, tracks) -> bool:
|
||||
"""Update an existing playlist or create it if it doesn't exist"""
|
||||
if not self.ensure_connection():
|
||||
|
|
|
|||
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
|
||||
|
||||
|
||||
__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
|
||||
|
||||
|
||||
def embed_known_source_ids(audio_file, metadata: dict) -> list:
|
||||
"""Embed ALREADY-KNOWN source IDs into a file's tags, no API re-fetch.
|
||||
|
||||
For the library re-tag / "Write Tags" paths: ``metadata`` carries flat id
|
||||
keys already stored in the DB (``spotify_track_id``, ``spotify_album_id``,
|
||||
``itunes_track_id``, ``itunes_album_id``, ``musicbrainz_recording_id``,
|
||||
``musicbrainz_release_id``, …). Reuses the SAME canonical, format-specific,
|
||||
Picard-compatible frame writer as the import post-process
|
||||
(``_write_embedded_metadata``) so the frames never diverge — it just skips
|
||||
the heavy multi-source re-search that ``embed_source_ids`` does.
|
||||
|
||||
The caller is responsible for saving the file. Returns the canonical tag
|
||||
names written (for logging/diagnostics).
|
||||
"""
|
||||
cfg = get_config_manager()
|
||||
symbols = get_mutagen_symbols()
|
||||
if not symbols or audio_file is None:
|
||||
return []
|
||||
try:
|
||||
id_tags = _collect_source_ids(metadata, cfg)
|
||||
# MusicBrainz ids aren't in _collect_source_ids (they come from the MB
|
||||
# processor at import time); add them from the DB when present, using
|
||||
# the canonical names the frame map already knows.
|
||||
if cfg.get("musicbrainz.embed_tags", True) is not False:
|
||||
if metadata.get("musicbrainz_recording_id"):
|
||||
id_tags["MUSICBRAINZ_RECORDING_ID"] = metadata["musicbrainz_recording_id"]
|
||||
if metadata.get("musicbrainz_release_id"):
|
||||
id_tags["MUSICBRAINZ_RELEASE_ID"] = metadata["musicbrainz_release_id"]
|
||||
if not id_tags:
|
||||
return []
|
||||
pp = _blank_post_process_state()
|
||||
pp["id_tags"] = id_tags
|
||||
_write_embedded_metadata(audio_file, metadata, pp, cfg, symbols)
|
||||
return list(id_tags.keys())
|
||||
except Exception as exc:
|
||||
logger.debug("embed_known_source_ids failed: %s", exc)
|
||||
return []
|
||||
|
||||
|
||||
def _blank_post_process_state() -> dict:
|
||||
"""A fully-defaulted post-process state so _write_embedded_metadata can run
|
||||
with only id_tags set (every enrichment branch no-ops on the blanks)."""
|
||||
return {
|
||||
"id_tags": {}, "track_title": "", "artist_name": "", "batch_artist_name": None,
|
||||
"metadata": {}, "recording_mbid": None, "artist_mbid": None, "release_mbid": "",
|
||||
"mb_genres": [], "isrc": None, "deezer_bpm": None, "deezer_isrc": None,
|
||||
"tidal_bpm": None, "hifi_bpm": None, "hifi_copyright": None, "audiodb_mood": None,
|
||||
"audiodb_style": None, "audiodb_genre": None, "tidal_isrc": None,
|
||||
"tidal_copyright": None, "hifi_isrc": None, "qobuz_isrc": None,
|
||||
"qobuz_copyright": None, "qobuz_label": None, "lastfm_tags": [],
|
||||
"lastfm_url": None, "genius_url": None, "release_year": None,
|
||||
}
|
||||
|
||||
|
||||
def embed_source_ids(audio_file, metadata: dict, context: dict = None, runtime=None):
|
||||
cfg = get_config_manager()
|
||||
symbols = get_mutagen_symbols()
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ from datetime import datetime, timedelta
|
|||
from utils.logging_config import get_logger
|
||||
from database.music_database import MusicDatabase
|
||||
from core.musicbrainz_service import MusicBrainzService
|
||||
from core.worker_utils import interruptible_sleep
|
||||
from core.worker_utils import interruptible_sleep, source_id_conflict
|
||||
|
||||
logger = get_logger("musicbrainz_worker")
|
||||
|
||||
|
|
@ -367,8 +367,17 @@ class MusicBrainzWorker:
|
|||
|
||||
if item_type == 'artist':
|
||||
result = self.mb_service.match_artist(item_name)
|
||||
if result and result.get('mbid'):
|
||||
self.mb_service.update_artist_mbid(item_id, result['mbid'], 'matched')
|
||||
mbid = result.get('mbid') if result else None
|
||||
# MB's combined score can match a weak name ("Grant" -> "Amy
|
||||
# Grant") when its own relevance rank is high. Guard against
|
||||
# assigning an mbid a differently-named artist already holds, so
|
||||
# one mbid can't be smeared across unrelated artists.
|
||||
conflict = (
|
||||
source_id_conflict(self.db, 'musicbrainz_id', mbid, item_id, item_name)
|
||||
if mbid else None
|
||||
)
|
||||
if mbid and not conflict:
|
||||
self.mb_service.update_artist_mbid(item_id, mbid, 'matched')
|
||||
# Issue #442 — pull alternate-spelling aliases (Japanese
|
||||
# kanji, Cyrillic, etc.) so the verifier can recognise
|
||||
# cross-script artist names without re-querying MB on
|
||||
|
|
@ -377,7 +386,7 @@ class MusicBrainzWorker:
|
|||
# empty list) so a transient MB outage never regresses
|
||||
# the enrichment outcome.
|
||||
try:
|
||||
aliases = self.mb_service.fetch_artist_aliases(result['mbid'])
|
||||
aliases = self.mb_service.fetch_artist_aliases(mbid)
|
||||
if aliases:
|
||||
self.mb_service.update_artist_aliases(item_id, aliases)
|
||||
logger.debug(
|
||||
|
|
@ -388,11 +397,17 @@ class MusicBrainzWorker:
|
|||
"Alias enrichment failed for artist '%s': %s", item_name, alias_err,
|
||||
)
|
||||
self.stats['matched'] += 1
|
||||
logger.info(f"Matched artist '{item_name}' → MBID: {result['mbid']}")
|
||||
logger.info(f"Matched artist '{item_name}' → MBID: {mbid}")
|
||||
else:
|
||||
self.mb_service.update_artist_mbid(item_id, None, 'not_found')
|
||||
self.stats['not_found'] += 1
|
||||
logger.debug(f"No match for artist '{item_name}'")
|
||||
if conflict:
|
||||
logger.debug(
|
||||
f"Artist '{item_name}' → MBID {mbid} skipped: "
|
||||
f"already claimed by '{conflict}'"
|
||||
)
|
||||
else:
|
||||
logger.debug(f"No match for artist '{item_name}'")
|
||||
|
||||
elif item_type == 'album':
|
||||
artist_name = item.get('artist')
|
||||
|
|
|
|||
|
|
@ -192,6 +192,14 @@ class NavidromeClient(MediaServerClient):
|
|||
"""Get available music folders from Navidrome."""
|
||||
if not self.ensure_connection():
|
||||
return []
|
||||
return self._fetch_music_folders()
|
||||
|
||||
def _fetch_music_folders(self) -> list:
|
||||
"""Fetch + parse the Navidrome music folders. Assumes the connection is
|
||||
already established (base_url/username set) and does NOT call
|
||||
ensure_connection — so it is safe to call from inside _setup_client's
|
||||
restore step without re-entering the connection guard (which would
|
||||
otherwise bail with _is_connecting=True and return an empty list)."""
|
||||
try:
|
||||
response = self._make_request('getMusicFolders')
|
||||
if not response:
|
||||
|
|
@ -216,7 +224,10 @@ class NavidromeClient(MediaServerClient):
|
|||
logger.info(f"Set music folder to: {folder_name} (ID: {self.music_folder_id})")
|
||||
from database.music_database import MusicDatabase
|
||||
db = MusicDatabase()
|
||||
# Persist the id (stable across renames) as the primary key,
|
||||
# keep the name for display + back-compat fallback.
|
||||
db.set_preference('navidrome_music_folder', folder_name)
|
||||
db.set_preference('navidrome_music_folder_id', folder['key'])
|
||||
return True
|
||||
# If folder_name is empty, clear the selection
|
||||
if not folder_name:
|
||||
|
|
@ -225,6 +236,7 @@ class NavidromeClient(MediaServerClient):
|
|||
from database.music_database import MusicDatabase
|
||||
db = MusicDatabase()
|
||||
db.set_preference('navidrome_music_folder', '')
|
||||
db.set_preference('navidrome_music_folder_id', '')
|
||||
logger.info("Cleared music folder selection — will use all libraries")
|
||||
return True
|
||||
logger.warning(f"Music folder '{folder_name}' not found")
|
||||
|
|
@ -276,14 +288,35 @@ class NavidromeClient(MediaServerClient):
|
|||
try:
|
||||
from database.music_database import MusicDatabase
|
||||
db = MusicDatabase()
|
||||
saved_id = db.get_preference('navidrome_music_folder_id')
|
||||
saved_folder = db.get_preference('navidrome_music_folder')
|
||||
if saved_folder:
|
||||
folders = self.get_music_folders()
|
||||
for folder in folders:
|
||||
if folder['title'] == saved_folder:
|
||||
self.music_folder_id = folder['key']
|
||||
logger.info(f"Restored music folder preference: {saved_folder} (ID: {self.music_folder_id})")
|
||||
break
|
||||
if saved_id or saved_folder:
|
||||
# Use the non-reentrant fetch: we're still inside
|
||||
# ensure_connection() here (_is_connecting=True), so the
|
||||
# public get_music_folders() would re-enter the guard and
|
||||
# return [], silently dropping the saved selection.
|
||||
folders = self._fetch_music_folders()
|
||||
# Match by id first (stable across renames in Navidrome);
|
||||
# fall back to name for installs saved before the id was
|
||||
# persisted.
|
||||
matched = None
|
||||
if saved_id:
|
||||
matched = next((f for f in folders if f['key'] == str(saved_id)), None)
|
||||
if matched is None and saved_folder:
|
||||
matched = next((f for f in folders if f['title'] == saved_folder), None)
|
||||
if matched is not None:
|
||||
self.music_folder_id = matched['key']
|
||||
logger.info(f"Restored music folder preference: {matched['title']} (ID: {self.music_folder_id})")
|
||||
# Self-heal drifted prefs: a pre-id install (no saved
|
||||
# id) or a folder renamed in Navidrome (stale name).
|
||||
# id stays the durable key; name is kept fresh so the
|
||||
# settings dropdown highlights the right option.
|
||||
if str(saved_id or '') != matched['key'] or (saved_folder or '') != matched['title']:
|
||||
try:
|
||||
db.set_preference('navidrome_music_folder_id', matched['key'])
|
||||
db.set_preference('navidrome_music_folder', matched['title'])
|
||||
except Exception as heal_err:
|
||||
logger.debug(f"Could not self-heal music folder prefs: {heal_err}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not restore music folder preference: {e}")
|
||||
else:
|
||||
|
|
@ -1091,6 +1124,60 @@ class NavidromeClient(MediaServerClient):
|
|||
logger.error(f"Error appending to Navidrome playlist '{playlist_name}': {e}")
|
||||
return False
|
||||
|
||||
def reconcile_playlist(self, playlist_name: str, tracks) -> bool:
|
||||
"""In-place reconcile (#792): add missing + remove gone via Subsonic
|
||||
updatePlaylist (songIdToAdd / songIndexToRemove), keeping the existing
|
||||
playlist object so its comment/identity survive — no delete/recreate.
|
||||
Creates the playlist if missing. Returns False so the caller can fall
|
||||
back to replace on any failure."""
|
||||
if not self.ensure_connection():
|
||||
return False
|
||||
try:
|
||||
from core.sync.playlist_edit import plan_playlist_reconcile
|
||||
existing_playlists = self.get_playlists_by_name(playlist_name)
|
||||
if not existing_playlists:
|
||||
logger.info(f"Navidrome reconcile: '{playlist_name}' doesn't exist — creating")
|
||||
return self.create_playlist(playlist_name, tracks)
|
||||
|
||||
primary = existing_playlists[0]
|
||||
existing_tracks = self.get_playlist_tracks(primary.id)
|
||||
current_ids = [str(t.id) for t in existing_tracks if getattr(t, 'id', None)]
|
||||
desired_ids = []
|
||||
for t in tracks:
|
||||
tid = (str(t.ratingKey) if hasattr(t, 'ratingKey')
|
||||
else str(t.id) if getattr(t, 'id', None)
|
||||
else str(t.get('id', '')) if isinstance(t, dict) else '')
|
||||
if tid:
|
||||
desired_ids.append(tid)
|
||||
|
||||
plan = plan_playlist_reconcile(current_ids, desired_ids)
|
||||
if not plan['add'] and not plan['remove']:
|
||||
return True
|
||||
|
||||
params = {'playlistId': primary.id}
|
||||
if plan['add']:
|
||||
params['songIdToAdd'] = plan['add']
|
||||
if plan['remove']:
|
||||
# Indices into the CURRENT list; remove descending so earlier
|
||||
# removals don't shift the indices of later ones.
|
||||
remove_set = set(plan['remove'])
|
||||
params['songIndexToRemove'] = sorted(
|
||||
(i for i, cid in enumerate(current_ids) if cid in remove_set),
|
||||
reverse=True,
|
||||
)
|
||||
response = self._make_request('updatePlaylist', params)
|
||||
if response and response.get('status') == 'ok':
|
||||
logger.info(
|
||||
f"Navidrome reconcile '{playlist_name}': +{len(plan['add'])} / "
|
||||
f"-{len(plan['remove'])} (playlist preserved)"
|
||||
)
|
||||
return True
|
||||
logger.error(f"Navidrome reconcile failed for '{playlist_name}'")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Error reconciling Navidrome playlist '{playlist_name}': {e}")
|
||||
return False
|
||||
|
||||
def update_playlist(self, playlist_name: str, tracks) -> bool:
|
||||
"""Update an existing playlist or create it if it doesn't exist. Handles duplicates."""
|
||||
if not self.ensure_connection():
|
||||
|
|
|
|||
|
|
@ -667,6 +667,53 @@ class PlexClient(MediaServerClient):
|
|||
logger.error(f"Error appending to Plex playlist '{playlist_name}': {e}")
|
||||
return False
|
||||
|
||||
def reconcile_playlist(self, playlist_name: str, tracks: List[TrackInfo]) -> bool:
|
||||
"""In-place reconcile (#792): bring an existing playlist's tracks to match
|
||||
``tracks`` by adding the missing ones and removing the gone ones, WITHOUT
|
||||
deleting/recreating the playlist — so its custom poster, summary, and
|
||||
ratingKey survive the sync. Creates the playlist if it doesn't exist.
|
||||
|
||||
Returns False on any failure so the caller can fall back to replace."""
|
||||
if not self.ensure_connection():
|
||||
return False
|
||||
try:
|
||||
from core.sync.playlist_edit import plan_playlist_reconcile
|
||||
try:
|
||||
existing = self.server.playlist(playlist_name)
|
||||
except NotFound:
|
||||
logger.info(f"Plex reconcile: '{playlist_name}' doesn't exist — creating")
|
||||
return self.create_playlist(playlist_name, tracks)
|
||||
|
||||
current_items = [i for i in existing.items() if hasattr(i, 'ratingKey')]
|
||||
current_ids = [str(i.ratingKey) for i in current_items]
|
||||
desired_ids = [str(t.ratingKey) for t in tracks if hasattr(t, 'ratingKey')]
|
||||
plan = plan_playlist_reconcile(current_ids, desired_ids)
|
||||
|
||||
add_set, remove_set = set(plan['add']), set(plan['remove'])
|
||||
to_add = [t for t in tracks if hasattr(t, 'ratingKey') and str(t.ratingKey) in add_set]
|
||||
to_remove = [i for i in current_items if str(i.ratingKey) in remove_set]
|
||||
|
||||
if to_add:
|
||||
existing.addItems(to_add)
|
||||
if to_remove:
|
||||
try:
|
||||
existing.removeItems(to_remove)
|
||||
except (AttributeError, TypeError):
|
||||
# Older plexapi: no batch removeItems — drop one at a time.
|
||||
for it in to_remove:
|
||||
try:
|
||||
existing.removeItem(it)
|
||||
except Exception as one_err:
|
||||
logger.debug("Plex reconcile: removeItem failed: %s", one_err)
|
||||
logger.info(
|
||||
f"Plex reconcile '{playlist_name}': +{len(to_add)} / -{len(to_remove)} "
|
||||
f"(playlist preserved)"
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error reconciling Plex playlist '{playlist_name}': {e}")
|
||||
return False
|
||||
|
||||
def update_playlist(self, playlist_name: str, tracks: List[TrackInfo]) -> bool:
|
||||
if not self.ensure_connection():
|
||||
return False
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ from datetime import datetime, timedelta
|
|||
from utils.logging_config import get_logger
|
||||
from database.music_database import MusicDatabase
|
||||
from core.qobuz_client import _qobuz_is_rate_limited
|
||||
from core.worker_utils import interruptible_sleep
|
||||
from core.worker_utils import accept_artist_match, interruptible_sleep
|
||||
from core.enrichment.manual_match_honoring import honor_stored_match
|
||||
|
||||
logger = get_logger("qobuz_worker")
|
||||
|
|
@ -301,13 +301,29 @@ class QobuzWorker:
|
|||
logger.debug(f"Name similarity: '{query_name}' vs '{result_name}' = {similarity:.2f}")
|
||||
return similarity >= self.name_similarity_threshold
|
||||
|
||||
def _verify_artist_id(self, item: Dict[str, Any], result_artist_id) -> bool:
|
||||
"""Verify/correct parent artist's Qobuz ID based on album/track match"""
|
||||
def _verify_artist_id(self, item: Dict[str, Any], result_artist_id,
|
||||
result_artist_name: Optional[str] = None) -> bool:
|
||||
"""Verify/correct parent artist's Qobuz ID based on album/track match.
|
||||
|
||||
Only corrects when the result's artist *name* matches our parent artist —
|
||||
otherwise a collaboration/compilation would stamp the wrong Qobuz id onto
|
||||
our artist. See the Deezer fix for the full write-up."""
|
||||
parent_qobuz_id = item.get('artist_qobuz_id')
|
||||
if not parent_qobuz_id or not result_artist_id:
|
||||
return True
|
||||
|
||||
if str(result_artist_id) != str(parent_qobuz_id):
|
||||
parent_name = item.get('artist') or ''
|
||||
if (result_artist_name and parent_name
|
||||
and not self._name_matches(parent_name, result_artist_name)):
|
||||
logger.info(
|
||||
f"Skipping artist-ID correction from {item['type']} "
|
||||
f"'{item['name']}': result artist '{result_artist_name}' "
|
||||
f"≠ parent '{parent_name}' (collab/compilation, not a "
|
||||
f"correction)"
|
||||
)
|
||||
return True
|
||||
|
||||
logger.info(
|
||||
f"Artist ID correction from {item['type']} '{item['name']}': "
|
||||
f"updating parent artist Qobuz ID from {parent_qobuz_id} to {result_artist_id}"
|
||||
|
|
@ -407,14 +423,19 @@ class QobuzWorker:
|
|||
|
||||
if result:
|
||||
result_name = result.get('name', '')
|
||||
if self._name_matches(artist_name, result_name):
|
||||
qobuz_artist_id = result.get('id')
|
||||
if not qobuz_artist_id:
|
||||
self._mark_status('artist', artist_id, 'error')
|
||||
self.stats['errors'] += 1
|
||||
logger.warning(f"Qobuz search result for '{artist_name}' has no ID")
|
||||
return
|
||||
|
||||
qobuz_artist_id = result.get('id')
|
||||
ok, reason = accept_artist_match(
|
||||
self.db, 'qobuz_id', qobuz_artist_id, artist_id, artist_name, result_name,
|
||||
)
|
||||
if not ok:
|
||||
self._mark_status('artist', artist_id, 'not_found')
|
||||
self.stats['not_found'] += 1
|
||||
logger.debug(f"Artist '{artist_name}' not matched: {reason}")
|
||||
elif not qobuz_artist_id:
|
||||
self._mark_status('artist', artist_id, 'error')
|
||||
self.stats['errors'] += 1
|
||||
logger.warning(f"Qobuz search result for '{artist_name}' has no ID")
|
||||
else:
|
||||
# Fetch full artist details
|
||||
full_artist = None
|
||||
try:
|
||||
|
|
@ -425,10 +446,6 @@ class QobuzWorker:
|
|||
self._update_artist(artist_id, result, full_artist)
|
||||
self.stats['matched'] += 1
|
||||
logger.info(f"Matched artist '{artist_name}' -> Qobuz ID: {qobuz_artist_id}")
|
||||
else:
|
||||
self._mark_status('artist', artist_id, 'not_found')
|
||||
self.stats['not_found'] += 1
|
||||
logger.debug(f"Name mismatch for artist '{artist_name}' (got '{result_name}')")
|
||||
else:
|
||||
if _qobuz_is_rate_limited():
|
||||
logger.warning(f"Rate limited while searching artist '{artist_name}', will retry")
|
||||
|
|
@ -467,7 +484,8 @@ class QobuzWorker:
|
|||
# Verify artist ID
|
||||
result_artist = result.get('artist', {})
|
||||
result_artist_id = result_artist.get('id') if result_artist else None
|
||||
self._verify_artist_id(item, result_artist_id)
|
||||
result_artist_name = result_artist.get('name') if result_artist else None
|
||||
self._verify_artist_id(item, result_artist_id, result_artist_name)
|
||||
|
||||
# Fetch full album details
|
||||
qobuz_album_id = result.get('id')
|
||||
|
|
@ -528,7 +546,8 @@ class QobuzWorker:
|
|||
# Verify artist ID
|
||||
result_artist = result.get('artist', result.get('performer', {}))
|
||||
result_artist_id = result_artist.get('id') if result_artist else None
|
||||
self._verify_artist_id(item, result_artist_id)
|
||||
result_artist_name = result_artist.get('name') if result_artist else None
|
||||
self._verify_artist_id(item, result_artist_id, result_artist_name)
|
||||
|
||||
# Fetch full track details
|
||||
qobuz_track_id = result.get('id')
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ _JOB_MODULES = [
|
|||
'core.repair_jobs.unknown_artist_fixer',
|
||||
'core.repair_jobs.discography_backfill',
|
||||
'core.repair_jobs.canonical_version_resolve',
|
||||
'core.repair_jobs.library_retag',
|
||||
]
|
||||
|
||||
|
||||
|
|
|
|||
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'
|
||||
help_text = (
|
||||
'Scans your library for albums that have no cover art stored in the database. '
|
||||
'For each missing cover, it searches the configured metadata sources using the '
|
||||
'album name and artist to find matching artwork. If Prefer Source is set, that '
|
||||
'source is tried first; otherwise the primary metadata source is used.\n\n'
|
||||
'For each missing cover, it searches for matching artwork using the album name '
|
||||
'and artist. If you have configured cover-art sources (Settings > metadata '
|
||||
'enhancement art order), those are used first; otherwise it falls back to '
|
||||
'Prefer Source (if set) or the primary metadata source.\n\n'
|
||||
'When artwork is found, a finding is created with the image URL so you can review '
|
||||
'and apply it. The job does not download or embed artwork automatically.\n\n'
|
||||
'Settings:\n'
|
||||
|
|
@ -66,6 +67,10 @@ class MissingCoverArtJob(RepairJob):
|
|||
settings = self._get_settings(context)
|
||||
primary_source = get_primary_source()
|
||||
source_priority = get_source_priority(primary_source)
|
||||
# User's configured cover-art source order (caa/deezer/itunes/spotify/
|
||||
# audiodb). Empty by default → falls back to the source-priority loop.
|
||||
art_order = (context.config_manager.get('metadata_enhancement.album_art_order')
|
||||
if context.config_manager else None)
|
||||
prefer_source = settings.get('prefer_source')
|
||||
if prefer_source and prefer_source != primary_source and prefer_source in source_priority:
|
||||
source_priority.remove(prefer_source)
|
||||
|
|
@ -170,11 +175,27 @@ class MissingCoverArtJob(RepairJob):
|
|||
|
||||
artwork_url = None
|
||||
|
||||
# Honor the user's configured cover-art sources first (the same
|
||||
# metadata_enhancement.album_art_order the post-process embed and the
|
||||
# Library Re-tag job use) so "cover art sources" means one thing
|
||||
# app-wide. Non-breaking: returns None when no order is configured
|
||||
# (the default), so we fall back to the prefer_source / metadata
|
||||
# source-priority loop below — unchanged behavior for existing users.
|
||||
try:
|
||||
from core.metadata.art_lookup import select_preferred_art_url
|
||||
artwork_url = select_preferred_art_url(
|
||||
artist_name, title,
|
||||
{'musicbrainz_release_id': None}, art_order,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug("preferred cover-art lookup failed for album %s: %s", album_id, e)
|
||||
|
||||
# Try source-specific IDs first, then title/artist search, in priority order.
|
||||
for source in source_priority:
|
||||
artwork_url = self._try_source(source, source_album_ids.get(source), title, artist_name)
|
||||
if artwork_url:
|
||||
break
|
||||
if not artwork_url:
|
||||
for source in source_priority:
|
||||
artwork_url = self._try_source(source, source_album_ids.get(source), title, artist_name)
|
||||
if artwork_url:
|
||||
break
|
||||
|
||||
if artwork_url:
|
||||
if context.report_progress:
|
||||
|
|
|
|||
|
|
@ -976,6 +976,7 @@ class RepairWorker:
|
|||
'unknown_artist': self._fix_unknown_artist,
|
||||
'acoustid_mismatch': self._fix_acoustid_mismatch,
|
||||
'missing_discography_track': self._fix_discography_backfill,
|
||||
'library_retag': self._fix_library_retag,
|
||||
}
|
||||
handler = handlers.get(finding_type)
|
||||
if not handler:
|
||||
|
|
@ -1356,6 +1357,43 @@ class RepairWorker:
|
|||
msg = 'Updated database thumbnail, but could not write art to files (read-only?)'
|
||||
return {'success': True, 'action': 'applied_cover_art', 'message': msg, 'art_result': art_result}
|
||||
|
||||
def _fix_library_retag(self, entity_type, entity_id, file_path, details):
|
||||
"""Apply a library re-tag finding: write each track's planned tags in
|
||||
place (core.tag_writer.write_tags_to_file) + optionally embed/refresh
|
||||
cover art. Only ADDS/overwrites the planned fields — no moves/renames."""
|
||||
tracks = details.get('tracks') or []
|
||||
if not tracks:
|
||||
return {'success': False, 'error': 'No tracks to re-tag in finding'}
|
||||
|
||||
# Resolve container/host path mismatches, then delegate to the shared
|
||||
# apply path the job's auto-fix mode also uses.
|
||||
download_folder = self._config_manager.get('soulseek.download_path', '') if self._config_manager else None
|
||||
resolved_plans = []
|
||||
for t in tracks:
|
||||
raw = t.get('file_path')
|
||||
if not raw:
|
||||
continue
|
||||
rp = _resolve_file_path(raw, self.transfer_folder, download_folder,
|
||||
config_manager=self._config_manager) or raw
|
||||
plan = {'file_path': rp, 'db_data': t.get('db_data') or {}}
|
||||
if t.get('full_meta'):
|
||||
plan['full_meta'] = t['full_meta']
|
||||
resolved_plans.append(plan)
|
||||
|
||||
from core.repair_jobs.library_retag import apply_track_plans
|
||||
res = apply_track_plans(resolved_plans, details.get('cover_action'), details.get('cover_url'),
|
||||
full=(details.get('depth') == 'full'))
|
||||
|
||||
if res['written'] == 0 and not res['cover_written']:
|
||||
return {'success': False,
|
||||
'error': 'Nothing could be written — files unreachable or read-only?'}
|
||||
msg = f"Re-tagged {res['written']} track(s)"
|
||||
if res['failed']:
|
||||
msg += f" ({res['failed']} failed)"
|
||||
if res['cover_written']:
|
||||
msg += ' + refreshed cover.jpg'
|
||||
return {'success': True, 'action': 'library_retag', 'message': msg, **res}
|
||||
|
||||
def _fix_metadata_gap(self, entity_type, entity_id, file_path, details):
|
||||
"""Apply found metadata fields to the track."""
|
||||
found_fields = details.get('found_fields')
|
||||
|
|
|
|||
371
core/search/by_id.py
Normal file
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]:
|
||||
"""Return (client, is_available) for an explicit metadata source request."""
|
||||
if source_name == 'spotify':
|
||||
if deps.spotify_client and deps.spotify_client.is_spotify_authenticated():
|
||||
# Available when real auth OR the no-creds SpotipyFree fallback can serve
|
||||
# (the client routes to free internally when auth is missing/limited).
|
||||
if deps.spotify_client and deps.spotify_client.is_spotify_metadata_available():
|
||||
return deps.spotify_client, True
|
||||
return None, False
|
||||
if source_name == 'itunes':
|
||||
|
|
|
|||
|
|
@ -1613,7 +1613,10 @@ class SoulseekClient(DownloadSourcePlugin):
|
|||
return result
|
||||
|
||||
_emit('staging', release=getattr(picked, 'album_title', folder_path) if picked else folder_path)
|
||||
copied = copy_audio_files_atomically(completed, Path(staging_dir))
|
||||
# remove_source=True: clean slskd's completed files once staged so they
|
||||
# don't pile up in the download folder (#796). Soulseek has no seeding,
|
||||
# unlike the torrent/usenet bundle paths which keep their originals.
|
||||
copied = copy_audio_files_atomically(completed, Path(staging_dir), remove_source=True)
|
||||
if not copied:
|
||||
result['error'] = 'No Soulseek album files copied to staging'
|
||||
return result
|
||||
|
|
|
|||
|
|
@ -569,6 +569,72 @@ class SpotifyClient:
|
|||
return self._itunes # Fall back to iTunes if no Discogs token
|
||||
return self._itunes
|
||||
|
||||
def _free_selected(self) -> bool:
|
||||
"""Whether the user picked 'Spotify Free' as their metadata source
|
||||
(the no-creds option, for when they haven't connected Spotify)."""
|
||||
try:
|
||||
return bool(config_manager.get('metadata.spotify_free', False))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def _free_installed(self) -> bool:
|
||||
from core.spotify_free_metadata import spotify_free_installed
|
||||
return spotify_free_installed()
|
||||
|
||||
def _free_wanted(self) -> bool:
|
||||
"""Does the user actually want the free source? OPT-IN: only when they
|
||||
picked 'Spotify Free'. This keeps free from auto-scraping for anyone who
|
||||
didn't choose it — a plain-'Spotify' user just waits out a rate-limit ban
|
||||
as before. A user on 'Spotify Free' who also connects an account uses the
|
||||
official account normally and free only bridges their bans."""
|
||||
return self._free_selected()
|
||||
|
||||
def _free_available(self) -> bool:
|
||||
"""Free CAN serve: package installed AND the user wants Spotify."""
|
||||
return self._free_installed() and self._free_wanted()
|
||||
|
||||
def is_spotify_metadata_available(self) -> bool:
|
||||
"""Whether SoulSync can serve Spotify metadata — real auth OR the
|
||||
no-creds source. Availability gates (search resolve, enrichment worker,
|
||||
watchlist) use THIS instead of ``is_spotify_authenticated()`` so the
|
||||
free source is reachable. Does NOT change auth semantics."""
|
||||
from core.spotify_free_metadata import should_offer_spotify_metadata
|
||||
try:
|
||||
authed = self.is_spotify_authenticated()
|
||||
except Exception:
|
||||
authed = False
|
||||
return should_offer_spotify_metadata(authed, self._free_available())
|
||||
|
||||
def _free_active(self) -> bool:
|
||||
"""Whether the no-creds source may serve THIS request: free available
|
||||
AND official can't (no auth, or rate-limited). ``is_spotify_authenticated()``
|
||||
already returns False during a rate-limit ban; the explicit rate-limit
|
||||
term covers the brief window before the auth cache refreshes. When authed
|
||||
+ healthy the official path returns first, so this never opens.
|
||||
|
||||
Two activations fall out of this: a no-auth user who chose Spotify Free
|
||||
(free is their source), and a connected user mid-rate-limit (free bridges
|
||||
the ban) — see _free_wanted()."""
|
||||
from core.spotify_free_metadata import should_use_free_fallback
|
||||
if not self._free_available():
|
||||
return False
|
||||
try:
|
||||
authed = self.is_spotify_authenticated()
|
||||
except Exception:
|
||||
authed = False
|
||||
return should_use_free_fallback(authed, _is_globally_rate_limited())
|
||||
|
||||
@property
|
||||
def _free_meta(self):
|
||||
"""Lazy SpotipyFree-backed metadata client (soft import — absence is
|
||||
not fatal; methods degrade to the iTunes/Deezer fallback)."""
|
||||
client = getattr(self, '_free_meta_client', None)
|
||||
if client is None:
|
||||
from core.spotify_free_metadata import SpotifyFreeMetadataClient
|
||||
client = SpotifyFreeMetadataClient()
|
||||
self._free_meta_client = client
|
||||
return client
|
||||
|
||||
def reload_config(self):
|
||||
"""Reload configuration and re-initialize client"""
|
||||
self._invalidate_auth_cache()
|
||||
|
|
@ -1276,6 +1342,17 @@ class SpotifyClient:
|
|||
logger.error(f"Error searching tracks via Spotify: {e}")
|
||||
# Fall through to fallback
|
||||
|
||||
# No-creds Spotify (SpotipyFree) before the iTunes/Deezer fallback —
|
||||
# only when official Spotify is unavailable (no auth / rate-limited).
|
||||
if allow_fallback and self._free_active():
|
||||
try:
|
||||
objs = [Track.from_spotify_track(t)
|
||||
for t in self._free_meta.search_tracks(query, effective_limit)]
|
||||
if objs:
|
||||
return objs
|
||||
except Exception as e:
|
||||
logger.debug("SpotipyFree track search failed: %s", e)
|
||||
|
||||
# Fallback (iTunes or Deezer — configured in settings)
|
||||
if allow_fallback:
|
||||
logger.debug(f"Using {self._fallback_source} fallback for track search: {query}")
|
||||
|
|
@ -1336,6 +1413,21 @@ class SpotifyClient:
|
|||
logger.error(f"Error searching artists via Spotify: {e}")
|
||||
# Fall through to iTunes fallback
|
||||
|
||||
# No-creds Spotify (SpotipyFree): keep Spotify catalog/matching when
|
||||
# official Spotify can't serve us (no auth / rate-limited), before the
|
||||
# iTunes/Deezer fallback. Gated by _free_active() so it never runs while
|
||||
# auth is healthy.
|
||||
if allow_fallback and self._free_active():
|
||||
try:
|
||||
objs = [Artist.from_spotify_artist(a)
|
||||
for a in self._free_meta.search_artists(query, limit)]
|
||||
if objs:
|
||||
query_lower = query.lower().strip()
|
||||
objs.sort(key=lambda a: (0 if a.name.lower().strip() == query_lower else 1))
|
||||
return objs
|
||||
except Exception as e:
|
||||
logger.debug("SpotipyFree artist search failed: %s", e)
|
||||
|
||||
# Fallback (iTunes or Deezer)
|
||||
if allow_fallback:
|
||||
logger.debug(f"Using {self._fallback_source} fallback for artist search: {query}")
|
||||
|
|
@ -1437,6 +1529,13 @@ class SpotifyClient:
|
|||
logger.error(f"Error fetching track details via Spotify: {e}")
|
||||
# Fall through to iTunes fallback
|
||||
|
||||
# No-creds Spotify (SpotipyFree) for a real Spotify track id when
|
||||
# official Spotify is unavailable (no auth / rate-limited).
|
||||
if allow_fallback and self._free_active() and not self._is_itunes_id(track_id):
|
||||
free = self._free_meta.get_track_details(track_id)
|
||||
if free:
|
||||
return free
|
||||
|
||||
# Fallback - only if ID is numeric (non-Spotify format)
|
||||
if allow_fallback and self._is_itunes_id(track_id):
|
||||
logger.debug(f"Using {fallback_src} fallback for track details: {track_id}")
|
||||
|
|
@ -1528,6 +1627,13 @@ class SpotifyClient:
|
|||
logger.error(f"Error fetching album via Spotify: {e}")
|
||||
# Fall through to fallback
|
||||
|
||||
# No-creds Spotify (SpotipyFree) for a real Spotify album id when
|
||||
# official Spotify is unavailable (no auth / rate-limited).
|
||||
if allow_fallback and self._free_active() and not self._is_itunes_id(album_id):
|
||||
free = self._free_meta.get_album(album_id)
|
||||
if free:
|
||||
return free
|
||||
|
||||
# Fallback - only if ID is numeric (non-Spotify format)
|
||||
if allow_fallback and self._is_itunes_id(album_id):
|
||||
logger.debug(f"Using {fallback_src} fallback for album: {album_id}")
|
||||
|
|
@ -1605,6 +1711,13 @@ class SpotifyClient:
|
|||
logger.error(f"Error fetching album tracks via Spotify: {e}")
|
||||
# Fall through to iTunes fallback
|
||||
|
||||
# No-creds Spotify (SpotipyFree) for a real Spotify album id when
|
||||
# official Spotify is unavailable (no auth / rate-limited).
|
||||
if allow_fallback and self._free_active() and not self._is_itunes_id(album_id):
|
||||
free = self._free_meta.get_album_tracks(album_id)
|
||||
if free:
|
||||
return free
|
||||
|
||||
# Fallback - only if ID is numeric (non-Spotify format)
|
||||
if allow_fallback and self._is_itunes_id(album_id):
|
||||
logger.debug(f"Using {fallback_src} fallback for album tracks: {album_id}")
|
||||
|
|
@ -1691,6 +1804,18 @@ class SpotifyClient:
|
|||
logger.error(f"Error fetching artist albums via Spotify: {e}")
|
||||
# Fall through to iTunes fallback
|
||||
|
||||
# No-creds Spotify (SpotipyFree) for a real Spotify artist id when
|
||||
# official Spotify is unavailable (no auth / rate-limited). The free
|
||||
# discography is already album-shaped — project to Album dataclasses.
|
||||
if allow_fallback and self._free_active() and not self._is_itunes_id(artist_id):
|
||||
try:
|
||||
free = [Album.from_spotify_album(a)
|
||||
for a in self._free_meta.get_artist_albums_list(artist_id, limit)]
|
||||
if free:
|
||||
return free
|
||||
except Exception as e:
|
||||
logger.debug("SpotipyFree artist albums failed: %s", e)
|
||||
|
||||
# Fallback - only if ID is numeric (non-Spotify format)
|
||||
if allow_fallback and self._is_itunes_id(artist_id):
|
||||
logger.debug(f"Using {fallback_src} fallback for artist albums: {artist_id}")
|
||||
|
|
@ -1745,6 +1870,13 @@ class SpotifyClient:
|
|||
logger.error(f"Error fetching artist via Spotify: {e}")
|
||||
# Fall through to iTunes fallback
|
||||
|
||||
# No-creds Spotify (SpotipyFree) for a real Spotify artist id when
|
||||
# official Spotify is unavailable (no auth / rate-limited).
|
||||
if allow_fallback and self._free_active() and not self._is_itunes_id(artist_id):
|
||||
free = self._free_meta.get_artist(artist_id)
|
||||
if free:
|
||||
return free
|
||||
|
||||
# Fallback - only if ID is numeric (non-Spotify format)
|
||||
if allow_fallback and self._is_itunes_id(artist_id):
|
||||
logger.debug(f"Using {fallback_src} fallback for artist: {artist_id}")
|
||||
|
|
|
|||
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 database.music_database import MusicDatabase
|
||||
from core.spotify_client import SpotifyClient, SpotifyRateLimitError
|
||||
from core.worker_utils import interruptible_sleep, set_album_api_track_count
|
||||
from core.worker_utils import (
|
||||
ARTIST_NAME_MATCH_THRESHOLD,
|
||||
interruptible_sleep,
|
||||
set_album_api_track_count,
|
||||
source_id_conflict,
|
||||
)
|
||||
from core.enrichment.manual_match_honoring import honor_stored_match
|
||||
|
||||
logger = get_logger("spotify_worker")
|
||||
|
|
@ -121,11 +126,19 @@ class SpotifyWorker:
|
|||
rate_limit_info = self.client.get_rate_limit_info() if rate_limited else None
|
||||
in_cooldown = self.client.get_post_ban_cooldown_remaining() > 0
|
||||
authenticated = self.client.sp is not None
|
||||
# Is the worker still serving via the no-creds Spotify Free source
|
||||
# despite the real-API ban? Only check WHEN rate-limited: during a
|
||||
# ban is_spotify_authenticated() returns False without an API probe,
|
||||
# so is_spotify_metadata_available() reduces to "is free available"
|
||||
# (no quota cost). Lets the UI show "via Spotify Free" instead of a
|
||||
# misleading "rate limited / waiting" while the worker keeps matching.
|
||||
using_free = bool(rate_limited and self.client.is_spotify_metadata_available())
|
||||
except Exception:
|
||||
authenticated = False
|
||||
rate_limited = False
|
||||
rate_limit_info = None
|
||||
in_cooldown = False
|
||||
using_free = False
|
||||
|
||||
return {
|
||||
'enabled': True,
|
||||
|
|
@ -134,6 +147,7 @@ class SpotifyWorker:
|
|||
'idle': is_idle,
|
||||
'authenticated': authenticated,
|
||||
'rate_limited': rate_limited,
|
||||
'using_free': using_free,
|
||||
'rate_limit': rate_limit_info,
|
||||
'daily_budget': self._get_daily_budget_info(),
|
||||
'current_item': self.current_item,
|
||||
|
|
@ -181,16 +195,33 @@ class SpotifyWorker:
|
|||
interruptible_sleep(self._stop_event, 1)
|
||||
continue
|
||||
|
||||
# Rate limit guard — if globally rate limited, sleep until ban expires
|
||||
if self.client.is_rate_limited():
|
||||
# Rate limit guard — if globally rate limited, sleep until ban
|
||||
# expires. EXCEPT: when Spotify Free is available it bridges the
|
||||
# ban (is_spotify_metadata_available() is True via the no-creds
|
||||
# source, and the client routes there), so we keep enriching
|
||||
# instead of stalling. Purely additive: with Spotify Free off,
|
||||
# is_spotify_metadata_available() is False during a ban and this
|
||||
# sleeps exactly as before.
|
||||
if self.client.is_rate_limited() and not self.client.is_spotify_metadata_available():
|
||||
info = self.client.get_rate_limit_info()
|
||||
remaining = info['remaining_seconds'] if info else 60
|
||||
logger.debug(f"Spotify globally rate limited, sleeping {remaining}s...")
|
||||
interruptible_sleep(self._stop_event, min(remaining, 60)) # Check again every 60s max
|
||||
continue
|
||||
|
||||
# Daily budget guard — worker-only cap to avoid saturating Spotify rate limits
|
||||
if self._is_daily_budget_exhausted():
|
||||
# Is the worker serving via the no-creds Spotify Free source this
|
||||
# iteration? The daily budget and post-ban cooldown both exist to
|
||||
# protect the REAL authenticated API from bans — they don't apply
|
||||
# to free (a different, anonymous path). Computed once and reused
|
||||
# below; the loop already probes auth, so no extra quota cost.
|
||||
try:
|
||||
free_serving = self.client._free_active()
|
||||
except Exception:
|
||||
free_serving = False
|
||||
|
||||
# Daily budget guard — worker-only cap to avoid saturating the REAL
|
||||
# Spotify API. Skipped while serving via free (free isn't that API).
|
||||
if not free_serving and self._is_daily_budget_exhausted():
|
||||
budget = self._get_daily_budget_info()
|
||||
resets_in = budget['resets_in_seconds']
|
||||
logger.info(f"Daily enrichment budget exhausted ({budget['used']}/{budget['limit']}), "
|
||||
|
|
@ -199,8 +230,9 @@ class SpotifyWorker:
|
|||
continue
|
||||
|
||||
# Post-ban cooldown guard — after ban expires, wait before resuming
|
||||
# to avoid immediately re-triggering the rate limit
|
||||
cooldown = self.client.get_post_ban_cooldown_remaining()
|
||||
# to avoid immediately re-triggering the rate limit. Only matters
|
||||
# for the real API, so skip it while serving via free.
|
||||
cooldown = 0 if free_serving else self.client.get_post_ban_cooldown_remaining()
|
||||
if cooldown > 0:
|
||||
logger.debug(f"Post-ban cooldown active ({cooldown}s left), sleeping...")
|
||||
interruptible_sleep(self._stop_event, min(cooldown, 60))
|
||||
|
|
@ -210,10 +242,12 @@ class SpotifyWorker:
|
|||
# We intentionally avoid calling is_spotify_authenticated() here
|
||||
# because it makes an API probe that can re-trigger rate limits
|
||||
# and lock users in an infinite rate-limit loop.
|
||||
if not self.client.is_spotify_authenticated():
|
||||
# Available = real auth OR the no-creds SpotipyFree fallback
|
||||
# (enrichment is metadata-only, so the free source can serve it).
|
||||
if not self.client.is_spotify_metadata_available():
|
||||
self.client.reload_config()
|
||||
if not self.client.is_spotify_authenticated():
|
||||
logger.debug("Spotify not authenticated, sleeping 30s...")
|
||||
if not self.client.is_spotify_metadata_available():
|
||||
logger.debug("Spotify metadata unavailable, sleeping 30s...")
|
||||
interruptible_sleep(self._stop_event, 30)
|
||||
continue
|
||||
|
||||
|
|
@ -239,7 +273,10 @@ class SpotifyWorker:
|
|||
continue
|
||||
|
||||
self._process_item(item)
|
||||
self._increment_daily_budget()
|
||||
# Only real-API work counts toward the daily cap — free-served
|
||||
# items don't touch the authenticated API's quota.
|
||||
if not free_serving:
|
||||
self._increment_daily_budget()
|
||||
interruptible_sleep(self._stop_event, self.inter_item_sleep)
|
||||
|
||||
except SpotifyRateLimitError:
|
||||
|
|
@ -483,12 +520,14 @@ class SpotifyWorker:
|
|||
logger.debug(f"No Spotify results for artist '{artist_name}'")
|
||||
return
|
||||
|
||||
# Find best fuzzy match — score all candidates, pick highest above threshold
|
||||
# Find best fuzzy match — score all candidates, pick highest above the
|
||||
# (stricter, artist-specific) threshold so short-name false positives
|
||||
# like "ODESZA"/"odessa" don't slip through.
|
||||
best_obj = None
|
||||
best_score = 0
|
||||
for artist_obj in results:
|
||||
score = self._name_similarity(artist_name, artist_obj.name)
|
||||
if score >= self.name_similarity_threshold and score > best_score:
|
||||
if score >= ARTIST_NAME_MATCH_THRESHOLD and score > best_score:
|
||||
best_obj = artist_obj
|
||||
best_score = score
|
||||
|
||||
|
|
@ -498,6 +537,19 @@ class SpotifyWorker:
|
|||
self._mark_status('artist', artist_id, 'error')
|
||||
self.stats['errors'] += 1
|
||||
return
|
||||
# Don't assign a Spotify id another (differently-named) artist
|
||||
# already holds — prevents one id smeared across artists.
|
||||
conflict = source_id_conflict(
|
||||
self.db, 'spotify_artist_id', best_obj.id, artist_id, artist_name
|
||||
)
|
||||
if conflict:
|
||||
self._mark_status('artist', artist_id, 'not_found')
|
||||
self.stats['not_found'] += 1
|
||||
logger.debug(
|
||||
f"Artist '{artist_name}' -> Spotify {best_obj.id} skipped: "
|
||||
f"already claimed by '{conflict}'"
|
||||
)
|
||||
return
|
||||
self._update_artist(artist_id, best_obj)
|
||||
self.stats['matched'] += 1
|
||||
logger.info(f"Matched artist '{artist_name}' -> Spotify ID: {best_obj.id} (score: {best_score:.2f})")
|
||||
|
|
|
|||
|
|
@ -24,6 +24,10 @@ from __future__ import annotations
|
|||
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
|
||||
from utils.logging_config import get_logger
|
||||
|
||||
logger = get_logger("sync.match_overrides")
|
||||
|
||||
|
||||
def resolve_match_overrides(
|
||||
source_tracks: List[Dict[str, Any]],
|
||||
|
|
@ -117,3 +121,76 @@ def record_manual_match(
|
|||
))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def resolve_durable_match_server_id(
|
||||
db: Any,
|
||||
profile_id: int,
|
||||
source_track_id: str,
|
||||
server_source: str,
|
||||
valid_server_ids: set,
|
||||
) -> Optional[str]:
|
||||
"""Current server track id for a DURABLE manual library match, or None.
|
||||
|
||||
Unlike ``sync_match_cache`` (wiped on every rescan), the
|
||||
``manual_library_track_matches`` table survives a scan — so consulting it
|
||||
here is what makes a user's Find & Add / manual match persist across a
|
||||
library rescan (#787). If the stored ``library_track_id`` went stale
|
||||
(a rescan re-keyed the track — common on Jellyfin/Navidrome), re-resolve
|
||||
it from the stored file path and self-heal the row so the next lookup is
|
||||
a direct hit.
|
||||
|
||||
Pure helper: ``db`` is injected. ``valid_server_ids`` is the set of
|
||||
string ids that currently exist in the server playlist's track list —
|
||||
a re-resolved id is only returned if it's actually present. Never raises.
|
||||
"""
|
||||
if not source_track_id:
|
||||
return None
|
||||
finder = getattr(db, "find_manual_library_match_by_source_track_id", None)
|
||||
if finder is None:
|
||||
return None
|
||||
try:
|
||||
match = finder(profile_id, str(source_track_id), server_source or "")
|
||||
except Exception:
|
||||
return None
|
||||
if not match:
|
||||
return None
|
||||
|
||||
lib_id = match.get("library_track_id")
|
||||
if lib_id is not None and str(lib_id) in valid_server_ids:
|
||||
return str(lib_id)
|
||||
|
||||
# Stale pointer — re-resolve via the stored file path and self-heal.
|
||||
file_path = match.get("library_file_path")
|
||||
resolver = getattr(db, "find_track_id_by_file_path", None)
|
||||
if file_path and resolver is not None:
|
||||
try:
|
||||
new_id = resolver(file_path)
|
||||
except Exception:
|
||||
new_id = None
|
||||
if new_id and str(new_id) in valid_server_ids:
|
||||
_self_heal_match_id(db, match, str(new_id))
|
||||
return str(new_id)
|
||||
return None
|
||||
|
||||
|
||||
def _self_heal_match_id(db: Any, match: Dict[str, Any], new_library_track_id: str) -> None:
|
||||
"""Rewrite a manual match's library_track_id after re-resolution. Best-effort."""
|
||||
saver = getattr(db, "save_manual_library_match", None)
|
||||
if saver is None:
|
||||
return
|
||||
try:
|
||||
saver(
|
||||
match.get("profile_id", 1),
|
||||
match.get("source", ""),
|
||||
match.get("source_track_id", ""),
|
||||
new_library_track_id,
|
||||
source_title=match.get("source_title"),
|
||||
source_artist=match.get("source_artist"),
|
||||
source_album=match.get("source_album"),
|
||||
source_context_json=match.get("source_context_json"),
|
||||
server_source=match.get("server_source", ""),
|
||||
library_file_path=match.get("library_file_path"),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug("manual match self-heal failed: %s", e)
|
||||
|
|
|
|||
|
|
@ -71,4 +71,64 @@ def remove_one_occurrence(
|
|||
return ids, False
|
||||
|
||||
|
||||
__all__ = ["plan_playlist_add", "remove_one_occurrence"]
|
||||
def plan_playlist_reconcile(
|
||||
current_ids: List[str],
|
||||
desired_ids: List[str],
|
||||
) -> dict:
|
||||
"""Plan an in-place reconcile of a server playlist toward a desired tracklist.
|
||||
|
||||
Used by ``sync_mode='reconcile'`` (#792): instead of deleting + recreating
|
||||
the playlist (which destroys its custom image, description, and identity),
|
||||
the caller keeps the existing playlist object and applies only the delta —
|
||||
adding the tracks that are missing and removing the ones no longer in the
|
||||
source. Pure, no I/O.
|
||||
|
||||
Returns ``{'add': [...], 'remove': [...]}`` (both lists of string ids):
|
||||
- ``add`` — desired ids not currently present, in desired order.
|
||||
- ``remove`` — current ids no longer desired (each occurrence kept once;
|
||||
duplicates of a still-desired id are left for the caller's
|
||||
dedupe to handle, never mass-removed).
|
||||
|
||||
Order-preserving and duplicate-safe: a desired id already present is not
|
||||
re-added; a current id that's still desired is not removed even if it
|
||||
appears more than once.
|
||||
"""
|
||||
desired = [str(t) for t in desired_ids]
|
||||
current = [str(t) for t in current_ids]
|
||||
current_set = set(current)
|
||||
desired_set = set(desired)
|
||||
add = [d for d in desired if d not in current_set]
|
||||
# Preserve order of removal as it appears in the current list; one entry per
|
||||
# id (the caller maps ids back to concrete playlist entries to delete).
|
||||
seen_remove = set()
|
||||
remove = []
|
||||
for c in current:
|
||||
if c not in desired_set and c not in seen_remove:
|
||||
seen_remove.add(c)
|
||||
remove.append(c)
|
||||
return {"add": add, "remove": remove}
|
||||
|
||||
|
||||
VALID_SYNC_MODES = ("replace", "append", "reconcile")
|
||||
|
||||
|
||||
def normalize_sync_mode(requested, configured, default: str = "replace") -> str:
|
||||
"""Resolve the effective playlist sync mode.
|
||||
|
||||
An explicit per-request value wins; otherwise the configured default
|
||||
(Settings > Playlist sync mode); anything unrecognized falls back to
|
||||
``default``. Keeping ``reconcile`` in ``VALID_SYNC_MODES`` is load-bearing —
|
||||
a validation list that omits it silently downgrades reconcile to replace,
|
||||
which is exactly the #792 regression this helper exists to prevent.
|
||||
"""
|
||||
mode = (requested or "") or (configured or "") or default
|
||||
return mode if mode in VALID_SYNC_MODES else default
|
||||
|
||||
|
||||
__all__ = [
|
||||
"plan_playlist_add",
|
||||
"remove_one_occurrence",
|
||||
"plan_playlist_reconcile",
|
||||
"normalize_sync_mode",
|
||||
"VALID_SYNC_MODES",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -136,10 +136,49 @@ def read_file_tags(file_path: str) -> Dict[str, Any]:
|
|||
return result
|
||||
|
||||
|
||||
# Known placeholder / "we don't really know" metadata values. A match in the
|
||||
# DB never warrants overwriting a real value already in the file (issue #800:
|
||||
# a mis-grouped track sits under a "Various Artists" / "[Unknown Album]" record,
|
||||
# and Write Tags would otherwise stamp that junk over the file's correct tags).
|
||||
_PLACEHOLDER_META_VALUES = frozenset({
|
||||
'various artists', 'various artist',
|
||||
'unknown artist', 'unknown album', 'unknown',
|
||||
'[unknown album]', '[unknown artist]', '[unknown]',
|
||||
'untitled album',
|
||||
})
|
||||
|
||||
|
||||
def is_placeholder_meta(value: Any) -> bool:
|
||||
"""True for empty or known-placeholder metadata strings (case-insensitive)."""
|
||||
if value is None:
|
||||
return True
|
||||
s = str(value).strip().lower()
|
||||
return s == '' or s in _PLACEHOLDER_META_VALUES
|
||||
|
||||
|
||||
def guard_placeholder_overwrite(db_val: Any, file_val: Any) -> Any:
|
||||
"""#800 guard: never replace a real file value with a placeholder.
|
||||
|
||||
Returns ``None`` (skip the write → preserve the file's value) ONLY when the
|
||||
DB value is a placeholder/empty AND the file already holds a real,
|
||||
non-placeholder value. Otherwise returns ``db_val`` unchanged — so a
|
||||
legitimate value still writes, including a genuine ``Various Artists`` album
|
||||
artist on a real compilation (there the file has no conflicting real value,
|
||||
so the guard doesn't fire).
|
||||
"""
|
||||
if is_placeholder_meta(db_val) and not is_placeholder_meta(file_val):
|
||||
return None
|
||||
return db_val
|
||||
|
||||
|
||||
def build_tag_diff(file_tags: Dict[str, Any], db_data: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Compare file tags against DB metadata. Returns a list of diffs:
|
||||
[{ field, file_value, db_value, changed }]
|
||||
[{ field, file_value, db_value, changed, protected }]
|
||||
|
||||
``protected`` marks a field the #800 guard is holding back: the DB value is
|
||||
a placeholder and the file's real value would be preserved, so it's shown
|
||||
as no-change rather than a wrong overwrite.
|
||||
"""
|
||||
fields = [
|
||||
('title', 'title', 'Title'),
|
||||
|
|
@ -179,12 +218,22 @@ def build_tag_diff(file_tags: Dict[str, Any], db_data: Dict[str, Any]) -> List[D
|
|||
# Only mark as changed if DB has a value AND it differs from file
|
||||
# (writer skips fields where DB value is empty, so don't show them as diffs)
|
||||
changed = bool(db_str) and file_str != db_str
|
||||
|
||||
# #800 — if the change would replace a real file value with a
|
||||
# placeholder (Various Artists / [Unknown Album] / …), hold it back:
|
||||
# the writer preserves the file's value, so show it as no-change.
|
||||
protected = False
|
||||
if changed and guard_placeholder_overwrite(db_val, file_val) is None:
|
||||
changed = False
|
||||
protected = True
|
||||
|
||||
diffs.append({
|
||||
'field': label,
|
||||
'file_key': file_key,
|
||||
'file_value': str(file_val) if file_val is not None else '',
|
||||
'db_value': str(db_val) if db_val is not None else '',
|
||||
'changed': changed,
|
||||
'protected': protected,
|
||||
})
|
||||
|
||||
# Cover art — special row
|
||||
|
|
@ -253,6 +302,23 @@ def write_tags_to_file(file_path: str, db_data: Dict[str, Any],
|
|||
artist = db_data.get('track_artist') or db_data.get('artist_name') # Per-track artist for compilations/DJ mixes
|
||||
album = db_data.get('album_title')
|
||||
album_artist = db_data.get('artist_name') # Album artist stays as the album-level artist
|
||||
|
||||
# #800 — never overwrite a real value already in the file with a
|
||||
# placeholder DB value (Various Artists / [Unknown Album] / …). A
|
||||
# mis-grouped track sits under such a record; writing it would destroy
|
||||
# the file's correct tags. Reads the file's current values and skips
|
||||
# only the placeholder-over-real fields (legit values, incl. a genuine
|
||||
# compilation's Various Artists, still write — see guard docstring).
|
||||
try:
|
||||
_current = read_file_tags(file_path)
|
||||
except Exception:
|
||||
_current = {}
|
||||
if not _current.get('error'):
|
||||
title = guard_placeholder_overwrite(title, _current.get('title'))
|
||||
artist = guard_placeholder_overwrite(artist, _current.get('artist'))
|
||||
album = guard_placeholder_overwrite(album, _current.get('album'))
|
||||
album_artist = guard_placeholder_overwrite(album_artist, _current.get('album_artist'))
|
||||
|
||||
year = db_data.get('year')
|
||||
genres = db_data.get('genres')
|
||||
track_num = db_data.get('track_number')
|
||||
|
|
@ -294,6 +360,24 @@ def write_tags_to_file(file_path: str, db_data: Dict[str, Any],
|
|||
year, genre_str, track_num, total_tracks,
|
||||
disc_num, bpm, artists_list=artists_list)
|
||||
|
||||
# Embed already-known source IDs (Spotify / iTunes / MusicBrainz) from
|
||||
# db_data, reusing the canonical import-time frame writer — no API
|
||||
# re-fetch. Only fires when db_data carries id keys, so the plain
|
||||
# "write the core tags" callers are unaffected.
|
||||
_src_meta = {k: db_data[k] for k in (
|
||||
'source', 'source_track_id', 'source_album_id', 'source_artist_id',
|
||||
'spotify_track_id', 'spotify_album_id', 'spotify_artist_id',
|
||||
'itunes_track_id', 'itunes_album_id', 'itunes_artist_id',
|
||||
'musicbrainz_recording_id', 'musicbrainz_release_id',
|
||||
) if db_data.get(k)}
|
||||
if _src_meta:
|
||||
try:
|
||||
from core.metadata.source import embed_known_source_ids
|
||||
if embed_known_source_ids(audio, _src_meta):
|
||||
written.append('source_ids')
|
||||
except Exception as e:
|
||||
logger.debug("source-id embed skipped for %s: %s", file_path, e)
|
||||
|
||||
# Embed cover art if requested
|
||||
if embed_cover:
|
||||
art_ok = False
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ from datetime import datetime, timedelta
|
|||
from utils.logging_config import get_logger
|
||||
from database.music_database import MusicDatabase
|
||||
from core.tidal_client import TidalClient
|
||||
from core.worker_utils import interruptible_sleep
|
||||
from core.worker_utils import accept_artist_match, interruptible_sleep
|
||||
from core.enrichment.manual_match_honoring import honor_stored_match
|
||||
|
||||
logger = get_logger("tidal_worker")
|
||||
|
|
@ -313,13 +313,29 @@ class TidalWorker:
|
|||
logger.debug(f"Name similarity: '{query_name}' vs '{result_name}' = {similarity:.2f}")
|
||||
return similarity >= self.name_similarity_threshold
|
||||
|
||||
def _verify_artist_id(self, item: Dict[str, Any], result_artist_id) -> bool:
|
||||
"""Verify/correct parent artist's Tidal ID based on album/track match"""
|
||||
def _verify_artist_id(self, item: Dict[str, Any], result_artist_id,
|
||||
result_artist_name: Optional[str] = None) -> bool:
|
||||
"""Verify/correct parent artist's Tidal ID based on album/track match.
|
||||
|
||||
Only corrects when the result's artist *name* matches our parent artist —
|
||||
otherwise a collaboration/compilation would stamp the wrong Tidal id onto
|
||||
our artist. See the Deezer fix for the full write-up."""
|
||||
parent_tidal_id = item.get('artist_tidal_id')
|
||||
if not parent_tidal_id or not result_artist_id:
|
||||
return True
|
||||
|
||||
if str(result_artist_id) != str(parent_tidal_id):
|
||||
parent_name = item.get('artist') or ''
|
||||
if (result_artist_name and parent_name
|
||||
and not self._name_matches(parent_name, result_artist_name)):
|
||||
logger.info(
|
||||
f"Skipping artist-ID correction from {item['type']} "
|
||||
f"'{item['name']}': result artist '{result_artist_name}' "
|
||||
f"≠ parent '{parent_name}' (collab/compilation, not a "
|
||||
f"correction)"
|
||||
)
|
||||
return True
|
||||
|
||||
logger.info(
|
||||
f"Artist ID correction from {item['type']} '{item['name']}': "
|
||||
f"updating parent artist Tidal ID from {parent_tidal_id} to {result_artist_id}"
|
||||
|
|
@ -419,14 +435,19 @@ class TidalWorker:
|
|||
result = self.client.search_artist(artist_name)
|
||||
if result:
|
||||
result_name = result.get('name', '')
|
||||
if self._name_matches(artist_name, result_name):
|
||||
tidal_artist_id = result.get('id')
|
||||
if not tidal_artist_id:
|
||||
self._mark_status('artist', artist_id, 'error')
|
||||
self.stats['errors'] += 1
|
||||
logger.warning(f"Tidal search result for '{artist_name}' has no ID")
|
||||
return
|
||||
|
||||
tidal_artist_id = result.get('id')
|
||||
ok, reason = accept_artist_match(
|
||||
self.db, 'tidal_id', tidal_artist_id, artist_id, artist_name, result_name,
|
||||
)
|
||||
if not ok:
|
||||
self._mark_status('artist', artist_id, 'not_found')
|
||||
self.stats['not_found'] += 1
|
||||
logger.debug(f"Artist '{artist_name}' not matched: {reason}")
|
||||
elif not tidal_artist_id:
|
||||
self._mark_status('artist', artist_id, 'error')
|
||||
self.stats['errors'] += 1
|
||||
logger.warning(f"Tidal search result for '{artist_name}' has no ID")
|
||||
else:
|
||||
# Fetch full artist details for image
|
||||
full_artist = None
|
||||
try:
|
||||
|
|
@ -437,10 +458,6 @@ class TidalWorker:
|
|||
self._update_artist(artist_id, result, full_artist)
|
||||
self.stats['matched'] += 1
|
||||
logger.info(f"Matched artist '{artist_name}' -> Tidal ID: {tidal_artist_id}")
|
||||
else:
|
||||
self._mark_status('artist', artist_id, 'not_found')
|
||||
self.stats['not_found'] += 1
|
||||
logger.debug(f"Name mismatch for artist '{artist_name}' (got '{result_name}')")
|
||||
else:
|
||||
self._mark_status('artist', artist_id, 'not_found')
|
||||
self.stats['not_found'] += 1
|
||||
|
|
@ -479,7 +496,8 @@ class TidalWorker:
|
|||
# Verify artist ID
|
||||
result_artist = result.get('artist', {})
|
||||
result_artist_id = result_artist.get('id') if result_artist else None
|
||||
self._verify_artist_id(item, result_artist_id)
|
||||
result_artist_name = result_artist.get('name') if result_artist else None
|
||||
self._verify_artist_id(item, result_artist_id, result_artist_name)
|
||||
|
||||
# Fetch full album details
|
||||
tidal_album_id = result.get('id')
|
||||
|
|
@ -533,7 +551,8 @@ class TidalWorker:
|
|||
# Verify artist ID
|
||||
result_artist = result.get('artist', {})
|
||||
result_artist_id = result_artist.get('id') if result_artist else None
|
||||
self._verify_artist_id(item, result_artist_id)
|
||||
result_artist_name = result_artist.get('name') if result_artist else None
|
||||
self._verify_artist_id(item, result_artist_id, result_artist_name)
|
||||
|
||||
# Fetch full track details
|
||||
tidal_track_id = result.get('id')
|
||||
|
|
|
|||
|
|
@ -20,6 +20,24 @@ from dataclasses import dataclass
|
|||
from typing import List, Optional, Protocol, runtime_checkable
|
||||
|
||||
|
||||
def normalize_client_url(raw: str) -> str:
|
||||
"""Clean a user-entered WebUI URL into something ``requests`` accepts.
|
||||
|
||||
Users routinely type a bare host like ``192.168.1.5:8080`` or
|
||||
``qbittorrent.lan:8080`` with no scheme. ``requests`` then raises
|
||||
"No connection adapters were found for '...'" because it can't pick an
|
||||
http/https adapter (a bare ``host:port`` even gets misparsed as
|
||||
``scheme=host``). Default a missing scheme to ``http://`` and trim a
|
||||
trailing slash. Empty input passes through unchanged.
|
||||
"""
|
||||
url = (raw or '').strip()
|
||||
if not url:
|
||||
return ''
|
||||
if '://' not in url:
|
||||
url = 'http://' + url
|
||||
return url.rstrip('/')
|
||||
|
||||
|
||||
@dataclass
|
||||
class TorrentStatus:
|
||||
"""Adapter-uniform view of one torrent's live state.
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ from typing import Any, List, Optional
|
|||
import requests as http_requests
|
||||
|
||||
from config.settings import config_manager
|
||||
from core.torrent_clients.base import TorrentStatus
|
||||
from core.torrent_clients.base import TorrentStatus, normalize_client_url
|
||||
from utils.logging_config import get_logger
|
||||
|
||||
logger = get_logger("torrent.deluge")
|
||||
|
|
@ -67,7 +67,7 @@ class DelugeAdapter:
|
|||
self._load_config()
|
||||
|
||||
def _load_config(self) -> None:
|
||||
self._url = (config_manager.get('torrent_client.url', '') or '').rstrip('/')
|
||||
self._url = normalize_client_url(config_manager.get('torrent_client.url', ''))
|
||||
# Deluge's WebUI auth uses a single password, not username+password.
|
||||
# We accept whichever field the user filled in — keeps the UI uniform.
|
||||
self._password = (
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ from typing import List, Optional
|
|||
import requests as http_requests
|
||||
|
||||
from config.settings import config_manager
|
||||
from core.torrent_clients.base import TorrentStatus
|
||||
from core.torrent_clients.base import TorrentStatus, normalize_client_url
|
||||
from utils.logging_config import get_logger
|
||||
|
||||
logger = get_logger("torrent.qbittorrent")
|
||||
|
|
@ -64,7 +64,7 @@ class QBittorrentAdapter:
|
|||
self._load_config()
|
||||
|
||||
def _load_config(self) -> None:
|
||||
self._url = (config_manager.get('torrent_client.url', '') or '').rstrip('/')
|
||||
self._url = normalize_client_url(config_manager.get('torrent_client.url', ''))
|
||||
self._username = config_manager.get('torrent_client.username', '') or ''
|
||||
self._password = config_manager.get('torrent_client.password', '') or ''
|
||||
self._category = config_manager.get('torrent_client.category', 'soulsync') or 'soulsync'
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ from typing import List, Optional
|
|||
import requests as http_requests
|
||||
|
||||
from config.settings import config_manager
|
||||
from core.torrent_clients.base import TorrentStatus
|
||||
from core.torrent_clients.base import TorrentStatus, normalize_client_url
|
||||
from utils.logging_config import get_logger
|
||||
|
||||
logger = get_logger("torrent.transmission")
|
||||
|
|
@ -57,7 +57,7 @@ class TransmissionAdapter:
|
|||
self._load_config()
|
||||
|
||||
def _load_config(self) -> None:
|
||||
url = (config_manager.get('torrent_client.url', '') or '').rstrip('/')
|
||||
url = normalize_client_url(config_manager.get('torrent_client.url', ''))
|
||||
# Transmission's RPC endpoint is always /transmission/rpc — if the
|
||||
# user pasted a bare host URL, append it. If they pasted the full
|
||||
# /transmission/rpc URL, leave it.
|
||||
|
|
|
|||
|
|
@ -489,12 +489,16 @@ class WatchlistScanner:
|
|||
self._spotify_disabled_reason = reason
|
||||
|
||||
def _spotify_available_for_run(self) -> bool:
|
||||
"""Check if Spotify should be used for this run."""
|
||||
"""Check if Spotify should be used for this run.
|
||||
|
||||
Available = real auth OR the no-creds SpotipyFree fallback (new-release
|
||||
detection is metadata-only — get_artist_albums — so the free source can
|
||||
serve it; the client routes internally when auth is missing/limited)."""
|
||||
if self._spotify_disabled_for_run:
|
||||
return False
|
||||
if not self.spotify_client:
|
||||
return False
|
||||
return self.spotify_client.is_spotify_authenticated()
|
||||
return self.spotify_client.is_spotify_metadata_available()
|
||||
|
||||
def _spotify_is_primary_source(self) -> bool:
|
||||
"""Check if Spotify is both authenticated and the configured primary metadata source.
|
||||
|
|
|
|||
|
|
@ -1,10 +1,105 @@
|
|||
"""Shared helpers for background workers."""
|
||||
|
||||
import logging
|
||||
import re
|
||||
import threading
|
||||
from difflib import SequenceMatcher
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Artist-match acceptance gate. Stricter than the 0.80 each worker uses for
|
||||
# album/track titles: artist names are short, so 0.80 lets distinct artists
|
||||
# slip through ("ODESZA"/"odessa", "Blance"/"Blanke", "Lady A"/"Lady Gaga" all
|
||||
# score 0.80-0.83). 0.85 rejects those while still tolerating real variation
|
||||
# that survives normalization.
|
||||
ARTIST_NAME_MATCH_THRESHOLD = 0.85
|
||||
|
||||
# Whitelist of artist source-id columns we'll interpolate into SQL — guards the
|
||||
# conflict query against any unexpected column name.
|
||||
_ARTIST_ID_COLUMNS = frozenset({
|
||||
'deezer_id', 'spotify_artist_id', 'itunes_artist_id', 'musicbrainz_id',
|
||||
'discogs_id', 'audiodb_id', 'qobuz_id', 'tidal_id', 'amazon_id', 'soul_id',
|
||||
})
|
||||
|
||||
|
||||
def normalize_artist_name(name: str) -> str:
|
||||
"""Lowercase, drop ' - ...' suffixes / parentheticals / punctuation, and
|
||||
collapse whitespace — the same normalization the per-worker matchers use."""
|
||||
name = (name or '').lower().strip()
|
||||
name = re.sub(r'\s+[-–—]\s+.*$', '', name)
|
||||
name = re.sub(r'\s*\(.*?\)\s*', ' ', name)
|
||||
name = re.sub(r'[^\w\s]', '', name)
|
||||
name = re.sub(r'\s+', ' ', name).strip()
|
||||
return name
|
||||
|
||||
|
||||
def artist_name_matches(query: str, result: str,
|
||||
threshold: float = ARTIST_NAME_MATCH_THRESHOLD) -> bool:
|
||||
"""True if two artist names match at/above ``threshold`` after normalization."""
|
||||
nq, nr = normalize_artist_name(query), normalize_artist_name(result)
|
||||
if not nq or not nr:
|
||||
return False
|
||||
return SequenceMatcher(None, nq, nr).ratio() >= threshold
|
||||
|
||||
|
||||
def _names_equivalent(a: str, b: str) -> bool:
|
||||
return normalize_artist_name(a) == normalize_artist_name(b)
|
||||
|
||||
|
||||
def source_id_conflict(database, id_column: str, source_id, artist_id,
|
||||
artist_name: str) -> Optional[str]:
|
||||
"""Return the name of a DIFFERENTLY-named library artist that already holds
|
||||
``source_id`` in ``id_column``, or None.
|
||||
|
||||
A same-named holder (the same artist indexed on two media servers) is NOT a
|
||||
conflict — both legitimately share the id. Only a different artist holding
|
||||
the id signals the kind of corruption where one source id gets smeared
|
||||
across unrelated artists.
|
||||
"""
|
||||
if source_id in (None, ''):
|
||||
return None
|
||||
if id_column not in _ARTIST_ID_COLUMNS:
|
||||
logger.debug(f"source_id_conflict: refusing unknown column {id_column!r}")
|
||||
return None
|
||||
try:
|
||||
with database._get_connection() as conn:
|
||||
rows = conn.execute(
|
||||
f"SELECT name FROM artists WHERE {id_column} = ? AND id != ?",
|
||||
(str(source_id), artist_id),
|
||||
).fetchall()
|
||||
except Exception as e:
|
||||
logger.debug(f"source_id_conflict check failed for {id_column}={source_id}: {e}")
|
||||
return None
|
||||
for (other_name,) in rows:
|
||||
if not _names_equivalent(artist_name, other_name):
|
||||
return other_name
|
||||
return None
|
||||
|
||||
|
||||
def accept_artist_match(database, id_column: str, source_id, artist_id,
|
||||
query_name: str, result_name: str,
|
||||
threshold: float = ARTIST_NAME_MATCH_THRESHOLD) -> tuple:
|
||||
"""Decide whether to store ``source_id`` on an artist.
|
||||
|
||||
Returns ``(ok: bool, reason: str)``. Accepts only when the result's name
|
||||
matches the library artist at/above ``threshold`` AND the id isn't already
|
||||
claimed by a differently-named artist. ``reason`` explains a rejection (for
|
||||
debug logging). This is the single gate every worker's artist match should
|
||||
pass through, so the 'one id smeared across many artists' bug can't recur.
|
||||
"""
|
||||
if not artist_name_matches(query_name, result_name, threshold):
|
||||
return False, (
|
||||
f"name mismatch '{query_name}' vs '{result_name}' (< {threshold})"
|
||||
)
|
||||
conflict = source_id_conflict(database, id_column, source_id, artist_id, query_name)
|
||||
if conflict:
|
||||
return False, (
|
||||
f"{id_column}={source_id} already claimed by '{conflict}' — "
|
||||
f"skipping to avoid a shared/duplicate id"
|
||||
)
|
||||
return True, ""
|
||||
|
||||
|
||||
def interruptible_sleep(stop_event: threading.Event, seconds: float, step: float = 0.5) -> bool:
|
||||
"""Sleep in chunks so shutdown can interrupt long waits."""
|
||||
|
|
|
|||
|
|
@ -790,6 +790,55 @@ class MusicDatabase:
|
|||
except Exception as e:
|
||||
logger.debug("Failed to purge cached tracks/albums with junk artist names: %s", e)
|
||||
|
||||
# One-time migration: clear source ids that enrichment wrongly
|
||||
# SHARED across differently-named artists. The album/track "artist
|
||||
# id correction" path (Deezer/AudioDB/Qobuz/Tidal) used to overwrite
|
||||
# an artist's source id from a match without a name check, so e.g.
|
||||
# everyone featured on Kendrick Lamar's curated "Black Panther" album
|
||||
# got stamped with Kendrick's Deezer id. The workers are now
|
||||
# name-guarded so this can't recur; clearing the bad rows lets the
|
||||
# next enrichment pass re-derive each artist's correct id.
|
||||
# Same-name duplicates (one artist indexed on two media servers,
|
||||
# legitimately sharing an id) are left alone via the DISTINCT-name
|
||||
# check, so this only touches genuine corruption.
|
||||
try:
|
||||
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='_source_id_dedupe_v1'")
|
||||
if not cursor.fetchone():
|
||||
_dedupe_id_cols = [
|
||||
('deezer_id', 'deezer_match_status'),
|
||||
('spotify_artist_id', 'spotify_match_status'),
|
||||
('itunes_artist_id', 'itunes_match_status'),
|
||||
('musicbrainz_id', 'musicbrainz_match_status'),
|
||||
('discogs_id', 'discogs_match_status'),
|
||||
('audiodb_id', 'audiodb_match_status'),
|
||||
('qobuz_id', 'qobuz_match_status'),
|
||||
('tidal_id', 'tidal_match_status'),
|
||||
]
|
||||
total_cleared = 0
|
||||
for id_col, status_col in _dedupe_id_cols:
|
||||
try:
|
||||
cursor.execute(f"""
|
||||
UPDATE artists
|
||||
SET {id_col} = NULL, {status_col} = NULL
|
||||
WHERE {id_col} IN (
|
||||
SELECT {id_col} FROM artists
|
||||
WHERE {id_col} IS NOT NULL AND {id_col} != ''
|
||||
GROUP BY {id_col}
|
||||
HAVING COUNT(DISTINCT LOWER(TRIM(name))) > 1
|
||||
)
|
||||
""")
|
||||
total_cleared += cursor.rowcount
|
||||
except Exception as col_err:
|
||||
logger.debug("Source-id dedupe skipped %s: %s", id_col, col_err)
|
||||
cursor.execute("CREATE TABLE _source_id_dedupe_v1 (applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)")
|
||||
if total_cleared > 0:
|
||||
logger.info(
|
||||
f"Cleared {total_cleared} duplicated source ids shared across "
|
||||
f"differently-named artists — they'll re-derive on next enrichment"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug("Failed to dedupe shared source ids: %s", e)
|
||||
|
||||
# HiFi API instances table
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS hifi_instances (
|
||||
|
|
@ -970,6 +1019,10 @@ class MusicDatabase:
|
|||
'canonical_album_id': 'TEXT DEFAULT NULL',
|
||||
'canonical_score': 'REAL DEFAULT NULL',
|
||||
'canonical_resolved_at': 'TIMESTAMP DEFAULT NULL',
|
||||
# #758 — set when the user MANUALLY pins an album version. The
|
||||
# auto resolve job (and any re-resolution) must never overwrite
|
||||
# a locked pin, so a manual match stays put across cycles.
|
||||
'canonical_locked': 'INTEGER DEFAULT 0',
|
||||
}
|
||||
for _col, _typedef in _canonical_cols.items():
|
||||
if album_cols and _col not in album_cols:
|
||||
|
|
@ -978,17 +1031,27 @@ class MusicDatabase:
|
|||
except Exception as e:
|
||||
logger.error("Error repairing core media schema columns: %s", e)
|
||||
|
||||
def set_album_canonical(self, album_id, source: str, canonical_album_id: str, score: float) -> bool:
|
||||
def set_album_canonical(self, album_id, source: str, canonical_album_id: str,
|
||||
score: float, locked: bool = False) -> bool:
|
||||
"""Persist the resolved canonical (source, album_id, score) for an album
|
||||
(#765 Stage 2). Returns True if a row was updated."""
|
||||
(#765 Stage 2). Returns True if a row was updated.
|
||||
|
||||
``locked=True`` marks a MANUAL pin (#758): the user explicitly chose this
|
||||
album version. A manual write always wins (overwrites any existing pin).
|
||||
An AUTO write (``locked=False``, the resolve job) will NOT overwrite a
|
||||
locked pin — the guard is in the WHERE clause so it's atomic.
|
||||
"""
|
||||
conn = self._get_connection()
|
||||
try:
|
||||
cursor = conn.cursor()
|
||||
# Auto writes can't clobber a manual lock; manual writes always apply.
|
||||
guard = "" if locked else " AND (canonical_locked IS NULL OR canonical_locked = 0)"
|
||||
cursor.execute(
|
||||
"UPDATE albums SET canonical_source = ?, canonical_album_id = ?, "
|
||||
"canonical_score = ?, canonical_resolved_at = CURRENT_TIMESTAMP "
|
||||
"WHERE id = ?",
|
||||
(source, str(canonical_album_id), float(score), album_id),
|
||||
"canonical_score = ?, canonical_locked = ?, "
|
||||
"canonical_resolved_at = CURRENT_TIMESTAMP "
|
||||
f"WHERE id = ?{guard}",
|
||||
(source, str(canonical_album_id), float(score), 1 if locked else 0, album_id),
|
||||
)
|
||||
conn.commit()
|
||||
return cursor.rowcount > 0
|
||||
|
|
@ -999,15 +1062,16 @@ class MusicDatabase:
|
|||
conn.close()
|
||||
|
||||
def get_album_canonical(self, album_id) -> Optional[dict]:
|
||||
"""Return ``{'source','album_id','score','resolved_at'}`` for an album's
|
||||
pinned canonical release, or ``None`` when unresolved (#765 Stage 2).
|
||||
Consumers treat ``None`` as 'fall back to today's behavior'."""
|
||||
"""Return ``{'source','album_id','score','resolved_at','locked'}`` for an
|
||||
album's pinned canonical release, or ``None`` when unresolved (#765 Stage
|
||||
2). ``locked`` is True for a manual pin (#758). Consumers treat ``None``
|
||||
as 'fall back to today's behavior'."""
|
||||
conn = self._get_connection()
|
||||
try:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"SELECT canonical_source, canonical_album_id, canonical_score, "
|
||||
"canonical_resolved_at FROM albums WHERE id = ?",
|
||||
"canonical_resolved_at, canonical_locked FROM albums WHERE id = ?",
|
||||
(album_id,),
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
|
|
@ -1018,6 +1082,7 @@ class MusicDatabase:
|
|||
'album_id': row[1],
|
||||
'score': row[2],
|
||||
'resolved_at': row[3],
|
||||
'locked': bool(row[4]),
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error("Error reading album canonical for %s: %s", album_id, e)
|
||||
|
|
@ -4621,21 +4686,29 @@ class MusicDatabase:
|
|||
CREATE INDEX IF NOT EXISTS idx_mltm_lib_track
|
||||
ON manual_library_track_matches (library_track_id)
|
||||
""")
|
||||
# Stable re-resolution key: a library rescan can drop/re-key
|
||||
# tracks (esp. Jellyfin/Navidrome GUIDs), leaving library_track_id
|
||||
# dangling. Storing the file path lets us re-find the current
|
||||
# track id after a scan so manual matches survive it.
|
||||
cursor.execute("PRAGMA table_info(manual_library_track_matches)")
|
||||
_mltm_cols = {r[1] for r in cursor.fetchall()}
|
||||
if 'library_file_path' not in _mltm_cols:
|
||||
cursor.execute("ALTER TABLE manual_library_track_matches ADD COLUMN library_file_path TEXT")
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating manual_library_track_matches table: {e}")
|
||||
|
||||
def save_manual_library_match(self, profile_id: int, source: str, source_track_id: str,
|
||||
library_track_id: str, **meta) -> bool:
|
||||
"""Insert or replace a manual match. meta keys: source_title, source_artist,
|
||||
source_album, source_context_json, server_source."""
|
||||
source_album, source_context_json, server_source, library_file_path."""
|
||||
try:
|
||||
with self._get_connection() as conn:
|
||||
conn.execute("""
|
||||
INSERT INTO manual_library_track_matches
|
||||
(profile_id, source, source_track_id, library_track_id,
|
||||
source_title, source_artist, source_album,
|
||||
source_context_json, server_source, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||
source_context_json, server_source, library_file_path, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(profile_id, source, source_track_id, server_source)
|
||||
DO UPDATE SET
|
||||
library_track_id = excluded.library_track_id,
|
||||
|
|
@ -4643,18 +4716,46 @@ class MusicDatabase:
|
|||
source_artist = excluded.source_artist,
|
||||
source_album = excluded.source_album,
|
||||
source_context_json = excluded.source_context_json,
|
||||
library_file_path = excluded.library_file_path,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
""", (
|
||||
profile_id, source, source_track_id, library_track_id,
|
||||
meta.get('source_title'), meta.get('source_artist'),
|
||||
meta.get('source_album'), meta.get('source_context_json'),
|
||||
meta.get('server_source', ''),
|
||||
meta.get('server_source', ''), meta.get('library_file_path'),
|
||||
))
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"save_manual_library_match error: {e}")
|
||||
return False
|
||||
|
||||
def find_track_id_by_file_path(self, file_path: str) -> Optional[str]:
|
||||
"""Return the current tracks.id for a file path, or None.
|
||||
|
||||
Used to re-resolve a manual match whose stored library_track_id went
|
||||
stale after a rescan re-keyed the track. Exact path first, then a
|
||||
basename fallback (handles server-vs-local path differences)."""
|
||||
if not file_path:
|
||||
return None
|
||||
try:
|
||||
conn = self._get_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT id FROM tracks WHERE file_path = ? LIMIT 1", (file_path,))
|
||||
row = cursor.fetchone()
|
||||
if row:
|
||||
return str(row[0])
|
||||
import os as _os
|
||||
fname = _os.path.basename(str(file_path).replace('\\', '/'))
|
||||
if fname:
|
||||
cursor.execute("SELECT id FROM tracks WHERE file_path LIKE ? LIMIT 1", (f"%{fname}",))
|
||||
row = cursor.fetchone()
|
||||
if row:
|
||||
return str(row[0])
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"find_track_id_by_file_path error: {e}")
|
||||
return None
|
||||
|
||||
def get_manual_library_match(self, profile_id: int, source: str,
|
||||
source_track_id: str, server_source: str = '') -> Optional[Dict[str, Any]]:
|
||||
"""Return match row dict or None."""
|
||||
|
|
@ -5948,8 +6049,12 @@ class MusicDatabase:
|
|||
except Exception as e:
|
||||
logger.debug("history logging: %s", e)
|
||||
|
||||
return True
|
||||
|
||||
# Truthy on success (existing `if track_success` callers keep
|
||||
# working); the specific value lets the scan worker tell a
|
||||
# genuinely new row from an updated one so it can reconcile
|
||||
# embedded IDs only for new arrivals.
|
||||
return 'inserted' if is_new_track else 'updated'
|
||||
|
||||
except Exception as e:
|
||||
error_text = str(e).lower()
|
||||
if (
|
||||
|
|
|
|||
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
|
||||
))
|
||||
|
||||
def _reconcile_or_replace(self, client, playlist_name: str, tracks) -> bool:
|
||||
"""Reconcile mode (#792): edit the playlist in place (add/remove delta,
|
||||
preserving its image + description + identity). If the client lacks
|
||||
reconcile or it fails, fall back to the destructive replace so the sync
|
||||
still succeeds — logged loudly so a failing in-place edit is diagnosable.
|
||||
"""
|
||||
fn = getattr(client, 'reconcile_playlist', None)
|
||||
if fn is None:
|
||||
return client.update_playlist(playlist_name, tracks)
|
||||
try:
|
||||
if fn(playlist_name, tracks):
|
||||
return True
|
||||
logger.warning(
|
||||
"Reconcile sync failed for '%s' — falling back to replace "
|
||||
"(playlist will be recreated this once)", playlist_name)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Reconcile sync errored for '%s' (%s) — falling back to replace", playlist_name, e)
|
||||
return client.update_playlist(playlist_name, tracks)
|
||||
|
||||
async def sync_playlist(self, playlist: SpotifyPlaylist, download_missing: bool = False, profile_id: int = None, sync_mode: str = 'replace') -> SyncResult:
|
||||
self._active_profile_id = profile_id
|
||||
# Check if THIS specific playlist is already syncing
|
||||
|
|
@ -378,6 +398,8 @@ class PlaylistSyncService:
|
|||
)
|
||||
if sync_mode == 'append':
|
||||
sync_success = media_client.append_to_playlist(playlist.name, valid_tracks)
|
||||
elif sync_mode == 'reconcile':
|
||||
sync_success = self._reconcile_or_replace(media_client, playlist.name, valid_tracks)
|
||||
else:
|
||||
sync_success = media_client.update_playlist(playlist.name, valid_tracks)
|
||||
|
||||
|
|
|
|||
|
|
@ -496,3 +496,45 @@ def test_multi_playlist_aggregates_grand_total():
|
|||
|
||||
# All 3 tracks discovered → 3 extra_data writes
|
||||
assert len(deps._db.extra_data_writes) == 3
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _canonical_best_score — #785: file/CSV playlists keep raw "Artist - Title"
|
||||
# titles (YouTube is cleaned at ingest); the worker must try the canonical form.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
from types import SimpleNamespace # noqa: E402
|
||||
|
||||
|
||||
def test_canonical_best_score_matches_file_style_title():
|
||||
# Raw "Artist - Title" scores low; canonical "Title" scores high → take it.
|
||||
def score(title, artist, dur, results):
|
||||
if title == 'Do I Wanna Know?':
|
||||
return ('MATCH', 0.95, None)
|
||||
return (None, 0.2, None)
|
||||
deps = SimpleNamespace(discovery_score_candidates=score)
|
||||
match, conf = dp._canonical_best_score(
|
||||
deps, 'Arctic Monkeys - Do I Wanna Know?', 'Arctic Monkeys', 0, ['r'])
|
||||
assert match == 'MATCH'
|
||||
assert conf == 0.95
|
||||
|
||||
|
||||
def test_canonical_best_score_clean_title_scored_once():
|
||||
calls = []
|
||||
def score(title, artist, dur, results):
|
||||
calls.append(title)
|
||||
return ('M', 0.9, None)
|
||||
deps = SimpleNamespace(discovery_score_candidates=score)
|
||||
match, conf = dp._canonical_best_score(deps, 'Do I Wanna Know?', 'Arctic Monkeys', 0, ['r'])
|
||||
assert (match, conf) == ('M', 0.9)
|
||||
assert calls == ['Do I Wanna Know?'] # canonical == original → no second score
|
||||
|
||||
|
||||
def test_canonical_best_score_keeps_original_when_better():
|
||||
# Best-of: if the raw title actually scores higher, keep it.
|
||||
def score(title, artist, dur, results):
|
||||
return ('CANON', 0.6, None) if title == 'Do I Wanna Know?' else ('ORIG', 0.9, None)
|
||||
deps = SimpleNamespace(discovery_score_candidates=score)
|
||||
match, conf = dp._canonical_best_score(
|
||||
deps, 'Arctic Monkeys - Do I Wanna Know?', 'Arctic Monkeys', 0, ['r'])
|
||||
assert (match, conf) == ('ORIG', 0.9)
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ AST-parsing the route file).
|
|||
from core.discovery.manual_match import (
|
||||
derive_manual_match_provider,
|
||||
is_drifted_for_redo,
|
||||
should_rediscover,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -104,3 +105,149 @@ def test_drift_default_provider_is_spotify_when_absent():
|
|||
extra = {'discovered': True} # no provider field
|
||||
assert is_drifted_for_redo(extra, 'spotify') is False
|
||||
assert is_drifted_for_redo(extra, 'musicbrainz') is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# should_rediscover — the Playlist Pipeline pre-scan gate
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_rediscovers_never_discovered_track():
|
||||
assert should_rediscover({}) is True
|
||||
assert should_rediscover(None) is True
|
||||
|
||||
|
||||
def test_skips_complete_discovery():
|
||||
extra = {
|
||||
'discovered': True,
|
||||
'matched_data': {'track_number': 3, 'album': {'release_date': '2020'}},
|
||||
}
|
||||
assert should_rediscover(extra) is False
|
||||
|
||||
|
||||
def test_rediscovers_incomplete_discovery():
|
||||
# Missing track_number / release_date / album.id — re-discover to backfill.
|
||||
extra = {'discovered': True, 'matched_data': {'name': 'X'}}
|
||||
assert should_rediscover(extra) is True
|
||||
|
||||
|
||||
def test_album_id_satisfies_completeness():
|
||||
extra = {
|
||||
'discovered': True,
|
||||
'matched_data': {'track_number': 1, 'album': {'id': 'al-1'}},
|
||||
}
|
||||
assert should_rediscover(extra) is False
|
||||
|
||||
|
||||
def test_rediscovers_wing_it_stub():
|
||||
extra = {'discovered': True, 'wing_it_fallback': True}
|
||||
assert should_rediscover(extra) is True
|
||||
|
||||
|
||||
def test_skips_manual_match():
|
||||
extra = {'discovered': True, 'manual_match': True}
|
||||
assert should_rediscover(extra) is False
|
||||
|
||||
|
||||
def test_skips_unmatched_by_user():
|
||||
extra = {'unmatched_by_user': True}
|
||||
assert should_rediscover(extra) is False
|
||||
|
||||
|
||||
def test_regression_manual_match_wins_over_stale_wing_it_flag():
|
||||
"""The #799 revert bug: extra_data is MERGED on save, so a track fixed
|
||||
after being a Wing It stub still carries wing_it_fallback=True alongside
|
||||
the new manual_match=True. The manual match MUST win — otherwise the
|
||||
pipeline re-discovers and silently reverts the user's pick to Wing It.
|
||||
|
||||
Before the fix the pre-scan checked wing_it_fallback first and returned
|
||||
True (re-discover). It must now skip."""
|
||||
extra = {
|
||||
'discovered': True,
|
||||
'wing_it_fallback': True, # stale flag left by the merge
|
||||
'manual_match': True, # the user's authoritative fix
|
||||
'matched_data': {'name': 'The Real Match'},
|
||||
}
|
||||
assert should_rediscover(extra) is False
|
||||
|
||||
|
||||
def test_manual_match_wins_even_without_other_fields():
|
||||
# Lean Fix-popup save shape (no track_number/album) must still be honored.
|
||||
extra = {'discovered': True, 'manual_match': True, 'wing_it_fallback': True}
|
||||
assert should_rediscover(extra) is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Equivalence guard: should_rediscover must match the ORIGINAL inline pre-scan
|
||||
# logic for EVERY flag combination except the one intended fix (manual_match
|
||||
# beating a stale wing_it_fallback). This pins that the auto Playlist Pipeline
|
||||
# behaves identically post-refactor — no regression.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
import itertools
|
||||
|
||||
|
||||
def _original_pre_scan(extra):
|
||||
"""Verbatim reproduction of the pre-refactor playlist.py branch logic.
|
||||
Returns True = re-discover (undiscovered_tracks), False = skip."""
|
||||
if extra.get('discovered'):
|
||||
if extra.get('wing_it_fallback'):
|
||||
return True
|
||||
elif extra.get('manual_match'):
|
||||
return False
|
||||
else:
|
||||
md = extra.get('matched_data', {})
|
||||
album = md.get('album', {})
|
||||
has_track_num = md.get('track_number')
|
||||
has_release = album.get('release_date') if isinstance(album, dict) else None
|
||||
has_album_id = album.get('id') if isinstance(album, dict) else None
|
||||
if has_track_num and (has_release or has_album_id):
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
elif extra.get('unmatched_by_user'):
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
|
||||
|
||||
_MATCHED_VARIANTS = {
|
||||
'absent': None, # key omitted
|
||||
'complete': {'track_number': 1, 'album': {'release_date': '2020'}},
|
||||
'incomplete': {'name': 'x'},
|
||||
}
|
||||
|
||||
|
||||
def test_should_rediscover_matches_original_logic_for_all_combinations():
|
||||
bools = [True, False]
|
||||
diverged = 0
|
||||
for discovered, wing, manual, unmatched, md_key in itertools.product(
|
||||
bools, bools, bools, bools, _MATCHED_VARIANTS
|
||||
):
|
||||
extra = {}
|
||||
if discovered:
|
||||
extra['discovered'] = True
|
||||
if wing:
|
||||
extra['wing_it_fallback'] = True
|
||||
if manual:
|
||||
extra['manual_match'] = True
|
||||
if unmatched:
|
||||
extra['unmatched_by_user'] = True
|
||||
if _MATCHED_VARIANTS[md_key] is not None:
|
||||
extra['matched_data'] = _MATCHED_VARIANTS[md_key]
|
||||
|
||||
new = should_rediscover(extra)
|
||||
old = _original_pre_scan(extra)
|
||||
|
||||
# The single intended divergence: a discovered track carrying BOTH a
|
||||
# stale wing_it_fallback AND a manual_match. Old re-discovered (the
|
||||
# bug); new skips (manual is authoritative).
|
||||
is_intended_fix = discovered and wing and manual
|
||||
if is_intended_fix:
|
||||
assert old is True and new is False, (extra, old, new)
|
||||
diverged += 1
|
||||
else:
|
||||
assert new == old, (extra, new, old)
|
||||
|
||||
# Sanity: the fix actually triggered on the expected subset.
|
||||
assert diverged > 0
|
||||
|
|
|
|||
|
|
@ -461,6 +461,45 @@ def test_auto_search_pick_does_not_inject_acoustid_bypass():
|
|||
assert "_user_manual_pick" not in ctx
|
||||
|
||||
|
||||
def test_skip_acoustid_track_flag_injects_bypass():
|
||||
"""Issue #797: when the album-download request had the per-request
|
||||
'Skip AcoustID verification' toggle on, the master worker stamps
|
||||
`_skip_acoustid=True` onto each track's track_info. The candidates
|
||||
helper must propagate that into the post-process context as
|
||||
`_skip_quarantine_check='acoustid'` so AcoustID never quarantines
|
||||
this request's files (e.g. correct downloads of non-English artists
|
||||
whose native-script metadata AcoustID can't reconcile)."""
|
||||
deps = _build_deps()
|
||||
_seed_task("t_skip_aid", track_info={"_skip_acoustid": True})
|
||||
|
||||
candidates = [_Candidate(filename="skip.flac", confidence=0.99)]
|
||||
track = _Track()
|
||||
|
||||
result = dc.attempt_download_with_candidates("t_skip_aid", candidates, track, batch_id="b1", deps=deps)
|
||||
|
||||
assert result is True
|
||||
ctx = matched_downloads_context["user1::skip.flac"]
|
||||
assert ctx["_skip_quarantine_check"] == "acoustid"
|
||||
# It's the toggle path, NOT a manual pick.
|
||||
assert "_user_manual_pick" not in ctx
|
||||
|
||||
|
||||
def test_no_skip_acoustid_flag_keeps_verification():
|
||||
"""Without the toggle (no `_skip_acoustid` on track_info), AcoustID
|
||||
verification must still run — the bypass is opt-in per request."""
|
||||
deps = _build_deps()
|
||||
_seed_task("t_no_skip_aid", track_info={}) # no _skip_acoustid
|
||||
|
||||
candidates = [_Candidate(filename="verify.flac", confidence=0.99)]
|
||||
track = _Track()
|
||||
|
||||
result = dc.attempt_download_with_candidates("t_no_skip_aid", candidates, track, batch_id="b1", deps=deps)
|
||||
|
||||
assert result is True
|
||||
ctx = matched_downloads_context["user1::verify.flac"]
|
||||
assert "_skip_quarantine_check" not in ctx
|
||||
|
||||
|
||||
def test_equal_confidence_candidates_prefer_better_peer_quality():
|
||||
"""Equal-confidence Soulseek candidates use peer quality as the tiebreaker."""
|
||||
deps = _build_deps()
|
||||
|
|
|
|||
|
|
@ -209,7 +209,6 @@ def test_post_process_matched_download_forwards_separate_metadata_runtime(tmp_pa
|
|||
|
||||
monkeypatch.setattr(import_pipeline, "record_soulsync_library_entry", _record_library)
|
||||
monkeypatch.setattr(import_pipeline, "check_and_remove_from_wishlist", lambda *args, **kwargs: None)
|
||||
monkeypatch.setattr(import_pipeline, "record_retag_download", lambda *args, **kwargs: None)
|
||||
|
||||
context = {
|
||||
"track_info": {"_playlist_folder_mode": True, "_playlist_name": "Playlist"},
|
||||
|
|
|
|||
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.database = temp_db
|
||||
worker.db = temp_db # worker code uses self.db (e.g. source_id_conflict)
|
||||
worker.mb_service = MagicMock()
|
||||
worker.mb_service.match_artist.return_value = {
|
||||
'mbid': '60d2ea34-1912-425f-bf9c-fc544e4448cd', 'name': 'Hiroyuki Sawano',
|
||||
|
|
@ -300,6 +301,7 @@ class TestWorkerAliasEnrichment:
|
|||
|
||||
worker = MusicBrainzWorker.__new__(MusicBrainzWorker)
|
||||
worker.database = temp_db
|
||||
worker.db = temp_db # worker code uses self.db (e.g. source_id_conflict)
|
||||
worker.mb_service = MagicMock()
|
||||
worker.mb_service.match_artist.return_value = None
|
||||
worker.stats = {'matched': 0, 'not_found': 0, 'errors': 0}
|
||||
|
|
@ -392,6 +394,7 @@ class TestWorkerAliasEnrichment:
|
|||
|
||||
worker = MusicBrainzWorker.__new__(MusicBrainzWorker)
|
||||
worker.database = temp_db
|
||||
worker.db = temp_db # worker code uses self.db (e.g. source_id_conflict)
|
||||
worker.mb_service = MagicMock()
|
||||
worker.mb_service.match_artist.return_value = {'mbid': 'mb-x', 'name': 'X'}
|
||||
worker.mb_service.fetch_artist_aliases.side_effect = Exception("boom")
|
||||
|
|
|
|||
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'}
|
||||
|
||||
|
||||
def test_fetch_music_folders_parses_without_ensure_connection(nav_client):
|
||||
"""The seam: _fetch_music_folders does its own _make_request + parse and
|
||||
does NOT gate on ensure_connection, so it is safe to call mid-connect."""
|
||||
nav_client.base_url = 'http://nav'
|
||||
nav_client.username = 'u'
|
||||
nav_client.password = 'p'
|
||||
folders_envelope = {'musicFolders': {'musicFolder': [
|
||||
{'id': 1, 'name': 'Music'},
|
||||
{'id': 2, 'name': 'Audiobooks'},
|
||||
]}}
|
||||
with patch.object(nav_client, '_make_request', return_value=folders_envelope):
|
||||
folders = nav_client._fetch_music_folders()
|
||||
assert folders == [
|
||||
{'title': 'Music', 'key': '1'},
|
||||
{'title': 'Audiobooks', 'key': '2'},
|
||||
]
|
||||
|
||||
|
||||
def _fake_request(endpoint, params=None):
|
||||
if endpoint == 'ping':
|
||||
return {'status': 'ok', 'version': '1.16.1'}
|
||||
if endpoint == 'getMusicFolders':
|
||||
return {'musicFolders': {'musicFolder': [
|
||||
{'id': 1, 'name': 'Music'},
|
||||
{'id': 2, 'name': 'Audiobooks'},
|
||||
]}}
|
||||
return None
|
||||
|
||||
|
||||
def _prefs(values):
|
||||
"""A MusicDatabase mock whose get_preference reads from `values`."""
|
||||
db = MagicMock()
|
||||
db.get_preference.side_effect = lambda key, *a, **k: values.get(key)
|
||||
return db
|
||||
|
||||
|
||||
def _connect_with(nav_client, fake_db):
|
||||
with patch('core.navidrome_client.config_manager.get_navidrome_config',
|
||||
return_value={'base_url': 'http://nav', 'username': 'u', 'password': 'p'}), \
|
||||
patch('database.music_database.MusicDatabase', return_value=fake_db), \
|
||||
patch.object(nav_client, '_make_request', side_effect=_fake_request):
|
||||
assert nav_client.ensure_connection() is True
|
||||
|
||||
|
||||
def test_setup_client_restores_saved_music_folder(nav_client):
|
||||
"""Regression for #789: a saved music-folder selection must survive
|
||||
_setup_client. The restore runs while still inside ensure_connection()
|
||||
(_is_connecting=True); the old code called the public get_music_folders(),
|
||||
which re-entered the guard, got [], and left music_folder_id=None — so
|
||||
every scan imported all libraries regardless of the user's selection."""
|
||||
fake_db = _prefs({'navidrome_music_folder_id': '2', 'navidrome_music_folder': 'Audiobooks'})
|
||||
_connect_with(nav_client, fake_db)
|
||||
assert nav_client.music_folder_id == '2'
|
||||
# Nothing drifted → no self-heal writes.
|
||||
fake_db.set_preference.assert_not_called()
|
||||
|
||||
|
||||
def test_setup_client_restores_by_id_after_folder_rename(nav_client):
|
||||
"""Hardening: the id is the primary key, so a folder renamed in Navidrome
|
||||
(stored name no longer matches its title) still resolves by id — and the
|
||||
stale name is healed so the settings dropdown stays correct."""
|
||||
fake_db = _prefs({'navidrome_music_folder_id': '2', 'navidrome_music_folder': 'Old Stale Name'})
|
||||
_connect_with(nav_client, fake_db)
|
||||
assert nav_client.music_folder_id == '2'
|
||||
fake_db.set_preference.assert_any_call('navidrome_music_folder', 'Audiobooks')
|
||||
|
||||
|
||||
def test_setup_client_name_fallback_self_heals_id(nav_client):
|
||||
"""Back-compat: installs saved before the id was persisted match by name,
|
||||
and the id is written back so a later rename can't break the match."""
|
||||
fake_db = _prefs({'navidrome_music_folder_id': None, 'navidrome_music_folder': 'Audiobooks'})
|
||||
_connect_with(nav_client, fake_db)
|
||||
assert nav_client.music_folder_id == '2'
|
||||
fake_db.set_preference.assert_any_call('navidrome_music_folder_id', '2')
|
||||
|
||||
|
||||
def test_navidrome_album_exposes_cover_art_url(nav_client):
|
||||
album = NavidromeAlbum({
|
||||
'id': 'album-1',
|
||||
|
|
|
|||
|
|
@ -164,6 +164,45 @@ class TestFindLibraryArtistForSource:
|
|||
)
|
||||
assert result is None
|
||||
|
||||
def test_ambiguous_source_id_skips_id_upgrade(self, db):
|
||||
"""Regression for the Kendrick/Jorja bug: when one Deezer id is
|
||||
stamped on several library artists (enrichment corruption), the id
|
||||
match is ambiguous and must NOT pick an arbitrary row — it returns
|
||||
None so the caller falls back to showing the source artist."""
|
||||
_insert_artist(db, artist_id="pk-kendrick", name="Kendrick Lamar",
|
||||
deezer_id="525046", server_source="plex")
|
||||
_insert_artist(db, artist_id="pk-jorja", name="Jorja Smith",
|
||||
deezer_id="525046", server_source="plex")
|
||||
_insert_artist(db, artist_id="pk-vince", name="Vince Staples",
|
||||
deezer_id="525046", server_source="plex")
|
||||
|
||||
# No name hint (the URL-driven path) → no id guess, no name fallback.
|
||||
assert find_library_artist_for_source(
|
||||
db, "deezer", "525046", active_server="plex"
|
||||
) is None
|
||||
|
||||
def test_ambiguous_source_id_still_allows_name_fallback(self, db):
|
||||
"""An ambiguous id shouldn't block a correct name match when the
|
||||
caller does have the name."""
|
||||
_insert_artist(db, artist_id="pk-kendrick", name="Kendrick Lamar",
|
||||
deezer_id="525046", server_source="plex")
|
||||
_insert_artist(db, artist_id="pk-jorja", name="Jorja Smith",
|
||||
deezer_id="525046", server_source="plex")
|
||||
|
||||
result = find_library_artist_for_source(
|
||||
db, "deezer", "525046", artist_name="Kendrick Lamar",
|
||||
active_server="plex",
|
||||
)
|
||||
assert result == "pk-kendrick"
|
||||
|
||||
def test_unique_source_id_still_matches(self, db):
|
||||
"""Positive control: a non-duplicated id still upgrades as before."""
|
||||
_insert_artist(db, artist_id="pk-solo", name="Solo Artist",
|
||||
deezer_id="999999", server_source="plex")
|
||||
assert find_library_artist_for_source(
|
||||
db, "deezer", "999999"
|
||||
) == "pk-solo"
|
||||
|
||||
def test_id_match_wins_over_name_match(self, db):
|
||||
"""If both a source-id match and a name match exist, the id match
|
||||
should take priority — it's the more reliable signal."""
|
||||
|
|
|
|||
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:
|
||||
def __init__(self, *, name='fake', artists=None, albums=None, tracks=None,
|
||||
fail_search=False, authed=True, connected=True):
|
||||
fail_search=False, authed=True, connected=True, meta_available=None):
|
||||
self.name = name
|
||||
self._artists = artists or []
|
||||
self._albums = albums or []
|
||||
|
|
@ -58,6 +58,10 @@ class _Client:
|
|||
self._fail = fail_search
|
||||
self._authed = authed
|
||||
self._connected = connected
|
||||
# When unset, metadata availability tracks auth (the common case). Set
|
||||
# explicitly to model the no-creds SpotipyFree fallback: not authed but
|
||||
# metadata still available.
|
||||
self._meta_available = meta_available
|
||||
|
||||
def search_artists(self, q, limit=10):
|
||||
if self._fail:
|
||||
|
|
@ -77,6 +81,9 @@ class _Client:
|
|||
def is_spotify_authenticated(self):
|
||||
return self._authed
|
||||
|
||||
def is_spotify_metadata_available(self):
|
||||
return self._authed if self._meta_available is None else self._meta_available
|
||||
|
||||
def is_connected(self):
|
||||
return self._connected
|
||||
|
||||
|
|
@ -157,12 +164,22 @@ def test_resolve_spotify_authed_returns_client():
|
|||
|
||||
|
||||
def test_resolve_spotify_unauthed_returns_none():
|
||||
deps = _build_deps(spotify_client=_Client(authed=False))
|
||||
# No auth AND no free fallback available → Spotify source unavailable.
|
||||
deps = _build_deps(spotify_client=_Client(authed=False, meta_available=False))
|
||||
client, ok = orchestrator.resolve_client('spotify', deps)
|
||||
assert client is None
|
||||
assert ok is False
|
||||
|
||||
|
||||
def test_resolve_spotify_unauthed_but_free_available_returns_client():
|
||||
# #798: no Spotify auth but the no-creds SpotipyFree fallback is available →
|
||||
# the Spotify source stays usable (the client routes to free internally).
|
||||
deps = _build_deps(spotify_client=_Client(authed=False, meta_available=True))
|
||||
client, ok = orchestrator.resolve_client('spotify', deps)
|
||||
assert client is deps.spotify_client
|
||||
assert ok is True
|
||||
|
||||
|
||||
def test_resolve_spotify_missing_returns_none():
|
||||
deps = _build_deps(spotify_client=None)
|
||||
client, ok = orchestrator.resolve_client('spotify', deps)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
from unittest.mock import MagicMock
|
||||
|
||||
from core.sync.match_overrides import record_manual_match, resolve_match_overrides
|
||||
from core.sync.match_overrides import (
|
||||
record_manual_match,
|
||||
resolve_durable_match_server_id,
|
||||
resolve_match_overrides,
|
||||
)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
|
@ -191,3 +195,84 @@ def test_record_handles_empty_optional_strings():
|
|||
assert kwargs["normalized_title"] == ""
|
||||
assert kwargs["normalized_artist"] == ""
|
||||
assert kwargs["server_track_title"] == ""
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# resolve_durable_match_server_id — manual match survives a rescan (#787)
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
class _FakeMatchDB:
|
||||
"""Minimal DB stub for the durable-match resolver."""
|
||||
def __init__(self, match=None, file_path_id=None):
|
||||
self._match = match
|
||||
self._file_path_id = file_path_id
|
||||
self.saved = []
|
||||
|
||||
def find_manual_library_match_by_source_track_id(self, profile_id, source_track_id, server_source):
|
||||
return dict(self._match) if self._match else None
|
||||
|
||||
def find_track_id_by_file_path(self, file_path):
|
||||
return self._file_path_id
|
||||
|
||||
def save_manual_library_match(self, profile_id, source, source_track_id, library_track_id, **meta):
|
||||
self.saved.append((library_track_id, meta))
|
||||
return True
|
||||
|
||||
|
||||
def test_durable_match_returns_id_when_still_valid():
|
||||
# Stored library id still present in the server playlist → direct hit.
|
||||
db = _FakeMatchDB(match={"library_track_id": "5001", "library_file_path": "/m/a.flac",
|
||||
"profile_id": 1, "source": "spotify", "source_track_id": "iron",
|
||||
"server_source": "plex"})
|
||||
out = resolve_durable_match_server_id(db, 1, "iron", "plex", {"5001", "5002"})
|
||||
assert out == "5001"
|
||||
assert db.saved == [] # no self-heal needed
|
||||
|
||||
|
||||
def test_durable_match_reresolves_stale_id_via_file_path_and_self_heals():
|
||||
# Rescan re-keyed the track: old id 5001 gone, file now lives at id 7777.
|
||||
db = _FakeMatchDB(
|
||||
match={"library_track_id": "5001", "library_file_path": "/m/a.flac",
|
||||
"profile_id": 1, "source": "spotify", "source_track_id": "iron", "server_source": "plex"},
|
||||
file_path_id="7777",
|
||||
)
|
||||
out = resolve_durable_match_server_id(db, 1, "iron", "plex", {"7777", "9000"})
|
||||
assert out == "7777" # re-resolved to the current id
|
||||
assert db.saved and db.saved[0][0] == "7777" # self-healed the stored id
|
||||
|
||||
|
||||
def test_durable_match_none_when_no_match():
|
||||
db = _FakeMatchDB(match=None)
|
||||
assert resolve_durable_match_server_id(db, 1, "iron", "plex", {"5001"}) is None
|
||||
|
||||
|
||||
def test_durable_match_none_when_stale_and_file_path_unresolvable():
|
||||
# Stale id AND the file path no longer resolves (file moved/removed) → no pair.
|
||||
db = _FakeMatchDB(
|
||||
match={"library_track_id": "5001", "library_file_path": "/m/gone.flac",
|
||||
"profile_id": 1, "source": "spotify", "source_track_id": "iron", "server_source": "plex"},
|
||||
file_path_id=None,
|
||||
)
|
||||
assert resolve_durable_match_server_id(db, 1, "iron", "plex", {"7777"}) is None
|
||||
assert db.saved == []
|
||||
|
||||
|
||||
def test_durable_match_none_when_reresolved_id_not_in_playlist():
|
||||
# File resolves to a track, but that track isn't in THIS playlist → don't pair.
|
||||
db = _FakeMatchDB(
|
||||
match={"library_track_id": "5001", "library_file_path": "/m/a.flac",
|
||||
"profile_id": 1, "source": "spotify", "source_track_id": "iron", "server_source": "plex"},
|
||||
file_path_id="7777",
|
||||
)
|
||||
assert resolve_durable_match_server_id(db, 1, "iron", "plex", {"1234"}) is None
|
||||
|
||||
|
||||
def test_durable_match_safe_when_db_lacks_methods():
|
||||
class Bare:
|
||||
pass
|
||||
assert resolve_durable_match_server_id(Bare(), 1, "iron", "plex", {"5001"}) is None
|
||||
|
||||
|
||||
def test_durable_match_empty_source_id_returns_none():
|
||||
db = _FakeMatchDB(match={"library_track_id": "5001"})
|
||||
assert resolve_durable_match_server_id(db, 1, "", "plex", {"5001"}) is None
|
||||
|
|
|
|||
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
|
||||
|
||||
|
||||
def test_low_fingerprint_score_never_skipped_same_script_artist(verifier):
|
||||
"""#797 guard — the #607 protection for a SAME-SCRIPT artist (Latin
|
||||
'Yoko Takahashi' on both sides) with only a cross-script TITLE must
|
||||
stay FAIL below the 0.95 floor. The #797 relaxation is keyed on the
|
||||
ARTIST spanning scripts, which this case is NOT, so nothing changes
|
||||
here. (Duplicates test_low_fingerprint_score_never_skipped's intent
|
||||
explicitly against the new code path.)"""
|
||||
_stub_lookup(verifier, recordings=[
|
||||
{'title': '残酷な天使のテーゼ', 'artist': 'Yoko Takahashi'},
|
||||
], best_score=0.85)
|
||||
|
||||
result, _msg = verifier.verify_audio_file(
|
||||
'/fake/path.flac',
|
||||
'Zankoku na Tenshi no Theze',
|
||||
'Yoko Takahashi',
|
||||
)
|
||||
assert result == VerificationResult.FAIL
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Issue #797 — non-English ARTIST whose name spans scripts
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_cross_script_artist_confirmed_via_alias_skips_below_095(verifier):
|
||||
"""#797 headline: requested 'Joe Hisaishi' (romanized), AcoustID
|
||||
returns the recording with the artist/title in their native kanji
|
||||
('久石譲'). The alias bridge confirms 久石譲 IS Joe Hisaishi, the
|
||||
fingerprint is solid (0.85, above the 0.80 trust floor) but below
|
||||
the old 0.95 language/script bar. Pre-#797 this FAILed and the
|
||||
correct file was quarantined. Now it SKIPs."""
|
||||
with patch(
|
||||
'core.acoustid_verification._resolve_expected_artist_aliases',
|
||||
return_value=['久石譲'],
|
||||
):
|
||||
_stub_lookup(verifier, recordings=[
|
||||
{'title': '風のとおり道', 'artist': '久石譲'},
|
||||
], best_score=0.85)
|
||||
|
||||
result, msg = verifier.verify_audio_file(
|
||||
'/fake/path.flac',
|
||||
'The Path of the Wind',
|
||||
'Joe Hisaishi',
|
||||
)
|
||||
assert result == VerificationResult.SKIP
|
||||
assert 'language/script' in msg.lower()
|
||||
|
||||
|
||||
def test_cross_script_artist_NOT_confirmed_still_fails(verifier):
|
||||
"""#797 tight scope: if the alias bridge can't confirm the artist
|
||||
(lookup returns nothing), we have no positive evidence the kanji
|
||||
artist IS the expected one — so the #797 relaxation must NOT fire.
|
||||
The relaxation only rescues a CONFIRMED cross-script artist.
|
||||
|
||||
Constructed so best_rec is set (partial title overlap → non-zero
|
||||
combined score) and the title stays under the strict threshold, so
|
||||
the flow reaches the same skip-decision point the rescue lives at —
|
||||
proving it doesn't fire without a confirmed artist."""
|
||||
with patch(
|
||||
'core.acoustid_verification._resolve_expected_artist_aliases',
|
||||
return_value=[],
|
||||
):
|
||||
_stub_lookup(verifier, recordings=[
|
||||
{'title': 'Summer Night', 'artist': '久石譲'},
|
||||
], best_score=0.85)
|
||||
|
||||
result, _msg = verifier.verify_audio_file(
|
||||
'/fake/path.flac',
|
||||
'Summer',
|
||||
'Joe Hisaishi',
|
||||
)
|
||||
assert result == VerificationResult.FAIL
|
||||
|
||||
|
||||
def test_old_loose_threshold_no_longer_fires_for_unrelated_titles(verifier):
|
||||
"""Pin the negative case for the old loose threshold (title_sim
|
||||
>= 0.55). 'Crown' vs 'Crown of Thorns' had similarity around 0.6
|
||||
|
|
|
|||
|
|
@ -360,6 +360,40 @@ def test_copy_audio_files_atomically_creates_staging_dir(tmp_path: Path) -> None
|
|||
assert staging.exists()
|
||||
|
||||
|
||||
def test_copy_audio_files_atomically_keeps_source_by_default(tmp_path: Path) -> None:
|
||||
"""Default (torrent/usenet): originals stay put (client keeps seeding)."""
|
||||
src = tmp_path / 'a.flac'
|
||||
src.write_bytes(b'a')
|
||||
staging = tmp_path / 'staging'
|
||||
out = copy_audio_files_atomically([src], staging)
|
||||
assert len(out) == 1
|
||||
assert src.exists() # source retained
|
||||
|
||||
|
||||
def test_copy_audio_files_atomically_removes_source_when_requested(tmp_path: Path) -> None:
|
||||
"""#796: Soulseek path removes slskd's completed files once staged so they
|
||||
don't pile up in the download folder."""
|
||||
src_a = tmp_path / 'a.flac'; src_a.write_bytes(b'a')
|
||||
src_c = tmp_path / 'c.flac'; src_c.write_bytes(b'c')
|
||||
staging = tmp_path / 'staging'
|
||||
out = copy_audio_files_atomically([src_a, src_c], staging, remove_source=True)
|
||||
assert len(out) == 2 # both staged
|
||||
assert not src_a.exists() and not src_c.exists() # sources removed
|
||||
assert sorted(Path(p).name for p in out) == ['a.flac', 'c.flac']
|
||||
|
||||
|
||||
def test_copy_audio_files_atomically_keeps_source_when_copy_fails(tmp_path: Path) -> None:
|
||||
"""Data safety: a source whose copy FAILS must never be deleted, even with
|
||||
remove_source=True."""
|
||||
src_ok = tmp_path / 'ok.flac'; src_ok.write_bytes(b'ok')
|
||||
src_missing = tmp_path / 'gone.flac' # never created -> copy fails
|
||||
staging = tmp_path / 'staging'
|
||||
out = copy_audio_files_atomically([src_ok, src_missing], staging, remove_source=True)
|
||||
assert len(out) == 1 # only ok staged
|
||||
assert not src_ok.exists() # staged source removed
|
||||
assert not src_missing.exists() # never existed (and not created)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Config-driven poll cadence
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
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)
|
||||
# And USLT was embedded
|
||||
client._embed_lyrics.assert_called_once()
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# RetagDeps integration — generate_lrc_file is now wired
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_retagdeps_accepts_generate_lrc_file_field():
|
||||
from core.library.retag import RetagDeps
|
||||
|
||||
# Mock the required + optional deps with do-nothing callables
|
||||
deps = RetagDeps(
|
||||
config_manager=MagicMock(),
|
||||
retag_lock=MagicMock(),
|
||||
spotify_client=MagicMock(),
|
||||
get_audio_quality_string=lambda *a: '',
|
||||
enhance_file_metadata=lambda *a: True,
|
||||
build_final_path_for_track=lambda *a: ('', ''),
|
||||
safe_move_file=lambda *a: None,
|
||||
cleanup_empty_directories=lambda *a: None,
|
||||
download_cover_art=lambda *a: None,
|
||||
docker_resolve_path=lambda x: x,
|
||||
_get_retag_state=lambda: {},
|
||||
_set_retag_state=lambda v: None,
|
||||
get_database=lambda: MagicMock(),
|
||||
generate_lrc_file=lambda *a: True,
|
||||
)
|
||||
assert callable(deps.generate_lrc_file)
|
||||
|
||||
|
||||
def test_retagdeps_generate_lrc_file_optional_for_backward_compat():
|
||||
"""Tests that built RetagDeps without the new field don't break."""
|
||||
from core.library.retag import RetagDeps
|
||||
|
||||
deps = RetagDeps(
|
||||
config_manager=MagicMock(),
|
||||
retag_lock=MagicMock(),
|
||||
spotify_client=MagicMock(),
|
||||
get_audio_quality_string=lambda *a: '',
|
||||
enhance_file_metadata=lambda *a: True,
|
||||
build_final_path_for_track=lambda *a: ('', ''),
|
||||
safe_move_file=lambda *a: None,
|
||||
cleanup_empty_directories=lambda *a: None,
|
||||
download_cover_art=lambda *a: None,
|
||||
docker_resolve_path=lambda x: x,
|
||||
_get_retag_state=lambda: {},
|
||||
_set_retag_state=lambda v: None,
|
||||
get_database=lambda: MagicMock(),
|
||||
)
|
||||
# Field defaults to None — no crash on construction.
|
||||
assert deps.generate_lrc_file is None
|
||||
|
|
|
|||
|
|
@ -90,6 +90,34 @@ def test_save_upserts_existing(db):
|
|||
assert row["source_title"] == "Updated"
|
||||
|
||||
|
||||
def test_save_persists_library_file_path(db):
|
||||
"""#787 durability: the file path is stored so a manual match can be
|
||||
re-resolved after a rescan re-keys the track."""
|
||||
ok = db.save_manual_library_match(1, "spotify", "track-abc", 42,
|
||||
library_file_path="/music/Artist/Album/01.flac")
|
||||
assert ok is True
|
||||
row = db.get_manual_library_match(1, "spotify", "track-abc")
|
||||
assert row["library_file_path"] == "/music/Artist/Album/01.flac"
|
||||
|
||||
|
||||
def test_find_track_id_by_file_path(db):
|
||||
"""Re-resolution primitive: locate the current tracks.id for a file path
|
||||
(exact, then basename fallback)."""
|
||||
with db._get_connection() as conn:
|
||||
conn.execute("PRAGMA foreign_keys=OFF")
|
||||
conn.execute(
|
||||
"INSERT INTO tracks (id, album_id, artist_id, title, file_path) VALUES (?, ?, ?, ?, ?)",
|
||||
("7777", 1, 1, "Track", "/music/Artist/Album/01.flac"),
|
||||
)
|
||||
# Exact path
|
||||
assert db.find_track_id_by_file_path("/music/Artist/Album/01.flac") == "7777"
|
||||
# Basename fallback (server vs local path shape)
|
||||
assert db.find_track_id_by_file_path("/different/root/01.flac") == "7777"
|
||||
# Unknown file → None
|
||||
assert db.find_track_id_by_file_path("/music/nope.flac") is None
|
||||
assert db.find_track_id_by_file_path("") is None
|
||||
|
||||
|
||||
def test_delete_by_id(db):
|
||||
db.save_manual_library_match(1, "spotify", "track-abc", 42)
|
||||
row = db.get_manual_library_match(1, "spotify", "track-abc")
|
||||
|
|
|
|||
|
|
@ -162,6 +162,37 @@ def test_missing_cover_art_prefers_explicit_source_over_primary(monkeypatch):
|
|||
assert context.findings[0]['details']['found_artwork_url'] == 'https://img/spotify-direct'
|
||||
|
||||
|
||||
def test_missing_cover_art_uses_configured_art_sources(monkeypatch):
|
||||
"""When cover-art sources are configured (album_art_order), the Filler pulls
|
||||
art from them and skips the metadata source-priority loop — same 'cover art
|
||||
sources' notion the Re-tag job and post-process embed honor."""
|
||||
conn = _make_db((1, 'Album', 1, '', 'sp-album', 'it-album', 'dz-album', 'dg-album', 'hy-album'))
|
||||
settings = {
|
||||
'repair.jobs.missing_cover_art.settings': {},
|
||||
'metadata_enhancement.album_art_order': ['itunes', 'deezer'],
|
||||
}
|
||||
findings = []
|
||||
context = SimpleNamespace(
|
||||
db=SimpleNamespace(_get_connection=lambda: conn),
|
||||
config_manager=SimpleNamespace(get=lambda key, default=None: settings.get(key, default)),
|
||||
check_stop=lambda: False, wait_if_paused=lambda: False,
|
||||
update_progress=lambda *a, **k: None, report_progress=lambda *a, **k: None,
|
||||
create_finding=lambda **kw: (findings.append(kw) or True),
|
||||
findings=findings,
|
||||
)
|
||||
monkeypatch.setattr(mca, 'get_primary_source', lambda: 'spotify')
|
||||
consulted = []
|
||||
monkeypatch.setattr(mca, 'get_client_for_source', lambda s: consulted.append(s) or _FakeClient())
|
||||
monkeypatch.setattr('core.metadata.art_lookup.select_preferred_art_url',
|
||||
lambda artist, album, meta, order, **k: 'https://configured/art.jpg')
|
||||
|
||||
result = mca.MissingCoverArtJob().scan(context)
|
||||
|
||||
assert result.findings_created == 1
|
||||
assert findings[0]['details']['found_artwork_url'] == 'https://configured/art.jpg'
|
||||
assert consulted == [] # source-priority loop skipped when configured art wins
|
||||
|
||||
|
||||
def test_missing_cover_art_uses_primary_when_prefer_unset(monkeypatch):
|
||||
conn = _make_db((1, 'Album', 1, '', None, None, None, None, None))
|
||||
context = _make_context(conn)
|
||||
|
|
|
|||
|
|
@ -2,7 +2,12 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
from core.sync.playlist_edit import plan_playlist_add, remove_one_occurrence
|
||||
from core.sync.playlist_edit import (
|
||||
normalize_sync_mode,
|
||||
plan_playlist_add,
|
||||
plan_playlist_reconcile,
|
||||
remove_one_occurrence,
|
||||
)
|
||||
|
||||
|
||||
# ── plan_playlist_add: link must not duplicate ────────────────────────────
|
||||
|
|
@ -78,3 +83,72 @@ def test_remove_single_occurrence():
|
|||
def test_remove_stringifies():
|
||||
new_ids, removed = remove_one_occurrence([1, 2, 2, 3], 2)
|
||||
assert removed and new_ids == ["1", "2", "3"]
|
||||
|
||||
|
||||
# ── plan_playlist_reconcile: in-place delta (#792) ────────────────────────
|
||||
|
||||
def test_reconcile_adds_new_keeps_existing():
|
||||
# 4-track playlist + a 5th in source → add only the 5th, remove nothing.
|
||||
plan = plan_playlist_reconcile(['a', 'b', 'c', 'd'], ['a', 'b', 'c', 'd', 'e'])
|
||||
assert plan == {'add': ['e'], 'remove': []}
|
||||
|
||||
|
||||
def test_reconcile_removes_gone():
|
||||
# Source dropped 'b' → remove it, add nothing.
|
||||
plan = plan_playlist_reconcile(['a', 'b', 'c'], ['a', 'c'])
|
||||
assert plan == {'add': [], 'remove': ['b']}
|
||||
|
||||
|
||||
def test_reconcile_add_and_remove_together():
|
||||
plan = plan_playlist_reconcile(['a', 'b', 'c'], ['a', 'c', 'd', 'e'])
|
||||
assert plan['add'] == ['d', 'e'] # desired order preserved
|
||||
assert plan['remove'] == ['b']
|
||||
|
||||
|
||||
def test_reconcile_noop_when_identical():
|
||||
assert plan_playlist_reconcile(['a', 'b'], ['a', 'b']) == {'add': [], 'remove': []}
|
||||
|
||||
|
||||
def test_reconcile_empty_desired_removes_all():
|
||||
assert plan_playlist_reconcile(['a', 'b'], []) == {'add': [], 'remove': ['a', 'b']}
|
||||
|
||||
|
||||
def test_reconcile_empty_current_adds_all():
|
||||
assert plan_playlist_reconcile([], ['a', 'b']) == {'add': ['a', 'b'], 'remove': []}
|
||||
|
||||
|
||||
def test_reconcile_stringifies_ids():
|
||||
plan = plan_playlist_reconcile([1, 2], [2, 3])
|
||||
assert plan == {'add': ['3'], 'remove': ['1']}
|
||||
|
||||
|
||||
def test_reconcile_duplicate_still_desired_not_removed():
|
||||
# 'a' appears twice and is still desired → never queued for removal;
|
||||
# a gone id is listed once even if it had duplicates.
|
||||
plan = plan_playlist_reconcile(['a', 'a', 'b', 'b'], ['a'])
|
||||
assert plan == {'add': [], 'remove': ['b']}
|
||||
|
||||
|
||||
# ── normalize_sync_mode: reconcile must survive resolution (#792 regression) ──
|
||||
|
||||
def test_normalize_keeps_reconcile_from_config():
|
||||
# The bug: a validation list omitting 'reconcile' downgraded the configured
|
||||
# setting to 'replace'. No per-request override → configured value wins.
|
||||
assert normalize_sync_mode(None, 'reconcile') == 'reconcile'
|
||||
assert normalize_sync_mode('', 'reconcile') == 'reconcile'
|
||||
|
||||
|
||||
def test_normalize_request_overrides_config():
|
||||
assert normalize_sync_mode('append', 'reconcile') == 'append'
|
||||
assert normalize_sync_mode('reconcile', 'replace') == 'reconcile'
|
||||
|
||||
|
||||
def test_normalize_falls_back_for_unknown():
|
||||
assert normalize_sync_mode('bogus', 'replace') == 'replace'
|
||||
assert normalize_sync_mode(None, None) == 'replace'
|
||||
assert normalize_sync_mode(None, 'also-bogus') == 'replace'
|
||||
|
||||
|
||||
def test_normalize_all_real_modes_pass_through():
|
||||
for m in ('replace', 'append', 'reconcile'):
|
||||
assert normalize_sync_mode(m, 'replace') == m
|
||||
|
|
|
|||
114
tests/test_retag_planner.py
Normal file
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.
|
||||
from pathlib import Path
|
||||
completed = [Path("/staged/01.flac"), Path("/staged/02.flac")]
|
||||
with patch("core.soulseek_client.copy_audio_files_atomically", lambda files, dest: list(files)):
|
||||
with patch("core.soulseek_client.copy_audio_files_atomically", lambda files, dest, **kw: list(files)):
|
||||
stub = _Stub(completed)
|
||||
with patch("core.soulseek_client.run_async", lambda x: x):
|
||||
result = SoulseekClient.download_album_to_staging(
|
||||
|
|
|
|||
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'
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# URL scheme normalization (#790)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_normalize_client_url_prepends_http_when_scheme_missing() -> None:
|
||||
from core.torrent_clients.base import normalize_client_url
|
||||
# The exact shapes users type: bare IP:port, bare DNS name:port, bare host.
|
||||
assert normalize_client_url('192.168.1.5:8080') == 'http://192.168.1.5:8080'
|
||||
assert normalize_client_url('qbittorrent.lan:8080') == 'http://qbittorrent.lan:8080'
|
||||
assert normalize_client_url('myhost') == 'http://myhost'
|
||||
|
||||
|
||||
def test_normalize_client_url_preserves_existing_scheme_and_trims() -> None:
|
||||
from core.torrent_clients.base import normalize_client_url
|
||||
assert normalize_client_url('http://host:8080') == 'http://host:8080'
|
||||
assert normalize_client_url('https://host') == 'https://host'
|
||||
assert normalize_client_url(' http://host:8080/ ') == 'http://host:8080'
|
||||
assert normalize_client_url('') == ''
|
||||
assert normalize_client_url(None) == ''
|
||||
|
||||
|
||||
def test_qbit_load_config_defaults_scheme_for_bare_host() -> None:
|
||||
"""Regression #790: a bare ``host:port`` config (no scheme) must become an
|
||||
http:// URL. Otherwise requests can't pick an adapter and raises
|
||||
'No connection adapters were found for ...', which surfaced to the user as
|
||||
a generic 'qbittorrent probe failed'."""
|
||||
adapter = QBittorrentAdapter.__new__(QBittorrentAdapter)
|
||||
import threading
|
||||
adapter._session = None
|
||||
adapter._session_lock = threading.Lock()
|
||||
with patch('core.torrent_clients.qbittorrent.config_manager') as cm:
|
||||
cm.get.side_effect = lambda key, default='': {
|
||||
'torrent_client.url': '192.168.1.5:8080',
|
||||
}.get(key, default)
|
||||
adapter._load_config()
|
||||
assert adapter._url == 'http://192.168.1.5:8080'
|
||||
|
||||
|
||||
def test_deluge_load_config_defaults_scheme_for_bare_host() -> None:
|
||||
adapter = DelugeAdapter.__new__(DelugeAdapter)
|
||||
import threading
|
||||
adapter._session = None
|
||||
adapter._session_lock = threading.Lock()
|
||||
with patch('core.torrent_clients.deluge.config_manager') as cm:
|
||||
cm.get.side_effect = lambda key, default='': {
|
||||
'torrent_client.url': 'deluge.lan:8112',
|
||||
}.get(key, default)
|
||||
adapter._load_config()
|
||||
assert adapter._url == 'http://deluge.lan:8112'
|
||||
|
||||
|
||||
def test_transmission_session_id_renegotiation() -> None:
|
||||
"""Transmission rejects the first call with 409 and a fresh
|
||||
``X-Transmission-Session-Id`` header; the adapter must store it
|
||||
|
|
|
|||
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.
|
||||
# Semver: MAJOR.MINOR.PATCH. Bump at each dev→main release.
|
||||
_SOULSYNC_BASE_VERSION = "2.6.6"
|
||||
_SOULSYNC_BASE_VERSION = "2.6.7"
|
||||
|
||||
def _build_version_string():
|
||||
"""Append short commit hash to version when available (e.g. 2.35+abc1234)."""
|
||||
|
|
@ -862,19 +862,6 @@ duplicate_cleaner_state = {
|
|||
duplicate_cleaner_lock = threading.Lock()
|
||||
duplicate_cleaner_executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="DuplicateCleaner")
|
||||
|
||||
# Retag Tool Globals
|
||||
retag_state = {
|
||||
"status": "idle",
|
||||
"phase": "Ready",
|
||||
"progress": 0,
|
||||
"current_track": "",
|
||||
"total_tracks": 0,
|
||||
"processed": 0,
|
||||
"error_message": "",
|
||||
}
|
||||
retag_lock = threading.Lock()
|
||||
retag_executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="RetagWorker")
|
||||
|
||||
# Download Missing Tracks Modal State Management
|
||||
# Thread-safe state tracking for modal download functionality.
|
||||
# Shared task/batch state now lives in core.runtime_state.
|
||||
|
|
@ -1709,7 +1696,6 @@ def _shutdown_runtime_components():
|
|||
(db_update_executor, "db update executor"),
|
||||
(quality_scanner_executor, "quality scanner executor"),
|
||||
(duplicate_cleaner_executor, "duplicate cleaner executor"),
|
||||
(retag_executor, "retag executor"),
|
||||
(sync_executor, "sync executor"),
|
||||
(missing_download_executor, "missing download executor"),
|
||||
(album_bundle_executor, "album bundle executor"),
|
||||
|
|
@ -2334,7 +2320,7 @@ def get_status():
|
|||
|
||||
status_data = {
|
||||
'metadata_source': metadata_status['metadata_source'],
|
||||
'spotify': metadata_status['spotify'],
|
||||
'spotify': _spotify_status_with_availability(metadata_status['spotify']),
|
||||
'media_server': _status_cache['media_server'],
|
||||
'soulseek': soulseek_data,
|
||||
'active_media_server': active_server,
|
||||
|
|
@ -3679,10 +3665,23 @@ def settings_config_status_endpoint():
|
|||
Drives the green/yellow header gradient. No API calls — just config reads.
|
||||
"""
|
||||
try:
|
||||
return jsonify({
|
||||
result = {
|
||||
service: {'configured': _is_service_configured(service)}
|
||||
for service in SERVICE_CONFIG_REGISTRY
|
||||
})
|
||||
}
|
||||
# Spotify Free: Spotify metadata can be available without credentials
|
||||
# (opt-in no-creds source). Surface that separately so the search source
|
||||
# picker offers Spotify, while `configured` (the Connections indicator)
|
||||
# keeps meaning "has client credentials".
|
||||
if 'spotify' in result:
|
||||
try:
|
||||
meta_avail = bool(spotify_client and spotify_client.is_spotify_metadata_available())
|
||||
except Exception:
|
||||
meta_avail = False
|
||||
result['spotify']['metadata_available'] = (
|
||||
result['spotify']['configured'] or meta_avail
|
||||
)
|
||||
return jsonify(result)
|
||||
except Exception as e:
|
||||
logger.error(f"config-status error: {e}")
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
|
@ -5621,6 +5620,7 @@ def start_sync():
|
|||
|
||||
# Search route bodies live in core/search/* — these routes are thin handlers.
|
||||
from core.search import basic as _search_basic
|
||||
from core.search import by_id as _search_by_id
|
||||
from core.search import library_check as _search_library_check
|
||||
from core.search import orchestrator as _search_orchestrator
|
||||
from core.search import stream as _search_stream
|
||||
|
|
@ -5739,6 +5739,34 @@ def enhanced_search():
|
|||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/enhanced-search/by-id', methods=['POST'])
|
||||
def enhanced_search_by_id():
|
||||
"""Resolve a pasted metadata link to a single album or track (#775).
|
||||
|
||||
A provider URL (Spotify/Apple/MusicBrainz/Deezer) is looked up directly
|
||||
on the owning source via its get-by-id — no fuzzy search, no scoring. The
|
||||
domain pins the source and the path pins album-vs-track, so it's
|
||||
unambiguous. Returns the same dropdown shape the normal enhanced search
|
||||
renders, plus the resolving ``source`` so the frontend can route
|
||||
downloads/imports through the existing flow.
|
||||
|
||||
Body: ``{"query": "<provider link or spotify: URI>"}``. Links only — a
|
||||
bare ID is rejected with a hint, since it carries no source or type.
|
||||
"""
|
||||
data = request.get_json() or {}
|
||||
raw = (data.get('query') or '').strip()
|
||||
if not raw:
|
||||
return jsonify(_search_by_id._empty_result(''))
|
||||
|
||||
try:
|
||||
deps = _build_search_deps()
|
||||
result = _search_by_id.resolve_identifier(raw, deps)
|
||||
return jsonify(result)
|
||||
except Exception as e:
|
||||
logger.error(f"Link/ID resolve error: {e}")
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/enhanced-search/source/<source_name>', methods=['POST'])
|
||||
def enhanced_search_source(source_name):
|
||||
"""Streaming NDJSON search for one alternate metadata source.
|
||||
|
|
@ -7638,6 +7666,26 @@ def _find_library_artist_for_source(database, source, source_artist_id, artist_n
|
|||
)
|
||||
|
||||
|
||||
def _resolve_source_artist_name(source, artist_id):
|
||||
"""Resolve a source artist's display name by id, or '' on any failure.
|
||||
|
||||
Reuses the #775 link-resolver's per-source artist fetch so we have one
|
||||
place that knows each source's get-by-id quirks. Used by the artist-detail
|
||||
library upgrade to disambiguate a duplicated/corrupt source id by name
|
||||
when the URL-driven navigation didn't carry a name.
|
||||
"""
|
||||
try:
|
||||
deps = _build_search_deps()
|
||||
client, _available = _search_orchestrator.resolve_client(source, deps)
|
||||
if client is None:
|
||||
return ''
|
||||
data = _search_by_id._fetch_artist(client, source, artist_id)
|
||||
return (data or {}).get('name') or ''
|
||||
except Exception as e:
|
||||
logger.debug(f"Source artist name resolution failed for {source}:{artist_id}: {e}")
|
||||
return ''
|
||||
|
||||
|
||||
def _build_source_only_artist_detail(artist_id, artist_name, source):
|
||||
"""Thin wrapper around ``core.artist_source_detail.build_source_only_artist_detail``.
|
||||
|
||||
|
|
@ -7739,6 +7787,18 @@ def get_artist_detail(artist_id):
|
|||
library_pk = _find_library_artist_for_source(
|
||||
database, source_param, artist_id, artist_name_arg
|
||||
)
|
||||
# URL-driven navigation carries no name, so a duplicated/corrupt
|
||||
# source id (one Deezer id on several artists) can't be matched by
|
||||
# the id alone — it's ambiguous and the lookup bails. Resolve the
|
||||
# artist's name from the source and retry so an owned artist still
|
||||
# gets the rich library view instead of the bare source one.
|
||||
if not library_pk and not artist_name_arg:
|
||||
resolved_name = _resolve_source_artist_name(source_param, artist_id)
|
||||
if resolved_name:
|
||||
artist_name_arg = resolved_name
|
||||
library_pk = _find_library_artist_for_source(
|
||||
database, source_param, artist_id, artist_name_arg
|
||||
)
|
||||
if library_pk:
|
||||
logger.info(
|
||||
f"Source-id {source_param}:{artist_id} matched library artist "
|
||||
|
|
@ -9336,6 +9396,13 @@ def _build_library_tag_db_data(track_data, album_genres=None):
|
|||
if artists_list:
|
||||
db_data['artists_list'] = artists_list
|
||||
|
||||
# Carry the known source IDs through so they get embedded too (the writer
|
||||
# only acts on the ones present). These come from t.* on the track row.
|
||||
for _k in ('spotify_track_id', 'itunes_track_id', 'musicbrainz_recording_id'):
|
||||
_v = track_data.get(_k)
|
||||
if _v:
|
||||
db_data[_k] = _v
|
||||
|
||||
return db_data
|
||||
|
||||
|
||||
|
|
@ -9761,6 +9828,159 @@ def get_write_tags_batch_status():
|
|||
return jsonify(state)
|
||||
|
||||
|
||||
# ── Reconcile embedded provider IDs (gap-fill DB from file tags) ──
|
||||
#
|
||||
# Files that SoulSync (or MusicBrainz Picard) already tagged carry Spotify /
|
||||
# iTunes / MusicBrainz / Deezer / Tidal / AudioDB / Genius IDs in their
|
||||
# metadata. Reading them back and gap-filling the {provider}_id +
|
||||
# {provider}_match_status='matched' columns lets the enrichment workers skip
|
||||
# the API lookup entirely — large API savings on an already-tagged library.
|
||||
# Gap-fill only: an existing id is never overwritten (see
|
||||
# core/library/embedded_id_reconcile.py).
|
||||
def _reconcile_library_tracks(conn, track_ids=None, on_progress=None, should_stop=None):
|
||||
"""Run the embedded-ID reconcile with web-server path resolution injected.
|
||||
|
||||
Thin wrapper over core.library.embedded_id_reconcile.reconcile_library that
|
||||
supplies the read_tags callable (docker/library path resolution + mutagen
|
||||
read). Used by the manual backfill job and available for any scoped
|
||||
reconcile. ``track_ids=None`` => whole library.
|
||||
"""
|
||||
from core.library.file_tags import read_embedded_tags
|
||||
from core.library.embedded_id_reconcile import reconcile_library
|
||||
|
||||
def _read_tags(file_path):
|
||||
resolved = _resolve_library_file_path(file_path)
|
||||
if not resolved:
|
||||
return None
|
||||
info = read_embedded_tags(resolved)
|
||||
return info.get('tags') if info.get('available') else None
|
||||
|
||||
return reconcile_library(conn, _read_tags, track_ids=track_ids,
|
||||
on_progress=on_progress, should_stop=should_stop)
|
||||
|
||||
|
||||
def _reconcile_after_scan(worker):
|
||||
"""Gap-fill embedded provider IDs for tracks a scan newly inserted.
|
||||
|
||||
Runs after a library scan/deep-scan completes, scoped to the rows the
|
||||
worker actually INSERTED this run (``worker._new_track_ids``). New files
|
||||
are written with empty provider-id columns, so this reads their tags and
|
||||
fills any IDs present — keeping the DB current without a manual backfill.
|
||||
Best-effort: never raises into the scan flow.
|
||||
"""
|
||||
try:
|
||||
new_ids = list(getattr(worker, '_new_track_ids', None) or [])
|
||||
if not new_ids:
|
||||
return
|
||||
n = len(new_ids)
|
||||
try:
|
||||
_db_update_phase_callback(
|
||||
f"Reading file tags for {n} new track{'s' if n != 1 else ''}…")
|
||||
except Exception: # noqa: S110 — best-effort UI phase, never block the reconcile
|
||||
pass
|
||||
|
||||
def _on_progress(totals, title):
|
||||
try:
|
||||
pct = (totals.processed / totals.total * 100) if totals.total else 100
|
||||
_db_update_progress_callback(title, totals.processed, totals.total, pct)
|
||||
except Exception: # noqa: S110 — best-effort UI progress tick
|
||||
pass
|
||||
|
||||
database = get_database()
|
||||
conn = database._get_connection()
|
||||
try:
|
||||
totals = _reconcile_library_tracks(conn, track_ids=new_ids, on_progress=_on_progress)
|
||||
logger.info(
|
||||
"[Reconcile] Post-scan: filled %d id(s) across %d row(s) from %d new "
|
||||
"track(s) (%d unreadable, %d conflicts)",
|
||||
totals.ids_filled, totals.entities_updated, totals.processed,
|
||||
totals.unreadable, totals.conflicts,
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
except Exception as e:
|
||||
logger.warning("[Reconcile] Post-scan reconcile failed (non-fatal): %s", e)
|
||||
|
||||
|
||||
_reconcile_ids_state = {
|
||||
'status': 'idle', # idle | running | done
|
||||
'total': 0,
|
||||
'processed': 0,
|
||||
'entities_updated': 0, # track/album/artist rows written
|
||||
'ids_filled': 0, # individual id columns filled
|
||||
'conflicts': 0, # embedded id disagreed with a stored id (not applied)
|
||||
'unreadable': 0, # files missing / unreadable by mutagen
|
||||
'current': '',
|
||||
}
|
||||
_reconcile_ids_lock = threading.Lock()
|
||||
|
||||
|
||||
@app.route('/api/library/reconcile-embedded-ids', methods=['POST'])
|
||||
def reconcile_embedded_ids():
|
||||
"""Scan every library file for embedded provider IDs and gap-fill them
|
||||
into the DB so enrichment workers skip the API lookup. Runs in the
|
||||
background; poll the status endpoint for progress."""
|
||||
try:
|
||||
with _reconcile_ids_lock:
|
||||
if _reconcile_ids_state['status'] == 'running':
|
||||
return jsonify({"success": False, "error": "A reconcile is already in progress"}), 409
|
||||
_reconcile_ids_state.update({
|
||||
'status': 'running', 'total': 0, 'processed': 0,
|
||||
'entities_updated': 0, 'ids_filled': 0, 'conflicts': 0,
|
||||
'unreadable': 0, 'current': 'Starting…',
|
||||
})
|
||||
|
||||
database = get_database()
|
||||
|
||||
def _run():
|
||||
conn = None
|
||||
try:
|
||||
conn = database._get_connection()
|
||||
|
||||
def _on_progress(totals, title):
|
||||
with _reconcile_ids_lock:
|
||||
_reconcile_ids_state.update({
|
||||
'total': totals.total,
|
||||
'processed': totals.processed,
|
||||
'entities_updated': totals.entities_updated,
|
||||
'ids_filled': totals.ids_filled,
|
||||
'conflicts': totals.conflicts,
|
||||
'unreadable': totals.unreadable,
|
||||
'current': title,
|
||||
})
|
||||
|
||||
# Whole-library backfill (track_ids=None). Shares the exact
|
||||
# orchestration used by the on-scan auto-reconcile hook.
|
||||
_reconcile_library_tracks(conn, on_progress=_on_progress)
|
||||
except Exception as e:
|
||||
logger.error(f"Reconcile embedded IDs background error: {e}")
|
||||
finally:
|
||||
if conn is not None:
|
||||
try:
|
||||
conn.close()
|
||||
except Exception: # noqa: S110 — connection cleanup, nothing to recover
|
||||
pass
|
||||
with _reconcile_ids_lock:
|
||||
_reconcile_ids_state['status'] = 'done'
|
||||
_reconcile_ids_state['current'] = ''
|
||||
|
||||
thread = threading.Thread(target=_run, daemon=True, name="ReconcileEmbeddedIds")
|
||||
thread.start()
|
||||
return jsonify({"success": True, "message": "Reconcile started"})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Reconcile embedded IDs kickoff error: {e}")
|
||||
with _reconcile_ids_lock:
|
||||
_reconcile_ids_state['status'] = 'idle'
|
||||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/library/reconcile-embedded-ids/status', methods=['GET'])
|
||||
def get_reconcile_embedded_ids_status():
|
||||
"""Poll the status of the embedded-ID reconcile job."""
|
||||
with _reconcile_ids_lock:
|
||||
return jsonify(dict(_reconcile_ids_state))
|
||||
|
||||
|
||||
# ── ReplayGain Analysis endpoints ──
|
||||
|
||||
|
|
@ -11096,6 +11316,18 @@ def library_manual_match():
|
|||
if cursor.rowcount == 0:
|
||||
return jsonify({"success": False, "error": "Entity not found"}), 404
|
||||
|
||||
# #758 — a manual ALBUM match also pins (and LOCKS) the canonical album
|
||||
# version to the chosen release, so the auto resolve job and every tool
|
||||
# that reads the canonical pin (track-number repair, reorganize,
|
||||
# missing-tracks) honor the user's edition instead of re-resolving back
|
||||
# to the deluxe. The lock survives future enrichment/resolution cycles.
|
||||
try:
|
||||
from core.metadata.canonical_version import should_pin_manual_canonical
|
||||
if should_pin_manual_canonical(entity_type, service):
|
||||
database.set_album_canonical(entity_id, service, service_id, 1.0, locked=True)
|
||||
except Exception as e:
|
||||
logger.warning("Manual canonical pin failed for album %s: %s", entity_id, e)
|
||||
|
||||
# Re-fetch fresh data
|
||||
artist_id = data.get('artist_id', entity_id)
|
||||
if entity_type != 'artist':
|
||||
|
|
@ -14267,45 +14499,6 @@ _download_retry_attempts = {} # {context_key: {'count': N, 'first_attempt': tim
|
|||
_download_retry_max = 10 # Max retries before giving up (10 seconds with 1s poll interval)
|
||||
_download_retry_lock = threading.Lock()
|
||||
|
||||
# Retag worker logic lives in core/library/retag.py.
|
||||
from core.library import retag as _library_retag
|
||||
|
||||
|
||||
def _build_retag_deps():
|
||||
"""Build the RetagDeps bundle from web_server.py globals on each call."""
|
||||
from database.music_database import get_database as _get_db
|
||||
|
||||
def _get_state():
|
||||
return retag_state
|
||||
|
||||
def _set_state(value):
|
||||
global retag_state
|
||||
retag_state = value
|
||||
|
||||
from core.metadata.lyrics import generate_lrc_file as _generate_lrc_file
|
||||
|
||||
return _library_retag.RetagDeps(
|
||||
config_manager=config_manager,
|
||||
retag_lock=retag_lock,
|
||||
spotify_client=spotify_client,
|
||||
get_audio_quality_string=_get_audio_quality_string,
|
||||
enhance_file_metadata=_enhance_file_metadata,
|
||||
build_final_path_for_track=_build_final_path_for_track,
|
||||
safe_move_file=_safe_move_file,
|
||||
cleanup_empty_directories=_cleanup_empty_directories,
|
||||
download_cover_art=_download_cover_art,
|
||||
docker_resolve_path=docker_resolve_path,
|
||||
_get_retag_state=_get_state,
|
||||
_set_retag_state=_set_state,
|
||||
get_database=_get_db,
|
||||
generate_lrc_file=_generate_lrc_file,
|
||||
)
|
||||
|
||||
|
||||
def _execute_retag(group_id, album_id):
|
||||
return _library_retag.execute_retag(group_id, album_id, _build_retag_deps())
|
||||
|
||||
|
||||
def _automatic_wishlist_cleanup_after_db_update():
|
||||
"""Automatic wishlist cleanup that runs after database updates."""
|
||||
return _cleanup_wishlist_after_db_update(logger=logger)
|
||||
|
|
@ -15035,6 +15228,11 @@ def _run_db_update_task(full_refresh, server_type):
|
|||
db_update_worker.connect_callback('finished', _db_update_finished_callback)
|
||||
db_update_worker.connect_callback('error', _db_update_error_callback)
|
||||
|
||||
# Auto-reconcile runs as the FINAL scan phase (inside run(), before the
|
||||
# 'finished' signal) so status stays 'running' through it — automations,
|
||||
# the dashboard card, and the Tools page all treat it as part of the scan.
|
||||
db_update_worker.post_scan_hook = _reconcile_after_scan
|
||||
|
||||
# This is a blocking call that runs the worker logic
|
||||
db_update_worker.run()
|
||||
|
||||
|
|
@ -15083,6 +15281,9 @@ def _run_deep_scan_task(server_type):
|
|||
db_update_worker.connect_callback('finished', _db_update_finished_callback)
|
||||
db_update_worker.connect_callback('error', _db_update_error_callback)
|
||||
|
||||
# Auto-reconcile runs as the final scan phase (see _run_database_update_task).
|
||||
db_update_worker.post_scan_hook = _reconcile_after_scan
|
||||
|
||||
# Run deep scan instead of normal run()
|
||||
db_update_worker.run_deep_scan()
|
||||
|
||||
|
|
@ -16454,115 +16655,6 @@ def stop_duplicate_cleaner():
|
|||
# == RETAG TOOL ENDPOINTS ==
|
||||
# ===============================
|
||||
|
||||
@app.route('/api/retag/stats', methods=['GET'])
|
||||
def get_retag_stats():
|
||||
"""Get retag tool statistics for the dashboard card."""
|
||||
from database.music_database import get_database
|
||||
db = get_database()
|
||||
stats = db.get_retag_stats()
|
||||
return jsonify({"success": True, **stats})
|
||||
|
||||
@app.route('/api/retag/groups', methods=['GET'])
|
||||
def get_retag_groups():
|
||||
"""Get all retag groups sorted by artist name."""
|
||||
from database.music_database import get_database
|
||||
db = get_database()
|
||||
groups = db.get_retag_groups()
|
||||
return jsonify({"success": True, "groups": groups})
|
||||
|
||||
@app.route('/api/retag/groups/<int:group_id>/tracks', methods=['GET'])
|
||||
def get_retag_group_tracks(group_id):
|
||||
"""Get tracks for a specific retag group."""
|
||||
from database.music_database import get_database
|
||||
db = get_database()
|
||||
tracks = db.get_retag_tracks(group_id)
|
||||
return jsonify({"success": True, "tracks": tracks})
|
||||
|
||||
@app.route('/api/retag/search', methods=['GET'])
|
||||
def search_retag_albums():
|
||||
"""Search for albums to use for retagging (uses Spotify/iTunes fallback)."""
|
||||
query = request.args.get('q', '').strip()
|
||||
if not query:
|
||||
return jsonify({"success": False, "error": "Query parameter 'q' is required"}), 400
|
||||
|
||||
limit = min(int(request.args.get('limit', 12)), 50)
|
||||
try:
|
||||
results = spotify_client.search_albums(query, limit=limit)
|
||||
albums = []
|
||||
for a in results:
|
||||
albums.append({
|
||||
'id': str(a.id),
|
||||
'name': a.name,
|
||||
'artist': ', '.join(a.artists) if a.artists else 'Unknown Artist',
|
||||
'release_date': a.release_date or '',
|
||||
'total_tracks': a.total_tracks,
|
||||
'image_url': a.image_url,
|
||||
'album_type': a.album_type or 'album'
|
||||
})
|
||||
return jsonify({"success": True, "albums": albums})
|
||||
except Exception as e:
|
||||
logger.error(f"[Retag] Album search error: {e}")
|
||||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
|
||||
@app.route('/api/retag/execute', methods=['POST'])
|
||||
def execute_retag():
|
||||
"""Start a retag operation for a group with a new album match."""
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return jsonify({"success": False, "error": "JSON body required"}), 400
|
||||
|
||||
group_id = data.get('group_id')
|
||||
album_id = data.get('album_id')
|
||||
if not group_id or not album_id:
|
||||
return jsonify({"success": False, "error": "group_id and album_id are required"}), 400
|
||||
|
||||
with retag_lock:
|
||||
if retag_state["status"] == "running":
|
||||
return jsonify({"success": False, "error": "A retag operation is already running"}), 409
|
||||
|
||||
retag_executor.submit(_execute_retag, group_id, str(album_id))
|
||||
return jsonify({"success": True, "message": "Retag operation started"})
|
||||
|
||||
@app.route('/api/retag/status', methods=['GET'])
|
||||
def get_retag_status():
|
||||
"""Get the current retag operation status."""
|
||||
with retag_lock:
|
||||
return jsonify(dict(retag_state))
|
||||
|
||||
@app.route('/api/retag/groups/<int:group_id>', methods=['DELETE'])
|
||||
def delete_retag_group(group_id):
|
||||
"""Delete a retag group (files are NOT deleted)."""
|
||||
from database.music_database import get_database
|
||||
db = get_database()
|
||||
success = db.delete_retag_group(group_id)
|
||||
if success:
|
||||
return jsonify({"success": True})
|
||||
else:
|
||||
return jsonify({"success": False, "error": "Group not found"}), 404
|
||||
|
||||
@app.route('/api/retag/groups/delete-batch', methods=['POST'])
|
||||
def delete_retag_groups_batch():
|
||||
"""Delete multiple retag groups at once."""
|
||||
from database.music_database import get_database
|
||||
data = request.get_json() or {}
|
||||
group_ids = data.get('group_ids', [])
|
||||
if not group_ids:
|
||||
return jsonify({"success": False, "error": "No group IDs provided"}), 400
|
||||
db = get_database()
|
||||
removed = 0
|
||||
for gid in group_ids:
|
||||
if db.delete_retag_group(int(gid)):
|
||||
removed += 1
|
||||
return jsonify({"success": True, "removed": removed})
|
||||
|
||||
@app.route('/api/retag/groups/clear-all', methods=['POST'])
|
||||
def clear_all_retag_groups():
|
||||
"""Delete all retag groups."""
|
||||
from database.music_database import get_database
|
||||
db = get_database()
|
||||
count = db.delete_all_retag_groups()
|
||||
return jsonify({"success": True, "removed": count})
|
||||
|
||||
# ===============================
|
||||
# == DOWNLOAD MISSING TRACKS ==
|
||||
# ===============================
|
||||
|
|
@ -18227,13 +18319,26 @@ def get_server_playlist_tracks(playlist_id):
|
|||
# exact/fuzzy passes entirely. Stale-cache safe — if the cached
|
||||
# server track no longer exists, the override is silently
|
||||
# skipped and normal matching runs.
|
||||
from core.sync.match_overrides import resolve_match_overrides
|
||||
from core.sync.match_overrides import resolve_durable_match_server_id, resolve_match_overrides
|
||||
_db_for_overrides = get_database()
|
||||
_override_pairs = resolve_match_overrides(
|
||||
source_tracks,
|
||||
server_tracks,
|
||||
lambda src_id: ((_db_for_overrides.read_sync_match_cache(src_id, active_server) or {}).get('server_track_id')),
|
||||
)
|
||||
# Set of server track ids currently in this playlist — used to validate
|
||||
# a re-resolved durable match actually exists before pairing it.
|
||||
_server_ids = {str(t.get('id')) for t in server_tracks if isinstance(t, dict) and t.get('id') is not None}
|
||||
_ov_profile = get_current_profile_id()
|
||||
|
||||
def _override_lookup(src_id):
|
||||
# 1) Fast override cache (cleared on every rescan).
|
||||
cached = _db_for_overrides.read_sync_match_cache(src_id, active_server) or {}
|
||||
if cached.get('server_track_id'):
|
||||
return cached['server_track_id']
|
||||
# 2) Durable manual library match — survives a rescan (#787), so a
|
||||
# Find & Add / manual match keeps pairing after a DB scan. Re-
|
||||
# resolves a stale id via the stored file path when needed.
|
||||
return resolve_durable_match_server_id(
|
||||
_db_for_overrides, _ov_profile, src_id, active_server, _server_ids
|
||||
)
|
||||
|
||||
_override_pairs = resolve_match_overrides(source_tracks, server_tracks, _override_lookup)
|
||||
|
||||
combined = reconcile_playlist(source_tracks, server_tracks, _override_pairs)
|
||||
|
||||
|
|
@ -18349,27 +18454,55 @@ def server_playlist_replace_track(playlist_id):
|
|||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
|
||||
|
||||
def _persist_find_and_add_match(source_track_id, server_source, server_track_id, server_track_title, source_title, source_artist):
|
||||
"""Wrap match-override persistence with the active DB. No-op when
|
||||
source_track_id is missing (e.g. add to a non-mirrored playlist)."""
|
||||
def _persist_find_and_add_match(source_track_id, server_source, server_track_id, server_track_title, source_title, source_artist, source='spotify'):
|
||||
"""Persist a Find & Add selection two ways. No-op when source_track_id is
|
||||
missing (e.g. add to a non-mirrored playlist).
|
||||
|
||||
1. ``sync_match_cache`` override — fast auto-match on the next sync, but
|
||||
wiped on every library rescan.
|
||||
2. A DURABLE manual library match (#787) — survives a rescan, so the
|
||||
source→library pairing sticks in the sync display AND the download loop
|
||||
stops re-fetching it. This is the unification the issue asked for:
|
||||
Find & Add now also records a manual library match (one-way; the manual
|
||||
match tool has no playlist to act on, so it doesn't reverse-create)."""
|
||||
if not source_track_id:
|
||||
return
|
||||
db = get_database()
|
||||
try:
|
||||
from core.sync.match_overrides import record_manual_match
|
||||
ok = record_manual_match(
|
||||
get_database(),
|
||||
db,
|
||||
source_track_id=source_track_id,
|
||||
server_source=server_source,
|
||||
server_track_id=server_track_id,
|
||||
server_track_title=server_track_title,
|
||||
source_title=source_title,
|
||||
source_artist=source_artist,
|
||||
server_track_title=server_track_title,
|
||||
)
|
||||
if ok:
|
||||
logger.info(f"[ServerPlaylist] Persisted Find & Add override: {source_track_id} → {server_track_id} ({server_source})")
|
||||
except Exception as e:
|
||||
logger.warning(f"[ServerPlaylist] Failed to persist Find & Add override: {e}")
|
||||
|
||||
# Durable manual library match — survives a rescan (the override above does not).
|
||||
try:
|
||||
from core.library import manual_library_match as _mlm
|
||||
file_path = ''
|
||||
try:
|
||||
_rows = db.api_get_tracks_by_ids([server_track_id])
|
||||
if _rows:
|
||||
file_path = _rows[0].get('file_path', '') or ''
|
||||
except Exception as _fp_err:
|
||||
logger.debug(f"[ServerPlaylist] file_path lookup for manual match failed: {_fp_err}")
|
||||
_mlm.save_match(
|
||||
db, get_current_profile_id(), (source or 'spotify'), str(source_track_id), str(server_track_id),
|
||||
source_title=source_title, source_artist=source_artist,
|
||||
server_source=server_source, library_file_path=file_path,
|
||||
)
|
||||
logger.info(f"[ServerPlaylist] Recorded durable manual library match: {source_track_id} → {server_track_id}")
|
||||
except Exception as e:
|
||||
logger.warning(f"[ServerPlaylist] Failed to record durable manual match: {e}")
|
||||
|
||||
|
||||
@app.route('/api/server/playlist/<playlist_id>/add-track', methods=['POST'])
|
||||
def server_playlist_add_track(playlist_id):
|
||||
|
|
@ -18391,6 +18524,9 @@ def server_playlist_add_track(playlist_id):
|
|||
source_title = data.get('source_title') or ''
|
||||
source_artist = data.get('source_artist') or ''
|
||||
server_track_title = data.get('server_track_title') or ''
|
||||
# Provider of the source track (spotify/deezer/...) for the durable
|
||||
# manual match. Retrieval is source-agnostic, so this is metadata.
|
||||
source_provider = data.get('source') or 'spotify'
|
||||
|
||||
if not track_id:
|
||||
return jsonify({"success": False, "error": "track_id required"}), 400
|
||||
|
|
@ -18431,7 +18567,7 @@ def server_playlist_add_track(playlist_id):
|
|||
except Exception:
|
||||
_existing = set()
|
||||
if str(track_id) in _existing:
|
||||
_persist_find_and_add_match(source_track_id, active_server, track_id, server_track_title or new_item.title, source_title, source_artist)
|
||||
_persist_find_and_add_match(source_track_id, active_server, track_id, server_track_title or new_item.title, source_title, source_artist, source_provider)
|
||||
return jsonify({"success": True, "message": "Track linked"})
|
||||
|
||||
logger.info(f"[ServerPlaylist] Adding track: '{new_item.title}' (ratingKey={new_item.ratingKey}) to playlist '{playlist_name}'")
|
||||
|
|
@ -18455,7 +18591,7 @@ def server_playlist_add_track(playlist_id):
|
|||
|
||||
new_id = str(raw_playlist.ratingKey)
|
||||
logger.info(f"[ServerPlaylist] Added track to playlist, playlist ID: {new_id}")
|
||||
_persist_find_and_add_match(source_track_id, active_server, track_id, server_track_title or new_item.title, source_title, source_artist)
|
||||
_persist_find_and_add_match(source_track_id, active_server, track_id, server_track_title or new_item.title, source_title, source_artist, source_provider)
|
||||
return jsonify({"success": True, "message": "Track added", "new_playlist_id": new_id})
|
||||
|
||||
elif active_server == 'jellyfin' and media_server_engine.client('jellyfin'):
|
||||
|
|
@ -18468,7 +18604,7 @@ def server_playlist_add_track(playlist_id):
|
|||
if plan['should_insert']:
|
||||
new_track_objs = [type('T', (), {'ratingKey': tid, 'title': ''})() for tid in plan['new_ids']]
|
||||
media_server_engine.client('jellyfin').update_playlist(playlist_name, new_track_objs)
|
||||
_persist_find_and_add_match(source_track_id, active_server, track_id, server_track_title, source_title, source_artist)
|
||||
_persist_find_and_add_match(source_track_id, active_server, track_id, server_track_title, source_title, source_artist, source_provider)
|
||||
return jsonify({"success": True, "message": "Track linked" if not plan['should_insert'] else "Track added"})
|
||||
|
||||
elif active_server == 'navidrome' and media_server_engine.client('navidrome'):
|
||||
|
|
@ -18481,7 +18617,7 @@ def server_playlist_add_track(playlist_id):
|
|||
if plan['should_insert']:
|
||||
new_track_objs = [type('T', (), {'ratingKey': tid, 'title': ''})() for tid in plan['new_ids']]
|
||||
media_server_engine.client('navidrome').create_playlist(playlist_name, new_track_objs, playlist_id=playlist_id)
|
||||
_persist_find_and_add_match(source_track_id, active_server, track_id, server_track_title, source_title, source_artist)
|
||||
_persist_find_and_add_match(source_track_id, active_server, track_id, server_track_title, source_title, source_artist, source_provider)
|
||||
return jsonify({"success": True, "message": "Track linked" if not plan['should_insert'] else "Track added"})
|
||||
|
||||
return jsonify({"success": False, "error": f"Unsupported server: {active_server}"}), 400
|
||||
|
|
@ -18685,8 +18821,12 @@ def mlm_save():
|
|||
db = get_database()
|
||||
profile_id = get_current_profile_id()
|
||||
# Validate library track exists before saving
|
||||
if not db.api_get_tracks_by_ids([library_track_id]):
|
||||
_lib_rows = db.api_get_tracks_by_ids([library_track_id])
|
||||
if not _lib_rows:
|
||||
return jsonify({"success": False, "error": "Library track not found — it may have been removed"}), 400
|
||||
# Store the file path so this match re-resolves after a rescan re-keys
|
||||
# the track (#787) — same durability the Find & Add path gets.
|
||||
_lib_file_path = (_lib_rows[0].get('file_path') or '') if _lib_rows else ''
|
||||
ok = mlm.save_match(
|
||||
db, profile_id, source, source_track_id, library_track_id,
|
||||
source_title=data.get('source_title', ''),
|
||||
|
|
@ -18694,6 +18834,7 @@ def mlm_save():
|
|||
source_album=data.get('source_album', ''),
|
||||
source_context_json=data.get('source_context_json', ''),
|
||||
server_source=data.get('server_source', ''),
|
||||
library_file_path=_lib_file_path,
|
||||
)
|
||||
if not ok:
|
||||
return jsonify({"success": False, "error": "Failed to save match"}), 500
|
||||
|
|
@ -18739,6 +18880,10 @@ def start_missing_tracks_process(playlist_id):
|
|||
is_album_download = data.get('is_album_download', False)
|
||||
album_context = data.get('album_context', None)
|
||||
artist_context = data.get('artist_context', None)
|
||||
# Issue #797 — per-request "Skip AcoustID verification" toggle from the
|
||||
# album-download modal. Stored on the batch and propagated per-track by
|
||||
# the master worker so AcoustID never quarantines this request's files.
|
||||
skip_acoustid = bool(data.get('skip_acoustid', False))
|
||||
|
||||
if not tracks:
|
||||
return jsonify({"success": False, "error": "No tracks provided"}), 400
|
||||
|
|
@ -18806,6 +18951,7 @@ def start_missing_tracks_process(playlist_id):
|
|||
'album_context': album_context,
|
||||
'artist_context': artist_context,
|
||||
'wing_it': wing_it,
|
||||
'skip_acoustid': skip_acoustid, # #797 per-request AcoustID bypass
|
||||
'batch_source': _downloads_history.detect_sync_source(playlist_id),
|
||||
}
|
||||
|
||||
|
|
@ -21159,10 +21305,17 @@ def _get_source_playlist_states(states, error_label, info_log_label=None):
|
|||
def _submit_sync_task(sync_playlist_id, playlist_name, spotify_tracks, playlist_image_url):
|
||||
"""Submit a sync to the shared executor (closes over sync_executor /
|
||||
_run_sync_task / get_current_profile_id so the lifted start_sync helper
|
||||
stays free of those globals)."""
|
||||
stays free of those globals).
|
||||
|
||||
Used by ALL per-source discovery syncs (Spotify-Public/Tidal/Deezer/Qobuz/
|
||||
YouTube/iTunes-link/ListenBrainz/Beatport). These have no per-request mode
|
||||
selector, so honor the configured default (Settings > Playlist sync mode) —
|
||||
otherwise they always ran 'replace' regardless of the setting (#792)."""
|
||||
from core.sync.playlist_edit import normalize_sync_mode
|
||||
_mode = normalize_sync_mode(None, config_manager.get('playlist_sync.mode', 'replace'))
|
||||
return sync_executor.submit(
|
||||
_run_sync_task, sync_playlist_id, playlist_name, spotify_tracks,
|
||||
None, get_current_profile_id(), playlist_image_url,
|
||||
None, get_current_profile_id(), playlist_image_url, _mode,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -23260,6 +23413,12 @@ def update_youtube_discovery_match():
|
|||
'confidence': 1.0,
|
||||
'matched_data': matched_data,
|
||||
'manual_match': True,
|
||||
# extra_data is MERGED on save, so explicitly clear
|
||||
# any stale stub/removal flags from before this fix —
|
||||
# otherwise a leftover wing_it_fallback would make the
|
||||
# pipeline re-discover and revert this manual pick.
|
||||
'wing_it_fallback': False,
|
||||
'unmatched_by_user': False,
|
||||
}
|
||||
db.update_mirrored_track_extra_data(db_track_id, extra_data)
|
||||
result['matched_data'] = matched_data
|
||||
|
|
@ -23652,9 +23811,11 @@ def start_playlist_sync():
|
|||
# playlist — only adds tracks that aren't there yet. Per-server clients
|
||||
# implement append via native add APIs (Plex addItems, Jellyfin POST
|
||||
# /Playlists/<id>/Items, Navidrome updatePlaylist?songIdToAdd=...).
|
||||
sync_mode = data.get('sync_mode', 'replace')
|
||||
if sync_mode not in ('replace', 'append'):
|
||||
sync_mode = 'replace'
|
||||
# Per-request sync_mode wins; otherwise use the configured default
|
||||
# (Settings > Playlist sync mode). Default 'replace' keeps today's behavior.
|
||||
from core.sync.playlist_edit import normalize_sync_mode
|
||||
sync_mode = normalize_sync_mode(data.get('sync_mode'),
|
||||
config_manager.get('playlist_sync.mode', 'replace'))
|
||||
|
||||
if not all([playlist_id, playlist_name, tracks_json]):
|
||||
return jsonify({"success": False, "error": "Missing playlist_id, name, or tracks."}), 400
|
||||
|
|
@ -34450,6 +34611,26 @@ def import_staging_suggestions():
|
|||
# WEBSOCKET (SOCKET.IO) EVENT HANDLERS AND BACKGROUND EMITTERS
|
||||
# ================================================================================================
|
||||
|
||||
def _spotify_status_with_availability(spotify_status):
|
||||
"""Augment the spotify status dict with the Spotify-Free availability flags
|
||||
the UI needs: ``metadata_available`` (search picker) and ``free_installed``
|
||||
(Settings source selector — 'Spotify Free' is selectable when the package is
|
||||
installed, since selecting it is the opt-in). Used by BOTH the /status
|
||||
endpoint and the WebSocket status push so they never drift."""
|
||||
out = dict(spotify_status or {})
|
||||
try:
|
||||
out['metadata_available'] = bool(
|
||||
spotify_client and spotify_client.is_spotify_metadata_available())
|
||||
except Exception:
|
||||
out['metadata_available'] = bool(out.get('authenticated'))
|
||||
try:
|
||||
from core.spotify_free_metadata import spotify_free_installed
|
||||
out['free_installed'] = spotify_free_installed()
|
||||
except Exception:
|
||||
out['free_installed'] = False
|
||||
return out
|
||||
|
||||
|
||||
def _build_status_payload():
|
||||
"""Build the same status payload used by GET /status, reading from the cache."""
|
||||
download_mode = config_manager.get('download_source.mode', 'hybrid')
|
||||
|
|
@ -34469,7 +34650,7 @@ def _build_status_payload():
|
|||
|
||||
return {
|
||||
'metadata_source': metadata_status['metadata_source'],
|
||||
'spotify': metadata_status['spotify'],
|
||||
'spotify': _spotify_status_with_availability(metadata_status['spotify']),
|
||||
'media_server': _status_cache.get('media_server', {}),
|
||||
'soulseek': soulseek_data,
|
||||
'active_media_server': config_manager.get_active_media_server(),
|
||||
|
|
@ -34714,12 +34895,24 @@ from core.enrichment.services import (
|
|||
|
||||
|
||||
def _spotify_resume_pre_check():
|
||||
"""Mirror the inline Spotify rate-limit guard from the legacy
|
||||
``/api/spotify-enrichment/resume`` route. Returns
|
||||
``(429, message)`` to short-circuit when banned, ``None`` when ok."""
|
||||
"""Guard the Spotify enrichment worker's resume button against a rate-limit
|
||||
ban. Returns ``(429, message)`` to short-circuit when banned, ``None`` ok.
|
||||
|
||||
Mirrors the worker's own loop: if the opt-in Spotify-Free fallback can
|
||||
serve enrichment, allow resume and let the worker bridge via the no-creds
|
||||
source during the ban. Block only when rate-limited AND nothing can serve
|
||||
(plain auth, no free) — where resuming would just sleep.
|
||||
"""
|
||||
try:
|
||||
if _spotify_rate_limited():
|
||||
return (429, 'Cannot resume while Spotify is rate limited')
|
||||
from core.spotify_free_metadata import should_block_rate_limited_resume
|
||||
try:
|
||||
metadata_available = bool(spotify_client.is_spotify_metadata_available())
|
||||
except Exception as e:
|
||||
logger.debug("spotify free-availability check failed: %s", e)
|
||||
metadata_available = False
|
||||
if should_block_rate_limited_resume(True, metadata_available):
|
||||
return (429, 'Cannot resume while Spotify is rate limited')
|
||||
except Exception as e:
|
||||
logger.debug("spotify rate-limit pre-check failed: %s", e)
|
||||
return None
|
||||
|
|
@ -34961,12 +35154,6 @@ def _emit_tool_progress_loop():
|
|||
socketio.emit('tool:duplicate-cleaner', state_copy)
|
||||
except Exception as e:
|
||||
logger.debug(f"Error emitting duplicate cleaner status: {e}")
|
||||
# Retag
|
||||
try:
|
||||
with retag_lock:
|
||||
socketio.emit('tool:retag', dict(retag_state))
|
||||
except Exception as e:
|
||||
logger.debug(f"Error emitting retag status: {e}")
|
||||
# DB Update
|
||||
try:
|
||||
with db_update_lock:
|
||||
|
|
|
|||
206
webui/index.html
206
webui/index.html
|
|
@ -2201,6 +2201,19 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Link lookup (#775): paste a provider link and resolve
|
||||
it directly on the owning source (Spotify / Apple Music
|
||||
/ MusicBrainz / Deezer) — no fuzzy search. Results render
|
||||
in the dropdown below and reuse the normal download/
|
||||
import flow. Links only: a bare ID is ambiguous. -->
|
||||
<div class="enh-id-lookup">
|
||||
<span class="enh-id-lookup-icon">🔗</span>
|
||||
<input type="text" id="enh-id-input" class="enh-id-input"
|
||||
placeholder="…or paste a Spotify / Apple Music / MusicBrainz / Deezer link"
|
||||
autocomplete="off" spellcheck="false">
|
||||
<button id="enh-id-btn" class="enh-id-btn" type="button">Look up</button>
|
||||
</div>
|
||||
|
||||
<!-- Enhanced Search Dropdown (Overlay Panel) -->
|
||||
<div id="enhanced-dropdown" class="enhanced-dropdown hidden">
|
||||
<div class="enhanced-dropdown-content">
|
||||
|
|
@ -3825,6 +3838,7 @@
|
|||
<label>Primary metadata source:</label>
|
||||
<select id="metadata-fallback-source">
|
||||
<option value="spotify">Spotify</option>
|
||||
<option value="spotify_free">Spotify Free (no credentials)</option>
|
||||
<option value="itunes">iTunes / Apple Music</option>
|
||||
<option value="deezer">Deezer</option>
|
||||
<option value="discogs">Discogs</option>
|
||||
|
|
@ -3832,7 +3846,13 @@
|
|||
</select>
|
||||
</div>
|
||||
<div class="callback-info">
|
||||
<div class="callback-help">Choose the primary source for artist, album, and track metadata. Spotify can only be selected while an active Spotify session exists. Discogs requires a personal token. MusicBrainz is always available but rate-limited to 1 req/sec.</div>
|
||||
<div class="callback-help">Where artist, album, and track metadata comes from:<br>
|
||||
• <strong>Spotify</strong> — official Spotify; connect your account in the Spotify section below.<br>
|
||||
• <strong>Spotify Free</strong> — the same Spotify catalog with no account needed. It's unofficial and best-effort (may break if Spotify changes its site), can't search albums by name (album results come from iTunes/Deezer instead), and can't read your personal library or playlists.<br>
|
||||
• <strong>Deezer</strong> / <strong>iTunes</strong> — free, no account.<br>
|
||||
• <strong>MusicBrainz</strong> — free, but limited to 1 request/sec.<br>
|
||||
• <strong>Discogs</strong> — needs a personal access token.<br><br>
|
||||
Tip: if you pick <strong>Spotify Free</strong> and later connect a real Spotify account, it uses the official account normally and only falls back to free while Spotify is rate-limited — then switches back automatically.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -6112,6 +6132,18 @@
|
|||
<div class="settings-group" data-stg="library">
|
||||
<h3>Playlist Sync Settings</h3>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Sync mode:</label>
|
||||
<select id="playlist-sync-mode" class="form-select">
|
||||
<option value="replace">Replace — recreate the playlist each sync (default)</option>
|
||||
<option value="reconcile">Reconcile — edit in place, keep image & description</option>
|
||||
<option value="append">Append — only add new tracks, never remove</option>
|
||||
</select>
|
||||
<div class="help-text">
|
||||
"Replace" deletes and recreates the server playlist every sync, which wipes its custom image/description. "Reconcile" updates the same playlist in place (adds new tracks, removes ones no longer in the source) so your custom image and description survive. "Append" only ever adds.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="create-backup" checked>
|
||||
|
|
@ -6516,37 +6548,6 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tool-card" id="metadata-updater-card">
|
||||
<div class="tool-card-header">
|
||||
<h4 class="tool-card-title">Metadata Updater</h4>
|
||||
<button class="tool-help-button" data-tool="metadata-updater"
|
||||
title="Learn more about this tool">?</button>
|
||||
</div>
|
||||
<p class="metadata-updater-description tool-card-info">Updates artist photos, genres,
|
||||
and album art from Spotify.</p>
|
||||
<div class="tool-card-controls">
|
||||
<select id="metadata-refresh-interval">
|
||||
<option value="180">6 months</option>
|
||||
<option value="90">3 months</option>
|
||||
<option value="30" selected>1 month</option>
|
||||
<option value="14">2 weeks</option>
|
||||
<option value="7">1 week</option>
|
||||
<option value="0">Full refresh</option>
|
||||
</select>
|
||||
<button id="metadata-update-button">Begin Update</button>
|
||||
</div>
|
||||
<div class="tool-card-progress-section">
|
||||
<p class="progress-phase-label" id="metadata-phase-label">Current Artist: Not
|
||||
running</p>
|
||||
<div class="progress-bar-container">
|
||||
<div class="progress-bar-fill" id="metadata-progress-bar" style="width: 0%;">
|
||||
</div>
|
||||
</div>
|
||||
<p class="progress-details-label" id="metadata-progress-label">0 / 0 artists (0.0%)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tool-card" id="quality-scanner-card">
|
||||
<div class="tool-card-header">
|
||||
<h4 class="tool-card-title">Quality Scanner</h4>
|
||||
|
|
@ -6590,6 +6591,43 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tool-card" id="reconcile-ids-card">
|
||||
<div class="tool-card-header">
|
||||
<h4 class="tool-card-title">Import IDs from File Tags</h4>
|
||||
<button class="tool-help-button" data-tool="reconcile-ids"
|
||||
title="Learn more about this tool">?</button>
|
||||
</div>
|
||||
<p class="tool-card-info">Read provider IDs (Spotify, MusicBrainz, iTunes, Deezer…) 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-header">
|
||||
<h4 class="tool-card-title">Duplicate Cleaner</h4>
|
||||
|
|
@ -6667,6 +6705,37 @@
|
|||
<div class="tools-section">
|
||||
<h3 class="tools-section-title">Metadata & Cache</h3>
|
||||
<div class="tools-grid">
|
||||
<div class="tool-card" id="metadata-updater-card">
|
||||
<div class="tool-card-header">
|
||||
<h4 class="tool-card-title">Metadata Updater</h4>
|
||||
<button class="tool-help-button" data-tool="metadata-updater"
|
||||
title="Learn more about this tool">?</button>
|
||||
</div>
|
||||
<p class="metadata-updater-description tool-card-info">Updates artist photos, genres,
|
||||
and album art from Spotify.</p>
|
||||
<div class="tool-card-controls">
|
||||
<select id="metadata-refresh-interval">
|
||||
<option value="180">6 months</option>
|
||||
<option value="90">3 months</option>
|
||||
<option value="30" selected>1 month</option>
|
||||
<option value="14">2 weeks</option>
|
||||
<option value="7">1 week</option>
|
||||
<option value="0">Full refresh</option>
|
||||
</select>
|
||||
<button id="metadata-update-button">Begin Update</button>
|
||||
</div>
|
||||
<div class="tool-card-progress-section">
|
||||
<p class="progress-phase-label" id="metadata-phase-label">Current Artist: Not
|
||||
running</p>
|
||||
<div class="progress-bar-container">
|
||||
<div class="progress-bar-fill" id="metadata-progress-bar" style="width: 0%;">
|
||||
</div>
|
||||
</div>
|
||||
<p class="progress-details-label" id="metadata-progress-label">0 / 0 artists (0.0%)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="tool-card" id="discovery-pool-card">
|
||||
<div class="tool-card-header">
|
||||
|
|
@ -6707,45 +6776,6 @@
|
|||
<button onclick="openManualLibraryMatchTool()">Open Library Match</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tool-card" id="retag-tool-card">
|
||||
<div class="tool-card-header">
|
||||
<h4 class="tool-card-title">Retag Tool</h4>
|
||||
<button class="tool-help-button" data-tool="retag-tool"
|
||||
title="Learn more about this tool">?</button>
|
||||
</div>
|
||||
<p class="tool-card-info">Fix metadata on previously downloaded albums & singles</p>
|
||||
<div class="tool-card-stats">
|
||||
<div class="stat-item">
|
||||
<span class="stat-item-label">Groups:</span>
|
||||
<span class="stat-item-value" id="retag-stat-groups">0</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-item-label">Tracks:</span>
|
||||
<span class="stat-item-value" id="retag-stat-tracks">0</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-item-label">Artists:</span>
|
||||
<span class="stat-item-value" id="retag-stat-artists">0</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-item-label">Status:</span>
|
||||
<span class="stat-item-value" id="retag-stat-status">Idle</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tool-card-controls">
|
||||
<button id="retag-open-button">Open Retag Tool</button>
|
||||
</div>
|
||||
<div class="tool-card-progress-section">
|
||||
<p class="progress-phase-label" id="retag-phase-label">Ready</p>
|
||||
<div class="progress-bar-container">
|
||||
<div class="progress-bar-fill" id="retag-progress-bar" style="width: 0%;">
|
||||
</div>
|
||||
</div>
|
||||
<p class="progress-details-label" id="retag-progress-label">0 / 0 tracks (0.0%)</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div></div>
|
||||
|
||||
<!-- ── Management ── -->
|
||||
|
|
@ -7788,40 +7818,6 @@
|
|||
|
||||
<!-- Tool Help Modal -->
|
||||
<!-- Retag Tool Modal -->
|
||||
<div class="retag-modal-overlay" id="retag-modal">
|
||||
<div class="retag-modal-container">
|
||||
<div class="retag-modal-header">
|
||||
<div class="retag-modal-header-left">
|
||||
<h2 class="retag-modal-title">Retag Tool</h2>
|
||||
</div>
|
||||
<div class="retag-header-actions">
|
||||
<button class="retag-clear-all-btn" id="retag-clear-all-btn" onclick="clearAllRetagGroups(this)">Clear All</button>
|
||||
<button class="retag-modal-close" onclick="closeRetagModal()">×</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-content">
|
||||
|
|
|
|||
|
|
@ -65,6 +65,14 @@ function _handleRateMonitorUpdate(data) {
|
|||
const grid = document.getElementById('rate-monitor-grid');
|
||||
if (!grid) return;
|
||||
|
||||
// Skip DOM writes while the equalizer is off-screen (you're on another page).
|
||||
// All pages stay mounted, so updating a hidden grid still fires every
|
||||
// MutationObserver on the document — including password-manager extensions
|
||||
// that re-scan the WHOLE DOM on each mutation — for zero visible benefit.
|
||||
// offsetParent is null when an ancestor is display:none. The next update that
|
||||
// arrives while the dashboard is visible renders normally.
|
||||
if (grid.offsetParent === null) return;
|
||||
|
||||
// The dashboard rate monitor uses the equalizer-bar visual — a
|
||||
// vertical-bar VU-meter row that fits any service count without
|
||||
// an orphan grid cell. Detail page / mobile breakpoints keep the
|
||||
|
|
|
|||
|
|
@ -487,7 +487,6 @@ function initializeWebSocket() {
|
|||
socket.on('tool:stream', (data) => updateStreamStatusFromData(data));
|
||||
socket.on('tool:quality-scanner', (data) => updateQualityScanProgressFromData(data));
|
||||
socket.on('tool:duplicate-cleaner', (data) => updateDuplicateCleanProgressFromData(data));
|
||||
socket.on('tool:retag', (data) => updateRetagStatusFromData(data));
|
||||
socket.on('tool:db-update', (data) => updateDbProgressFromData(data));
|
||||
socket.on('tool:metadata', (data) => updateMetadataStatusFromData(data));
|
||||
socket.on('tool:logs', (data) => updateLogsFromData(data));
|
||||
|
|
|
|||
|
|
@ -2451,6 +2451,11 @@ async function startMissingTracksProcess(playlistId) {
|
|||
const forceDownloadCheckbox = document.getElementById(`force-download-all-${playlistId}`);
|
||||
const forceDownloadAll = forceDownloadCheckbox ? forceDownloadCheckbox.checked : false;
|
||||
|
||||
// Issue #797 — per-request "Skip AcoustID verification" toggle. Absent
|
||||
// checkbox (other call sites) → false, so behavior is unchanged there.
|
||||
const skipAcoustidCheckbox = document.getElementById(`skip-acoustid-${playlistId}`);
|
||||
const skipAcoustid = skipAcoustidCheckbox ? skipAcoustidCheckbox.checked : false;
|
||||
|
||||
// Check if playlist folder mode toggle is enabled (only for sync page playlists)
|
||||
const playlistFolderMode = typeof isPlaylistOrganizeEnabled === 'function'
|
||||
? isPlaylistOrganizeEnabled(playlistId)
|
||||
|
|
@ -2495,6 +2500,7 @@ async function startMissingTracksProcess(playlistId) {
|
|||
force_download_all: forceDownloadAll || isWingIt,
|
||||
ignore_manual_matches: forceDownloadAll,
|
||||
wing_it: isWingIt,
|
||||
skip_acoustid: skipAcoustid,
|
||||
};
|
||||
|
||||
// If this is an artist album download, use album name and include full context
|
||||
|
|
@ -4401,12 +4407,15 @@ async function startPlaylistSync(playlistId, syncModeOverride = null) {
|
|||
let syncMode = syncModeOverride;
|
||||
if (!syncMode) {
|
||||
const modeSelect = document.getElementById(`sync-mode-${playlistId}`);
|
||||
syncMode = (modeSelect && modeSelect.value) || 'replace';
|
||||
// Empty value = "use the Settings default" — send nothing so the
|
||||
// backend falls back to playlist_sync.mode (#792). Don't hardcode
|
||||
// 'replace' here or it shadows the global setting.
|
||||
syncMode = (modeSelect && modeSelect.value) || '';
|
||||
}
|
||||
if (syncMode !== 'replace' && syncMode !== 'append') {
|
||||
syncMode = 'replace';
|
||||
if (syncMode && !['replace', 'reconcile', 'append'].includes(syncMode)) {
|
||||
syncMode = '';
|
||||
}
|
||||
console.log(`🚀 [${new Date().toTimeString().split(' ')[0]}] Starting sync for playlist: ${playlistId} (mode: ${syncMode})`);
|
||||
console.log(`🚀 [${new Date().toTimeString().split(' ')[0]}] Starting sync for playlist: ${playlistId} (mode: ${syncMode || 'default(setting)'})`);
|
||||
const playlist = spotifyPlaylists.find(p => p.id === playlistId);
|
||||
if (!playlist) {
|
||||
console.error(`❌ Could not find playlist data for ID: ${playlistId}`);
|
||||
|
|
|
|||
|
|
@ -274,6 +274,9 @@ async function _emPollSelected() {
|
|||
|
||||
function _emStatusInfo(status) {
|
||||
if (!status || !status.enabled) return { cls: 'disabled', label: 'Disabled' };
|
||||
// Rate-limited on the real API but still matching via the no-creds Spotify
|
||||
// Free source — show as running, not stuck (#798 bridge).
|
||||
if (status.using_free) return { cls: 'running', label: 'Running (Spotify Free)' };
|
||||
if (status.rate_limited) return { cls: 'ratelimited', label: 'Rate-limited' };
|
||||
if (status.paused) return { cls: 'paused', label: 'Paused' };
|
||||
if (status.idle) return { cls: 'idle', label: 'Idle' };
|
||||
|
|
@ -709,6 +712,9 @@ function _emUpdateHeaderLive() {
|
|||
cls += ' em-banner--warn';
|
||||
html = '⚙️ This source isn’t configured — add its credentials in Settings. '
|
||||
+ 'Browsing works, but matches and retries won’t run until it’s set up.';
|
||||
} else if (status && status.using_free) {
|
||||
// Real API banned but bridging via the no-creds Spotify Free source.
|
||||
html = '✓ Spotify is rate-limited — matching via Spotify Free until the ban lifts.';
|
||||
} else if (status && status.rate_limited) {
|
||||
cls += ' em-banner--warn';
|
||||
const rl = status.rate_limit || {};
|
||||
|
|
|
|||
|
|
@ -457,6 +457,10 @@ function updateSpotifyEnrichmentStatusFromData(data) {
|
|||
|
||||
const notAuthenticated = data.authenticated === false;
|
||||
const isRateLimited = data.rate_limited === true;
|
||||
// The real API is banned but the worker is still matching via the no-creds
|
||||
// Spotify Free source — treat it as running, not stuck (#798 bridge).
|
||||
const bridgingFree = data.using_free === true;
|
||||
const rateLimitedStuck = isRateLimited && !bridgingFree;
|
||||
const budgetExhausted = data.daily_budget && data.daily_budget.exhausted;
|
||||
|
||||
button.classList.remove('active', 'paused', 'complete', 'no-auth');
|
||||
|
|
@ -464,7 +468,7 @@ function updateSpotifyEnrichmentStatusFromData(data) {
|
|||
button.classList.add('paused');
|
||||
} else if (notAuthenticated) {
|
||||
button.classList.add('no-auth');
|
||||
} else if (isRateLimited || budgetExhausted) {
|
||||
} else if (rateLimitedStuck || budgetExhausted) {
|
||||
button.classList.add('paused');
|
||||
} else if (data.idle) {
|
||||
button.classList.add('complete');
|
||||
|
|
@ -479,7 +483,8 @@ function updateSpotifyEnrichmentStatusFromData(data) {
|
|||
if (tooltipStatus) {
|
||||
if (data.paused) { tooltipStatus.textContent = 'Paused'; }
|
||||
else if (notAuthenticated) { tooltipStatus.textContent = 'Not Authenticated'; }
|
||||
else if (isRateLimited) { tooltipStatus.textContent = 'Rate Limited'; }
|
||||
else if (rateLimitedStuck) { tooltipStatus.textContent = 'Rate Limited'; }
|
||||
else if (bridgingFree) { tooltipStatus.textContent = 'Running (Spotify Free)'; }
|
||||
else if (budgetExhausted) { tooltipStatus.textContent = 'Daily Limit Reached'; }
|
||||
else if (data.idle) { tooltipStatus.textContent = 'Complete'; }
|
||||
else if (data.running) { tooltipStatus.textContent = 'Running'; }
|
||||
|
|
@ -491,10 +496,12 @@ function updateSpotifyEnrichmentStatusFromData(data) {
|
|||
tooltipCurrent.textContent = notAuthenticated ? 'Connect Spotify in Settings to enrich' : 'Click to resume';
|
||||
} else if (notAuthenticated) {
|
||||
tooltipCurrent.textContent = 'Connect Spotify in Settings to enrich';
|
||||
} else if (isRateLimited) {
|
||||
} else if (rateLimitedStuck) {
|
||||
const info = data.rate_limit || {};
|
||||
const remaining = info.remaining_seconds || 0;
|
||||
tooltipCurrent.textContent = remaining > 0 ? `Waiting ${Math.ceil(remaining / 60)}m for rate limit to clear` : 'Waiting for rate limit to clear';
|
||||
} else if (bridgingFree && data.current_item && data.current_item.name) {
|
||||
tooltipCurrent.textContent = `Now: ${data.current_item.name} (via Spotify Free)`;
|
||||
} else if (budgetExhausted) {
|
||||
const resets = data.daily_budget.resets_in_seconds || 0;
|
||||
const hours = Math.floor(resets / 3600);
|
||||
|
|
@ -2725,7 +2732,7 @@ async function loadRepairFindings() {
|
|||
duplicate_tracks: 'Duplicate', incomplete_album: 'Incomplete',
|
||||
path_mismatch: 'Path Mismatch', metadata_gap: 'Missing Metadata',
|
||||
missing_cover_art: 'Missing Art', track_number_mismatch: 'Track Number',
|
||||
missing_lossy_copy: 'No Lossy Copy'
|
||||
missing_lossy_copy: 'No Lossy Copy', library_retag: 'Re-tag'
|
||||
};
|
||||
|
||||
// Finding types that have an automated fix action
|
||||
|
|
@ -2740,6 +2747,7 @@ async function loadRepairFindings() {
|
|||
missing_lossy_copy: 'Convert',
|
||||
acoustid_mismatch: 'Fix',
|
||||
missing_discography_track: 'Add to Wishlist',
|
||||
library_retag: 'Apply Tags',
|
||||
};
|
||||
|
||||
container.innerHTML = items.map(f => {
|
||||
|
|
@ -2905,6 +2913,40 @@ function _renderFindingDetail(f) {
|
|||
if (f.file_path) rows.push(['Full Path', f.file_path, 'path']);
|
||||
return _gridRows(rows) + _renderPlayButton(f);
|
||||
|
||||
case 'library_retag': {
|
||||
const tracks = Array.isArray(d.tracks) ? d.tracks : [];
|
||||
const changed = tracks.filter(t => t.changes && Object.keys(t.changes).length);
|
||||
const meta = [];
|
||||
if (d.source) meta.push(`Source: ${d.source}`);
|
||||
if (d.mode) meta.push(`Mode: ${d.mode}`);
|
||||
if (d.cover_action) meta.push(`Cover: ${d.cover_action}`);
|
||||
let html = '';
|
||||
if (meta.length) {
|
||||
html += `<div class="repair-finding-meta" style="margin-bottom:8px">${_escFinding(meta.join(' · '))}</div>`;
|
||||
}
|
||||
if (!changed.length && d.cover_action) {
|
||||
html += `<div class="repair-finding-desc">Tags already correct — this would refresh cover art only.</div>`;
|
||||
}
|
||||
// Per-track old → new diff (cap the rendered list so huge albums stay sane).
|
||||
changed.slice(0, 40).forEach(t => {
|
||||
const label = t.title || (t.file_path || '').split(/[\\/]/).pop();
|
||||
const rows = Object.entries(t.changes).map(([field, c]) => [
|
||||
field.replace(/_/g, ' '),
|
||||
`${(c.old === '' || c.old == null) ? '∅' : c.old} → ${c.new}`,
|
||||
'highlight',
|
||||
]);
|
||||
html += `<div style="margin:8px 0 2px;font-weight:600">${_escFinding(label)}</div>`;
|
||||
html += _gridRows(rows);
|
||||
});
|
||||
if (changed.length > 40) {
|
||||
html += `<div class="repair-finding-meta" style="margin-top:6px">…and ${changed.length - 40} more track(s)</div>`;
|
||||
}
|
||||
if (Array.isArray(d.unmatched) && d.unmatched.length) {
|
||||
html += `<div class="repair-finding-meta" style="margin-top:8px">Unmatched (left untouched): ${_escFinding(d.unmatched.join(', '))}</div>`;
|
||||
}
|
||||
return html || '<div class="repair-finding-desc">No changes.</div>';
|
||||
}
|
||||
|
||||
case 'acoustid_mismatch': {
|
||||
let html = media + '<div style="margin-bottom:8px">';
|
||||
html += _renderScoreBar(d.fingerprint_score, 'Fingerprint');
|
||||
|
|
|
|||
|
|
@ -3413,6 +3413,27 @@ function closeHelperSearch() {
|
|||
// projects that span multiple commits before shipping. Strip the flag at
|
||||
// release time and add a real `date:` line at the top of the version block.
|
||||
const WHATS_NEW = {
|
||||
'2.6.7': [
|
||||
{ date: 'June 5, 2026 — 2.6.7 release' },
|
||||
{ title: 'Spotify Free — Spotify metadata with no credentials, and a rate-limit bridge (#798)', desc: 'a new "Spotify Free (no credentials)" metadata source. Pick it in Settings → Metadata to get Spotify search + enrichment without connecting a Spotify account (it uses the public web-player data via the optional spotapi package). It also works as a rate-limit bridge for connected users: if your real Spotify auth gets rate-limited, enrichment automatically falls through to the free source instead of stalling, then returns to your real auth once the ban lifts. The worker can be resumed mid-ban, the dashboard now shows "Running (Spotify Free)" instead of looking stuck, and the daily-API budget no longer blocks free work (it only ever capped real-API calls). Opt-in — nothing changes unless you select it.', page: 'settings' },
|
||||
{ title: 'Import IDs from File Tags — recover the provider IDs already in your files', desc: 'a new "Import IDs from File Tags" tool (Tools → Database & Scanning) reads the Spotify / iTunes / MusicBrainz / Deezer / Tidal / AudioDB / Genius / Last.fm IDs that SoulSync (or MusicBrainz Picard) already embedded in your files and fills them into the database. The media-server API never exposes these IDs, so the only way to get them is from the files themselves — and once they\'re in the DB, the enrichment workers skip every entity that already has an ID, saving a large amount of API traffic on an already-tagged library. Gap-fill only: it never overwrites an existing match, and it\'s atomically guarded so it can\'t clobber a match a worker makes at the same moment. New tracks also get this automatically as the final phase of every library scan, so it stays current without re-running the tool.', page: 'dashboard' },
|
||||
{ title: 'Library Re-tag — rewrite your library\'s tags from the source, safely', desc: 'a proper Library Re-tag job replaces the old Retag tool. It matches each file to its source tracklist and rewrites the tags — title, artist, album, track / disc numbers, cover art, and the embedded source IDs — and shows you a per-track old→new diff before anything is written. Standard dry-run pattern (you see exactly what would change and opt in to apply), Light / Full depth settings, and it pulls cover art + metadata from your configured source order.', page: 'dashboard' },
|
||||
{ title: 'Paste a metadata link to open an artist, album, or track (#775)', desc: 'the Search page now accepts a pasted metadata link. Paste a Spotify / iTunes / Deezer / etc. artist, album, or track URL and SoulSync resolves it and opens that exact item instead of running a name search — handy when a name search is ambiguous or you already have the link. Bare IDs are rejected as ambiguous (a link carries the entity type), and a clear not-found hint shows when nothing resolves.', page: 'search' },
|
||||
{ title: 'Mobile: a full responsive pass across the app (#793, #795)', desc: 'a comprehensive small-screen pass. The artist-detail page, enhanced track table, music player and Now Playing modal, sync buttons, discover carousels, the downloads page, the notification panel, and the mini-player all lay out cleanly on phones now, plus scroll-render and password-manager-extension compatibility improvements.' },
|
||||
{ title: 'Playlist sync: new "Reconcile" mode that edits in place (#792)', desc: 'playlist sync gains a "Reconcile" mode alongside Replace and Append. Replace deletes and recreates the server playlist every sync (which wipes its custom image / description); Reconcile updates the same playlist in place — adds new tracks, removes ones no longer in the source — so your custom image and description survive. Choose it per playlist in the sync-mode setting.', page: 'sync' },
|
||||
{ title: 'Manual album match now locks the edition it\'s pinned to (#758)', desc: 'manually matching an album now pins AND locks that exact edition. Previously the auto canonical resolver could drag a manually-matched regular edition back to the deluxe on the next cycle — reporting missing songs or renumbering tracks. The manual choice is now the authority every downstream tool reads (track-number repair, reorganize, missing-tracks), and the auto resolver won\'t override it. A new manual match still wins if you change your mind.', page: 'library' },
|
||||
{ title: 'Write Tags won\'t overwrite a correct file with placeholder data (#800)', desc: 'Write Tags will no longer stamp a correctly-tagged file with placeholder database values like "Various Artists" or "[Unknown Album]". If your file already holds a real value and the database only has a placeholder, the file\'s value is preserved instead of being destroyed. A legitimate value (including a genuine compilation\'s "Various Artists") still writes normally.', page: 'library' },
|
||||
{ title: 'AcoustID stops quarantining correct downloads of non-English artists (#797)', desc: 'AcoustID verification no longer false-quarantines correct downloads from non-English artists. When the fingerprint database returns the artist/title in its original script (e.g. Japanese kanji for Joe Hisaishi) and your metadata is romanized, the title can\'t match across scripts — so a correct file used to get quarantined. When the artist is confirmed across scripts via MusicBrainz aliases, the file is now kept instead of quarantined. A per-request "Skip AcoustID verification" toggle was also added to the download flow.', page: 'downloads' },
|
||||
{ title: 'Fix: wrong artist on the artist-detail page when a source ID was duplicated', desc: 'fixed a class of bugs where the artist-detail page could show the wrong artist, and where one enrichment worker could stamp a single source ID onto several different artists. Artist matching is tightened (a 0.85 confidence gate + a shared uniqueness guard), a one-time startup repair de-duplicates any source IDs shared across multiple artists, and the library view is kept when a source ID is ambiguous instead of jumping to the wrong artist.', page: 'library' },
|
||||
{ title: 'Fix: manual playlist fixes reverted on the next mirrored-sync run (#799)', desc: 'a manual fix on a mirrored playlist no longer reverts to "Wing It" on the next discovery / sync run — the manual match is checked first and its flag is cleared correctly. Also stopped the wide "Server Playlists" sync tab from stretching full-width.', page: 'sync' },
|
||||
{ title: 'Find & Add: manual matches now survive a library rescan (#787)', desc: 'a match made via Find & Add (and via the manual-match tool) now records a durable manual match plus the file path, so it survives a library rescan instead of being lost and re-flagged.' },
|
||||
{ title: 'Fix: streamed tracks played with no sound', desc: 'fixed streamed tracks occasionally playing silently — the browser\'s Web Audio context could be left suspended. It\'s now resumed on the play event across every play path.', page: 'dashboard' },
|
||||
{ title: 'Navidrome: respect the selected music library + survive renames (#789)', desc: 'Navidrome now respects the music library you selected (it previously ignored the selection and imported all libraries), and pins that selection by id rather than name so it survives a library rename.', page: 'settings' },
|
||||
{ title: 'Fix: file / CSV playlists failed to match raw "Artist - Title" titles (#785)', desc: 'file and CSV playlists whose rows are raw "Artist - Title" strings now match correctly — the discovery worker also searches the canonical title form.', page: 'sync' },
|
||||
{ title: 'Fix: torrent client URL without http:// failed to connect (#790)', desc: 'a torrent client URL entered without an http:// or https:// scheme now connects instead of failing the connection probe.', page: 'settings' },
|
||||
{ title: 'Fix: Soulseek album bundle left completed files in the slskd folder (#796)', desc: 'Soulseek album-bundle downloads no longer leave their completed copies behind in the slskd download folder after the bundle finishes.', page: 'downloads' },
|
||||
{ title: 'Cover Art Filler + Library Re-tag honor your configured cover-art sources', desc: 'both the Cover Art Filler and the new Library Re-tag job now pull artwork from your configured cover-art source order instead of a fixed path, so the cover you get matches your source preferences.', page: 'dashboard' },
|
||||
],
|
||||
'2.6.6': [
|
||||
{ date: 'June 3, 2026 — 2.6.6 release' },
|
||||
{ title: 'Fix: qBittorrent 5.2.0+ would not connect (HTTP 204 login)', desc: 'qBittorrent 5.2.0 changed its /api/v2/auth/login endpoint to answer a successful login with HTTP 204 (No Content) instead of the old HTTP 200 + "Ok." body. SoulSync required the literal "Ok." response, so on 5.2.0+ every login failed with "HTTP 204 body=" — the connection probe and all torrent actions were dead even though qBittorrent itself logged a successful login. Login is now accepted on the SID auth cookie and/or a success response (the old "Ok." or the new empty 204), while bad credentials (which qBittorrent reports as HTTP 200 + "Fails.") are still rejected. Covers 5.2.0 / 5.2.1; no more whitelist-bypass workaround needed.', page: 'settings' },
|
||||
|
|
@ -3572,6 +3593,63 @@ const WHATS_NEW = {
|
|||
// Section shape: { title, description, features: [bullet strings],
|
||||
// usage_note?: 'optional hint shown at the bottom' }
|
||||
const VERSION_MODAL_SECTIONS = [
|
||||
{
|
||||
title: "Spotify Free — metadata with no credentials (#798)",
|
||||
description: "a new \"Spotify Free\" metadata source that uses Spotify's public web-player data, so you can get Spotify search + enrichment without connecting an account. For connected users it also bridges rate-limit bans automatically — when your real auth is rate-limited, enrichment keeps running through the free source instead of stalling, then returns to your real auth once the ban lifts.",
|
||||
features: [
|
||||
"pick \"Spotify Free (no credentials)\" in Settings → Metadata to use it without a Spotify account",
|
||||
"automatic rate-limit bridge for connected users — no more enrichment stalling out during a ban",
|
||||
"the worker resumes mid-ban, the dashboard shows \"Running (Spotify Free)\" instead of looking stuck",
|
||||
"the daily-API budget no longer blocks free work — it only ever capped real-API calls",
|
||||
"opt-in; requires the optional spotapi package",
|
||||
],
|
||||
usage_note: "Settings → Metadata → Spotify Free",
|
||||
},
|
||||
{
|
||||
title: "Import IDs from File Tags",
|
||||
description: "your files often already carry the Spotify / MusicBrainz / iTunes / Deezer IDs that SoulSync (or MusicBrainz Picard) embedded when they were tagged — but the media-server scan can't see them. This tool reads them straight from the files into the database, so the enrichment workers can skip those lookups entirely. On an already-tagged library that's a large amount of saved API traffic.",
|
||||
features: [
|
||||
"reads embedded Spotify / iTunes / MusicBrainz / Deezer / Tidal / AudioDB / Genius / Last.fm IDs into the DB",
|
||||
"gap-fill only — never overwrites an existing match, and atomically guarded against races",
|
||||
"new tracks also get it automatically as the final phase of every library scan",
|
||||
"enrichment workers skip any entity that already has an ID, so this directly cuts API calls",
|
||||
],
|
||||
usage_note: "Tools → Database & Scanning → Import IDs from File Tags",
|
||||
},
|
||||
{
|
||||
title: "Library Re-tag",
|
||||
description: "a proper Library Re-tag job (replacing the old Retag tool): it matches each file to its source tracklist and rewrites the tags — title, artist, album, track / disc numbers, cover art, and the embedded source IDs — and shows a per-track old→new diff so you can review before anything is written.",
|
||||
features: [
|
||||
"per-track old→new diff in the finding card before you apply",
|
||||
"standard dry-run pattern — see what would change, then opt in to apply",
|
||||
"Light / Full depth settings; cover art + metadata pulled from your configured source order",
|
||||
"writes the embedded source IDs too, so re-tagged files survive future scans",
|
||||
],
|
||||
usage_note: "Dashboard → Manage Workers → Library Re-tag",
|
||||
},
|
||||
{
|
||||
title: "Paste a metadata link to open it (#775)",
|
||||
description: "the Search page now takes a pasted metadata link. Drop in a Spotify / iTunes / Deezer artist, album, or track URL and SoulSync opens that exact item instead of running a name search — perfect when a name search is ambiguous or you already have the link.",
|
||||
features: [
|
||||
"paste an artist / album / track URL → opens the exact item",
|
||||
"bare IDs are rejected as ambiguous (a link carries the entity type)",
|
||||
"clear not-found hint when nothing resolves",
|
||||
],
|
||||
usage_note: "Search page",
|
||||
},
|
||||
{
|
||||
title: "Recent Fixes & Polish (2.6.7)",
|
||||
description: "a stack of fixes and refinements that shipped alongside the headline features.",
|
||||
features: [
|
||||
"Manual album match now LOCKS the edition it's pinned to (#758) — the auto resolver can't drag it back to the deluxe",
|
||||
"Write Tags won't overwrite a correct file with placeholder data like \"Various Artists\" / \"[Unknown Album]\" (#800)",
|
||||
"AcoustID stops false-quarantining correct downloads of non-English artists (#797)",
|
||||
"full mobile / small-screen responsive pass across the app (#793, #795)",
|
||||
"new \"Reconcile\" playlist sync mode that edits in place and keeps your custom image / description (#792)",
|
||||
"fixes: wrong artist on duplicated source IDs, manual fixes reverting on mirrored sync (#799), Find & Add matches surviving rescans (#787), Navidrome library selection (#789), silent streamed tracks, torrent URL without scheme (#790), file/CSV playlist matching (#785)",
|
||||
],
|
||||
usage_note: "browse the What's New panel for the full 2.6.7 changelog",
|
||||
},
|
||||
{
|
||||
title: "Artist Map, Reimagined",
|
||||
description: "the Discover artist map got a full rework — it now reads like a living constellation of your library instead of a flat blob. Explore one genre island at a time, watch bubbles bloom into place, and open a side panel with everything about the artist under your cursor.",
|
||||
|
|
|
|||
|
|
@ -23,6 +23,11 @@ function initializeMediaPlayer() {
|
|||
audioPlayer.addEventListener('error', onAudioError);
|
||||
audioPlayer.addEventListener('loadstart', onAudioLoadStart);
|
||||
audioPlayer.addEventListener('canplay', onAudioCanPlay);
|
||||
// Universal: once the visualizer routes this element through
|
||||
// npAudioContext, the element is silent while that context is
|
||||
// suspended. The 'play' event fires on EVERY playback start (every
|
||||
// code path, incl. ones that bypass setPlayingState), so resume here.
|
||||
audioPlayer.addEventListener('play', npEnsureAudioContextRunning);
|
||||
|
||||
// Set initial volume — restore the saved level (Spotify-style), else 70%.
|
||||
const _savedVol = npLoadSavedVolume();
|
||||
|
|
@ -2661,6 +2666,20 @@ function getNpAlbumArtUrl() {
|
|||
// WEB AUDIO VISUALIZER
|
||||
// ===============================
|
||||
|
||||
// Once createMediaElementSource() captures the <audio> element, ALL of its
|
||||
// output is routed through this AudioContext — so if the context is suspended
|
||||
// (browsers create it suspended under the autoplay policy, and we init it from
|
||||
// an async play().then callback that's outside the gesture), the track plays
|
||||
// but no sound reaches the speakers. Resuming must happen on every play start,
|
||||
// not only when the visualizer loop runs. Safe to call anytime.
|
||||
function npEnsureAudioContextRunning() {
|
||||
try {
|
||||
if (npAudioContext && npAudioContext.state === 'suspended') {
|
||||
npAudioContext.resume().catch(() => {});
|
||||
}
|
||||
} catch (_) { /* no-op */ }
|
||||
}
|
||||
|
||||
function npInitVisualizer() {
|
||||
if (npVizInitialized || !audioPlayer) return;
|
||||
try {
|
||||
|
|
@ -2676,6 +2695,9 @@ function npInitVisualizer() {
|
|||
npAnalyser.connect(npAudioContext.destination);
|
||||
}
|
||||
npVizInitialized = true;
|
||||
// Freshly created contexts start suspended — resume so the rerouted
|
||||
// element is actually audible (this is the no-sound-on-play bug).
|
||||
npEnsureAudioContextRunning();
|
||||
} catch (e) {
|
||||
console.warn('Web Audio visualizer init failed, using CSS fallback:', e.message);
|
||||
// Mark as CSS fallback
|
||||
|
|
|
|||
|
|
@ -109,7 +109,8 @@
|
|||
}
|
||||
|
||||
#artist-hero-section #artist-detail-image {
|
||||
width: 100% !important
|
||||
width: 100% !important;
|
||||
max-width: none !important;
|
||||
}
|
||||
|
||||
#media-player {
|
||||
|
|
@ -127,6 +128,7 @@
|
|||
width: 100%;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.enh-compact-item.artist-card {
|
||||
|
|
@ -1039,22 +1041,16 @@
|
|||
gap: 16px;
|
||||
}
|
||||
|
||||
.artist-image-container {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
}
|
||||
|
||||
.artist-image,
|
||||
.artist-image-fallback {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
}
|
||||
|
||||
.artist-info h1,
|
||||
.artist-name {
|
||||
font-size: 24px !important;
|
||||
white-space: normal;
|
||||
.artist-hero-section .artist-name {
|
||||
font-size: 1.6em;
|
||||
text-align: center;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.artist-hero-badges {
|
||||
|
|
@ -1076,7 +1072,7 @@
|
|||
.artist-hero-bio {
|
||||
font-size: 12px;
|
||||
-webkit-line-clamp: 3;
|
||||
max-height: 54px;
|
||||
max-height: fit-content;
|
||||
}
|
||||
|
||||
.artist-hero-numbers {
|
||||
|
|
@ -1186,7 +1182,7 @@
|
|||
}
|
||||
|
||||
.discover-card {
|
||||
width: 160px;
|
||||
width: 45%;
|
||||
}
|
||||
|
||||
/* Horizontal scroll tabs */
|
||||
|
|
@ -3079,10 +3075,14 @@
|
|||
|
||||
/* Notification panel */
|
||||
.notif-panel {
|
||||
width: calc(100vw - 24px);
|
||||
max-width: 100%;
|
||||
right: 12px;
|
||||
bottom: 62px;
|
||||
/* JS positions this inline from the bell's rect (right/bottom), which on
|
||||
mobile pushes it off-screen (the bell isn't flush right). Override the
|
||||
inline styles with !important and anchor both sides so it always fits. */
|
||||
left: 12px !important;
|
||||
right: 12px !important;
|
||||
width: auto !important;
|
||||
max-width: none !important;
|
||||
bottom: 62px !important;
|
||||
max-height: 55vh;
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
|
@ -3190,9 +3190,10 @@
|
|||
}
|
||||
|
||||
.notif-panel {
|
||||
right: 8px;
|
||||
width: calc(100vw - 16px);
|
||||
bottom: 54px;
|
||||
left: 8px !important;
|
||||
right: 8px !important;
|
||||
width: auto !important;
|
||||
bottom: 54px !important;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -3310,3 +3311,225 @@
|
|||
padding: 9px 12px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
Mobile fixes (v2) — phone-only, all inside max-width:768px. Desktop untouched.
|
||||
============================================================================ */
|
||||
@media (max-width: 768px) {
|
||||
/* Center the artist hero action buttons + match-status chips on phone
|
||||
(moved here from the base rule so desktop stays left-aligned). */
|
||||
.artist-hero-actions { justify-content: center; }
|
||||
.enhanced-match-status-row { justify-content: center; }
|
||||
|
||||
/* #8 Page-container heroes carry desktop-sized padding/margins that waste
|
||||
space and look off on a phone. Tighten them up. */
|
||||
.tools-maintenance-hero,
|
||||
.watchlist-artist-config-hero,
|
||||
.watchlist-detail-hero,
|
||||
.discover-hero {
|
||||
padding: 14px 14px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
/* #9 Sync page header: title + the three action buttons (Auto-Sync / Library
|
||||
Match / Sync History) don't fit side-by-side. Stack the header and let the
|
||||
buttons wrap so all three are reachable. */
|
||||
.sync-header-row {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 12px;
|
||||
}
|
||||
.sync-header-row > div:last-child {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.sync-header-row .sync-history-btn {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
/* #6 Enhanced-view track table: a 6+ column table can't fit a phone and the
|
||||
panel clips the overflow, so only the first column showed. Drop table
|
||||
layout entirely and make each row a single flex line: play · title (takes
|
||||
the space) · duration · the ⋯ actions button. Secondary columns hidden. */
|
||||
.enhanced-track-table,
|
||||
.enhanced-track-table thead,
|
||||
.enhanced-track-table tbody,
|
||||
.enhanced-track-table tr,
|
||||
.enhanced-track-table td,
|
||||
.enhanced-track-table th {
|
||||
display: block;
|
||||
width: auto;
|
||||
}
|
||||
.enhanced-track-table thead { display: none; }
|
||||
.enhanced-track-table tr {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 6px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
.enhanced-track-table td,
|
||||
.enhanced-track-table th {
|
||||
padding: 0;
|
||||
border: none;
|
||||
white-space: normal;
|
||||
}
|
||||
.enhanced-track-table .col-play {
|
||||
flex: 0 0 auto;
|
||||
width: auto;
|
||||
}
|
||||
.enhanced-track-table .col-title {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
word-break: break-word;
|
||||
font-size: 13px;
|
||||
}
|
||||
.enhanced-track-table .col-duration {
|
||||
flex: 0 0 auto;
|
||||
width: auto;
|
||||
font-size: 11px;
|
||||
opacity: 0.65;
|
||||
}
|
||||
.enhanced-track-table .col-mobile-actions {
|
||||
display: block;
|
||||
flex: 0 0 auto;
|
||||
width: auto;
|
||||
text-align: right;
|
||||
}
|
||||
/* Everything else folds into the ⋯ actions sheet — hide on the row. */
|
||||
.enhanced-track-table .col-num,
|
||||
.enhanced-track-table .col-disc,
|
||||
.enhanced-track-table .col-format,
|
||||
.enhanced-track-table .col-bitrate,
|
||||
.enhanced-track-table .col-bpm,
|
||||
.enhanced-track-table .col-path,
|
||||
.enhanced-track-table .col-match,
|
||||
.enhanced-track-table .col-queue,
|
||||
.enhanced-track-table .col-writetag,
|
||||
.enhanced-track-table .col-delete,
|
||||
.enhanced-track-table .col-report,
|
||||
.enhanced-track-table tr > th:first-child {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
/* #7 Mini media player: on desktop it's a 340px widget pinned bottom-right
|
||||
(right:132px) — that overflows a phone and overlaps the bottom search.
|
||||
Make it a full-width bar that sits just ABOVE the global search bar
|
||||
(which is at bottom:16px, height:38px on mobile). */
|
||||
.media-player {
|
||||
left: 10px;
|
||||
right: 10px;
|
||||
width: auto;
|
||||
bottom: 62px;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
/* ── Now Playing modal — full responsive pass ──
|
||||
Desktop is a two-column (art | controls) layout with heavy 40-48px
|
||||
padding. Existing mobile rules made it full-screen + stacked the body but
|
||||
left the panels' desktop padding and the controls/queue/lyrics untouched. */
|
||||
.np-body {
|
||||
padding: 24px 16px 28px;
|
||||
gap: 20px;
|
||||
}
|
||||
.np-left,
|
||||
.np-right {
|
||||
width: 100%;
|
||||
}
|
||||
/* Album art scales with the screen instead of a fixed 220px. */
|
||||
.np-album-art-container {
|
||||
width: min(220px, 66vw);
|
||||
height: auto;
|
||||
aspect-ratio: 1 / 1;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.np-track-info { text-align: center; }
|
||||
.np-action-buttons { justify-content: center; }
|
||||
.np-util-row { justify-content: center; flex-wrap: wrap; }
|
||||
/* Playback controls: tighten the gap so the row fits a phone. */
|
||||
.np-controls-row { gap: 12px; justify-content: center; }
|
||||
/* Queue + lyrics panels: drop the 40px desktop side padding that crushed
|
||||
content on a narrow screen; give them a bit more vertical room. */
|
||||
.np-queue-panel { padding: 0 16px 16px; }
|
||||
.np-lyrics-panel { padding: 0 16px; }
|
||||
.np-queue-body { max-height: 240px; }
|
||||
.np-lyrics-body { max-height: 300px; }
|
||||
}
|
||||
|
||||
/* When the expanded Now Playing modal is open, hide the floating mini-player —
|
||||
it has a higher z-index (99998) than the modal overlay (10001), so it would
|
||||
otherwise float on top of the modal's content. Both are body-level siblings
|
||||
(#media-player follows #np-modal-overlay), and the overlay drops its .hidden
|
||||
class when open. Not width-scoped — applies on desktop too. */
|
||||
.np-modal-overlay:not(.hidden) ~ #media-player {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
/* Recommended-artist carousel cards → 2-up wrap on mobile. The base rule
|
||||
pins them with flex:0 0 160px, which overrides plain width — so override
|
||||
the flex-basis too for the 45% to take. */
|
||||
.recommended-card--carousel {
|
||||
flex: 0 0 45%;
|
||||
width: 45%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
/* ── Downloads page (.adl-*) ──
|
||||
Had ZERO mobile rules. Desktop is a two-column layout: the main list
|
||||
(.adl-main, flex:1) + a fixed 366px batch panel with a left border that
|
||||
scrolls independently. On a phone that doesn't fit — stack it. */
|
||||
.adl-layout {
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
.adl-batch-panel {
|
||||
width: 100%;
|
||||
flex-shrink: 1;
|
||||
border-left: none;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||
padding: 14px 4px 0;
|
||||
overflow-y: visible; /* grows in the page flow instead of own scroll */
|
||||
}
|
||||
|
||||
/* Header: title + controls sat in one row; stack so the pills get full width. */
|
||||
.adl-header {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 10px;
|
||||
}
|
||||
.adl-title { font-size: 1.3rem; }
|
||||
.adl-controls {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 8px;
|
||||
}
|
||||
/* Filter pills (All / Active / Queued / Completed / Failed) + count +
|
||||
Cancel All / Clear Completed: wrap instead of overflowing off-screen. */
|
||||
.adl-filter-pills {
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
.adl-pill { padding: 6px 12px; }
|
||||
/* Action buttons full-width-friendly so they're tappable. */
|
||||
.adl-cancel-all-btn,
|
||||
.adl-clear-btn { flex: 1 1 auto; }
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
/* Sidebar visualizer is pinned at the (desktop) sidebar's right edge, so on
|
||||
mobile it floats over the page content when the off-canvas sidebar drawer
|
||||
is closed. Show it ONLY while the drawer is open. The .active/.viz-* rules
|
||||
set display with 2-class specificity, so this 3-class + !important selector
|
||||
is needed to beat them; when the drawer IS open it doesn't match, so the
|
||||
visualizer's normal display rules take over again. */
|
||||
.sidebar:not(.mobile-open) ~ .sidebar-visualizer {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1066,18 +1066,24 @@ function explorerFitToView() {
|
|||
});
|
||||
}
|
||||
|
||||
// Scroll wheel zoom (no modifier needed inside viewport)
|
||||
document.addEventListener('wheel', (e) => {
|
||||
// Scroll wheel zoom (no modifier needed inside viewport).
|
||||
// IMPORTANT: attach to the viewport element, NOT document. A non-passive wheel
|
||||
// listener on document disables the browser's compositor (async) scrolling for
|
||||
// the ENTIRE app — every wheel/trackpad scroll then runs through the main thread.
|
||||
// Scoping it to the viewport keeps zoom working while the rest of the app keeps
|
||||
// smooth compositor scrolling.
|
||||
(function attachExplorerWheelZoom() {
|
||||
const viewport = document.getElementById('explorer-viewport');
|
||||
if (!viewport || !viewport.contains(e.target)) return;
|
||||
// Check if we're on the explorer page
|
||||
const page = document.getElementById('playlist-explorer-page');
|
||||
if (!page || !page.classList.contains('active')) return;
|
||||
if (!viewport) return;
|
||||
viewport.addEventListener('wheel', (e) => {
|
||||
const page = document.getElementById('playlist-explorer-page');
|
||||
if (!page || !page.classList.contains('active')) return;
|
||||
|
||||
e.preventDefault();
|
||||
const step = e.deltaY > 0 ? -0.08 : 0.08;
|
||||
explorerZoom(step);
|
||||
}, { passive: false });
|
||||
e.preventDefault();
|
||||
const step = e.deltaY > 0 ? -0.08 : 0.08;
|
||||
explorerZoom(step);
|
||||
}, { passive: false });
|
||||
})();
|
||||
|
||||
// Middle-click / right-click drag to pan
|
||||
document.addEventListener('mousedown', (e) => {
|
||||
|
|
@ -1836,6 +1842,9 @@ async function _serverSelectTrack(trackIndex, mode, newTrackId, el) {
|
|||
source_track_id: srcTrack.source_track_id || '',
|
||||
source_title: srcTrack.name || '',
|
||||
source_artist: srcTrack.artist || '',
|
||||
// Provider of the source track, so the durable manual match
|
||||
// (#787) records the right source. Retrieval is source-agnostic.
|
||||
source: srcTrack.source || _serverEditorState.mirroredPlaylist?.source || '',
|
||||
})
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -271,6 +271,79 @@ function initializeSearchModeToggle() {
|
|||
});
|
||||
}
|
||||
|
||||
// ── Link / ID lookup (#775) ──────────────────────────────────────
|
||||
// Paste a provider link or bare ID; the backend resolves it directly
|
||||
// on the owning source (no fuzzy search) and returns the single hit
|
||||
// plus that source. We adopt the resolved source as the active one so
|
||||
// the existing album re-fetch + download/import flow routes correctly,
|
||||
// then render through the same dropdown path as a normal search.
|
||||
const idInput = document.getElementById('enh-id-input');
|
||||
const idBtn = document.getElementById('enh-id-btn');
|
||||
|
||||
async function submitIdLookup() {
|
||||
if (!idInput) return;
|
||||
const raw = idInput.value.trim();
|
||||
if (!raw) return;
|
||||
|
||||
// Show the controller's loading UI while resolving.
|
||||
if (loadingState) {
|
||||
loadingState.classList.remove('hidden');
|
||||
const loadingText = document.getElementById('enhanced-loading-text');
|
||||
if (loadingText) loadingText.textContent = 'Resolving link…';
|
||||
}
|
||||
if (emptyState) emptyState.classList.add('hidden');
|
||||
if (resultsContainer) resultsContainer.classList.add('hidden');
|
||||
showDropdown();
|
||||
|
||||
let data;
|
||||
try {
|
||||
const resp = await fetch('/api/enhanced-search/by-id', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ query: raw }),
|
||||
});
|
||||
data = await resp.json();
|
||||
} catch (err) {
|
||||
console.error('Link/ID lookup failed:', err);
|
||||
if (loadingState) loadingState.classList.add('hidden');
|
||||
if (typeof showToast === 'function') showToast('Link/ID lookup failed.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (data && data.available && data.source) {
|
||||
// Adopt the resolving source as active, seed its cache with the
|
||||
// single hit, and render via the shared state-driven path.
|
||||
searchController.state.activeSource = data.source;
|
||||
searchController.state.query = raw;
|
||||
searchController.state.sources[data.source] = {
|
||||
db_artists: [],
|
||||
artists: data.artists || [],
|
||||
albums: data.albums || [],
|
||||
tracks: data.tracks || [],
|
||||
};
|
||||
if (typeof searchController.renderSourceRow === 'function') {
|
||||
searchController.renderSourceRow();
|
||||
}
|
||||
_renderFromState(searchController.state);
|
||||
} else {
|
||||
// Not a link, or nothing resolved — surface the backend's hint
|
||||
// via a toast (non-destructive) and show the empty state.
|
||||
if (loadingState) loadingState.classList.add('hidden');
|
||||
if (resultsContainer) resultsContainer.classList.add('hidden');
|
||||
if (emptyState) emptyState.classList.remove('hidden');
|
||||
showDropdown();
|
||||
const msg = (data && data.message) || 'No match for that link.';
|
||||
if (typeof showToast === 'function') showToast(msg, 'warning');
|
||||
}
|
||||
}
|
||||
|
||||
if (idBtn) idBtn.addEventListener('click', submitIdLookup);
|
||||
if (idInput) {
|
||||
idInput.addEventListener('keypress', (e) => {
|
||||
if (e.key === 'Enter') submitIdLookup();
|
||||
});
|
||||
}
|
||||
|
||||
// Close button inside dropdown (mobile)
|
||||
const dropdownCloseBtn = document.getElementById('enhanced-dropdown-close');
|
||||
if (dropdownCloseBtn) {
|
||||
|
|
|
|||
|
|
@ -64,8 +64,14 @@ function syncMetadataSourceSelection(source) {
|
|||
|
||||
function _isMetadataSourceSelectable(source) {
|
||||
if (source === 'spotify') {
|
||||
// Official Spotify needs a connected session.
|
||||
return _lastStatusPayload?.spotify?.authenticated === true;
|
||||
}
|
||||
if (source === 'spotify_free') {
|
||||
// No-creds Spotify only needs the SpotipyFree package installed —
|
||||
// selecting it IS the opt-in, so it must NOT depend on having selected it.
|
||||
return _lastStatusPayload?.spotify?.free_installed === true;
|
||||
}
|
||||
if (source === 'discogs') {
|
||||
const token = document.getElementById('discogs-token');
|
||||
return !!token?.value?.trim();
|
||||
|
|
@ -143,6 +149,25 @@ function handleMetadataSourceChange(event) {
|
|||
}
|
||||
|
||||
let _settingsInitialized = false;
|
||||
// Tell password-manager extensions (Bitwarden / 1Password / LastPass) to ignore
|
||||
// this app's credential inputs. The settings page is full of API-key / token /
|
||||
// secret fields; password managers treat them as login forms and re-scan the
|
||||
// whole (large, constantly-mutating) DOM on every change, which can peg the main
|
||||
// thread for seconds. These attributes make them skip the fields entirely.
|
||||
function _markCredentialFieldsNoAutofill(root) {
|
||||
const scope = root || document;
|
||||
scope.querySelectorAll('input, textarea').forEach((el) => {
|
||||
if (el.dataset.bwignore !== undefined) return; // already tagged
|
||||
el.setAttribute('data-bwignore', ''); // Bitwarden
|
||||
el.setAttribute('data-1p-ignore', ''); // 1Password
|
||||
el.setAttribute('data-lpignore', 'true'); // LastPass
|
||||
if (!el.getAttribute('autocomplete')) el.setAttribute('autocomplete', 'off');
|
||||
});
|
||||
}
|
||||
// Run once on load (inputs exist from page load — all pages are mounted).
|
||||
if (document.readyState !== 'loading') _markCredentialFieldsNoAutofill();
|
||||
else document.addEventListener('DOMContentLoaded', () => _markCredentialFieldsNoAutofill());
|
||||
|
||||
function initializeSettings() {
|
||||
// This function is called when the settings page is loaded.
|
||||
// It attaches event listeners to all interactive elements on the page.
|
||||
|
|
@ -151,6 +176,9 @@ function initializeSettings() {
|
|||
if (_settingsInitialized) return;
|
||||
_settingsInitialized = true;
|
||||
|
||||
// Re-tag in case any inputs were added dynamically since page load.
|
||||
_markCredentialFieldsNoAutofill(document.getElementById('settings-page'));
|
||||
|
||||
// Accent color listeners (live preview + custom picker toggle)
|
||||
initAccentColorListeners();
|
||||
|
||||
|
|
@ -1021,8 +1049,13 @@ async function loadSettingsData() {
|
|||
// Populate Discogs settings
|
||||
document.getElementById('discogs-token').value = settings.discogs?.token || '';
|
||||
|
||||
// Populate Metadata source setting
|
||||
document.getElementById('metadata-fallback-source').value = settings.metadata?.fallback_source || 'deezer';
|
||||
// Populate Metadata source setting. 'Spotify Free' is stored as
|
||||
// fallback_source='spotify' + spotify_free=true (so all downstream
|
||||
// 'spotify' routing is unchanged) — map it back to the dropdown value.
|
||||
const _fbSrc = settings.metadata?.fallback_source || 'deezer';
|
||||
const _metaSel = (_fbSrc === 'spotify' && settings.metadata?.spotify_free === true)
|
||||
? 'spotify_free' : _fbSrc;
|
||||
document.getElementById('metadata-fallback-source').value = _metaSel;
|
||||
|
||||
// Populate Hydrabase settings
|
||||
const hbConfig = settings.hydrabase || {};
|
||||
|
|
@ -1175,6 +1208,8 @@ async function loadSettingsData() {
|
|||
|
||||
// Populate Playlist Sync settings
|
||||
document.getElementById('create-backup').checked = settings.playlist_sync?.create_backup !== false;
|
||||
const _syncModeEl = document.getElementById('playlist-sync-mode');
|
||||
if (_syncModeEl) _syncModeEl.value = settings.playlist_sync?.mode || 'replace';
|
||||
|
||||
// Populate Post-Download Conversion settings
|
||||
document.getElementById('downsample-hires').checked = settings.lossy_copy?.downsample_hires === true;
|
||||
|
|
@ -2731,12 +2766,19 @@ async function saveSettings(quiet = false) {
|
|||
const discogsTokenPresent = !!discogsTokenInput?.value?.trim();
|
||||
let metadataSource = metadataSourceSelect?.value || 'deezer';
|
||||
const spotifySessionActive = _lastStatusPayload?.spotify?.authenticated === true;
|
||||
const spotifyFreeInstalled = _lastStatusPayload?.spotify?.free_installed === true;
|
||||
if (metadataSource === 'spotify' && !spotifySessionActive) {
|
||||
metadataSource = _metadataSourceFallback('spotify');
|
||||
if (metadataSourceSelect) metadataSourceSelect.value = metadataSource;
|
||||
if (!quiet) {
|
||||
showToast('Spotify is disconnected, so the primary metadata source was switched.', 'warning');
|
||||
}
|
||||
} else if (metadataSource === 'spotify_free' && !spotifyFreeInstalled) {
|
||||
metadataSource = _metadataSourceFallback('spotify_free');
|
||||
if (metadataSourceSelect) metadataSourceSelect.value = metadataSource;
|
||||
if (!quiet) {
|
||||
showToast('Spotify Free needs the SpotipyFree package installed.', 'warning');
|
||||
}
|
||||
} else if (metadataSource === 'discogs' && !discogsTokenPresent) {
|
||||
metadataSource = _metadataSourceFallback('discogs');
|
||||
if (metadataSourceSelect) metadataSourceSelect.value = metadataSource;
|
||||
|
|
@ -2818,7 +2860,10 @@ async function saveSettings(quiet = false) {
|
|||
token: document.getElementById('discogs-token').value,
|
||||
},
|
||||
metadata: {
|
||||
fallback_source: metadataSource
|
||||
// 'Spotify Free' is stored as the spotify source + a flag, so all
|
||||
// downstream 'spotify' routing is unchanged.
|
||||
fallback_source: metadataSource === 'spotify_free' ? 'spotify' : metadataSource,
|
||||
spotify_free: metadataSource === 'spotify_free'
|
||||
},
|
||||
hydrabase: {
|
||||
url: document.getElementById('hydrabase-url').value,
|
||||
|
|
@ -2938,7 +2983,8 @@ async function saveSettings(quiet = false) {
|
|||
allow_duplicate_tracks: document.getElementById('allow-duplicate-tracks').checked
|
||||
},
|
||||
playlist_sync: {
|
||||
create_backup: document.getElementById('create-backup').checked
|
||||
create_backup: document.getElementById('create-backup').checked,
|
||||
mode: document.getElementById('playlist-sync-mode')?.value || 'replace'
|
||||
},
|
||||
content_filter: {
|
||||
allow_explicit: document.getElementById('allow-explicit').checked
|
||||
|
|
|
|||
|
|
@ -110,6 +110,10 @@ async function fetchSourceConfiguredMap() {
|
|||
for (const src of SOURCE_ORDER) {
|
||||
if (_ALWAYS_CONFIGURED_SOURCES.has(src)) {
|
||||
map[src] = true;
|
||||
} else if (src === 'spotify') {
|
||||
// Spotify Free: available without credentials when the
|
||||
// opt-in no-creds source is on (metadata_available).
|
||||
map[src] = !!(data[src] && (data[src].configured || data[src].metadata_available));
|
||||
} else {
|
||||
map[src] = !!(data[src] && data[src].configured);
|
||||
}
|
||||
|
|
@ -1265,8 +1269,10 @@ function playlistModalDownloadSyncFooterHtml(playlistId, options = {}) {
|
|||
}
|
||||
|
||||
return `${downloadBtns}
|
||||
<select id="sync-mode-${playlistId}" class="playlist-modal-sync-mode" title="Replace overwrites the server playlist; Append only adds new tracks (preserves user-added)">
|
||||
<option value="replace" selected>Replace</option>
|
||||
<select id="sync-mode-${playlistId}" class="playlist-modal-sync-mode" title="Default uses your Settings > Playlist sync mode. Replace overwrites the server playlist; Reconcile edits it in place (keeps custom image/description); Append only adds new tracks.">
|
||||
<option value="" selected>Sync mode: default</option>
|
||||
<option value="replace">Replace</option>
|
||||
<option value="reconcile">Reconcile (keep image/desc)</option>
|
||||
<option value="append">Append only</option>
|
||||
</select>
|
||||
<button id="sync-btn-${playlistId}" class="playlist-modal-btn playlist-modal-btn-primary" onclick="startPlaylistSync('${playlistId}')" ${isSyncing ? 'disabled' : ''}>${isSyncing ? '⏳ Syncing...' : 'Sync Playlist'}</button>`;
|
||||
|
|
@ -1477,6 +1483,10 @@ async function openDownloadMissingModalForArtistAlbum(virtualPlaylistId, playlis
|
|||
<input type="checkbox" id="force-download-all-${virtualPlaylistId}">
|
||||
<span>Force Download All</span>
|
||||
</label>
|
||||
<label class="force-download-toggle" title="Skip the AcoustID fingerprint check for this request. Useful for non-English artists whose original-language metadata AcoustID can't match against the romanized request (issue #797).">
|
||||
<input type="checkbox" id="skip-acoustid-${virtualPlaylistId}">
|
||||
<span>Skip AcoustID verification</span>
|
||||
</label>
|
||||
${contextType === 'playlist' ? `
|
||||
<label class="force-download-toggle">
|
||||
<input type="checkbox" id="playlist-folder-mode-${virtualPlaylistId}">
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue