merge: pull upstream/main (2.7.4) into feature/best-quality-search-mode

- Keep our v3 ranked-targets quality system (filter_and_rank, QualityTarget)
  in soulseek_client.py, settings.js, database presets, and index.html
- Take upstream removal of standalone quality-scanner code:
  QualityScannerDeps + run_quality_scanner moved to repair job
  (core/repair_jobs/quality_upgrade_scanner.py)
- Take upstream AAC-tier addition in database/music_database.py default profile
- Take upstream removal of /api/quality-scanner/* routes from web_server.py
- Remove test_discovery_quality_scanner.py (deleted upstream)
- 47 upstream commits absorbed (2.7.3 + 2.7.4 including re-identify flow,
  dead-folder cleanup, track-number prefix strip, and more)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
dev 2026-06-19 16:54:51 +02:00
commit b761229a00
93 changed files with 7128 additions and 1860 deletions

View file

@ -9,9 +9,9 @@ on:
workflow_dispatch: workflow_dispatch:
inputs: inputs:
version_tag: version_tag:
description: 'Version tag (e.g. 2.7.2)' description: 'Version tag (e.g. 2.7.3)'
required: true required: true
default: '2.7.2' default: '2.7.3'
jobs: jobs:
build-and-push: build-and-push:

View file

@ -44,7 +44,8 @@
}, },
"metadata_enhancement": { "metadata_enhancement": {
"enabled": true, "enabled": true,
"embed_album_art": true "embed_album_art": true,
"single_to_album": false
}, },
"file_organization": { "file_organization": {
"enabled": true, "enabled": true,

View file

@ -662,7 +662,12 @@ class ConfigManager:
# source whose art is smaller is skipped so the next source is # source whose art is smaller is skipped so the next source is
# tried — stops a low-res Cover Art Archive upload from winning. # tried — stops a low-res Cover Art Archive upload from winning.
# 0 disables the size gate. # 0 disables the size gate.
"min_art_size": 1000 "min_art_size": 1000,
# When a track matches a SINGLE release, look up the parent ALBUM
# that contains it and tag it as that album, so it groups with its
# album-mates and gets the album cover (not the single's). Off by
# default — it's an extra per-import metadata lookup.
"single_to_album": False
}, },
"musicbrainz": { "musicbrainz": {
"embed_tags": True "embed_tags": True
@ -882,6 +887,20 @@ class ConfigManager:
config[keys[-1]] = value config[keys[-1]] = value
self._save_config() self._save_config()
def resolve_secret(self, key: str, posted: Any) -> str:
"""Resolve a secret value coming back from the settings UI.
The UI renders a saved-but-untouched secret as the REDACTED_SENTINEL (shown
masked); empty or that sentinel means "use the stored value", while a real
string is a genuine new secret. A connection-test endpoint should test the
EFFECTIVE secret, not the mask otherwise testing a saved-but-untouched
token sends the sentinel and the source rejects it (#870)."""
if isinstance(posted, str):
posted = posted.strip()
if not posted or posted == self.REDACTED_SENTINEL:
return self.get(key, '') or ''
return posted
def get_spotify_config(self) -> Dict[str, str]: def get_spotify_config(self) -> Dict[str, str]:
return self.get('spotify', {}) return self.get('spotify', {})

View file

@ -660,8 +660,13 @@ class AutoImportWorker:
auto_process = self._config_manager.get('auto_import.auto_process', True) auto_process = self._config_manager.get('auto_import.auto_process', True)
try: try:
# Phase 3: Identify # Phase 3: Identify.
identification = self._identify_folder(candidate) # Re-identify (#889): if the user designated this exact file's release in
# the Re-identify modal, a hint short-circuits the guessing — we match
# straight against the chosen album. No hint → byte-identical to before.
rematch_hint, identification = self._resolve_rematch_hint(candidate)
if identification is None:
identification = self._identify_folder(candidate)
if not identification: if not identification:
self._record_result(candidate, 'needs_identification', 0.0, self._record_result(candidate, 'needs_identification', 0.0,
error_message='Could not identify album from tags, folder name, or fingerprint') error_message='Could not identify album from tags, folder name, or fingerprint')
@ -690,7 +695,10 @@ class AutoImportWorker:
high_conf_matches = [m for m in match_result.get('matches', []) if m['confidence'] >= 0.8] high_conf_matches = [m for m in match_result.get('matches', []) if m['confidence'] >= 0.8]
has_strong_individual_matches = len(high_conf_matches) > 0 has_strong_individual_matches = len(high_conf_matches) > 0
if (confidence >= threshold or has_strong_individual_matches) and auto_process: # A re-identify is an explicit user choice — let it auto-process like a
# strong match (still gated on the global auto_process preference).
if (confidence >= threshold or has_strong_individual_matches
or rematch_hint is not None) and auto_process:
# Phase 5: Auto-process — insert an in-progress row # Phase 5: Auto-process — insert an in-progress row
# so the UI sees the import the moment it starts, # so the UI sees the import the moment it starts,
# then update it with the final status when done. # then update it with the final status when done.
@ -709,6 +717,13 @@ class AutoImportWorker:
confidence = max(confidence, effective_conf) confidence = max(confidence, effective_conf)
if success: if success:
self._bump_stat('auto_processed') self._bump_stat('auto_processed')
# Re-identify (#889): only NOW that the new home exists do we
# consume the hint and (if replace was chosen) delete the old
# row + file — so a failed import never loses the original. Pass
# the landing paths so we never delete a file the re-import landed
# at the SAME place (picking the release it's already in).
if rematch_hint is not None:
self._finalize_rematch_hint(rematch_hint, getattr(candidate, '_reid_final_paths', None))
else: else:
self._bump_stat('failed') self._bump_stat('failed')
@ -1003,6 +1018,75 @@ class AutoImportWorker:
except Exception: except Exception:
return False return False
# ── Re-identify hints (#889) ──
def _resolve_rematch_hint(self, candidate: 'FolderCandidate'):
"""If this staged file carries a user-designated re-identify hint, return
``(hint, identification)`` so matching skips the guessing tiers; otherwise
``(None, None)`` and the caller falls back to normal identification.
Fail-safe: ANY error (no table, DB hiccup) returns ``(None, None)`` so a
re-identify problem can never break ordinary auto-import. Only single-file
candidates are eligible a re-identify always stages exactly one track."""
try:
files = candidate.audio_files or []
if len(files) != 1:
return None, None
from core.imports.rematch_hints import (
build_identification_from_hint,
find_hint_for_file,
quick_file_signature,
)
file_path = files[0]
sig = quick_file_signature(file_path)
conn = self.database._get_connection()
try:
cursor = conn.cursor()
hint = find_hint_for_file(cursor, file_path, sig)
finally:
conn.close()
if hint is None:
return None, None
logger.info("[Auto-Import] Re-identify hint for %s%s '%s' (%s)",
candidate.name, hint.album_type or 'release',
hint.album_name or '?', hint.source)
return hint, build_identification_from_hint(hint)
except Exception as e:
logger.debug("[Auto-Import] rematch-hint lookup skipped: %s", e)
return None, None
def _finalize_rematch_hint(self, hint, new_paths=None) -> None:
"""Post-success: delete the replaced library row + file (if the user chose
replace) and consume the hint so it's single-use. ``new_paths`` are where the
re-import landed passed through so the same-home guard never deletes a file
the import wrote at the old location. Best-effort a cleanup failure is
logged, never raised, since the re-import already succeeded."""
try:
from core.imports.rematch_hints import consume_hint, delete_replaced_track
def _resolve_old(stored):
# The old row's path is a STORED path (Docker/media-server view) — map
# it to a file this process can actually unlink, same as everywhere else.
try:
from core.library.path_resolver import resolve_library_file_path
return resolve_library_file_path(stored, config_manager=getattr(self, '_config_manager', None))
except Exception:
return None
conn = self.database._get_connection()
try:
cursor = conn.cursor()
removed = delete_replaced_track(cursor, hint.replace_track_id,
resolve_fn=_resolve_old, new_paths=new_paths)
consume_hint(cursor, hint.id)
conn.commit()
finally:
conn.close()
if removed:
logger.info("[Auto-Import] Re-identify replaced old track — removed %s", removed)
except Exception as e:
logger.warning("[Auto-Import] rematch-hint finalize failed (import still OK): %s", e)
# ── Identification ── # ── Identification ──
def _identify_folder(self, candidate: FolderCandidate) -> Optional[Dict]: def _identify_folder(self, candidate: FolderCandidate) -> Optional[Dict]:
@ -1431,8 +1515,11 @@ class AutoImportWorker:
def _match_tracks(self, candidate: FolderCandidate, identification: Dict) -> Optional[Dict]: def _match_tracks(self, candidate: FolderCandidate, identification: Dict) -> Optional[Dict]:
"""Match staging files to the identified album's tracklist.""" """Match staging files to the identified album's tracklist."""
# Singles: no album tracklist to match against — the file IS the match # Singles: no album tracklist to match against — the file IS the match.
if candidate.is_single or identification.get('is_single'): # force_album_match (set by a re-identify hint) overrides this: even a lone
# staged file is matched INTO the chosen album, so it inherits the album's
# year / track number / art instead of the bare singles stub (#889).
if not identification.get('force_album_match') and (candidate.is_single or identification.get('is_single')):
conf = identification.get('identification_confidence', 0.7) conf = identification.get('identification_confidence', 0.7)
track_data = { track_data = {
'name': identification.get('track_name', identification.get('album_name', '')), 'name': identification.get('track_name', identification.get('album_name', '')),
@ -1600,6 +1687,7 @@ class AutoImportWorker:
processed = 0 processed = 0
errors = [] errors = []
reid_final_paths = [] # #889: where the pipeline landed each file (same-home guard)
all_matches = list(match_result.get('matches', [])) all_matches = list(match_result.get('matches', []))
# Album total duration — sum of every matched track's duration. # Album total duration — sum of every matched track's duration.
@ -1780,6 +1868,11 @@ class AutoImportWorker:
self._process_callback(context_key, context, file_path) self._process_callback(context_key, context, file_path)
processed += 1 processed += 1
# Capture where the pipeline actually landed the file (#889 same-home
# guard) — the pipeline writes it back into the mutable context.
_landed = context.get('_final_processed_path')
if _landed:
reid_final_paths.append(_landed)
logger.info(f"[Auto-Import] Processed: {track_number}. {track_name}") logger.info(f"[Auto-Import] Processed: {track_number}. {track_name}")
except Exception as e: except Exception as e:
@ -1803,6 +1896,13 @@ class AutoImportWorker:
except Exception as e: except Exception as e:
logger.debug("automation emit failed: %s", e) logger.debug("automation emit failed: %s", e)
# Stash landing paths on the candidate so _finalize_rematch_hint can avoid
# deleting a file the re-import landed at the SAME place (#889).
try:
candidate._reid_final_paths = reid_final_paths
except Exception as e:
logger.debug("could not stash reid final paths: %s", e)
return processed > 0 return processed > 0
# ── Database ── # ── Database ──

View file

@ -171,12 +171,7 @@ ACTIONS: list[dict] = [
{"type": "update_discovery_pool", "label": "Update Discovery", "icon": "compass", {"type": "update_discovery_pool", "label": "Update Discovery", "icon": "compass",
"description": "Refresh discovery pool with new tracks", "available": True}, "description": "Refresh discovery pool with new tracks", "available": True},
{"type": "start_quality_scan", "label": "Run Quality Scan", "icon": "bar-chart", {"type": "start_quality_scan", "label": "Run Quality Scan", "icon": "bar-chart",
"description": "Scan for low-quality audio files", "available": True, "description": "Run the Quality Upgrade Finder (scope is set in Library Maintenance)", "available": True},
"config_fields": [
{"key": "scope", "type": "select", "label": "Scope",
"options": [{"value": "watchlist", "label": "Watchlist Artists"}, {"value": "library", "label": "Full Library"}],
"default": "watchlist"}
]},
{"type": "backup_database", "label": "Backup Database", "icon": "save", {"type": "backup_database", "label": "Backup Database", "icon": "save",
"description": "Create timestamped database backup", "available": True}, "description": "Create timestamped database backup", "available": True},
{"type": "refresh_beatport_cache", "label": "Refresh Beatport Cache", "icon": "music", {"type": "refresh_beatport_cache", "label": "Refresh Beatport Cache", "icon": "music",

View file

@ -123,10 +123,10 @@ class AutomationDeps:
duplicate_cleaner_lock: Any duplicate_cleaner_lock: Any
duplicate_cleaner_executor: Any duplicate_cleaner_executor: Any
run_duplicate_cleaner: Callable[..., Any] run_duplicate_cleaner: Callable[..., Any]
get_quality_scanner_state: Callable[[], dict] # Triggers a "Run Now" of a library-maintenance repair job by id (e.g.
quality_scanner_lock: Any # 'quality_upgrade'). Returns truthy if the job was queued. Replaces the old
quality_scanner_executor: Any # standalone quality-scanner executor/state (the scanner is now a repair job).
run_quality_scanner: Callable[..., Any] run_repair_job_now: Callable[[str], Any]
# --- Download orchestrator + queue accessors --- # --- Download orchestrator + queue accessors ---
download_orchestrator: Any download_orchestrator: Any

View file

@ -1,83 +1,35 @@
"""Automation handler: ``start_quality_scan`` action. """Automation handler: ``start_quality_scan`` action.
Lifted from ``web_server._register_automation_handlers`` (the The quality scanner was redesigned from an auto-acting tool into the
``_auto_start_quality_scan`` closure). Submits the quality scanner ``quality_upgrade`` library-maintenance repair job (findings-based, reviewed
to its executor with the configured scope (default: ``watchlist``) before anything is wishlisted). This action now simply triggers a "Run Now" of
then polls the shared state dict. that job; its progress and findings surface in Library Maintenance. The action
name is kept so existing automation rules keep working.
""" """
from __future__ import annotations from __future__ import annotations
import time
from typing import Any, Dict from typing import Any, Dict
from core.automation.deps import AutomationDeps from core.automation.deps import AutomationDeps
_TIMEOUT_SECONDS = 7200 # 2 hours
_POLL_INTERVAL_SECONDS = 3
_INITIAL_DELAY_SECONDS = 1
def auto_start_quality_scan(config: Dict[str, Any], deps: AutomationDeps) -> Dict[str, Any]: def auto_start_quality_scan(config: Dict[str, Any], deps: AutomationDeps) -> Dict[str, Any]:
automation_id = config.get('_automation_id') automation_id = config.get('_automation_id')
state = deps.get_quality_scanner_state()
if state.get('status') == 'running':
return {'status': 'skipped', 'reason': 'Quality scan already running'}
scope = config.get('scope', 'watchlist') triggered = deps.run_repair_job_now('quality_upgrade')
# Pre-set status before submit so the polling loop doesn't see a if not triggered:
# stale 'finished' from a previous run.
with deps.quality_scanner_lock:
state['status'] = 'running'
deps.quality_scanner_executor.submit(deps.run_quality_scanner, scope, deps.get_current_profile_id())
deps.update_progress(
automation_id, log_line=f'Quality scan started (scope: {scope})', log_type='info',
)
# Monitor progress (max 2 hours).
time.sleep(_INITIAL_DELAY_SECONDS)
poll_start = time.time()
while time.time() - poll_start < _TIMEOUT_SECONDS:
time.sleep(_POLL_INTERVAL_SECONDS)
current_status = state.get('status', 'idle')
if current_status not in ('running',):
break
deps.update_progress( deps.update_progress(
automation_id, automation_id, status='error', phase='Unavailable',
phase=state.get('phase', 'Scanning...'), log_line='Quality Upgrade job could not be triggered (library worker unavailable)',
progress=state.get('progress', 0),
processed=state.get('processed', 0),
total=state.get('total', 0),
)
else:
deps.update_progress(
automation_id, status='error',
phase='Timed out', log_line='Quality scan timed out after 2 hours',
log_type='error', log_type='error',
) )
return {'status': 'error', 'reason': 'Timed out', '_manages_own_progress': True} return {'status': 'error', 'reason': 'library worker unavailable',
'_manages_own_progress': True}
final_status = state.get('status', 'idle')
if final_status == 'error':
err = state.get('error_message', 'Unknown error')
deps.update_progress(
automation_id, status='error', progress=100,
phase='Error', log_line=err, log_type='error',
)
return {'status': 'error', 'reason': err, '_manages_own_progress': True}
issues = state.get('low_quality', 0)
deps.update_progress( deps.update_progress(
automation_id, status='finished', progress=100, automation_id, status='finished', progress=100, phase='Triggered',
phase='Complete', log_line='Quality Upgrade scan queued — findings appear in Library Maintenance',
log_line=f'Quality scan complete — {issues} issues found',
log_type='success', log_type='success',
) )
return { return {'status': 'completed', 'triggered': True, '_manages_own_progress': True}
'status': 'completed', 'scope': scope, '_manages_own_progress': True,
'tracks_scanned': state.get('processed', 0),
'quality_met': state.get('quality_met', 0),
'low_quality': issues,
'matched': state.get('matched', 0),
}

View file

@ -136,7 +136,7 @@ def register_all(deps: AutomationDeps) -> None:
engine.register_action_handler( engine.register_action_handler(
'start_quality_scan', 'start_quality_scan',
lambda config: auto_start_quality_scan(config, deps), lambda config: auto_start_quality_scan(config, deps),
lambda: deps.get_quality_scanner_state().get('status') == 'running', lambda: False, # repair worker dedupes Run-Now requests itself
) )
engine.register_action_handler( engine.register_action_handler(
'backup_database', 'backup_database',

View file

@ -8,7 +8,15 @@ from datetime import datetime, timedelta
from utils.logging_config import get_logger from utils.logging_config import get_logger
from database.music_database import MusicDatabase from database.music_database import MusicDatabase
from core.deezer_client import DeezerClient from core.deezer_client import DeezerClient
from core.worker_utils import accept_artist_match, interruptible_sleep, set_album_api_track_count from core.worker_utils import (
accept_artist_match,
artist_name_matches,
interruptible_sleep,
owned_album_titles,
pick_artist_by_catalog,
release_titles,
set_album_api_track_count,
)
from core.enrichment.manual_match_honoring import honor_stored_match from core.enrichment.manual_match_honoring import honor_stored_match
logger = get_logger("deezer_worker") logger = get_logger("deezer_worker")
@ -401,7 +409,20 @@ class DeezerWorker:
logger.debug(f"Preserving existing Deezer ID for artist '{artist_name}': {existing_id}") logger.debug(f"Preserving existing Deezer ID for artist '{artist_name}': {existing_id}")
return return
result = self.client.search_artist(artist_name) # Multi-candidate search (was single search_artist) so same-name artists
# can be disambiguated: gate by name, then pick the one whose catalog
# overlaps the albums this library owns.
results = self.client.search_artists(artist_name, limit=5)
gated = [a for a in (results or []) if artist_name_matches(artist_name, getattr(a, 'name', ''))]
chosen, _overlap = pick_artist_by_catalog(
gated,
owned_album_titles(self.db, artist_id),
lambda a: release_titles(self.client.get_artist_albums_list(a.id)),
)
# search_artists returns lean Artist objects; fetch the full dict (same
# shape the old search_artist returned) for storage.
result = self.client.get_artist_info(chosen.id) if chosen else None
if result: if result:
result_name = result.get('name', '') result_name = result.get('name', '')
ok, reason = accept_artist_match( ok, reason = accept_artist_match(

View file

@ -1,43 +1,23 @@
"""Background worker for the library quality scanner. """Shared metadata match + result-normalization helpers for quality matching.
`run_quality_scanner(scope, profile_id, deps)` is the function the These were the matching guts of the old auto-acting quality-scanner worker (now
quality-scanner endpoint kicks off in a thread to scan the library removed quality scanning is the ``quality_upgrade`` library-maintenance repair
for low-quality tracks (below the user's configured quality profile) job in ``core/repair_jobs/quality_upgrade.py``). They're kept here as a single
and add provider matches to the wishlist: source of truth and imported by that job:
1. Reset scanner state, load quality profile + minimum acceptable tier. - ``_search_tracks_for_source`` query one metadata source's ``search_tracks``.
2. Load tracks from DB based on scope: - ``_normalize_track_match`` / ``_normalize_track_album`` / ``_normalize_track_artists``
- 'watchlist' tracks for watchlisted artists only. turn a provider track into the wishlist-ready dict (typed Album converters
- other all library tracks. with legacy duck-typed fallback).
3. For each track: - ``_track_name`` / ``_track_artist_names`` / ``_extract_lookup_value`` accessors.
- Stop-request gate (state['status'] != 'running').
- Probe REAL audio quality (bit depth / sample rate / bitrate) and check it
against the user's v3 ranked targets via quality_meets_profile — the SAME
strict core the download import guard uses (fallback ignored). Extension
tier is only a fallback label when the file can't be probed.
- Skip tracks that satisfy a target (quality_met).
- For low-quality tracks: matching_engine search query gen, score
candidates against the configured metadata source priority
(artist + title similarity, album-type bonus), pick best match >=
0.7 confidence.
- On match: add normalized track data to wishlist via
`wishlist_service.add_track_to_wishlist` with
source_type='quality_scanner' and a source_context that captures
original file_path, format tier, bitrate, and match confidence.
4. After all tracks: status='finished', progress=100, activity feed
entry, emit `quality_scan_completed` event for automation engine.
5. On critical exception: status='error', error message captured.
""" """
from __future__ import annotations from __future__ import annotations
import logging import logging
import time
from dataclasses import dataclass
from datetime import datetime
from typing import Any, Callable, Dict, Optional from typing import Any, Callable, Dict, Optional
from core.metadata.registry import get_client_for_source, get_primary_source, get_source_priority from core.metadata.registry import get_client_for_source
from core.metadata.types import Album from core.metadata.types import Album
from core.wishlist.payloads import ensure_wishlist_track_format from core.wishlist.payloads import ensure_wishlist_track_format
@ -63,24 +43,6 @@ _TYPED_ALBUM_CONVERTERS: Dict[str, Callable[[Dict[str, Any]], Album]] = {
} }
@dataclass
class QualityScannerDeps:
"""Bundle of cross-cutting deps the quality scanner needs."""
quality_scanner_state: dict
quality_scanner_lock: Any # threading.Lock
QUALITY_TIERS: dict
matching_engine: Any
automation_engine: Any
get_quality_tier_from_extension: Callable
add_activity_item: Callable
# Probe real audio quality (mutagen-read bit depth / sample rate / bitrate)
# so the scan checks against the SAME ranked-target core as the download
# quality guard — not just the file extension. Injected for testability.
probe_audio_quality: Callable = None
# Resolve a DB-stored (often RELATIVE, e.g. "Artist/Album/Track.flac") path
# to an absolute on-disk file by checking the transfer/download/library
# dirs. Without this the probe opens a relative path and fails.
resolve_library_file_path: Callable = None
def _extract_lookup_value(value: Any, *names: str, default: Any = None) -> Any: def _extract_lookup_value(value: Any, *names: str, default: Any = None) -> Any:
@ -315,400 +277,3 @@ def _search_tracks_for_source(source: str, query: str, limit: int = 5, client: A
except Exception as exc: except Exception as exc:
logger.debug("Could not search %s for %s: %s", source, query, exc) logger.debug("Could not search %s for %s: %s", source, query, exc)
return [] return []
def run_quality_scanner(scope='watchlist', profile_id=1, deps: QualityScannerDeps = None):
"""Main quality scanner worker function"""
from core.wishlist_service import get_wishlist_service
from database.music_database import MusicDatabase
try:
with deps.quality_scanner_lock:
deps.quality_scanner_state["status"] = "running"
deps.quality_scanner_state["phase"] = "Initializing scan..."
deps.quality_scanner_state["progress"] = 0
deps.quality_scanner_state["processed"] = 0
deps.quality_scanner_state["total"] = 0
deps.quality_scanner_state["quality_met"] = 0
deps.quality_scanner_state["low_quality"] = 0
deps.quality_scanner_state["matched"] = 0
deps.quality_scanner_state["results"] = []
deps.quality_scanner_state["error_message"] = ""
logger.info(f"[Quality Scanner] Starting scan with scope: {scope}")
# Get database instance
db = MusicDatabase()
# Load the user's v3 ranked targets — the SAME quality definition the
# download import guard uses. A track is "low quality" when its REAL
# measured quality (bit depth + sample rate + bitrate) satisfies none of
# the targets. Strict: fallback is ignored (it's a download-time
# concession, not a definition of what counts as good enough), so the
# scanner surfaces every genuinely-upgradeable track.
from core.quality.selection import targets_from_profile, quality_meets_profile
quality_profile = db.get_quality_profile()
profile_targets, _fallback_enabled = targets_from_profile(quality_profile)
logger.info(
"[Quality Scanner] Profile targets (strict): %s",
[t.label for t in profile_targets] or "(none — no constraint)",
)
# Get tracks to scan based on scope
with deps.quality_scanner_lock:
deps.quality_scanner_state["phase"] = "Loading tracks from database..."
if scope == 'watchlist':
# Get watchlist artists
watchlist_artists = db.get_watchlist_artists(profile_id=profile_id)
if not watchlist_artists:
with deps.quality_scanner_lock:
deps.quality_scanner_state["status"] = "finished"
deps.quality_scanner_state["phase"] = "No watchlist artists found"
deps.quality_scanner_state["error_message"] = "Please add artists to watchlist first"
logger.warning("[Quality Scanner] No watchlist artists found")
return
# Get artist names from watchlist
artist_names = [artist.artist_name for artist in watchlist_artists]
logger.info(f"[Quality Scanner] Scanning {len(artist_names)} watchlist artists")
# Get all tracks for these artists by name
conn = db._get_connection()
placeholders = ','.join(['?' for _ in artist_names])
tracks_to_scan = conn.execute(
f"SELECT t.id, t.title, t.artist_id, t.album_id, t.file_path, t.bitrate, a.name as artist_name, al.title as album_title "
f"FROM tracks t "
f"JOIN artists a ON t.artist_id = a.id "
f"JOIN albums al ON t.album_id = al.id "
f"WHERE a.name IN ({placeholders}) AND t.file_path IS NOT NULL",
artist_names
).fetchall()
conn.close()
else:
# Scan all library tracks
with deps.quality_scanner_lock:
deps.quality_scanner_state["phase"] = "Loading all library tracks..."
conn = db._get_connection()
tracks_to_scan = conn.execute(
"SELECT t.id, t.title, t.artist_id, t.album_id, t.file_path, t.bitrate, a.name as artist_name, al.title as album_title "
"FROM tracks t "
"JOIN artists a ON t.artist_id = a.id "
"JOIN albums al ON t.album_id = al.id "
"WHERE t.file_path IS NOT NULL"
).fetchall()
conn.close()
total_tracks = len(tracks_to_scan)
logger.info(f"[Quality Scanner] Found {total_tracks} tracks to scan")
with deps.quality_scanner_lock:
deps.quality_scanner_state["total"] = total_tracks
deps.quality_scanner_state["phase"] = f"Scanning {total_tracks} tracks..."
source_priority = get_source_priority(get_primary_source())
if not source_priority:
with deps.quality_scanner_lock:
deps.quality_scanner_state["status"] = "error"
deps.quality_scanner_state["phase"] = "No metadata provider available"
deps.quality_scanner_state["error_message"] = "No metadata provider is available for quality scanning"
logger.info("[Quality Scanner] No metadata provider available")
return
logger.info("[Quality Scanner] Using metadata source priority: %s", source_priority)
wishlist_service = get_wishlist_service()
add_to_wishlist = getattr(wishlist_service, 'add_track_to_wishlist', None)
if add_to_wishlist is None:
add_to_wishlist = getattr(wishlist_service, 'add_spotify_track_to_wishlist', None)
if add_to_wishlist is None:
raise AttributeError("Wishlist service does not expose an add-to-wishlist method")
# Count tracks whose file couldn't be probed — surfaces a systematic
# path/access problem (e.g. the library mount differs in this container).
probe_failed_count = 0
# Scan each track
for idx, track_row in enumerate(tracks_to_scan, 1):
# Check for stop request
if deps.quality_scanner_state.get('status') != 'running':
logger.info(f"[Quality Scanner] Stop requested, halting at track {idx}/{total_tracks}")
break
try:
track_id, title, artist_id, album_id, file_path, bitrate, artist_name, album_title = track_row
# Probe the REAL audio quality (bit depth / sample rate / bitrate)
# and check it against the ranked targets — same core as the
# download guard. The DB stores paths RELATIVE to the library root
# (e.g. "Artist/Album/Track.flac"), so resolve to an absolute
# on-disk path first or mutagen can't open the file.
resolved_path = None
if deps.resolve_library_file_path:
try:
resolved_path = deps.resolve_library_file_path(file_path)
except Exception:
resolved_path = None
if not resolved_path:
try:
from core.imports.paths import docker_resolve_path
resolved_path = docker_resolve_path(file_path) if file_path else file_path
except Exception:
resolved_path = file_path
aq = deps.probe_audio_quality(resolved_path) if deps.probe_audio_quality else None
if aq is not None:
tier_name = aq.label()
else:
# Probe failed — the file couldn't be read at resolved_path.
# Bit-depth/sample-rate can't be assessed without reading the
# file, so this track can't be judged. Count + log it so a
# systematic path problem is visible instead of silently
# passing the whole library.
probe_failed_count += 1
if probe_failed_count <= 5:
logger.warning(
"[Quality Scanner] Could not probe audio quality (file not "
"readable?): db_path=%r resolved=%r", file_path, resolved_path,
)
tier_name = deps.get_quality_tier_from_extension(file_path)[0]
# Update progress
with deps.quality_scanner_lock:
deps.quality_scanner_state["processed"] = idx
deps.quality_scanner_state["progress"] = (idx / total_tracks) * 100
deps.quality_scanner_state["phase"] = f"Scanning: {artist_name} - {title}"
# Check if it meets the profile (strict — satisfies a real target).
if quality_meets_profile(aq, profile_targets):
# Quality met
with deps.quality_scanner_lock:
deps.quality_scanner_state["quality_met"] += 1
continue
# Low quality track found
with deps.quality_scanner_lock:
deps.quality_scanner_state["low_quality"] += 1
logger.info(f"[Quality Scanner] Low quality: {artist_name} - {title} ({tier_name}, {file_path})")
# Attempt to match using the active metadata provider
matched = False
matched_track_data = None
best_source = None
attempted_any_provider = False
try:
# Generate search queries using matching engine
temp_track = type('TempTrack', (), {
'name': title,
'artists': [artist_name],
'album': album_title
})()
search_queries = deps.matching_engine.generate_download_queries(temp_track)
logger.info(f"[Quality Scanner] Generated {len(search_queries)} search queries for {artist_name} - {title}")
# Find best match using confidence scoring
best_match = None
best_confidence = 0.0
min_confidence = 0.7 # Match existing standard
for _query_idx, search_query in enumerate(search_queries):
try:
for source in source_priority:
client = get_client_for_source(source)
if not client or not hasattr(client, 'search_tracks'):
continue
attempted_any_provider = True
provider_matches = _search_tracks_for_source(source, search_query, limit=5, client=client)
time.sleep(0.5) # Rate limit metadata API calls
if not provider_matches:
continue
# Score each result using matching engine
for provider_track in provider_matches:
try:
# Calculate artist confidence
artist_confidence = 0.0
provider_artists = _track_artist_names(provider_track)
if provider_artists:
for result_artist in provider_artists:
artist_sim = deps.matching_engine.similarity_score(
deps.matching_engine.normalize_string(artist_name),
deps.matching_engine.normalize_string(result_artist)
)
artist_confidence = max(artist_confidence, artist_sim)
# Calculate title confidence
title_confidence = deps.matching_engine.similarity_score(
deps.matching_engine.normalize_string(title),
deps.matching_engine.normalize_string(_track_name(provider_track))
)
# Combined confidence (50% artist + 50% title)
combined_confidence = (artist_confidence * 0.5 + title_confidence * 0.5)
# Small bonus for album tracks over singles
_at = _extract_lookup_value(provider_track, 'album_type', default='') or ''
if _at == 'album':
combined_confidence += 0.02
elif _at == 'ep':
combined_confidence += 0.01
candidate_artist = provider_artists[0] if provider_artists else 'Unknown Artist'
candidate_name = _track_name(provider_track)
logger.info(
f"[Quality Scanner] Candidate ({source}): '{candidate_artist}' - "
f"'{candidate_name}' (confidence: {combined_confidence:.3f})"
)
# Update best match if this is better
if combined_confidence > best_confidence and combined_confidence >= min_confidence:
best_confidence = combined_confidence
best_match = provider_track
best_source = source
logger.info(
f"[Quality Scanner] New best match ({source}): {candidate_artist} - "
f"{candidate_name} (confidence: {combined_confidence:.3f})"
)
except Exception as e:
logger.error(f"[Quality Scanner] Error scoring result: {e}")
continue
# If we found a very high confidence match, stop searching this query
if best_confidence >= 0.9:
logger.info(f"[Quality Scanner] High confidence match found ({best_confidence:.3f}), stopping search")
break
except Exception as e:
logger.debug(f"[Quality Scanner] Error searching with query '{search_query}': {e}")
continue
if not attempted_any_provider:
with deps.quality_scanner_lock:
deps.quality_scanner_state["status"] = "error"
deps.quality_scanner_state["phase"] = "No metadata provider available"
deps.quality_scanner_state["error_message"] = "No metadata provider is available for quality scanning"
logger.info("[Quality Scanner] No metadata provider available")
return
# Process best match
if best_match:
matched = True
final_artist = _track_artist_names(best_match)[0] if _track_artist_names(best_match) else 'Unknown Artist'
final_name = _track_name(best_match)
final_source = best_source or 'metadata'
logger.info(
f"[Quality Scanner] Final match ({final_source}): {final_artist} - "
f"{final_name} (confidence: {best_confidence:.3f})"
)
# Build normalized track data for wishlist
matched_track_data = _normalize_track_match(best_match, final_source)
# Add to wishlist
source_context = {
'quality_scanner': True,
'original_file_path': file_path,
'original_format': tier_name,
'original_bitrate': bitrate,
'match_confidence': best_confidence,
'scan_date': datetime.now().isoformat()
}
success = add_to_wishlist(
track_data=matched_track_data,
failure_reason=f"Low quality - {tier_name.replace('_', ' ').title()} format",
source_type='quality_scanner',
source_context=source_context,
profile_id=profile_id
)
if success:
with deps.quality_scanner_lock:
deps.quality_scanner_state["matched"] += 1
logger.info(f"[Quality Scanner] Matched and added to wishlist: {artist_name} - {title}")
else:
logger.error(f"[Quality Scanner] Failed to add to wishlist: {artist_name} - {title}")
else:
logger.warning(
f"[Quality Scanner] No suitable metadata match found "
f"(best confidence: {best_confidence:.3f}, required: {min_confidence:.3f})"
)
except Exception as matching_error:
logger.error(f"[Quality Scanner] Matching error for {artist_name} - {title}: {matching_error}")
# Store result
result_entry = {
'track_id': track_id,
'title': title,
'artist': artist_name,
'album': album_title,
'file_path': file_path,
'current_format': tier_name,
'bitrate': bitrate,
'matched': matched,
'match_id': matched_track_data['id'] if matched_track_data else None,
'provider': best_source if matched else None,
'spotify_id': matched_track_data['id'] if matched_track_data else None,
}
with deps.quality_scanner_lock:
deps.quality_scanner_state["results"].append(result_entry)
if not matched:
logger.warning(f"[Quality Scanner] No metadata match found for: {artist_name} - {title}")
except Exception as track_error:
logger.error(f"[Quality Scanner] Error processing track: {track_error}")
continue
if probe_failed_count:
logger.warning(
"[Quality Scanner] %d/%d tracks could not be probed (file unreadable) — "
"their quality could NOT be verified and they were left unflagged. If this "
"is most of the library, the stored file paths don't resolve to readable "
"files in this container.", probe_failed_count, total_tracks,
)
# Scan complete (don't overwrite if already stopped by user)
with deps.quality_scanner_lock:
was_stopped = deps.quality_scanner_state["status"] != "running"
deps.quality_scanner_state["status"] = "finished"
deps.quality_scanner_state["progress"] = 100
if not was_stopped:
deps.quality_scanner_state["phase"] = "Scan complete"
logger.info(f"[Quality Scanner] Scan {'stopped' if was_stopped else 'complete'}: {deps.quality_scanner_state['processed']} processed, "
f"{deps.quality_scanner_state['low_quality']} low quality, {deps.quality_scanner_state['matched']} matched to metadata providers")
# Add activity
deps.add_activity_item("", "Quality Scan Complete",
f"{deps.quality_scanner_state['matched']} tracks added to wishlist", "Now")
try:
if deps.automation_engine:
deps.automation_engine.emit('quality_scan_completed', {
'quality_met': str(deps.quality_scanner_state.get('quality_met', 0)),
'low_quality': str(deps.quality_scanner_state.get('low_quality', 0)),
'total_scanned': str(deps.quality_scanner_state.get('processed', 0)),
})
except Exception as e:
logger.debug("emit quality_scan_completed failed: %s", e)
except Exception as e:
logger.error(f"[Quality Scanner] Critical error: {e}")
import traceback
traceback.print_exc()
with deps.quality_scanner_lock:
deps.quality_scanner_state["status"] = "error"
deps.quality_scanner_state["error_message"] = str(e)
deps.quality_scanner_state["phase"] = f"Error: {str(e)}"

View file

@ -216,7 +216,22 @@ def build_reset_query(
table = meta['table'] table = meta['table']
ms = match_status_column(service) ms = match_status_column(service)
la = last_attempted_column(service) la = last_attempted_column(service)
set_clause = f"SET {ms} = NULL, {la} = NULL" set_parts = [f"{ms} = NULL", f"{la} = NULL"]
# Also forget the stored source ID so re-matching actually RE-RESOLVES the
# entity. Without this, the worker hits its existing-id short-circuit, sees
# the old (possibly WRONG) id and just re-confirms it — which is why "click
# to rematch" never fixed a mis-matched same-name artist (#868). Tracks keep
# their ids in file tags rather than a column, so only artist/album clear one.
if entity_type in ('artist', 'album'):
try:
from core.source_ids import id_column
id_col = id_column(service, entity_type)
except Exception:
id_col = None
if id_col:
set_parts.append(f"{id_col} = NULL")
set_clause = "SET " + ", ".join(set_parts)
if scope == 'item': if scope == 'item':
if not entity_id: if not entity_id:

View file

@ -0,0 +1,98 @@
"""Canonical album grouping for the SoulSync standalone import.
SoulSync grouped imported tracks into albums by the album NAME string
(``_stable_soulsync_id("artist::album_name")``). That splits one release into
several album rows whenever the name string drifts between imports (case,
punctuation, ``(Deluxe Edition)`` suffixes, source-A-vs-B spelling), and every
downstream tool (Library Re-tag, Cover-Art Filler) then dresses each split row
in its own cover so songs that belong to one album end up with different art
(Sokhi).
This module is the pure, seam-testable heart of "group by canonical id, not
name": when an imported track carries a metadata-source RELEASE id, prefer
matching an existing album row by that id over the fragile name string, so the
SAME release always lands in ONE album row regardless of how its name was typed.
Scope (deliberate): this unifies differently-named imports of the SAME release.
It does NOT merge a track that genuinely matched a SINGLE release (a different
release id) into its parent album that needs single->album resolution upstream
and is a separate change. New imports only; existing rows are left untouched.
Pure SQL-over-a-cursor; no app singletons, so it tests against an in-memory DB.
"""
from __future__ import annotations
from typing import Any, Optional
from utils.logging_config import get_logger
logger = get_logger("imports.album_grouping")
# Album source-id columns this grouping may key on. An allowlist (not arbitrary
# interpolation) — the column name IS spliced into SQL, so it must be a known,
# trusted identifier. Mirrors get_library_source_id_columns()' 'album' values.
ALLOWED_ALBUM_SOURCE_COLS = frozenset({
"spotify_album_id",
"itunes_album_id",
"deezer_id",
"soul_id",
"discogs_id",
"musicbrainz_release_id",
})
def find_existing_soulsync_album_id(
cursor: Any,
*,
name_key_id: str,
artist_id: str,
album_name: str,
album_source_col: Optional[str] = None,
album_source_id: Optional[str] = None,
) -> Optional[str]:
"""Resolve the existing ``soulsync`` album row a track should join, or None
(caller inserts a new row keyed by ``name_key_id``).
Match precedence:
1. ``name_key_id`` the exact prior stable-name-hash id (unchanged
behaviour: a re-import with the identical name hits its own row).
2. ``album_source_col == album_source_id`` CANONICAL grouping: an
existing row already carrying THIS release's source id, so a
differently-named import of the same release unifies instead of
splitting. Only when the column is allow-listed and the id is non-empty.
3. ``(title, artist_id)`` the legacy name match (kept so nothing that
grouped before stops grouping now).
"""
cursor.execute(
"SELECT id FROM albums WHERE id = ? AND server_source = 'soulsync'",
(name_key_id,),
)
row = cursor.fetchone()
if row:
return row[0]
if album_source_col in ALLOWED_ALBUM_SOURCE_COLS and album_source_id:
try:
cursor.execute(
f"SELECT id FROM albums WHERE {album_source_col} = ? "
"AND server_source = 'soulsync' LIMIT 1",
(album_source_id,),
)
row = cursor.fetchone()
if row:
return row[0]
except Exception as exc:
# That source has no dedicated album column on this DB (e.g. Deezer
# doesn't split per-entity id columns) — fall through to the name
# match rather than break the import. Mirrors the guarded source-id
# UPDATE the caller already does on insert.
logger.debug("album source-id lookup skipped (%s): %s", album_source_col, exc)
cursor.execute(
"SELECT id FROM albums WHERE title COLLATE NOCASE = ? AND artist_id = ? "
"AND server_source = 'soulsync' LIMIT 1",
(album_name, artist_id),
)
row = cursor.fetchone()
return row[0] if row else None

View file

@ -156,8 +156,11 @@ def score_file_against_track(
score = 0.0 score = 0.0
# Title similarity (TITLE_WEIGHT). Falls back to filename stem when # Title similarity (TITLE_WEIGHT). Falls back to filename stem when
# the file has no title tag. # the file has no title tag — strip a leading track-number prefix off that
# stem (#890) so "01 - Sun It Rises" scores against "Sun It Rises".
title = file_tags.get('title') or os.path.splitext(os.path.basename(file_path))[0] title = file_tags.get('title') or os.path.splitext(os.path.basename(file_path))[0]
from core.imports.paths import strip_leading_track_number
title = strip_leading_track_number(title)
track_name = track.get('name', '') track_name = track.get('name', '')
score += similarity(title, track_name) * TITLE_WEIGHT score += similarity(title, track_name) * TITLE_WEIGHT

View file

@ -8,6 +8,10 @@ from __future__ import annotations
from typing import Any, Dict, Optional from typing import Any, Dict, Optional
from utils.logging_config import get_logger
logger = get_logger("imports.context")
def _as_dict(value: Any) -> Dict[str, Any]: def _as_dict(value: Any) -> Dict[str, Any]:
return value if isinstance(value, dict) else {} return value if isinstance(value, dict) else {}
@ -179,7 +183,12 @@ def get_import_clean_title(
if not title: if not title:
track_info = get_import_track_info(context) track_info = get_import_track_info(context)
title = _first_value(track_info, "name", "title", default="") title = _first_value(track_info, "name", "title", default="")
return str(title or default) title = str(title or default)
# #890: strip a leading track-number prefix that leaked from a filename stem
# (e.g. "01 - Sun It Rises" → "Sun It Rises") so it matches the canonical title.
# Conservative — clean source titles ("7 Rings" etc.) pass through untouched.
from core.imports.paths import strip_leading_track_number
return strip_leading_track_number(title)
def get_import_clean_album( def get_import_clean_album(
@ -319,7 +328,11 @@ def build_import_album_info(
(album_info or {}).get("track_number") (album_info or {}).get("track_number")
or track_info.get("track_number") or track_info.get("track_number")
or original_search.get("track_number") or original_search.get("track_number")
or 1 # "Track 01" bug: default to 0 (the codebase's "unknown" sentinel,
# same as total_tracks below), NOT 1. A fabricated 1 looks
# authoritative and blocks the pipeline's downstream recovery
# (embedded file tag / resolve chain); 0 lets it fall through.
or 0
) )
disc_number = ( disc_number = (
(album_info or {}).get("disc_number") (album_info or {}).get("disc_number")
@ -436,4 +449,86 @@ def detect_album_info_web(context, artist_context=None):
force_album=True, force_album=True,
) )
return None # Last resort: the track matched a SINGLE with no usable album context —
# look up the parent ALBUM that actually contains it (gated, fail-safe).
return _resolve_single_to_parent_album(context, artist_context)
def _resolve_single_to_parent_album(context, artist_context):
"""A single-matched track -> a promoted album_info for its parent album, or
None. GATED by ``metadata_enhancement.single_to_album`` (default OFF it's a
per-import metadata lookup, so it's opt-in). Fail-safe: any miss/error returns
None so the track stays exactly as it was matched (never worse than today)."""
try:
from core.metadata.common import get_config_manager
if not get_config_manager().get("metadata_enhancement.single_to_album", False):
return None
except Exception:
return None
try:
source = get_import_source(context)
track_info = get_import_track_info(context)
original_search = get_import_original_search(context)
track_title = (track_info.get("name") or original_search.get("title") or "").strip()
artist_name = (extract_artist_name(artist_context)
or get_import_clean_artist(context, default="")).strip()
if not source or not track_title or not artist_name:
return None
artist_id = str(get_import_source_ids(context).get("artist_id") or "")
from core.metadata.album_tracks import (
get_artist_albums_for_source,
get_artist_album_tracks,
)
from core.imports.single_to_album import resolve_single_to_album
def _acc(o, *ks):
for k in ks:
v = o.get(k) if isinstance(o, dict) else getattr(o, k, None)
if v:
return v
return None
def fetch_candidates():
albums = get_artist_albums_for_source(
source, artist_id, artist_name=artist_name,
album_type="album", limit=20) or []
return [{"name": _acc(a, "name", "title"),
"album_type": _acc(a, "album_type") or "album",
"id": _acc(a, "id", "album_id")} for a in albums]
def fetch_tracks(alb):
payload = get_artist_album_tracks(
str(alb.get("id") or ""), artist_name=artist_name,
album_name=alb.get("name") or "") or {}
return [(_acc(t, "title", "name", "track_name") or "")
for t in (payload.get("tracks") or [])]
album = resolve_single_to_album(
track_title,
fetch_album_candidates=fetch_candidates,
fetch_album_tracks=fetch_tracks)
if not album or not album.get("name"):
return None
logger.info("single->album: re-homed '%s' onto parent album '%s'",
track_title, album["name"])
promoted = build_import_album_info(
context,
album_info={
"album_name": album["name"],
"track_number": track_info.get("track_number"),
"disc_number": track_info.get("disc_number"),
"album_image_url": "",
"confidence": 0.5,
},
force_album=True,
)
# build_import_album_info resolves album_name via get_import_clean_album,
# which prefers original_search.album (the SINGLE's name); override it
# with the resolved parent album so grouping + tags use the album.
promoted["album_name"] = album["name"]
return promoted
except Exception as e:
logger.debug("single->album resolution failed: %s", e)
return None

View file

@ -158,6 +158,33 @@ def clean_track_title(track_title: str, artist_name: str) -> str:
return cleaned if cleaned else original return cleaned if cleaned else original
# A leading track-number prefix is EITHER a zero-padded number (01, 04, 099 — no
# real song title starts with one), OR a plain number followed by a real separator
# AND a space ("3 - ", "12. "). Deliberately NOT a bare "number space word", so it
# leaves "7 Rings", "99 Luftballons", "50 Ways to Leave Your Lover" and
# "1-800-273-8255" untouched.
_TRACK_NUM_PREFIX_RE = re.compile(r"^\s*(?:0\d{1,2}[\s._)\-]*|\d{1,3}\s*[._)\-]\s+)(?=\S)")
def strip_leading_track_number(title: str) -> str:
"""Conservatively remove a leading track-number prefix from a track title.
Fixes #890 — files named ``01 - Sun It Rises.flac`` whose stem leaks into the
title as ``01 - Sun It Rises``, which then never matches the canonical
``Sun It Rises`` (false "missing"). Only strips an unambiguous track-number
prefix; a coincidental leading number that's part of the title is preserved, and
it never reduces a title to empty or a bare number."""
s = (title or "").strip()
if not s:
return title or ""
stripped = _TRACK_NUM_PREFIX_RE.sub("", s, count=1).strip()
# Keep the original if stripping left nothing real — empty, a bare number, or
# only punctuation (e.g. "01 - " → "-"). A real title has a letter/digit.
if stripped.isdigit() or not re.search(r"[^\W_]", stripped):
return s
return stripped
def get_album_type_display(raw_type, track_count) -> str: def get_album_type_display(raw_type, track_count) -> str:

View file

@ -693,9 +693,17 @@ def post_process_matched_download(context_key, context, file_path, runtime, meta
# See ``core/imports/track_number.py`` for the resolution # See ``core/imports/track_number.py`` for the resolution
# chain — pure function, unit-tested in isolation, single # chain — pure function, unit-tested in isolation, single
# place to fix the rule. # place to fix the rule.
from core.imports.track_number import resolve_track_number from core.imports.track_number import resolve_track_number, read_embedded_track_number
track_info_for_resolve = context.get('track_info') if isinstance(context, dict) else None track_info_for_resolve = context.get('track_info') if isinstance(context, dict) else None
track_number = resolve_track_number(album_info, track_info_for_resolve, file_path) # "Track 01" bug: a single Deezer track is matched via an endpoint
# that omits track_position, so the context never carried the real
# number. The downloaded file itself does (deemix/source wrote it),
# so read it as a source between metadata and the filename guess.
embedded_track_number = read_embedded_track_number(file_path)
track_number = resolve_track_number(
album_info, track_info_for_resolve, file_path,
embedded_track_number=embedded_track_number,
)
logger.debug( logger.debug(
"Final track_number processing: source=%s album_info=%s resolved=%s", "Final track_number processing: source=%s album_info=%s resolved=%s",
album_info.get('source', 'unknown'), album_info.get('source', 'unknown'),

View file

@ -153,6 +153,83 @@ def get_quarantined_source_keys(quarantine_dir: str) -> set:
return keys return keys
def quarantine_group_key(
expected_artist: Any, expected_track: Any, context: Any = None
) -> Optional[str]:
"""Grouping key for "the same intended download target".
#876: when several sources are downloaded for one wishlist/queue
track they each fail verification and land in quarantine as separate
entries. They are *alternatives for the same song*, so they should
group together and once the user accepts one, the rest are
redundant failed attempts at a song they now own.
The key identifies the *intended* target what SoulSync was trying to
fetch NOT the downloaded file's own tags. That matters: the file's
metadata is frequently *wrong* (that's why it failed acoustid /
integrity), whereas the target is fixed and identical across every
alternative for one song (they're all Soulseek uploads of the *same*
source track), so grouping by it is an exact relationship, not a fuzzy
metadata guess.
Prefers a stable target-track id from the sidecar `context.track_info`
when present isrc, then source id, then uri since those are exact
and constant across siblings. Falls back to the normalized
artist|track name only for legacy/thin sidecars that carry no context.
Keys are kind-prefixed so an id-based key never collides with a
name-based one.
Returns ``None`` when nothing identifies the target (no usable id and
both name fields empty). Callers treat a ``None`` key as "its own
singleton group" — ungroupable entries must never collapse together.
"""
ti = {}
if isinstance(context, dict):
maybe_ti = context.get("track_info")
if isinstance(maybe_ti, dict):
ti = maybe_ti
isrc = str(ti.get("isrc") or "").strip().lower()
if isrc:
return f"isrc:{isrc}"
tid = str(ti.get("id") or "").strip()
if tid:
return f"id:{tid}"
uri = str(ti.get("uri") or "").strip()
if uri:
return f"uri:{uri}"
artist = " ".join(str(expected_artist or "").split()).lower()
track = " ".join(str(expected_track or "").split()).lower()
if not artist and not track:
return None
return f"nm:{artist}|{track}"
def find_quarantine_siblings(quarantine_dir: str, entry_id: str) -> List[str]:
"""Other entry ids that share ``entry_id``'s intended-target group key.
Returns the ids of every *other* quarantine entry whose
`expected_artist`/`expected_track` normalize to the same key as
``entry_id`` (see :func:`quarantine_group_key`). Excludes ``entry_id``
itself. Returns ``[]`` when the entry is missing, has an ungroupable
(``None``) key, or has no siblings. Never raises.
"""
if not entry_id:
return []
entries = list_quarantine_entries(quarantine_dir)
target_key = None
for e in entries:
if e.get("id") == entry_id:
target_key = e.get("group_key")
break
if target_key is None:
return []
return [
e["id"]
for e in entries
if e.get("id") != entry_id and e.get("group_key") == target_key
]
def list_quarantine_entries(quarantine_dir: str) -> List[Dict[str, Any]]: def list_quarantine_entries(quarantine_dir: str) -> List[Dict[str, Any]]:
"""Enumerate quarantined files paired with their sidecars. """Enumerate quarantined files paired with their sidecars.
@ -213,6 +290,11 @@ def list_quarantine_entries(quarantine_dir: str) -> List[Dict[str, Any]]:
"reason": sidecar.get("quarantine_reason", "Unknown reason"), "reason": sidecar.get("quarantine_reason", "Unknown reason"),
"expected_track": sidecar.get("expected_track", ""), "expected_track": sidecar.get("expected_track", ""),
"expected_artist": sidecar.get("expected_artist", ""), "expected_artist": sidecar.get("expected_artist", ""),
"group_key": quarantine_group_key(
sidecar.get("expected_artist", ""),
sidecar.get("expected_track", ""),
ctx,
),
"timestamp": sidecar.get("timestamp", ""), "timestamp": sidecar.get("timestamp", ""),
"size_bytes": size_bytes, "size_bytes": size_bytes,
"has_full_context": isinstance(sidecar.get("context"), dict), "has_full_context": isinstance(sidecar.get("context"), dict),

View file

@ -0,0 +1,92 @@
"""#889 Phase 4/5: apply a re-identify — stage the library file + write the hint.
When the user confirms a release in the Re-identify modal, we:
1. COPY (never move) the track's library file into the auto-import staging folder,
so the original is untouched until the re-import succeeds,
2. fingerprint the staged copy (rename-proof binding), and
3. write a single-use hint carrying the chosen release's IDs (+ ``replace_track_id``
when 'replace original' is ticked).
The auto-import worker then picks the staged file up, finds the hint, and re-imports
it against the user-chosen release (Phase 2). The pieces here are split so the
naming + hint construction are pure/unit-tested and the actual copy is injectable.
"""
from __future__ import annotations
import os
import shutil
from typing import Any, Callable, Dict, Optional
from core.imports.paths import sanitize_filename
from core.imports.rematch_hints import RematchHint, quick_file_signature
def staged_destination(staging_dir: str, real_path: str, library_track_id: Any) -> str:
"""Where the staged copy lands: a single loose file in the staging ROOT (so the
worker treats it as a single-track candidate), named to keep the extension and
be unique + traceable to the track it re-identifies. The filename is cosmetic
matching is driven by the hint, not the name."""
base = os.path.basename(real_path)
stem, ext = os.path.splitext(base)
safe_stem = sanitize_filename(stem).strip() or "track"
name = f"{safe_stem} [reid-{library_track_id}]{ext}"
return os.path.join(staging_dir, name)
def stage_file_for_reidentify(
real_path: str,
staging_dir: str,
library_track_id: Any,
*,
copy_fn: Callable[[str, str], object] = shutil.copy2,
signature_fn: Callable[[str], Optional[str]] = quick_file_signature,
) -> Dict[str, Any]:
"""Copy the library file into staging and fingerprint the copy. Returns
``{staged_path, content_hash}``. Raises ``FileNotFoundError`` if the source is
gone (caller surfaces a clear error rather than writing a dangling hint)."""
if not real_path or not os.path.isfile(real_path):
raise FileNotFoundError(real_path or "(empty path)")
os.makedirs(staging_dir, exist_ok=True)
dest = staged_destination(staging_dir, real_path, library_track_id)
copy_fn(real_path, dest)
return {"staged_path": dest, "content_hash": signature_fn(dest)}
def build_reidentify_hint(
library_track_id: Any,
hint_fields: Dict[str, Any],
staged_path: str,
content_hash: Optional[str],
*,
replace: bool,
) -> RematchHint:
"""Pure: assemble the RematchHint from the resolved release fields + staging
info. ``replace_track_id`` is the library row to delete on success, but only
when 'replace original' was ticked. ``exempt_dedup`` is always True a
re-identify is explicit and must bypass dedup-skip."""
return RematchHint(
staged_path=staged_path,
content_hash=content_hash,
source=hint_fields.get("source") or "",
isrc=hint_fields.get("isrc"),
track_id=hint_fields.get("track_id"),
album_id=hint_fields.get("album_id"),
artist_id=hint_fields.get("artist_id"),
track_title=hint_fields.get("track_title"),
album_name=hint_fields.get("album_name"),
artist_name=hint_fields.get("artist_name"),
album_type=hint_fields.get("album_type"),
track_number=hint_fields.get("track_number"),
disc_number=hint_fields.get("disc_number"),
replace_track_id=(int(library_track_id) if replace and str(library_track_id).isdigit() else
(library_track_id if replace else None)),
exempt_dedup=True,
)
__all__ = [
"staged_destination",
"stage_file_for_reidentify",
"build_reidentify_hint",
]

View file

@ -0,0 +1,334 @@
"""Re-identify hints (#889) — a single-use, user-designated answer to "which
release does this already-imported track belong to".
Flow: the user clicks *Re-identify* on a library track, searches a source, and
picks the exact release (single / EP / album) it should live under. We write a
**hint** here and stage the file for auto-import. The import flow then reads the
hint at the very TOP of matching before any fuzzy tier builds the match from
these exact IDs, and consumes the row. So the original ambiguity that mis-filed
the track (which release?) is gone: the user already answered it.
Two safety properties live in the hint, not the import code:
- ``replace_track_id`` the library row to delete AFTER the re-import lands (so a
re-identify *replaces* rather than *duplicates*). Cleanup is deferred to success
so a failed import can never lose the file.
- ``exempt_dedup`` always set: a re-identify is an explicit user action and must
not be silently dropped by the quality dedup-skip (which would otherwise see the
incoming file as a duplicate of the very row we're replacing).
This module is pure DB mechanics over an injected ``cursor`` (sqlite3-style,
``?`` params) no connection management, no app state so the create / find /
consume seam is unit-tested against an in-memory DB with no live metadata client.
The binding is keyed on the staged path, with ``content_hash`` as a rename-proof
fallback in case the staging watcher normalizes the filename on ingest.
"""
from __future__ import annotations
import os
from dataclasses import dataclass
from typing import Any, Callable, Optional
# Columns in INSERT/SELECT order — single source of truth so the dataclass, the
# write, and the read can't drift apart.
_FIELDS = (
"staged_path",
"content_hash",
"source",
"isrc",
"track_id",
"album_id",
"artist_id",
"track_title",
"album_name",
"artist_name",
"album_type",
"track_number",
"disc_number",
"replace_track_id",
"exempt_dedup",
)
@dataclass
class RematchHint:
"""One user-designated re-identify answer. ``id``/``status`` are set by the DB."""
staged_path: str
source: str
content_hash: Optional[str] = None
isrc: Optional[str] = None
track_id: Optional[str] = None
album_id: Optional[str] = None
artist_id: Optional[str] = None
track_title: Optional[str] = None
album_name: Optional[str] = None
artist_name: Optional[str] = None
album_type: Optional[str] = None
track_number: Optional[int] = None
disc_number: Optional[int] = None
replace_track_id: Optional[int] = None
exempt_dedup: bool = True
id: Optional[int] = None
status: str = "pending"
def _values(self) -> tuple:
return (
self.staged_path,
self.content_hash,
self.source,
self.isrc,
self.track_id,
self.album_id,
self.artist_id,
self.track_title,
self.album_name,
self.artist_name,
self.album_type,
self.track_number,
self.disc_number,
self.replace_track_id,
1 if self.exempt_dedup else 0,
)
def _row_to_hint(row: Any) -> RematchHint:
"""Map a sqlite3.Row (or any mapping/sequence-by-name) to a RematchHint."""
def g(key, default=None):
try:
return row[key]
except (KeyError, IndexError, TypeError):
return default
return RematchHint(
id=g("id"),
staged_path=g("staged_path") or "",
content_hash=g("content_hash"),
source=g("source") or "",
isrc=g("isrc"),
track_id=g("track_id"),
album_id=g("album_id"),
artist_id=g("artist_id"),
track_title=g("track_title"),
album_name=g("album_name"),
artist_name=g("artist_name"),
album_type=g("album_type"),
track_number=g("track_number"),
disc_number=g("disc_number"),
replace_track_id=g("replace_track_id"),
exempt_dedup=bool(g("exempt_dedup", 1)),
status=g("status") or "pending",
)
def create_hint(cursor: Any, hint: RematchHint) -> int:
"""Insert a pending hint; return its new id. Caller owns commit."""
placeholders = ", ".join("?" for _ in _FIELDS)
cursor.execute(
f"INSERT INTO rematch_hints ({', '.join(_FIELDS)}) VALUES ({placeholders})",
hint._values(),
)
new_id = cursor.lastrowid
hint.id = new_id
return new_id
def find_hint_for_file(
cursor: Any,
staged_path: str,
content_hash: Optional[str] = None,
) -> Optional[RematchHint]:
"""Return the newest PENDING hint for a staged file, or ``None``.
Matched by exact ``staged_path`` first; if that misses and a ``content_hash``
is given, fall back to it (covers a staging watcher that renamed the file on
ingest). Only ``status='pending'`` rows are returned, so a consumed hint is
never reused."""
if staged_path:
cursor.execute(
"SELECT * FROM rematch_hints WHERE staged_path = ? AND status = 'pending' "
"ORDER BY id DESC LIMIT 1",
(staged_path,),
)
row = cursor.fetchone()
if row is not None:
return _row_to_hint(row)
# Try by basename too — the watcher may move the file into a different dir.
base = os.path.basename(staged_path)
if base and base != staged_path:
cursor.execute(
"SELECT * FROM rematch_hints WHERE staged_path LIKE ? AND status = 'pending' "
"ORDER BY id DESC LIMIT 1",
("%/" + base,),
)
row = cursor.fetchone()
if row is not None:
return _row_to_hint(row)
if content_hash:
cursor.execute(
"SELECT * FROM rematch_hints WHERE content_hash = ? AND status = 'pending' "
"ORDER BY id DESC LIMIT 1",
(content_hash,),
)
row = cursor.fetchone()
if row is not None:
return _row_to_hint(row)
return None
def consume_hint(cursor: Any, hint_id: int) -> None:
"""Mark a hint consumed (single-use). Caller owns commit."""
cursor.execute(
"UPDATE rematch_hints SET status = 'consumed', consumed_at = CURRENT_TIMESTAMP "
"WHERE id = ?",
(hint_id,),
)
def list_pending_hints(cursor: Any) -> list:
"""All pending hints (newest first) — for a 'pending re-identify' view and
orphan recovery when a staged file never imports."""
cursor.execute("SELECT * FROM rematch_hints WHERE status = 'pending' ORDER BY id DESC")
return [_row_to_hint(r) for r in cursor.fetchall()]
def build_identification_from_hint(hint: RematchHint) -> dict:
"""Turn a hint into the ``identification`` dict the auto-import matcher expects,
so a re-identify SKIPS the guessing tiers entirely and matches straight against
the user-chosen release. Mirrors the shape `_identify_folder` returns (album_id
/ source / track_number drive the album fetch + filetrack match)."""
return {
"album_id": hint.album_id or None,
"album_name": hint.album_name or hint.track_title or "",
"artist_name": hint.artist_name or "",
"artist_id": hint.artist_id or "",
"track_name": hint.track_title or "",
"track_id": hint.track_id or "",
"image_url": "",
"release_date": "",
"track_number": hint.track_number or 1,
"total_tracks": 1,
"source": hint.source,
"method": "rematch_hint",
"identification_confidence": 1.0,
# is_single reflects the CHOSEN release, but force_album_match makes the
# matcher FETCH that release (even for a lone staged file) instead of taking
# the singles fast-path — so the re-imported track gets the real album
# metadata: year, the correct in-album track number, and the album art.
"is_single": (str(hint.album_type or "").lower() == "single"),
"force_album_match": True,
"album_type": hint.album_type,
}
def _canonical(path: Optional[str]) -> str:
"""Canonical form of a path for same-file comparison (symlinks + case + sep)."""
if not path:
return ""
try:
return os.path.normcase(os.path.realpath(path))
except OSError:
return os.path.normcase(os.path.normpath(path))
def delete_replaced_track(
cursor: Any,
replace_track_id: Any,
*,
unlink=os.remove,
resolve_fn: Optional[Callable[[str], Optional[str]]] = None,
new_paths: Optional[list] = None,
) -> Optional[str]:
"""Remove the OLD library row a re-identify replaces, and its file.
Called only AFTER the re-import has landed the track at its new home, so the
original is never lost on failure. Safe by construction:
* **Same-home guard (CRITICAL):** if the re-import landed at the SAME file as the
old one (``new_paths`` the paths the import actually wrote), this is a no-op:
we DON'T delete the row or the file, because that file IS the re-imported track.
This is what stops "re-identify to the release it's already in" from deleting
the file (the import reuses the same row, so deleting it would orphan the file).
* the file is unlinked only if it still exists and **no other track row references
it** (guards against yanking a file a different row legitimately points to).
Returns the path it removed, or ``None`` if there was nothing to do. ``unlink`` is
injectable for tests. ``resolve_fn`` maps the STORED DB path to the file's actual
on-disk location (the stored path may be a Docker/media-server view this process
can't read literally — without it we'd delete the row but orphan the file)."""
if not replace_track_id:
return None
cursor.execute("SELECT file_path FROM tracks WHERE id = ?", (replace_track_id,))
row = cursor.fetchone()
if row is None:
return None
old_path = (row["file_path"] if not isinstance(row, (tuple, list)) else row[0]) or ""
if not old_path:
cursor.execute("DELETE FROM tracks WHERE id = ?", (replace_track_id,))
return None
# Resolve the old stored path to its real on-disk location up front.
real_path = old_path
if resolve_fn is not None:
try:
real_path = resolve_fn(old_path) or old_path
except Exception:
real_path = old_path
# Same-home guard: if the re-import wrote to this very file, do NOTHING — the row
# is the re-imported track's row and the file is its file. Deleting either would
# be data loss (the "picked the same release" bug).
if new_paths:
landed = {_canonical(p) for p in new_paths if p}
if _canonical(real_path) in landed or _canonical(old_path) in landed:
return None
cursor.execute("DELETE FROM tracks WHERE id = ?", (replace_track_id,))
# Only unlink if no surviving row still points at this file (rows store the
# stored path, so compare against the stored path, not the resolved one).
cursor.execute("SELECT 1 FROM tracks WHERE file_path = ? LIMIT 1", (old_path,))
if cursor.fetchone() is not None:
return None
try:
if os.path.exists(real_path): # real_path resolved above
unlink(real_path)
return real_path
except OSError:
pass
return None
def quick_file_signature(path: str, *, chunk: int = 65536) -> Optional[str]:
"""A cheap, rename-proof content fingerprint: size + first/last chunk, hashed.
Audio files are large, so a full hash is wasteful when we only need to re-bind
a hint to *this* file after a possible rename. Size + head + tail is plenty to
distinguish staged files in practice. Returns ``None`` if the file can't be
read (caller falls back to path-only binding)."""
import hashlib
try:
size = os.path.getsize(path)
h = hashlib.sha256()
h.update(str(size).encode())
with open(path, "rb") as f:
h.update(f.read(chunk))
if size > chunk:
f.seek(max(0, size - chunk))
h.update(f.read(chunk))
return h.hexdigest()
except OSError:
return None
__all__ = [
"RematchHint",
"create_hint",
"find_hint_for_file",
"consume_hint",
"list_pending_hints",
"build_identification_from_hint",
"delete_replaced_track",
"quick_file_signature",
]

View file

@ -0,0 +1,247 @@
"""#889 Phase 3: search a metadata source for the releases a track appears on.
The Re-identify modal lets the user search ANY configured source (tabs, defaulting
to the active one) and shows the SAME song across its different collections
single / EP / album so they can pick which release the track should be filed
under. Two steps, deliberately split:
* ``search_release_candidates(source, query)`` lightweight DISPLAY rows from the
normal typed ``search_tracks`` (title, artist, release name, type badge, year,
track count, art, ISRC, track_id). No album_id needed to draw the list.
* ``resolve_hint_fields(source, track_id)`` runs ONCE, on the row the user
picks: ``get_track_details`` yields the album_id / isrc / track#/disc the hint
needs. We don't pay that lookup for every search result, only the chosen one.
Pure normalization + injected client factory, so the search/normalize/resolve seam
is unit-tested with a fake client and no network.
"""
from __future__ import annotations
from typing import Any, Callable, Dict, List, Optional
def _get(obj: Any, key: str, default=None):
"""Read ``key`` from either an object (attr) or a mapping (item)."""
if obj is None:
return default
if isinstance(obj, dict):
return obj.get(key, default)
return getattr(obj, key, default)
def _year(release_date: Any) -> Optional[str]:
s = str(release_date or "").strip()
return s[:4] if len(s) >= 4 and s[:4].isdigit() else None
def infer_release_type(album_type: Any, total_tracks: Any) -> str:
"""Normalize a source's release type to one of album / ep / single / compilation.
Sources disagree: Spotify has no 'EP' EPs come back as ``album_type='single'``
with several tracks; MusicBrainz/Deezer label EPs properly. So when a 'single'
carries more than a handful of tracks, call it an EP for the badge. The actual
filing is unaffected that's driven by the real album_id, not this label."""
t = str(album_type or "").strip().lower()
try:
n = int(total_tracks) if total_tracks is not None else 0
except (TypeError, ValueError):
n = 0
if t in ("compilation", "comp"):
return "compilation"
if t == "ep":
return "ep"
if t == "album":
return "album" # an explicit album stays an album; only 'single' gets promoted to EP
if t == "single":
# 13 tracks → single; 4+ → almost always an EP in practice.
return "ep" if n >= 4 else "single"
# Unknown type: infer purely from track count.
if n >= 7:
return "album"
if n >= 4:
return "ep"
if n >= 1:
return "single"
return t or "album"
def normalize_search_result(result: Any, source: str) -> Optional[Dict[str, Any]]:
"""One typed search Track (or raw dict) → a display row, or ``None`` if it has
no usable id/title. ``album`` is just a name at search time; album_id is
resolved later for the picked row only."""
track_id = _get(result, "id") or _get(result, "track_id")
title = _get(result, "name") or _get(result, "title")
if not track_id or not title:
return None
artists = _get(result, "artists")
if isinstance(artists, list):
artist_name = ", ".join(str(_get(a, "name", a) if not isinstance(a, str) else a) for a in artists)
else:
artist_name = str(artists or _get(result, "artist") or "")
album = _get(result, "album")
album_name = album if isinstance(album, str) else (_get(album, "name") or "")
raw_type = _get(result, "album_type")
total = _get(result, "total_tracks")
ext = _get(result, "external_ids") or {}
isrc = _get(result, "isrc") or (ext.get("isrc") if isinstance(ext, dict) else None)
return {
"source": source,
"track_id": str(track_id),
"track_title": str(title),
"artist_name": artist_name,
"album_name": str(album_name or ""),
"album_type": infer_release_type(raw_type, total),
"raw_album_type": str(raw_type or ""),
"total_tracks": int(total) if isinstance(total, int) else None,
"year": _year(_get(result, "release_date")),
"image_url": _get(result, "image_url") or "",
"isrc": isrc or None,
}
def search_release_candidates(
source: str,
query: str,
*,
limit: int = 25,
client_factory: Optional[Callable[[str], Any]] = None,
) -> List[Dict[str, Any]]:
"""Search ``source`` for tracks matching ``query`` → normalized display rows.
Returns ``[]`` (never raises) when the source has no client or errors the UI
just shows an empty tab. Rows keep duplicate releases; the UI groups them."""
query = (query or "").strip()
if not query:
return []
factory = client_factory or _default_client_factory
try:
client = factory(source)
except Exception:
client = None
if client is None or not hasattr(client, "search_tracks"):
return []
try:
results = client.search_tracks(query, limit=limit)
except TypeError:
results = client.search_tracks(query) # clients with no limit kwarg
except Exception:
return []
rows: List[Dict[str, Any]] = []
for r in results or []:
row = normalize_search_result(r, source)
if row is not None:
rows.append(row)
return rows
def resolve_hint_fields(
source: str,
track_id: str,
*,
client_factory: Optional[Callable[[str], Any]] = None,
) -> Optional[Dict[str, Any]]:
"""Resolve the picked track to the fields a hint needs (album_id critically,
plus isrc / track# / disc# / album name+type). One lookup for one chosen row.
Returns ``None`` if it can't be resolved (caller surfaces an error)."""
factory = client_factory or _default_client_factory
try:
client = factory(source)
except Exception:
client = None
if client is None or not hasattr(client, "get_track_details"):
return None
try:
details = client.get_track_details(track_id)
except Exception:
return None
if not details:
return None
album = _get(details, "album") or {}
album_id = _get(album, "id") if not isinstance(album, str) else None
album_name = _get(album, "name") if not isinstance(album, str) else album
album_type = _get(album, "album_type") or _get(details, "album_type")
total = _get(album, "total_tracks") or _get(details, "total_tracks")
artists = _get(details, "artists") or []
artist_id = None
artist_name = ""
if isinstance(artists, list) and artists:
artist_id = _get(artists[0], "id")
artist_name = ", ".join(str(_get(a, "name", a) if not isinstance(a, str) else a) for a in artists)
ext = _get(details, "external_ids") or {}
isrc = _get(details, "isrc") or (ext.get("isrc") if isinstance(ext, dict) else None)
if not album_id:
return None # without an album_id the import can't fetch the tracklist
return {
"source": source,
"track_id": str(track_id),
"album_id": str(album_id),
"artist_id": str(artist_id) if artist_id else None,
"track_title": _get(details, "name") or _get(details, "title") or "",
"album_name": str(album_name or ""),
"artist_name": artist_name,
"album_type": infer_release_type(album_type, total),
"track_number": _get(details, "track_number"),
"disc_number": _get(details, "disc_number") or 1,
"isrc": isrc or None,
}
def _default_client_factory(source: str):
from core.metadata.registry import get_client_for_source
return get_client_for_source(source)
def available_sources() -> List[Dict[str, Any]]:
"""The source tabs for the modal: every metadata source with a live client,
the primary one flagged ``active`` so the UI selects it by default."""
from core.metadata.registry import (
METADATA_SOURCE_PRIORITY,
get_client_for_source,
get_primary_source,
)
try:
primary = get_primary_source()
except Exception:
primary = None
out: List[Dict[str, Any]] = []
seen = set()
for src in METADATA_SOURCE_PRIORITY:
if src in seen:
continue
seen.add(src)
try:
client = get_client_for_source(src)
except Exception:
client = None
if client is None or not hasattr(client, "search_tracks"):
continue
out.append({
"source": src,
"label": src.replace("_", " ").title(),
"active": src == primary,
})
# Guarantee the primary is selectable + first even if priority ordering missed it.
if primary and not any(s["active"] for s in out):
out.insert(0, {"source": primary, "label": primary.replace("_", " ").title(), "active": True})
return out
__all__ = [
"infer_release_type",
"normalize_search_result",
"search_release_candidates",
"resolve_hint_fields",
"available_sources",
]

View file

@ -569,19 +569,22 @@ def record_soulsync_library_entry(context: Dict[str, Any], artist_context: Dict[
# ── Album row: same insert-or-fill-empty-fields shape ── # ── Album row: same insert-or-fill-empty-fields shape ──
album_source_col = source_columns.get("album") album_source_col = source_columns.get("album")
cursor.execute( # Group by CANONICAL release id when we have one (not just the name
"SELECT id FROM albums WHERE id = ? AND server_source = 'soulsync'", # string), so differently-named imports of the SAME release land in
(album_id,), # one album row instead of splitting — which left the repair jobs
# dressing each split row in its own cover art (Sokhi). Precedence:
# name-hash id -> source release id -> (title, artist). Falls back to
# the legacy name match, so nothing that grouped before stops now.
from core.imports.album_grouping import find_existing_soulsync_album_id
existing_album_id = find_existing_soulsync_album_id(
cursor, name_key_id=album_id, artist_id=artist_id, album_name=album_name,
album_source_col=album_source_col, album_source_id=album_source_id,
) )
row = cursor.fetchone() if existing_album_id is not None:
if not row: album_id = existing_album_id
cursor.execute( row = (album_id,)
"SELECT id FROM albums WHERE title COLLATE NOCASE = ? AND artist_id = ? AND server_source = 'soulsync' LIMIT 1", else:
(album_name, artist_id), row = None
)
row = cursor.fetchone()
if row:
album_id = row[0]
if row: if row:
_fill_empty_columns( _fill_empty_columns(

View file

@ -0,0 +1,124 @@
"""Single -> parent-album resolution.
When a track is matched to a SINGLE release (album_type 'single', the single's
name usually equal to the track title), it carries the single's name + the
single's source album id. The canonical grouping in
[core/imports/album_grouping.py] then files it under a different album row than
its album-mates, and the album-grouped repair jobs dress that row in the
single's art — songs of one album end up with different covers (Sokhi).
This module re-homes such a track onto the ALBUM it actually belongs to, so it
carries the album's name/id and groups with the rest of the album.
Design: the SELECTION is a pure, conservative function (no I/O), and the lookup
loop takes INJECTED fetchers, so both are unit-testable without a live metadata
client. CONSERVATIVE by intent it only re-homes a track when a real
``album``-type release's tracklist *contains that exact track*. It never
promotes a genuine standalone single and never guesses, because a wrong
promotion would mis-home a real single onto an album (the inverse bug).
"""
from __future__ import annotations
import re
from typing import Any, Callable, Dict, List, Optional
_WS = re.compile(r"\s+")
# Trailing version qualifiers that differ between a single and its album cut but
# don't change track identity (kept conservative — only the obvious ones).
_QUALIFIER = re.compile(
r"\s*[\(\[]\s*(album version|single version|radio edit|remaster(ed)?( \d{4})?)\s*[\)\]]\s*$",
re.IGNORECASE,
)
def _norm(s: Any) -> str:
"""Lowercase, strip a trailing '(Album Version)'-style qualifier, collapse
whitespace so 'Song' matches 'Song (Album Version)'."""
t = str(s or "").strip().lower()
t = _QUALIFIER.sub("", t)
return _WS.sub(" ", t).strip()
def _get(obj: Any, *keys: str, default=None):
for k in keys:
if isinstance(obj, dict):
if obj.get(k) is not None:
return obj.get(k)
else:
v = getattr(obj, k, None)
if v is not None:
return v
return default
def select_parent_album(track_title: str, candidate_albums: List[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
"""Pick the parent ALBUM for ``track_title`` from normalized candidates, or
None. Each candidate is ``{name, album_type, tracks: [title, ...], ...}``.
Conservative rules a candidate qualifies ONLY when:
* it is an ``album`` release (never single / ep / compilation), and
* its name is not just the track title (that IS the single), and
* its tracklist contains the track by exact normalized title.
Returns the FIRST qualifying candidate (caller passes them in priority
order, so the result is deterministic).
"""
tgt = _norm(track_title)
if not tgt:
return None
for alb in candidate_albums or []:
if str(_get(alb, "album_type", default="album")).lower() != "album":
continue
if _norm(_get(alb, "name", "title", default="")) == tgt:
continue
tracks = _get(alb, "tracks", default=[]) or []
if any(_norm(t) == tgt for t in tracks):
return alb
return None
def resolve_single_to_album(
track_title: str,
*,
fetch_album_candidates: Callable[[], List[Dict[str, Any]]],
fetch_album_tracks: Callable[[Dict[str, Any]], List[str]],
max_albums: int = 8,
) -> Optional[Dict[str, Any]]:
"""Find the parent album for a single-matched track. I/O is INJECTED so this
is testable without a live client:
* ``fetch_album_candidates()`` -> the artist's ALBUM-type releases (dicts
with name/album_type/id/source), in priority order.
* ``fetch_album_tracks(album)`` -> that album's track titles.
Probes at most ``max_albums`` albums, lazily (stops at the first that
contains the track). Fail-safe: any error / no confident match -> None
(the track stays as it was matched). Returns the normalized winning album
``{name, album_type, album_id, source, tracks}`` or None.
"""
if not _norm(track_title):
return None
try:
albums = fetch_album_candidates() or []
except Exception:
return None
probed = 0
for alb in albums:
if str(_get(alb, "album_type", default="album")).lower() != "album":
continue
if probed >= max_albums:
break
probed += 1
try:
tracks = fetch_album_tracks(alb) or []
except Exception:
continue
normalized = {
"name": _get(alb, "name", "title", default=""),
"album_type": "album",
"album_id": _get(alb, "id", "album_id"),
"source": _get(alb, "source"),
"tracks": list(tracks),
}
if select_parent_album(track_title, [normalized]):
return normalized
return None

View file

@ -65,17 +65,64 @@ def _coerce_spotify_data(track_info: Any) -> dict:
return {} return {}
def read_embedded_track_number(file_path: str) -> Optional[int]:
"""Read the track position from a downloaded audio file's own tags.
Streaming sources (Deezer/deemix, Qobuz, Tidal) and most Soulseek
uploads write the correct album position into the file itself. That
tag is authoritative for the *source's* idea of the track's place on
its album more reliable than a filename guess so the resolver
consults it before falling back to the filename / default-1 floor.
Issue #874-adjacent / "Track 01" bug: a single Deezer track is matched
via Deezer's ``/search/track`` endpoint, which omits ``track_position``
(core/deezer_client.py), so the metadata context never carried the
real number but the downloaded file *does* (deemix wrote it). This
recovers it with no network call.
Returns a positive int, or None when the file has no usable
tracknumber tag / can't be read. Never raises. Handles the common
``"2/15"`` (number/total) form by taking the leading number.
"""
if not file_path:
return None
try:
from mutagen import File as MutagenFile
audio = MutagenFile(file_path, easy=True)
if audio is None:
return None
raw = audio.get('tracknumber')
if isinstance(raw, list):
raw = raw[0] if raw else None
if raw is None:
return None
# "2/15" -> "2"; bare "2" -> "2".
text = str(raw).split('/', 1)[0].strip()
return _coerce_positive(text)
except Exception:
return None
def resolve_track_number( def resolve_track_number(
album_info: Any, album_info: Any,
track_info: Any, track_info: Any,
file_path: str, file_path: str,
embedded_track_number: Any = None,
) -> Optional[int]: ) -> Optional[int]:
"""Walk the resolution chain and return the first valid positive """Walk the resolution chain and return the first valid positive
int found, or None when every source is missing / unusable. int found, or None when every source is missing / unusable.
Caller is responsible for the final default-1 floor leaving Order: album_info -> track_info -> nested spotify_data -> filename ->
that out of this function so tests can pin "everything missing ``embedded_track_number`` (the source-written file tag, when the caller
supplies it). Caller is responsible for the final default-1 floor
leaving that out of this function so tests can pin "everything missing
returns None" separate from the floor behaviour. returns None" separate from the floor behaviour.
``embedded_track_number`` is passed in (not read here) so this stays a
pure function the file I/O lives in :func:`read_embedded_track_number`.
It is consulted **last**, only when every other source came up empty, so
it can never override a value the pre-fix resolver already produced it
only fills the gap that would otherwise hit the default-1 floor.
""" """
album_info = album_info if isinstance(album_info, dict) else {} album_info = album_info if isinstance(album_info, dict) else {}
track_info = track_info if isinstance(track_info, dict) else {} track_info = track_info if isinstance(track_info, dict) else {}
@ -96,10 +143,19 @@ def resolve_track_number(
# default-1 floor is the single source of that fallback — # default-1 floor is the single source of that fallback —
# otherwise this resolver would silently fill 1 and the # otherwise this resolver would silently fill 1 and the
# downstream floor logic would have no effect. # downstream floor logic would have no effect.
if not file_path: if file_path:
return None try:
try: from_filename = extract_explicit_track_number(file_path)
from_filename = extract_explicit_track_number(file_path) except Exception:
except Exception: from_filename = None
from_filename = None ff = _coerce_positive(from_filename)
return _coerce_positive(from_filename) if ff is not None:
return ff
# Embedded source-written file tag is consulted LAST — only when every
# other source (metadata + the ripped-album "NN - Title" filename) came
# up empty. This is deliberate: it can ONLY fill the gap that would
# otherwise hit the caller's default-1 floor, so it never overrides a
# value the pre-fix resolver would have used. A correctly-named file
# with a stale/wrong embedded tag is therefore never regressed.
return _coerce_positive(embedded_track_number)

View file

@ -9,7 +9,15 @@ from datetime import datetime, timedelta
from utils.logging_config import get_logger from utils.logging_config import get_logger
from database.music_database import MusicDatabase from database.music_database import MusicDatabase
from core.itunes_client import iTunesClient from core.itunes_client import iTunesClient
from core.worker_utils import accept_artist_match, interruptible_sleep, set_album_api_track_count from core.worker_utils import (
accept_artist_match,
artist_name_matches,
interruptible_sleep,
owned_album_titles,
pick_artist_by_catalog,
release_titles,
set_album_api_track_count,
)
from core.enrichment.manual_match_honoring import honor_stored_match from core.enrichment.manual_match_honoring import honor_stored_match
logger = get_logger("itunes_worker") logger = get_logger("itunes_worker")
@ -392,20 +400,30 @@ class iTunesWorker:
logger.debug(f"No iTunes results for artist '{artist_name}'") logger.debug(f"No iTunes results for artist '{artist_name}'")
return return
for artist_obj in results: # Candidates clearing the name gate (results are source-ranked, so [0] is
# the legacy "first passing" pick), then disambiguate same-name artists by
# which one's catalog overlaps the albums this library owns.
gated = [a for a in results if artist_name_matches(artist_name, a.name)]
chosen, _overlap = pick_artist_by_catalog(
gated,
owned_album_titles(self.db, artist_id),
lambda a: release_titles(self.client.get_artist_albums(a.id)),
)
if chosen:
ok, reason = accept_artist_match( ok, reason = accept_artist_match(
self.db, 'itunes_artist_id', artist_obj.id, artist_id, self.db, 'itunes_artist_id', chosen.id, artist_id,
artist_name, artist_obj.name, artist_name, chosen.name,
) )
if ok: if ok:
if not self._is_itunes_id(artist_obj.id): if not self._is_itunes_id(chosen.id):
logger.warning(f"Rejecting non-iTunes ID '{artist_obj.id}' for artist '{artist_name}'") logger.warning(f"Rejecting non-iTunes ID '{chosen.id}' for artist '{artist_name}'")
self._mark_status('artist', artist_id, 'error') self._mark_status('artist', artist_id, 'error')
self.stats['errors'] += 1 self.stats['errors'] += 1
return return
self._update_artist(artist_id, artist_obj) self._update_artist(artist_id, chosen)
self.stats['matched'] += 1 self.stats['matched'] += 1
logger.info(f"Matched artist '{artist_name}' -> iTunes ID: {artist_obj.id}") logger.info(f"Matched artist '{artist_name}' -> iTunes ID: {chosen.id}")
return return
self._mark_status('artist', artist_id, 'not_found') self._mark_status('artist', artist_id, 'not_found')

View file

@ -0,0 +1,53 @@
"""What counts as a *residual* file — a leftover with no value once the audio it
accompanied is gone: OS junk, cover/scan images, and lyric/metadata sidecars.
Single source of truth shared by:
* the **Reorganize** cleanup, which strips these from a source dir after every
track has moved out (so the empty-dir pruner can take the folder), and
* the **Empty Folder Cleaner** job, which can optionally treat a folder holding
ONLY residual files as removable (#891).
Defining "disposable" in one place keeps the two features agreeing on what a "dead
folder" is. Pure predicates — no filesystem access — so they're unit-tested in
isolation. The whitelist is deliberately conservative: anything NOT recognized here
(a booklet ``.pdf``, a video, a ``.txt`` note) is treated as real content and kept.
"""
from __future__ import annotations
import os
# OS / tooling junk.
JUNK_FILES = {'.ds_store', 'thumbs.db', 'desktop.ini', '.directory', 'album.nfo~'}
# Cover art + booklet scans.
IMAGE_EXTS = {'.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.tiff', '.tif'}
# Lyric / metadata / playlist sidecars that are worthless without their audio.
SIDECAR_EXTS = {'.lrc', '.nfo', '.cue', '.m3u', '.m3u8'}
def _ext(name: str) -> str:
return os.path.splitext(name or '')[1].lower()
def is_junk(name: str) -> bool:
return (name or '').lower() in JUNK_FILES
def is_image(name: str) -> bool:
return _ext(name) in IMAGE_EXTS
def is_sidecar(name: str) -> bool:
return _ext(name) in SIDECAR_EXTS
def is_disposable(name: str) -> bool:
"""True if this file is junk, a cover/scan image, or a lyric/metadata sidecar —
i.e. safe to delete from a folder that has no audio left."""
return is_junk(name) or is_image(name) or is_sidecar(name)
__all__ = [
'JUNK_FILES', 'IMAGE_EXTS', 'SIDECAR_EXTS',
'is_junk', 'is_image', 'is_sidecar', 'is_disposable',
]

View file

@ -1847,14 +1847,8 @@ def _prune_empty_album_dirs(artist_dir: str) -> None:
# Sidecars that live alongside ONE audio file (same filename stem). # Sidecars that live alongside ONE audio file (same filename stem).
_TRACK_SIDECAR_EXTS = ('.lrc', '.nfo', '.txt', '.cue', '.json') _TRACK_SIDECAR_EXTS = ('.lrc', '.nfo', '.txt', '.cue', '.json')
# Sidecars that live at the ALBUM level (one per directory). # Album-level leftovers (cover images, .lrc, etc.) are classified by the shared
_ALBUM_SIDECARS = ( # `core.library.residual_files.is_disposable` predicate — see `_delete_album_sidecars`.
'cover.jpg', 'cover.jpeg', 'cover.png',
'folder.jpg', 'folder.png',
'front.jpg', 'front.png',
'album.jpg', 'album.png',
'artwork.jpg', 'artwork.png',
)
# Audio extensions used to decide whether a source directory still has # Audio extensions used to decide whether a source directory still has
# tracks the user might care about (i.e. a per-track failure left audio # tracks the user might care about (i.e. a per-track failure left audio
@ -1954,16 +1948,30 @@ def _delete_track_sidecars(audio_path: str) -> None:
def _delete_album_sidecars(src_dir: str) -> None: def _delete_album_sidecars(src_dir: str) -> None:
"""Delete album-level sidecars (cover.jpg, folder.jpg, etc.) from """Delete album-level *residual* files from ``src_dir`` — any cover/scan image,
`src_dir`. Used during end-of-run cleanup when no audio files remain lyric/metadata sidecar (.lrc/.nfo/.cue/.m3u), or OS junk. Called during
in the directory. Best-effort individual failures are debug-logged.""" end-of-run cleanup ONLY when no audio remains in the directory, so everything
for name in _ALBUM_SIDECARS: here is leftover from the album that just moved (#891 — previously this only
sidecar = os.path.join(src_dir, name) removed a fixed list of cover names, so ``back.jpg`` / ``disc.jpg`` / ``.webp``
if os.path.isfile(sidecar): survived and kept the folder un-prunable).
Uses the shared ``is_disposable`` predicate so it agrees with the Empty Folder
Cleaner on what's a dead leftover; anything unrecognized (a booklet ``.pdf``, a
video) is deliberately LEFT. Best-effort individual failures are debug-logged."""
from core.library.residual_files import is_disposable
try:
entries = os.listdir(src_dir)
except OSError:
return
for name in entries:
if not is_disposable(name):
continue
full = os.path.join(src_dir, name)
if os.path.isfile(full):
try: try:
os.remove(sidecar) os.remove(full)
except OSError as e: except OSError as e:
logger.debug(f"[Reorganize] Couldn't remove album sidecar {sidecar}: {e}") logger.debug(f"[Reorganize] Couldn't remove residual file {full}: {e}")
def _has_remaining_audio(directory: str) -> bool: def _has_remaining_audio(directory: str) -> bool:

View file

@ -242,18 +242,25 @@ def enhance_file_metadata(file_path: str, context: dict, artist: dict, album_inf
logger.warning("[Metadata Debug] Artist: %s", artist.get("name", "MISSING") if artist else "None") logger.warning("[Metadata Debug] Artist: %s", artist.get("name", "MISSING") if artist else "None")
logger.warning("[Metadata Debug] Album info: %s", album_info.get("album_name", "MISSING") if album_info else "None") logger.warning("[Metadata Debug] Album info: %s", album_info.get("album_name", "MISSING") if album_info else "None")
logger.error("[Metadata Debug] Traceback:\n%s", traceback.format_exc()) logger.error("[Metadata Debug] Traceback:\n%s", traceback.format_exc())
# We cleared the file's art early; if the rewrite then crashed # The file was saved with tags CLEARED up front (so stale tags never
# before re-embedding, the on-disk file (already saved cleared at # linger), then the failure-prone enrichment ran. By the time most
# the start) would be left art-less. Best-effort: put the original # failures hit — the external source-id embed / cover-art fetch — the
# art back and persist it so a mid-enrichment crash never destroys # core tags (album/artist/title/track from the matched context) are
# the cover (#764). Guarded so a failure here can't mask the # already on the in-memory object but NOT yet on disk; the on-disk
# original error. # file is still the cleared one. Persist the in-memory tags now (and
# restore the original art too, #764) so a mid-enrichment crash leaves
# a correctly-tagged file instead of an UNTAGGED one (Sokhi: tracks
# landing in Rockbox's 'untagged' bucket after a 'processing failed').
#
# Previously this save was gated on there being original art to
# restore, so an art-less file lost its tags entirely on any crash.
# Guarded so a failure here can't mask the original error.
try: try:
if audio_file is not None and art_snapshot and restore_embedded_art( if audio_file is not None:
audio_file, symbols, art_snapshot if art_snapshot:
): restore_embedded_art(audio_file, symbols, art_snapshot)
save_audio_file(audio_file, symbols) save_audio_file(audio_file, symbols)
logger.info("Restored original cover art after enrichment error.") logger.info("Persisted core tags (and restored art) after enrichment error.")
except Exception as restore_exc: except Exception as restore_exc:
logger.debug("Art restore after error failed: %s", restore_exc) logger.debug("Tag/art persist after error failed: %s", restore_exc)
return False return False

View file

@ -5,6 +5,7 @@ from datetime import datetime, timedelta
from difflib import SequenceMatcher from difflib import SequenceMatcher
from utils.logging_config import get_logger from utils.logging_config import get_logger
from core.musicbrainz_client import MusicBrainzClient from core.musicbrainz_client import MusicBrainzClient
from core.worker_utils import catalog_overlap_score, pick_artist_by_catalog
from database.music_database import MusicDatabase from database.music_database import MusicDatabase
logger = get_logger("musicbrainz_service") logger = get_logger("musicbrainz_service")
@ -117,23 +118,51 @@ class MusicBrainzService:
if conn: if conn:
conn.close() conn.close()
def match_artist(self, artist_name: str) -> Optional[Dict[str, Any]]: def _candidate_release_titles(self, mbid: str) -> list:
"""Release-group titles for a candidate MBID — the catalog side of
same-name artist disambiguation."""
if not mbid:
return []
try:
data = self.mb_client.get_artist(mbid, includes=['release-groups'])
except Exception:
return []
groups = (data or {}).get('release-groups') or []
return [g.get('title') for g in groups if isinstance(g, dict) and g.get('title')]
def match_artist(self, artist_name: str, owned_titles: Optional[list] = None) -> Optional[Dict[str, Any]]:
""" """
Match an artist by name to MusicBrainz Match an artist by name to MusicBrainz.
``owned_titles`` the library artist's owned album titles. When given and
more than one strong same-name candidate exists, the one whose release
groups overlap those owned titles is chosen (disambiguates the ~5 "Rone"s);
omitted falls back to the highest-confidence candidate as before.
Returns: Returns:
Dict with 'mbid', 'name', 'confidence' or None if no good match Dict with 'mbid', 'name', 'confidence' or None if no good match
""" """
# Check cache first # Check cache first
cached = self._check_cache('artist', artist_name) cached = self._check_cache('artist', artist_name)
if cached: if cached:
logger.debug(f"Cache hit for artist '{artist_name}'") cached_mbid = cached.get('musicbrainz_id')
return { # Don't trust a cached mbid whose catalog has ZERO overlap with the
'mbid': cached['musicbrainz_id'], # albums this library owns — that's the wrong same-name artist (and a
'name': artist_name, # re-match would otherwise be blocked for up to the 90-day cache TTL,
'confidence': cached['confidence'], # #868). Fall through to a fresh, disambiguated resolve in that case.
'cached': True stale_wrong_match = bool(
} cached_mbid and owned_titles
and catalog_overlap_score(owned_titles, self._candidate_release_titles(cached_mbid)) == 0
)
if not stale_wrong_match:
logger.debug(f"Cache hit for artist '{artist_name}'")
return {
'mbid': cached_mbid,
'name': artist_name,
'confidence': cached['confidence'],
'cached': True
}
logger.debug(f"Cached MB match for '{artist_name}' has no owned-catalog overlap — re-resolving")
# Search MusicBrainz # Search MusicBrainz
try: try:
@ -144,25 +173,30 @@ class MusicBrainzService:
self._save_to_cache('artist', artist_name, None, None, None, 0) self._save_to_cache('artist', artist_name, None, None, None, 0)
return None return None
# Find best match # Score every candidate (name similarity 60% + MB's own relevance 40%).
best_match = None scored = []
best_confidence = 0
for result in results: for result in results:
mb_name = result.get('name', '') mb_name = result.get('name', '')
mb_score = result.get('score', 0) # MusicBrainz search score mb_score = result.get('score', 0) # MusicBrainz search score
# Calculate our own similarity
similarity = self._calculate_similarity(artist_name, mb_name) similarity = self._calculate_similarity(artist_name, mb_name)
# Combine MusicBrainz score with our similarity (weighted)
# Cap at 100 to prevent edge cases where MB score > 100 # Cap at 100 to prevent edge cases where MB score > 100
confidence = min(100, int((similarity * 60) + (mb_score / 100 * 40))) confidence = min(100, int((similarity * 60) + (mb_score / 100 * 40)))
scored.append((confidence, result))
if confidence > best_confidence: scored.sort(key=lambda s: s[0], reverse=True)
best_confidence = confidence
best_match = result # Among the strong (>=70) candidates, disambiguate same-name artists by
# which one's release groups overlap the albums this library owns.
gated = [r for conf, r in scored if conf >= 70]
best_match = None
best_confidence = scored[0][0] if scored else 0
if gated:
chosen, _overlap = pick_artist_by_catalog(
gated, owned_titles or [],
lambda r: self._candidate_release_titles(r.get('id')),
)
best_match = chosen
best_confidence = next(conf for conf, r in scored if r is chosen)
# Only return matches with confidence >= 70% # Only return matches with confidence >= 70%
if best_match and best_confidence >= 70: if best_match and best_confidence >= 70:
mbid = best_match.get('id') mbid = best_match.get('id')

View file

@ -5,7 +5,7 @@ from datetime import datetime, timedelta
from utils.logging_config import get_logger from utils.logging_config import get_logger
from database.music_database import MusicDatabase from database.music_database import MusicDatabase
from core.musicbrainz_service import MusicBrainzService from core.musicbrainz_service import MusicBrainzService
from core.worker_utils import interruptible_sleep, source_id_conflict from core.worker_utils import interruptible_sleep, owned_album_titles, source_id_conflict
logger = get_logger("musicbrainz_worker") logger = get_logger("musicbrainz_worker")
@ -366,7 +366,8 @@ class MusicBrainzWorker:
return return
if item_type == 'artist': if item_type == 'artist':
result = self.mb_service.match_artist(item_name) result = self.mb_service.match_artist(
item_name, owned_titles=owned_album_titles(self.db, item_id))
mbid = result.get('mbid') if result else None mbid = result.get('mbid') if result else None
# MB's combined score can match a weak name ("Grant" -> "Amy # MB's combined score can match a weak name ("Grant" -> "Amy
# Grant") when its own relevance rank is high. Guard against # Grant") when its own relevance rank is high. Guard against

View file

@ -51,6 +51,7 @@ _JOB_MODULES = [
'core.repair_jobs.discography_backfill', 'core.repair_jobs.discography_backfill',
'core.repair_jobs.canonical_version_resolve', 'core.repair_jobs.canonical_version_resolve',
'core.repair_jobs.library_retag', 'core.repair_jobs.library_retag',
'core.repair_jobs.quality_upgrade',
] ]

View file

@ -22,37 +22,36 @@ from __future__ import annotations
import os import os
from typing import Iterable, List from typing import Iterable, List
from core.library.residual_files import JUNK_FILES, is_disposable, is_junk # noqa: F401 — JUNK_FILES/is_junk re-exported
from core.repair_jobs import register_job from core.repair_jobs import register_job
from core.repair_jobs.base import JobContext, JobResult, RepairJob from core.repair_jobs.base import JobContext, JobResult, RepairJob
from utils.logging_config import get_logger from utils.logging_config import get_logger
logger = get_logger("repair_jobs.empty_folder_cleaner") logger = get_logger("repair_jobs.empty_folder_cleaner")
# Files that don't count as real content — safe to delete along with the folder.
JUNK_FILES = {'.ds_store', 'thumbs.db', 'desktop.ini', '.directory', 'album.nfo~'}
def is_junk(name: str) -> bool:
return (name or '').lower() in JUNK_FILES
def dir_is_removable(files: Iterable[str], surviving_subdirs: Iterable[str], def dir_is_removable(files: Iterable[str], surviving_subdirs: Iterable[str],
*, ignore_junk: bool = True) -> bool: *, ignore_junk: bool = True, ignore_disposable: bool = False) -> bool:
"""Pure: is a directory safe to remove? """Pure: is a directory safe to remove?
Removable iff it has **no surviving subdirectories** and **no real files** Removable iff it has **no surviving subdirectories** and **no real files**
where "no real files" means literally empty, or (when ``ignore_junk``) only where "no real files" means literally empty, or (when ``ignore_junk``) only
OS-junk files. ``surviving_subdirs`` is the list of child dirs that are NOT OS-junk files, or (when ``ignore_disposable`` #891) only *residual* files:
themselves being removed (i.e. still hold content). junk + cover/scan images + lyric/metadata sidecars. ``ignore_disposable`` is the
broader opt-in that clears the cover.jpg-only folders a reorganize leaves behind.
``surviving_subdirs`` is the list of child dirs that are NOT themselves being
removed (i.e. still hold content).
""" """
if list(surviving_subdirs): if list(surviving_subdirs):
return False return False
files = list(files) files = list(files)
if not files: if not files:
return True return True
if not ignore_junk: if ignore_disposable:
return False return all(is_disposable(f) for f in files)
return all(is_junk(f) for f in files) if ignore_junk:
return all(is_junk(f) for f in files)
return False
@register_job @register_job
@ -65,15 +64,18 @@ class EmptyFolderCleanerJob(RepairJob):
'relocations, and deletions — empty artist/album folders, or folders that ' 'relocations, and deletions — empty artist/album folders, or folders that '
'hold only OS junk like .DS_Store / Thumbs.db.\n\n' 'hold only OS junk like .DS_Store / Thumbs.db.\n\n'
'A finding is created for each. Applying one deletes the folder (after ' 'A finding is created for each. Applying one deletes the folder (after '
're-checking it is still empty). Folders that contain any real file — a ' 're-checking it is still empty). Folders that contain any real file — an '
'cover image, an audio track, anything — are never touched, the library ' 'audio track, a booklet, anything not recognized as a leftover — are never '
'root is never removed, and it cascades: a folder left empty once its ' 'touched, the library root is never removed, and it cascades: a folder left '
'empty children are removed is cleaned too.' 'empty once its empty children are removed is cleaned too.\n\n'
'Enable "Also remove image/sidecar-only folders" to clear the cover.jpg / '
'.lrc leftovers a Library Reorganize leaves behind — folders whose only '
'remaining files are cover/scan images or lyric/metadata sidecars.'
) )
icon = 'repair-icon-folder' icon = 'repair-icon-folder'
default_enabled = False default_enabled = False
default_interval_hours = 168 # weekly — empties accrue slowly default_interval_hours = 168 # weekly — empties accrue slowly
default_settings = {'remove_junk_files': True} default_settings = {'remove_junk_files': True, 'remove_residual_files': False}
auto_fix = False auto_fix = False
def scan(self, context: JobContext) -> JobResult: def scan(self, context: JobContext) -> JobResult:
@ -86,10 +88,15 @@ class EmptyFolderCleanerJob(RepairJob):
root = os.path.realpath(root) root = os.path.realpath(root)
ignore_junk = True ignore_junk = True
ignore_disposable = False
try: try:
if context.config_manager: if context.config_manager:
ignore_junk = bool(context.config_manager.get( ignore_junk = bool(context.config_manager.get(
'repair.jobs.empty_folder_cleaner.remove_junk_files', True)) 'repair.jobs.empty_folder_cleaner.remove_junk_files', True))
# #891: also clear folders left holding only images / .lrc / sidecars
# (what a reorganize leaves behind). Opt-in — default off.
ignore_disposable = bool(context.config_manager.get(
'repair.jobs.empty_folder_cleaner.remove_residual_files', False))
except Exception: # noqa: S110 — setting read is best-effort; defaults to True except Exception: # noqa: S110 — setting read is best-effort; defaults to True
pass pass
@ -108,17 +115,29 @@ class EmptyFolderCleanerJob(RepairJob):
surviving = [d for d in dirnames surviving = [d for d in dirnames
if os.path.join(dirpath, d) not in flagged] if os.path.join(dirpath, d) not in flagged]
if not dir_is_removable(filenames, surviving, ignore_junk=ignore_junk): if not dir_is_removable(filenames, surviving,
ignore_junk=ignore_junk, ignore_disposable=ignore_disposable):
result.skipped += 1 result.skipped += 1
continue continue
flagged.add(dirpath) flagged.add(dirpath)
junk = [f for f in filenames if is_junk(f)] junk = [f for f in filenames if is_junk(f)]
# Files that will be swept along with the folder (junk always; images/
# sidecars only when the residual option is on).
purgeable = [f for f in filenames
if is_junk(f) or (ignore_disposable and is_disposable(f))]
residual = [f for f in purgeable if not is_junk(f)]
rel = os.path.relpath(dirpath, root) rel = os.path.relpath(dirpath, root)
if context.report_progress: if context.report_progress:
context.report_progress(log_line=f'Empty folder: {rel}', log_type='info') context.report_progress(log_line=f'Empty folder: {rel}', log_type='info')
if context.create_finding: if context.create_finding:
try: try:
if residual:
extra = f' (only {len(residual)} leftover image/sidecar file(s))'
elif junk:
extra = f' (only {len(junk)} junk file(s))'
else:
extra = ''
inserted = context.create_finding( inserted = context.create_finding(
job_id=self.job_id, job_id=self.job_id,
finding_type='empty_folder', finding_type='empty_folder',
@ -127,13 +146,13 @@ class EmptyFolderCleanerJob(RepairJob):
entity_id=dirpath, entity_id=dirpath,
file_path=dirpath, file_path=dirpath,
title=f'Empty folder: {os.path.basename(dirpath) or rel}', title=f'Empty folder: {os.path.basename(dirpath) or rel}',
description=(f'"{rel}" holds no music' description=(f'"{rel}" holds no music' + extra + ' — safe to remove.'),
+ (f' (only {len(junk)} junk file(s))' if junk else '')
+ ' — safe to remove.'),
details={ details={
'folder_path': dirpath, 'folder_path': dirpath,
'junk_files': junk, 'junk_files': junk,
'purgeable_files': purgeable,
'remove_junk': ignore_junk, 'remove_junk': ignore_junk,
'remove_disposable': ignore_disposable,
}) })
if inserted: if inserted:
result.findings_created += 1 result.findings_created += 1
@ -158,12 +177,17 @@ class EmptyFolderCleanerJob(RepairJob):
def remove_empty_folder(folder_path: str, *, junk_files: List[str], remove_junk: bool, def remove_empty_folder(folder_path: str, *, junk_files: List[str], remove_junk: bool,
root: str, listdir, isdir, islink, remove_file, rmdir) -> dict: root: str, listdir, isdir, islink, remove_file, rmdir,
remove_disposable: bool = False) -> dict:
"""Pure-ish orchestration for the apply handler — RE-CHECKS the folder is still """Pure-ish orchestration for the apply handler — RE-CHECKS the folder is still
removable, then deletes any junk + the folder. Effects injected for testing. removable, then deletes any purgeable leftovers + the folder. Effects injected
for testing.
Returns ``{'removed': bool, 'error': str|None}``. Refuses to touch the root, a With ``remove_disposable`` (#891) the re-check also treats cover images and
symlink, a non-dir, or a folder that gained real content since the scan. lyric/metadata sidecars as removable, and sweeps them before rmdir. Returns
``{'removed': bool, 'error': str|None}``. Refuses to touch the root, a symlink, a
non-dir, or a folder that gained REAL content (audio, a booklet, anything not
recognized as residual) since the scan.
""" """
if not folder_path or not isdir(folder_path): if not folder_path or not isdir(folder_path):
return {'removed': False, 'error': 'Folder no longer exists'} return {'removed': False, 'error': 'Folder no longer exists'}
@ -172,19 +196,21 @@ def remove_empty_folder(folder_path: str, *, junk_files: List[str], remove_junk:
if root and os.path.realpath(folder_path) == os.path.realpath(root): if root and os.path.realpath(folder_path) == os.path.realpath(root):
return {'removed': False, 'error': 'Refusing to remove the library root'} return {'removed': False, 'error': 'Refusing to remove the library root'}
# Re-check at apply time: only junk/empty now? (Anything else = leave it.) def _purgeable(e: str) -> bool:
return (remove_junk and is_junk(e)) or (remove_disposable and is_disposable(e))
# Re-check at apply time: only purgeable leftovers now? (Anything else = leave it.)
entries = list(listdir(folder_path)) entries = list(listdir(folder_path))
real_entries = [e for e in entries if not (remove_junk and is_junk(e))] real_entries = [e for e in entries if not _purgeable(e)]
if real_entries: if real_entries:
return {'removed': False, 'error': 'Folder is no longer empty — left untouched'} return {'removed': False, 'error': 'Folder is no longer empty — left untouched'}
if remove_junk: for e in entries:
for j in entries: if _purgeable(e):
if is_junk(j): try:
try: remove_file(os.path.join(folder_path, e))
remove_file(os.path.join(folder_path, j)) except Exception: # noqa: S110 — leftover best-effort; rmdir below fails loudly if blocked
except Exception: # noqa: S110 — junk best-effort; rmdir below fails loudly if blocked pass
pass
try: try:
rmdir(folder_path) rmdir(folder_path)
except Exception as e: except Exception as e:

View file

@ -0,0 +1,720 @@
"""Quality Upgrade Finder maintenance job.
Replaces the old auto-acting "Quality Scanner" tool. That tool decided quality
purely by file EXTENSION (so a 128 kbps MP3 and a 320 kbps MP3 looked identical),
ignored the bitrate-based quality profile, and silently dumped every match
straight into the wishlist with no review which, on the default profile, meant
flagging an entire non-lossless library at once.
This job does it the way the rest of the app works: it SCANS (watchlist artists
or the whole library), judges each track against the user's quality profile using
BOTH format and bitrate, and for anything below the preferred quality it searches
the configured metadata source for a better version and emits a FINDING. Nothing
is queued until you review and Apply the finding at which point the matched
track (carrying its album context) is added to the wishlist, exactly like every
other acquisition path.
The quality decision (``meets_preferred_quality``) is a pure function so it can be
unit-tested without a database or network. Transcode/"fake lossless" detection is
intentionally NOT done here that's the separate Fake Lossless Detector job.
"""
from __future__ import annotations
import os
import time
from typing import Any, Dict, List, Optional, Tuple
from core.metadata.registry import get_client_for_source, get_primary_source, get_source_priority
from core.repair_jobs import register_job
from core.repair_jobs.base import JobContext, JobResult, RepairJob
# Reuse the (tested) provider search + result-normalization helpers from the old
# scanner module so matching stays a single source of truth.
from core.discovery.quality_scanner import (
_extract_lookup_value,
_normalize_track_match,
_search_tracks_for_source,
_track_artist_names,
_track_name,
)
from core.library.file_tags import read_embedded_tags
from core.library.path_resolver import resolve_library_file_path
from utils.logging_config import get_logger
logger = get_logger("repair_jobs.quality_upgrade")
# Quality ranks — higher is better. Lossless tops everything; lossy tiers fall out
# of bitrate. 0 means "below the lowest tracked tier / unknown".
RANK_LOSSLESS = 4
RANK_320 = 3
RANK_256 = 2
RANK_192 = 1
RANK_BELOW = 0
LOSSLESS_EXTENSIONS = {'.flac', '.alac', '.ape', '.wav', '.aiff', '.aif', '.dsf', '.dff', '.m4a'}
# NB: .m4a is ambiguous (ALAC vs AAC); we treat the *format* as lossy-capable and
# rely on bitrate below — a true ALAC .m4a reports a lossless-scale bitrate.
# Quality-profile bucket key -> rank.
_PROFILE_KEY_RANK = {
'flac': RANK_LOSSLESS,
'mp3_320': RANK_320,
'mp3_256': RANK_256,
'mp3_192': RANK_192,
}
# Per-source file-tag key holding that source's own track ID (written by enrichment).
_SOURCE_TRACK_ID_TAG = {
'spotify': 'spotify_track_id',
'deezer': 'deezer_track_id',
'itunes': 'itunes_track_id',
'audiodb': 'audiodb_track_id',
'musicbrainz': 'musicbrainz_releasetrackid',
'tidal': 'tidal_track_id',
}
# Reject a fuzzy candidate whose length differs from ours by more than this (ms) —
# catches wrong versions (live/edit/remix) that share a title. Exact tiers skip it.
_DURATION_TOLERANCE_MS = 5000
def _normalize_kbps(bitrate: Optional[int]) -> Optional[int]:
"""Library bitrate may be stored in bps (e.g. 320000) or kbps (320).
Normalize to kbps. Returns None when unknown/zero."""
if not bitrate:
return None
try:
b = int(bitrate)
except (TypeError, ValueError):
return None
if b <= 0:
return None
return b // 1000 if b > 4000 else b
def classify_track_quality(file_path: str, bitrate: Optional[int]) -> Optional[int]:
"""Rank a file by format + bitrate. Returns a RANK_* value, or None when it
can't be judged (a lossy file with no known bitrate)."""
ext = os.path.splitext(file_path or '')[1].lower()
kbps = _normalize_kbps(bitrate)
# Lossless containers: a real lossless file has a high bitrate; a low one is a
# lossy stream in a lossless container — but flagging that is the Fake Lossless
# Detector's job, so here we treat the lossless *format* as top rank.
if ext in {'.flac', '.alac', '.ape', '.wav', '.aiff', '.aif', '.dsf', '.dff'}:
return RANK_LOSSLESS
# .m4a / lossy: judge purely by bitrate. A lossless-scale bitrate (ALAC in m4a,
# or a mislabeled lossless) ranks as lossless.
if kbps is None:
return None
if kbps >= 800:
return RANK_LOSSLESS
if kbps >= 280:
return RANK_320
if kbps >= 200:
return RANK_256
if kbps >= 150:
return RANK_192
return RANK_BELOW
def preferred_quality_floor(quality_profile: Dict[str, Any]) -> Optional[int]:
"""The lowest acceptable quality rank from the profile's ENABLED buckets — the
floor a track must meet. Returns None when nothing is enabled (caller should
then flag nothing, rather than flagging everything)."""
qualities = (quality_profile or {}).get('qualities', {}) or {}
enabled_ranks = [
_PROFILE_KEY_RANK[key]
for key, cfg in qualities.items()
if isinstance(cfg, dict) and cfg.get('enabled') and key in _PROFILE_KEY_RANK
]
if not enabled_ranks:
return None
return min(enabled_ranks)
def meets_preferred_quality(file_path: str, bitrate: Optional[int],
quality_profile: Dict[str, Any]) -> bool:
"""Pure decision: does this track already meet the user's preferred quality?
A track meets quality when its format+bitrate rank is at least the profile's
floor (the worst quality the user still accepts). This honors a profile that
enables, say, FLAC *and* MP3-320: a 320 kbps MP3 passes, a 128 kbps MP3 does
not. With nothing enabled, everything passes (we never flag the whole library
on an empty profile)."""
floor = preferred_quality_floor(quality_profile)
if floor is None:
return True
file_rank = classify_track_quality(file_path, bitrate)
if file_rank is None:
# Lossy file with unknown bitrate: only judgeable when the floor is
# lossless (then any lossy file is below it). Otherwise don't flag.
ext = os.path.splitext(file_path or '')[1].lower()
if floor == RANK_LOSSLESS and ext not in LOSSLESS_EXTENSIONS:
return False
return True
return file_rank >= floor
def _rank_label(rank: Optional[int]) -> str:
return {
RANK_LOSSLESS: 'Lossless', RANK_320: 'MP3 320', RANK_256: 'MP3 256',
RANK_192: 'MP3 192', RANK_BELOW: 'low bitrate',
}.get(rank, 'unknown')
def _norm_isrc(value: Any) -> str:
"""Canonicalize an ISRC for comparison: uppercase, strip dashes/spaces."""
if not value:
return ''
return str(value).upper().replace('-', '').replace(' ', '').strip()
def _read_file_ids(file_path: str) -> Dict[str, str]:
"""Read the identifiers enrichment embedded in the file's tags.
Enrichment matches every track to the metadata sources and writes the IDs
(ISRC + per-source track IDs) into the file so an already-enriched track
carries its exact identity. Returns a dict with a normalized ``isrc`` plus any
``<source>_track_id`` tags present; empty dict when unreadable / not enriched."""
resolved = resolve_library_file_path(file_path) if file_path else None
if not resolved and file_path and os.path.isfile(file_path):
resolved = file_path
if not resolved:
return {}
try:
info = read_embedded_tags(resolved)
except Exception:
return {}
if not info or not info.get('available'):
return {}
tags = info.get('tags') or {}
out: Dict[str, str] = {}
isrc = _norm_isrc(tags.get('isrc'))
if isrc:
out['isrc'] = isrc
for tag_key in set(_SOURCE_TRACK_ID_TAG.values()):
val = tags.get(tag_key)
if val:
out[tag_key] = str(val)
return out
def _duration_ok(want_ms: Any, got_ms: Any, tolerance_ms: int = _DURATION_TOLERANCE_MS) -> bool:
"""Wrong-version guard: True when the candidate's length is within tolerance of
ours or when either length is unknown (never reject on missing data)."""
try:
w, g = int(want_ms or 0), int(got_ms or 0)
except (TypeError, ValueError):
return True
if w <= 0 or g <= 0:
return True
return abs(w - g) <= tolerance_ms
def _match_via_track_id(file_ids: Dict[str, str],
source_priority: List[str]) -> Tuple[Optional[Any], Optional[str]]:
"""Most-direct path: enrichment already wrote this track's per-source IDs into
the file. If we have the active source's own track ID, fetch that exact track by
ID no search at all. Returns (track, source) or (None, None)."""
for source in source_priority:
tag_key = _SOURCE_TRACK_ID_TAG.get(source)
track_id = file_ids.get(tag_key) if tag_key else None
if not track_id:
continue
client = get_client_for_source(source)
if not client or not hasattr(client, 'get_track_details'):
continue
try:
track = client.get_track_details(str(track_id))
except Exception:
track = None
if track:
return track, source
return None, None
def _candidate_isrc(cand: Any) -> str:
"""Pull an ISRC off a provider search result (Track / dict), checking the
common shapes: a flat ``isrc`` or a nested ``external_ids.isrc``."""
direct = _extract_lookup_value(cand, 'isrc')
if direct:
return _norm_isrc(direct)
ext = _extract_lookup_value(cand, 'external_ids')
if isinstance(ext, dict):
return _norm_isrc(ext.get('isrc'))
return ''
def _match_via_isrc(isrc: str, source_priority: List[str]) -> Tuple[Optional[Any], Optional[str]]:
"""Exact-match a track by its ISRC via each source's ``isrc:`` search.
ISRC is the universal cross-source recording key, so this resolves the EXACT
track (with its real album) instead of fuzzy-matching by name. Guarded: only
a candidate whose own ISRC equals ours is accepted, so a source that ignores
the ``isrc:`` syntax and returns unrelated hits can't produce a false match.
Returns (track, source) or (None, None)."""
if not isrc:
return None, None
for source in source_priority:
client = get_client_for_source(source)
if not client or not hasattr(client, 'search_tracks'):
continue
try:
results = _search_tracks_for_source(source, f'isrc:{isrc}', limit=5, client=client)
except Exception:
results = []
for cand in results or []:
if _candidate_isrc(cand) == isrc:
return cand, source
return None, None
# Column order for the _load_tracks SELECT — rows come back as dicts keyed by these.
_TRACK_COLS = (
'id', 'title', 'file_path', 'bitrate', 'duration', 'artist_name', 'album_title',
'album_id', 'track_number', 'spotify_album_id', 'itunes_album_id', 'deezer_id',
'musicbrainz_release_id', 'audiodb_id',
)
# Human-readable note per match tier (search uses a confidence % instead).
_MATCH_NOTE = {
'track_id': 'exact track ID', 'isrc': 'exact ISRC match',
'album': 'matched within album',
}
# Per-source column holding that source's album ID on the albums table.
_SOURCE_ALBUM_ID_COL = {
'spotify': 'spotify_album_id',
'itunes': 'itunes_album_id',
'deezer': 'deezer_id',
'musicbrainz': 'musicbrainz_release_id',
'audiodb': 'audiodb_id',
}
def _norm_title(value: Any) -> str:
"""Collapse a title to alphanumerics for tolerant comparison."""
return ''.join(ch for ch in str(value or '').lower() if ch.isalnum())
def _find_track_in_album(items: Any, title: str, track_number: Any, engine: Any,
want_duration_ms: Any = None) -> Optional[Any]:
"""Pick the track in an album's tracklist that matches ours — exact normalized
title first (track_number then duration break ties), then a high-similarity
fuzzy fallback that respects the duration guard."""
want = _norm_title(title)
exact = []
best, best_score = None, 0.0
for it in items or []:
it_name = _extract_lookup_value(it, 'name', 'title', default='')
if want and _norm_title(it_name) == want:
exact.append(it)
continue
if engine and it_name:
if not _duration_ok(want_duration_ms, _extract_lookup_value(it, 'duration_ms', 'duration')):
continue
score = engine.similarity_score(
engine.normalize_string(title), engine.normalize_string(it_name))
if score > best_score and score >= 0.85:
best, best_score = it, score
if exact:
if track_number:
for it in exact:
if _extract_lookup_value(it, 'track_number') == track_number:
return it
# Multiple same-title cuts (e.g. album + live): prefer the closest length.
if want_duration_ms and len(exact) > 1:
exact.sort(key=lambda it: abs(int(want_duration_ms) - int(
_extract_lookup_value(it, 'duration_ms', 'duration', default=0) or 0)))
return exact[0]
return best
def _match_via_album(engine: Any, source_priority: List[str], artist: str, album_title: str,
title: str, track_number: Any, stored_album_ids: Dict[str, str],
want_duration_ms: Any = None) -> Tuple[Optional[Any], Optional[str]]:
"""Structured artist → album → track match. For each source: use the album's
stored source ID if we already have it (enriched album), else find the album
by searching ``artist album``; then pull that album's tracklist and locate our
track in it. This pins the right album (exact context) without needing the
track itself to be enriched. Returns (track, source) or (None, None)."""
if not album_title:
return None, None
for source in source_priority:
client = get_client_for_source(source)
if not client or not hasattr(client, 'get_album_tracks'):
continue
album_id = stored_album_ids.get(source)
album_name = album_title
if not album_id and hasattr(client, 'search_albums'):
try:
albums = client.search_albums(f'{artist} {album_title}'.strip(), limit=5)
except Exception:
albums = []
best_alb, best_s = None, 0.0
for alb in albums or []:
aname = _extract_lookup_value(alb, 'name', 'title', default='')
s = engine.similarity_score(
engine.normalize_string(album_title), engine.normalize_string(aname))
if s > best_s and s >= 0.80:
best_alb, best_s = alb, s
if best_alb is not None:
album_id = _extract_lookup_value(best_alb, 'id')
album_name = _extract_lookup_value(best_alb, 'name', 'title', default=album_title)
if not album_id:
continue
try:
resp = client.get_album_tracks(str(album_id))
except Exception:
resp = None
items = resp.get('items') if isinstance(resp, dict) else None
match = _find_track_in_album(items, title, track_number, engine, want_duration_ms)
if match is None:
continue
# The album tracklist's tracks usually omit the album object — attach it so
# the wishlist add carries the correct album context.
if isinstance(match, dict):
alb = match.get('album')
if not isinstance(alb, dict) or not alb.get('name'):
match['album'] = {'name': album_name, 'images': []}
return match, source
return None, None
def _find_best_match(engine: Any, source_priority: List[str], title: str, artist: str,
album: str, min_confidence: float,
want_duration_ms: Any = None) -> Tuple[Optional[Any], float, Optional[str], bool]:
"""Search the configured metadata sources for the best replacement match.
Returns (best_track, confidence, source, attempted_any_provider)."""
temp_track = type('TempTrack', (), {'name': title, 'artists': [artist], 'album': album})()
queries = engine.generate_download_queries(temp_track)
best, best_conf, best_src = None, 0.0, None
attempted = False
for query in queries:
for source in source_priority:
client = get_client_for_source(source)
if not client or not hasattr(client, 'search_tracks'):
continue
attempted = True
matches = _search_tracks_for_source(source, query, limit=5, client=client)
time.sleep(0.5) # be gentle on metadata APIs
for cand in matches or []:
# Wrong-version guard: a candidate whose length is way off is a
# different cut (live/edit/remix) — reject before it can win.
if not _duration_ok(want_duration_ms, _extract_lookup_value(cand, 'duration_ms', 'duration')):
continue
cand_artists = _track_artist_names(cand)
artist_conf = max(
(engine.similarity_score(engine.normalize_string(artist),
engine.normalize_string(n)) for n in cand_artists),
default=0.0,
)
title_conf = engine.similarity_score(
engine.normalize_string(title), engine.normalize_string(_track_name(cand)))
conf = artist_conf * 0.5 + title_conf * 0.5
album_type = _extract_lookup_value(cand, 'album_type', default='') or ''
if album_type == 'album':
conf += 0.02
elif album_type == 'ep':
conf += 0.01
if conf > best_conf and conf >= min_confidence:
best, best_conf, best_src = cand, conf, source
if best_conf >= 0.9:
break
if best_conf >= 0.9:
break
return best, best_conf, best_src, attempted
@register_job
class QualityUpgradeJob(RepairJob):
job_id = 'quality_upgrade'
display_name = 'Quality Upgrade Finder'
description = 'Finds library tracks below your preferred quality and proposes a better version'
help_text = (
'Scans your library (or just your watchlist artists) and compares each '
"track against your Quality Profile using BOTH the file format and its "
'bitrate — so a 128 kbps MP3 is no longer treated the same as a 320 kbps '
'one, and enabling MP3-320/256 in your profile actually counts.\n\n'
'For every track below your preferred quality it resolves the exact better '
'version using the most precise identity available, in order: the source '
"track ID enrichment wrote into the file → the file's ISRC → the album's "
'tracklist (by stored album ID or album search) → a name/artist search. The '
'fuzzy steps also reject candidates whose length is off (wrong live/edit cut). '
'It skips tracks it already proposed, so re-runs are cheap. Nothing is queued '
'automatically: applying a finding adds that matched track — with its album '
'context — to the wishlist, the same as any other download.\n\n'
'Settings:\n'
'- Scope: "watchlist" (watchlisted artists only) or "all" (whole library)\n'
'- Min confidence: minimum match confidence (0-1) to surface a finding\n\n'
'Note: detecting fake/transcoded lossless files is handled by the separate '
'Fake Lossless Detector job.'
)
icon = 'repair-icon-lossy'
default_enabled = False
default_interval_hours = 168
default_settings = {'scope': 'watchlist', 'min_confidence': 0.7}
setting_options = {'scope': ['watchlist', 'all']}
auto_fix = False
def _get_settings(self, context: JobContext) -> Dict[str, Any]:
cfg = context.config_manager
scope = 'watchlist'
min_conf = 0.7
if cfg:
scope = cfg.get(self.get_config_key('settings.scope'), 'watchlist') or 'watchlist'
try:
min_conf = float(cfg.get(self.get_config_key('settings.min_confidence'), 0.7))
except (TypeError, ValueError):
min_conf = 0.7
return {'scope': scope, 'min_confidence': min_conf}
def _load_tracks(self, db: Any, scope: str) -> List[dict]:
conn = db._get_connection()
try:
base = (
"SELECT t.id, t.title, t.file_path, t.bitrate, t.duration, "
"a.name AS artist_name, al.title AS album_title, t.album_id, t.track_number, "
"al.spotify_album_id, al.itunes_album_id, al.deezer_id, "
"al.musicbrainz_release_id, al.audiodb_id "
"FROM tracks t "
"JOIN artists a ON t.artist_id = a.id "
"JOIN albums al ON t.album_id = al.id "
"WHERE t.file_path IS NOT NULL AND t.file_path != ''"
)
if scope == 'watchlist':
artists = db.get_watchlist_artists(profile_id=1)
names = [getattr(ar, 'artist_name', None) for ar in artists]
names = [n for n in names if n]
if not names:
return []
placeholders = ','.join('?' for _ in names)
rows = conn.execute(
base + f" AND a.name IN ({placeholders})", names).fetchall()
else:
rows = conn.execute(base).fetchall()
return [dict(zip(_TRACK_COLS, r, strict=False)) for r in rows]
finally:
conn.close()
def _load_existing_finding_ids(self, db: Any) -> set:
"""Track IDs that already have a finding for this job (any status). Lets a
re-run skip tracks we've already proposed/dismissed without re-hitting the
metadata API pending stays deduped, and a dismissed track stays dismissed."""
conn = db._get_connection()
try:
rows = conn.execute(
"SELECT entity_id FROM repair_findings WHERE job_id = ? AND entity_type = 'track'",
(self.job_id,)).fetchall()
return {str(r[0]) for r in rows if r and r[0] is not None}
except Exception:
return set()
finally:
conn.close()
def estimate_scope(self, context: JobContext) -> int:
try:
return len(self._load_tracks(context.db, self._get_settings(context)['scope']))
except Exception:
return 0
def scan(self, context: JobContext) -> JobResult:
result = JobResult()
settings = self._get_settings(context)
scope = settings['scope']
min_conf = settings['min_confidence']
db = context.db
quality_profile = db.get_quality_profile()
if preferred_quality_floor(quality_profile) is None:
logger.info("[Quality Upgrade] No quality buckets enabled in profile — nothing to flag")
return result
try:
tracks = self._load_tracks(db, scope)
except Exception as e:
logger.error("[Quality Upgrade] Error loading tracks: %s", e, exc_info=True)
result.errors += 1
return result
total = len(tracks)
if context.update_progress:
context.update_progress(0, total)
if context.report_progress:
context.report_progress(phase=f'Checking quality on {total} tracks...', total=total)
# Tracks we've already proposed/dismissed — skip them so a re-run doesn't
# re-resolve the same tracks against the metadata API.
already_found = self._load_existing_finding_ids(db)
# Metadata source for matching — resolved lazily so we only fail if we
# actually find a low-quality track that needs a match.
engine = None
source_priority: List[str] = []
for i, row in enumerate(tracks):
if context.check_stop():
return result
if i % 10 == 0 and context.wait_if_paused():
return result
track_id = row['id']
title = row['title']
file_path = row['file_path']
bitrate = row['bitrate']
duration_ms = row.get('duration')
artist_name = row['artist_name']
album_title = row['album_title']
album_id = row['album_id']
track_number = row.get('track_number')
stored_album_ids = {
src: row[col] for src, col in _SOURCE_ALBUM_ID_COL.items() if row.get(col)
}
result.scanned += 1
if str(track_id) in already_found:
result.findings_skipped_dedup += 1
continue
if meets_preferred_quality(file_path, bitrate, quality_profile):
result.skipped += 1
if context.update_progress and (i + 1) % 25 == 0:
context.update_progress(i + 1, total)
continue
# Below preferred quality — find a better version to propose.
if engine is None:
from core.matching_engine import MusicMatchingEngine
engine = MusicMatchingEngine()
source_priority = get_source_priority(get_primary_source()) or []
if not source_priority:
logger.warning("[Quality Upgrade] No metadata provider available — cannot propose upgrades")
return result
if context.is_spotify_rate_limited():
logger.info("[Quality Upgrade] Spotify rate-limited — stopping scan early")
return result
current_rank = classify_track_quality(file_path, bitrate)
current_label = _rank_label(current_rank)
if context.report_progress:
context.report_progress(
scanned=i + 1, total=total,
log_line=f'Low quality ({current_label}): {artist_name} - {title}',
log_type='info')
# Read the identifiers enrichment embedded in the file once (ISRC +
# per-source track IDs), used by the two most-exact tiers below.
file_ids = _read_file_ids(file_path)
# Tiered match, best identity first, loosest last:
# 0. The active source's OWN track ID, embedded in the file by
# enrichment → fetch that exact track by ID. No search at all.
# 1. ISRC (also in the tags) → exact track on any source.
# 2. Album → track: stored album source ID if we have it (enriched
# album), else find the album by search, then locate our track in
# its tracklist. Pins the right album even when the track itself
# isn't enriched. (artist → album → track)
# 3. Plain artist+title search with similarity scoring. (artist → track)
# The fuzzy tiers (2-3) also apply a duration guard to reject wrong cuts.
best, source, conf, attempted = None, None, 0.0, False
matched_via = 'track_id'
best, source = _match_via_track_id(file_ids, source_priority)
if best:
conf, attempted = 1.0, True
if not best:
matched_via = 'isrc'
best, source = _match_via_isrc(file_ids.get('isrc', ''), source_priority)
if best:
conf, attempted = 1.0, True
if not best:
matched_via = 'album'
try:
best, source = _match_via_album(
engine, source_priority, artist_name or '', album_title or '',
title, track_number, stored_album_ids, duration_ms)
except Exception as e:
logger.debug("[Quality Upgrade] Album match error for %s - %s: %s", artist_name, title, e)
best = None
if best:
conf, attempted = 1.0, True
if not best:
matched_via = 'search'
try:
best, conf, source, attempted = _find_best_match(
engine, source_priority, title, artist_name or '', album_title or '',
min_conf, duration_ms)
except Exception as e:
logger.debug("[Quality Upgrade] Match error for %s - %s: %s", artist_name, title, e)
result.errors += 1
continue
if not best:
if matched_via == 'search' and not attempted:
logger.warning("[Quality Upgrade] No metadata provider responded — stopping")
return result
result.skipped += 1
continue
matched = _normalize_track_match(best, source or 'metadata')
# Carry album context: prefer the matched album, fall back to the
# library album the low-quality track came from.
alb = matched.get('album')
if (not isinstance(alb, dict) or not alb.get('name')) and album_title:
matched['album'] = {'name': album_title, 'images': (alb or {}).get('images', []) if isinstance(alb, dict) else []}
if context.create_finding:
try:
inserted = context.create_finding(
job_id=self.job_id,
finding_type='quality_upgrade',
severity='info',
entity_type='track',
entity_id=str(track_id),
file_path=file_path,
title=f'Upgrade: {artist_name} - {title} ({current_label})',
description=(
f'"{title}" by {artist_name} is {current_label}, below your preferred '
f'quality. Best match: "{_track_name(best)}" via {source} '
f'({_MATCH_NOTE.get(matched_via, "matched") if matched_via != "search" else f"confidence {conf:.0%}"}). '
'Apply to add it to the wishlist.'),
details={
'track_id': track_id,
'track_title': title,
'artist': artist_name,
'album_id': album_id,
'album_title': album_title,
'current_format': current_label,
'current_bitrate': bitrate,
'match_confidence': conf,
'matched_via': matched_via,
'provider': source,
'matched_track_data': matched,
})
if inserted:
result.findings_created += 1
else:
result.findings_skipped_dedup += 1
except Exception as e:
logger.debug("[Quality Upgrade] create finding failed for track %s: %s", track_id, e)
result.errors += 1
if context.update_progress and (i + 1) % 10 == 0:
context.update_progress(i + 1, total)
if context.update_progress:
context.update_progress(total, total)
logger.info("[Quality Upgrade] %d scanned, %d upgrades found, %d met/skip",
result.scanned, result.findings_created, result.skipped)
return result

View file

@ -17,7 +17,7 @@ import threading
import time import time
import uuid import uuid
from difflib import SequenceMatcher from difflib import SequenceMatcher
from datetime import datetime, timedelta from datetime import datetime, timedelta, timezone
from typing import Any, Dict, List, Optional, Tuple from typing import Any, Dict, List, Optional, Tuple
from core.metadata_service import ( from core.metadata_service import (
@ -585,13 +585,29 @@ class RepairWorker:
logger.info("Repair worker thread finished") logger.info("Repair worker thread finished")
@staticmethod
def _hours_since(finished_at_iso: str, now_utc: datetime) -> float:
"""Hours between a stored ``finished_at`` and ``now_utc``, both in UTC.
``finished_at`` is written by SQLite's CURRENT_TIMESTAMP, which is ALWAYS
UTC (and naive). #885: the scheduler compared it against ``datetime.now()``
(naive LOCAL), so the localUTC offset leaked into the elapsed time. For a
zone AHEAD of UTC (Australia/Sydney = +11) every job looked ~11h stale and
fired every poll; behind UTC (the Americas) it just waited too long. Parse
the naive timestamp AS UTC and subtract a UTC ``now`` so scheduling is
timezone-independent."""
dt = datetime.fromisoformat(finished_at_iso)
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return (now_utc - dt).total_seconds() / 3600
def _pick_next_job(self) -> Optional[str]: def _pick_next_job(self) -> Optional[str]:
"""Pick the next job to run based on staleness priority. """Pick the next job to run based on staleness priority.
Returns job_id of the stalest job whose interval has elapsed, Returns job_id of the stalest job whose interval has elapsed,
or None if nothing is due. or None if nothing is due.
""" """
now = datetime.now() now = datetime.now(timezone.utc)
best_job_id = None best_job_id = None
best_staleness = -1 best_staleness = -1
@ -613,8 +629,7 @@ class RepairWorker:
continue continue
try: try:
last_finished = datetime.fromisoformat(last_run['finished_at']) elapsed_hours = self._hours_since(last_run['finished_at'], now)
elapsed_hours = (now - last_finished).total_seconds() / 3600
if elapsed_hours < interval_hours: if elapsed_hours < interval_hours:
continue # Not due yet continue # Not due yet
@ -982,6 +997,7 @@ class RepairWorker:
'quality_upgrade': self._fix_quality_upgrade, 'quality_upgrade': self._fix_quality_upgrade,
'missing_discography_track': self._fix_discography_backfill, 'missing_discography_track': self._fix_discography_backfill,
'library_retag': self._fix_library_retag, 'library_retag': self._fix_library_retag,
'quality_upgrade': self._fix_quality_upgrade,
} }
handler = handlers.get(finding_type) handler = handlers.get(finding_type)
if not handler: if not handler:
@ -1008,6 +1024,36 @@ class RepairWorker:
except Exception as e: except Exception as e:
return {'success': False, 'error': str(e)} return {'success': False, 'error': str(e)}
def _fix_quality_upgrade(self, entity_type, entity_id, file_path, details):
"""Add the matched higher-quality version to the wishlist (with album
context). Applying a Quality Upgrade finding is the user-approved step
that the old auto-acting Quality Scanner did without review."""
track_data = details.get('matched_track_data')
if not track_data:
return {'success': False, 'error': 'No matched track in finding'}
try:
success = self.db.add_to_wishlist(
spotify_track_data=track_data,
failure_reason=f"Quality upgrade — current file is {details.get('current_format', 'low quality')}",
source_type='repair',
source_info={
'job': 'quality_upgrade',
'original_file_path': file_path,
'original_format': details.get('current_format'),
'original_bitrate': details.get('current_bitrate'),
'album_title': details.get('album_title'),
'match_confidence': details.get('match_confidence'),
'provider': details.get('provider'),
},
)
track_name = track_data.get('name', '?')
if success:
return {'success': True, 'action': 'added_to_wishlist',
'message': f"Added '{track_name}' to wishlist for re-download"}
return {'success': False, 'error': f"Could not add '{track_name}' to wishlist (may already exist or be blocklisted)"}
except Exception as e:
return {'success': False, 'error': str(e)}
def _fix_dead_file(self, entity_type, entity_id, file_path, details): def _fix_dead_file(self, entity_type, entity_id, file_path, details):
"""Fix a dead file reference. Action depends on details['_fix_action']: """Fix a dead file reference. Action depends on details['_fix_action']:
'redownload' (default) add to wishlist + remove DB entry 'redownload' (default) add to wishlist + remove DB entry
@ -1522,6 +1568,7 @@ class RepairWorker:
resolved, resolved,
junk_files=details.get('junk_files') or [], junk_files=details.get('junk_files') or [],
remove_junk=bool(details.get('remove_junk', True)), remove_junk=bool(details.get('remove_junk', True)),
remove_disposable=bool(details.get('remove_disposable', False)),
root=self.transfer_folder, root=self.transfer_folder,
listdir=os.listdir, isdir=os.path.isdir, islink=os.path.islink, listdir=os.listdir, isdir=os.path.isdir, islink=os.path.islink,
remove_file=os.remove, rmdir=os.rmdir, remove_file=os.remove, rmdir=os.rmdir,

View file

@ -427,8 +427,11 @@ class SoulseekClient(DownloadSourcePlugin):
if f'.{file_ext}' not in audio_extensions: if f'.{file_ext}' not in audio_extensions:
continue continue
quality = file_ext if file_ext in ['flac', 'mp3', 'ogg', 'aac', 'wma'] else 'unknown' # .m4a is the usual AAC container — bucket it as 'aac' (the
# quality filter treats AAC as an opt-in tier; off by default).
quality = 'aac' if file_ext == 'm4a' else (
file_ext if file_ext in ['flac', 'mp3', 'ogg', 'aac', 'wma'] else 'unknown')
# Create TrackResult # Create TrackResult
# Convert duration from seconds to milliseconds (slskd returns seconds, Spotify uses ms) # Convert duration from seconds to milliseconds (slskd returns seconds, Spotify uses ms)
raw_duration = file_data.get('length') raw_duration = file_data.get('length')
@ -1151,7 +1154,9 @@ class SoulseekClient(DownloadSourcePlugin):
ext = Path(filename).suffix.lower() ext = Path(filename).suffix.lower()
if ext not in audio_extensions: if ext not in audio_extensions:
continue continue
quality = ext.lstrip('.') if ext.lstrip('.') in ['flac', 'mp3', 'ogg', 'aac', 'wma'] else 'unknown' _qext = ext.lstrip('.')
quality = 'aac' if _qext == 'm4a' else (
_qext if _qext in ['flac', 'mp3', 'ogg', 'aac', 'wma'] else 'unknown')
raw_duration = file_data.get('length') raw_duration = file_data.get('length')
duration_ms = raw_duration * 1000 if raw_duration else None duration_ms = raw_duration * 1000 if raw_duration else None
slskd_attrs = {a['type']: a['value'] for a in file_data.get('attributes', [])} slskd_attrs = {a['type']: a['value'] for a in file_data.get('attributes', [])}
@ -1965,6 +1970,7 @@ class SoulseekClient(DownloadSourcePlugin):
'mp3_320': (1, 50), 'mp3_320': (1, 50),
'mp3_256': (1, 40), 'mp3_256': (1, 40),
'mp3_192': (1, 30), 'mp3_192': (1, 30),
'aac': (1, 50),
'other': (0, 500), 'other': (0, 500),
} }

View file

@ -12,6 +12,9 @@ from core.spotify_client import SpotifyClient, SpotifyRateLimitError
from core.worker_utils import ( from core.worker_utils import (
ARTIST_NAME_MATCH_THRESHOLD, ARTIST_NAME_MATCH_THRESHOLD,
interruptible_sleep, interruptible_sleep,
owned_album_titles,
pick_artist_by_catalog,
release_titles,
set_album_api_track_count, set_album_api_track_count,
source_id_conflict, source_id_conflict,
) )
@ -44,6 +47,12 @@ class SpotifyWorker:
# Current item being processed (for UI tooltip) # Current item being processed (for UI tooltip)
self.current_item = None self.current_item = None
# Whether the worker is serving via the no-creds Spotify Free source as of
# its last loop iteration. Cached from the loop's _free_active() probe so
# get_stats() can report it without an auth API call (#887: a no-auth user
# whose enrichment runs on Free was shown "Not Authenticated").
self._serving_via_free = False
# Statistics # Statistics
self.stats = { self.stats = {
'matched': 0, 'matched': 0,
@ -133,8 +142,14 @@ class SpotifyWorker:
# real-API daily budget (the worker set _budget_exhausted_use_free — # real-API daily budget (the worker set _budget_exhausted_use_free —
# a cheap attribute read). Lets the UI show "Running (Spotify Free)" # a cheap attribute read). Lets the UI show "Running (Spotify Free)"
# instead of a misleading "rate limited" / "daily limit reached". # instead of a misleading "rate limited" / "daily limit reached".
# #887: the loop's cached _free_active() result is the comprehensive
# signal — it's True for a no-auth user enriching via Spotify Free by
# default (prefer-free is on unless disabled), not just the rate-limit
# / budget bridges. The extra terms stay as a fallback for the brief
# window before the loop's first iteration sets the cache.
using_free = bool( using_free = bool(
(rate_limited and self.client.is_spotify_metadata_available()) getattr(self, '_serving_via_free', False)
or (rate_limited and self.client.is_spotify_metadata_available())
or getattr(self.client, '_budget_exhausted_use_free', False) or getattr(self.client, '_budget_exhausted_use_free', False)
) )
except Exception: except Exception:
@ -261,6 +276,9 @@ class SpotifyWorker:
free_serving = self.client._free_active() free_serving = self.client._free_active()
except Exception: except Exception:
free_serving = False free_serving = False
# Cache for get_stats() so the dashboard status reflects that the
# worker IS enriching via Free even with no official auth (#887).
self._serving_via_free = free_serving
# Daily budget guard — pause ONLY when the budget is spent AND we # Daily budget guard — pause ONLY when the budget is spent AND we
# can't serve via free (no free available). Otherwise free took over. # can't serve via free (no free available). Otherwise free took over.
@ -563,16 +581,23 @@ class SpotifyWorker:
logger.debug(f"No Spotify results for artist '{artist_name}'") logger.debug(f"No Spotify results for artist '{artist_name}'")
return return
# Find best fuzzy match — score all candidates, pick highest above the # Candidates clearing the (stricter, artist-specific) name gate, best
# (stricter, artist-specific) threshold so short-name false positives # name-score first so [0] is the legacy "first/highest" pick.
# like "ODESZA"/"odessa" don't slip through. scored = [
best_obj = None (self._name_similarity(artist_name, a.name), a)
best_score = 0 for a in results
for artist_obj in results: ]
score = self._name_similarity(artist_name, artist_obj.name) gated = [a for score, a in sorted(scored, key=lambda s: s[0], reverse=True)
if score >= ARTIST_NAME_MATCH_THRESHOLD and score > best_score: if score >= ARTIST_NAME_MATCH_THRESHOLD]
best_obj = artist_obj
best_score = score # Same-name disambiguation: when more than one "Rone" clears the gate,
# pick the one whose catalog overlaps the albums this library owns.
best_obj, _overlap = pick_artist_by_catalog(
gated,
owned_album_titles(self.db, artist_id),
lambda a: release_titles(self.client.get_artist_albums(a.id)),
)
best_score = self._name_similarity(artist_name, best_obj.name) if best_obj else 0
if best_obj: if best_obj:
if not self._is_spotify_id(best_obj.id): if not self._is_spotify_id(best_obj.id):

View file

@ -193,17 +193,44 @@ def numeric_tokens_differ(title_a: str, title_b: str) -> bool:
string similarity ('Vol.4' vs 'Vol.4.5' = 0.97) and token-subset checks string similarity ('Vol.4' vs 'Vol.4.5' = 0.97) and token-subset checks
both wave these through, which hung volume 4.5's cover art on volume 4 both wave these through, which hung volume 4.5's cover art on volume 4
(Sokhi). Shared digits on both sides ('1989' vs '1989 (Deluxe)') are (Sokhi). Shared digits on both sides ('1989' vs '1989 (Deluxe)') are
fine.""" fine.
Tokenises on non-word runs but KEEPS word characters of every script, so a
digit glued to a non-latin word stays its own digit-bearing token. Stripping
to [a-z0-9] turned CJK into spaces, collapsing 'サウンドトラック2' to a bare
'2' that a shared number elsewhere ('第2期' = season 2) already covered so
'Soundtrack' and 'Soundtrack2' both reduced to {'2'} and matched, hanging the
wrong cover (Sokhi again)."""
def _digit_tokens(text: str) -> frozenset: def _digit_tokens(text: str) -> frozenset:
tokens = re.sub(r"[^a-z0-9]+", " ", (text or "").casefold()).split() # \W is Unicode-aware for str: CJK/kana count as word chars, so a digit
# stays attached to its word instead of collapsing to a bare '2'.
tokens = re.sub(r"\W+", " ", (text or "").casefold()).split()
return frozenset(t for t in tokens if any(c.isdigit() for c in t)) return frozenset(t for t in tokens if any(c.isdigit() for c in t))
return _digit_tokens(title_a) != _digit_tokens(title_b) return _digit_tokens(title_a) != _digit_tokens(title_b)
def base_title_before_dash(title: str) -> str:
"""The base title before Spotify's ' - <qualifier>' version separator.
Spotify renders versions as 'Calma - Remix' / 'Song - Radio Edit' /
'Track - Remastered 2019'. Libraries (and the files people actually have)
very often store just the base 'Calma' so a literal search for
'Calma - Remix' finds nothing and the OR-fuzzy fallback then floods on the
common qualifier word ('remix' matches every remix). This returns the base
('Calma') for a base-title search fallback. Splits on the FIRST ' - ' (the
spaced hyphen is Spotify's separator; a bare hyphen inside a word is left
alone). Returns the title unchanged when there's no separator."""
if not title:
return title
idx = title.find(' - ')
return title[:idx].strip() if idx > 0 else title
__all__ = [ __all__ = [
"titles_plausibly_same", "titles_plausibly_same",
"strip_redundant_context_qualifiers", "strip_redundant_context_qualifiers",
"strip_subtitle_qualifiers", "strip_subtitle_qualifiers",
"numeric_tokens_differ", "numeric_tokens_differ",
"base_title_before_dash",
] ]

View file

@ -1655,6 +1655,8 @@ class TidalClient:
ids: List[str] = [] ids: List[str] = []
next_path: Optional[str] = None next_path: Optional[str] = None
consecutive_429 = 0
MAX_PAGE_RETRIES = 4
while True: while True:
if next_path: if next_path:
@ -1687,6 +1689,28 @@ class TidalClient:
logger.warning(f"Tidal collection page request failed: {e}") logger.warning(f"Tidal collection page request failed: {e}")
break break
# Rate limited mid-walk → retry the SAME cursor page with backoff
# rather than silently truncating the collection. Without this a 429
# on page ~5 capped a 513-track favorites list at ~98 (issue #880);
# the regular-playlist paginator already retries 429 the same way.
if resp.status_code == 429:
consecutive_429 += 1
if consecutive_429 <= MAX_PAGE_RETRIES:
backoff = 5.0 * consecutive_429 # 5s, 10s, 15s, 20s
logger.warning(
f"Tidal collection {expected_type} rate limited (429) — "
f"retry {consecutive_429}/{MAX_PAGE_RETRIES} in {backoff}s "
f"({len(ids)} fetched so far)"
)
time.sleep(backoff)
continue # next_path/cursor unchanged → re-request same page
logger.error(
f"Tidal collection {expected_type} still rate limited after "
f"{MAX_PAGE_RETRIES} retries — returning {len(ids)} IDs "
f"(PARTIAL: the collection may be larger)"
)
break
if resp.status_code != 200: if resp.status_code != 200:
# 401/403 = scope/permission issue. Token predates the # 401/403 = scope/permission issue. Token predates the
# `collection.read` scope expansion or the user revoked # `collection.read` scope expansion or the user revoked
@ -1704,6 +1728,8 @@ class TidalClient:
) )
break break
consecutive_429 = 0 # a good page → reset the retry budget
try: try:
data = resp.json() data = resp.json()
except ValueError as e: except ValueError as e:

View file

@ -198,7 +198,15 @@ class NZBGetAdapter:
size=size_bytes, size=size_bytes,
downloaded=downloaded_bytes, downloaded=downloaded_bytes,
download_speed=speed, download_speed=speed,
save_path=group.get('DestDir'), # A QUEUED group's DestDir is the in-progress intermediate dir
# (NZBGet names it '<NZBName>.#<NZBID>' and empties/renames it once
# the move completes). Never offer it as a final save_path — finalize
# only from the HISTORY entry (real FinalDir/DestDir). Otherwise a
# PP_FINISHED group (which maps to 'completed') would finalize on the
# incomplete '....#2141' dir, which is then gone -> "No audio files
# found in /…/incomplete/….#2141" (Swigs). Mirrors the SAB adapter
# ignoring its incomplete_path.
save_path=None,
category=group.get('Category'), category=group.get('Category'),
) )
@ -217,7 +225,15 @@ class NZBGetAdapter:
size=size_bytes, size=size_bytes,
downloaded=size_bytes if not is_failed else 0, downloaded=size_bytes if not is_failed else 0,
download_speed=0, download_speed=0,
save_path=entry.get('DestDir'), # Prefer FinalDir — the location after a post-processing script (or
# NZBGet's own move) relocated the files; DestDir can still point at
# the intermediate '….#NZBID' dir. Swigs: files landed in
# /data/soulseek/… (FinalDir) while DestDir stayed /…/incomplete/….#2141.
# Fall back to DestDir when FinalDir is empty (no PP move). Empty/
# whitespace -> None so the plugin waits for a real path.
save_path=(str(entry.get('FinalDir') or '').strip()
or str(entry.get('DestDir') or '').strip()
or None),
category=entry.get('Category'), category=entry.get('Category'),
error=status_field if is_failed else None, error=status_field if is_failed else None,
) )

162
core/wishlist/ignore.py Normal file
View file

@ -0,0 +1,162 @@
"""Wishlist ignore-list — a TTL'd skip-gate for the wishlist (#874).
When a user removes a track from the wishlist or cancels an in-flight
wishlist download, SoulSync would otherwise re-add it on the next
automatic cycle (watchlist scan, failed-track capture, or the cancel
handler's own re-add), so the same release downloads → fails/cancels →
re-queues forever. The ignore list records the user's "stop
auto-grabbing this" intent, and the wishlist *add* path checks it,
skipping automatic re-adds until the entry ages out.
It is deliberately softer than the blocklist:
- it **expires** after ``IGNORE_TTL_DAYS`` so the track is re-attempted
again later rather than banned forever, and
- it **never blocks a manual force-download** only the automatic
re-queue. (Manual downloads don't go through ``add_to_wishlist`` at
all, and an explicit manual *add* both bypasses the gate and clears
any existing ignore for the track.)
This module is pure decision logic no database handle and no clock of
its own; the caller passes ``now``. The SQL lives in ``MusicDatabase``
as a thin wrapper around these helpers, which keeps the TTL / id-matching
rules unit-testable without a database.
"""
from __future__ import annotations
from datetime import datetime, timedelta
from typing import Any, Dict, Iterable, List, Optional, Set, Tuple
IGNORE_TTL_DAYS = 30
# Recognised reasons (free-text tolerated; these are the canonical two).
REASON_REMOVED = "removed"
REASON_CANCELLED = "cancelled"
def normalize_ignore_id(track_id: Any) -> str:
"""Canonical key for a wishlist track id.
The wishlist stores some ids as a composite ``<track_id>::<album_id>``
(when ``wishlist.allow_duplicate_tracks`` is on). The add-path gate
keys on the bare track id (``spotify_track_data['id']``), so we strip
the ``::album`` suffix here so an ignore recorded from a composite-id
wishlist row still matches the bare-id add attempt, and vice-versa.
Returns ``''`` for falsy/blank input.
"""
s = str(track_id or "").strip()
if not s:
return ""
return s.split("::", 1)[0]
def _parse_ts(value: Any) -> Optional[datetime]:
"""Best-effort parse of a sqlite TIMESTAMP / ISO string / datetime."""
if isinstance(value, datetime):
return value
if not value:
return None
s = str(value).strip()
for fmt in (
"%Y-%m-%d %H:%M:%S",
"%Y-%m-%dT%H:%M:%S",
"%Y-%m-%d %H:%M:%S.%f",
"%Y-%m-%dT%H:%M:%S.%f",
):
try:
return datetime.strptime(s, fmt)
except ValueError:
continue
try:
return datetime.fromisoformat(s)
except ValueError:
return None
def is_expired(created_at: Any, now: datetime, ttl_days: int = IGNORE_TTL_DAYS) -> bool:
"""True when an ignore entry created at ``created_at`` has aged past TTL.
Fail-SAFE in the gate's favour: an unparseable/blank timestamp is
treated as **expired** (returns True) so a corrupt row can never wedge
a track out of the wishlist permanently the worst case is the
ignore silently lapses and the track becomes eligible again.
"""
created = _parse_ts(created_at)
if created is None:
return True
return now >= created + timedelta(days=ttl_days)
def active_ignored_ids(
rows: Iterable[Dict[str, Any]], now: datetime, ttl_days: int = IGNORE_TTL_DAYS
) -> Set[str]:
"""Set of normalized track ids whose ignore entry is still within TTL."""
out: Set[str] = set()
for row in rows or []:
tid = normalize_ignore_id(row.get("track_id"))
if tid and not is_expired(row.get("created_at"), now, ttl_days):
out.add(tid)
return out
def is_ignored(
rows: Iterable[Dict[str, Any]], track_id: Any, now: datetime, ttl_days: int = IGNORE_TTL_DAYS
) -> bool:
"""Whether ``track_id`` matches an in-TTL entry among ``rows``."""
key = normalize_ignore_id(track_id)
if not key:
return False
return key in active_ignored_ids(rows, now, ttl_days)
def extract_display(data: Any) -> Tuple[str, str]:
"""Pull a (track_name, artist_name) pair from a Spotify-shaped dict.
Tolerates ``artists`` as a list of dicts or bare strings, and missing
fields. Used to give ignore-list rows a human label for the UI.
Returns ``('', '')`` when nothing usable is present.
"""
if not isinstance(data, dict):
return "", ""
name = str(data.get("name") or "").strip()
artist = ""
artists = data.get("artists") or []
if isinstance(artists, list) and artists:
first = artists[0]
if isinstance(first, dict):
artist = str(first.get("name") or "").strip()
else:
artist = str(first or "").strip()
return name, artist
def ignore_wishlist_track(
database: Any,
profile_id: int,
track_id: Any,
reason: str,
spotify_data: Any = None,
) -> bool:
"""Record an ignore entry for a wishlist track. Best-effort; never raises.
Copies the track's display name/artist from ``spotify_data`` when
provided (callers should capture it BEFORE removing the wishlist row,
since the row may be gone afterwards); otherwise leaves them blank.
Returns True when an entry was written.
"""
key = normalize_ignore_id(track_id)
if not key or database is None:
return False
name, artist = extract_display(spotify_data or {})
try:
return bool(
database.add_to_wishlist_ignore(
key,
track_name=name,
artist_name=artist,
reason=reason or REASON_REMOVED,
profile_id=profile_id,
)
)
except Exception:
return False

View file

@ -310,12 +310,30 @@ def remove_track_from_wishlist(
if not spotify_track_id: if not spotify_track_id:
return {"success": False, "error": "No spotify_track_id provided"}, 400 return {"success": False, "error": "No spotify_track_id provided"}, 400
success = get_wishlist_service().remove_track_from_wishlist( service = get_wishlist_service()
_db = getattr(service, "database", None)
# #874: capture the track's display info BEFORE removal (the row is
# gone afterwards) so the ignore-list entry carries a human label.
_ignore_data = None
try:
if _db is not None:
_ignore_data = _db.get_wishlist_spotify_data(
spotify_track_id, profile_id=runtime.profile_id)
except Exception:
_ignore_data = None
success = service.remove_track_from_wishlist(
spotify_track_id, spotify_track_id,
profile_id=runtime.profile_id, profile_id=runtime.profile_id,
) )
if success: if success:
# #874: a user-initiated remove means "stop auto-requeuing this".
# Record a TTL'd ignore so the watchlist/auto-processor doesn't
# re-add it. Best-effort — never fails the remove.
from core.wishlist.ignore import ignore_wishlist_track, REASON_REMOVED
ignore_wishlist_track(_db, runtime.profile_id,
spotify_track_id, REASON_REMOVED, spotify_data=_ignore_data)
runtime.logger.info("Successfully removed track from wishlist: %s", spotify_track_id) runtime.logger.info("Successfully removed track from wishlist: %s", spotify_track_id)
return {"success": True, "message": "Track removed from wishlist"}, 200 return {"success": True, "message": "Track removed from wishlist"}, 200
@ -358,13 +376,20 @@ def remove_album_from_wishlist(
if matched: if matched:
spotify_track_id = track.get("track_id") or track.get("spotify_track_id") or track.get("id") spotify_track_id = track.get("track_id") or track.get("spotify_track_id") or track.get("id")
if spotify_track_id: if spotify_track_id:
tracks_to_remove.append(spotify_track_id) # Keep the loaded spotify_data alongside the id so the #874
# ignore entry can be labelled without a second DB read.
tracks_to_remove.append((spotify_track_id, spotify_data))
from core.wishlist.ignore import ignore_wishlist_track, REASON_REMOVED
_db = getattr(wishlist_service, "database", None)
removed_count = 0 removed_count = 0
album_remove_pid = runtime.profile_id album_remove_pid = runtime.profile_id
for spotify_track_id in tracks_to_remove: for spotify_track_id, track_spotify_data in tracks_to_remove:
if wishlist_service.remove_track_from_wishlist(spotify_track_id, profile_id=album_remove_pid): if wishlist_service.remove_track_from_wishlist(spotify_track_id, profile_id=album_remove_pid):
removed_count += 1 removed_count += 1
# #874: user removed the whole album → ignore each track.
ignore_wishlist_track(_db, album_remove_pid,
spotify_track_id, REASON_REMOVED, spotify_data=track_spotify_data)
if removed_count > 0: if removed_count > 0:
runtime.logger.info("Successfully removed %s tracks from album %s", removed_count, album_id) runtime.logger.info("Successfully removed %s tracks from album %s", removed_count, album_id)
@ -390,11 +415,22 @@ def remove_batch_from_wishlist(
if not spotify_track_ids or not isinstance(spotify_track_ids, list): if not spotify_track_ids or not isinstance(spotify_track_ids, list):
return {"success": False, "error": "Missing or invalid spotify_track_ids"}, 400 return {"success": False, "error": "Missing or invalid spotify_track_ids"}, 400
from core.wishlist.ignore import ignore_wishlist_track, REASON_REMOVED
service = get_wishlist_service()
_db = getattr(service, "database", None)
removed = 0 removed = 0
pid = runtime.profile_id pid = runtime.profile_id
for track_id in spotify_track_ids: for track_id in spotify_track_ids:
if get_wishlist_service().remove_track_from_wishlist(track_id, profile_id=pid): # Capture label before the row is deleted (#874).
_data = None
try:
if _db is not None:
_data = _db.get_wishlist_spotify_data(track_id, profile_id=pid)
except Exception:
_data = None
if service.remove_track_from_wishlist(track_id, profile_id=pid):
removed += 1 removed += 1
ignore_wishlist_track(_db, pid, track_id, REASON_REMOVED, spotify_data=_data)
runtime.logger.info("Batch removed %s track(s) from wishlist", removed) runtime.logger.info("Batch removed %s track(s) from wishlist", removed)
return { return {

View file

@ -214,7 +214,11 @@ class WishlistService:
"preview_url": track_data.get("preview_url") if isinstance(track_data, dict) else None, "preview_url": track_data.get("preview_url") if isinstance(track_data, dict) else None,
"external_urls": track_data.get("external_urls", {}) if isinstance(track_data, dict) else {}, "external_urls": track_data.get("external_urls", {}) if isinstance(track_data, dict) else {},
"popularity": track_data.get("popularity", 0) if isinstance(track_data, dict) else 0, "popularity": track_data.get("popularity", 0) if isinstance(track_data, dict) else 0,
"track_number": track_data.get("track_number", 1) if isinstance(track_data, dict) else 1, # "Track 01" bug: 0 = "unknown position", NOT a fabricated 1.
# A fake 1 looks authoritative and blocks the import
# pipeline's track-number recovery; 0 lets it recover the
# real position (file tag / source lookup) before the floor.
"track_number": track_data.get("track_number", 0) if isinstance(track_data, dict) else 0,
"disc_number": track_data.get("disc_number", 1) if isinstance(track_data, dict) else 1, "disc_number": track_data.get("disc_number", 1) if isinstance(track_data, dict) else 1,
} }

View file

@ -101,6 +101,108 @@ def accept_artist_match(database, id_column: str, source_id, artist_id,
return True, "" return True, ""
# --- Same-name artist disambiguation by owned-catalog overlap -------------
# The name gate (above) can't separate two artists who share a name ("Rone" has
# ~5). The decisive signal is the library itself: the user owns albums by the
# RIGHT one. So when several candidates clear the name gate, fetch each one's
# catalog and pick the one whose releases overlap the albums actually owned.
def normalize_release_title(title: str) -> str:
"""Collapse an album/release title for tolerant comparison — drop edition
suffixes ('(Deluxe)', ' - Remastered'), punctuation, and case."""
t = (title or '').lower().strip()
t = re.sub(r'\s*\(.*?\)\s*', ' ', t)
t = re.sub(r'\s*\[.*?\]\s*', ' ', t)
t = re.sub(r'\s+[-–—]\s+.*$', '', t)
t = re.sub(r'[^\w\s]', '', t)
t = re.sub(r'\s+', ' ', t).strip()
return t
def catalog_overlap_score(owned_titles, candidate_titles, threshold: float = 0.85) -> int:
"""How many OWNED album titles appear in the candidate's catalog (fuzzy,
edition-insensitive). The disambiguation signal higher = better match."""
owned = {normalize_release_title(t) for t in (owned_titles or []) if t}
owned.discard('')
cand = {normalize_release_title(t) for t in (candidate_titles or []) if t}
cand.discard('')
if not owned or not cand:
return 0
score = 0
for o in owned:
if o in cand or any(SequenceMatcher(None, o, c).ratio() >= threshold for c in cand):
score += 1
return score
def pick_artist_by_catalog(candidates, owned_titles, fetch_titles) -> tuple:
"""Choose, among same-name candidates, the one whose catalog best overlaps the
OWNED albums. Returns ``(chosen, overlap_score)``.
``candidates`` artist objects already past the name gate (order = the
worker's existing best-by-name order; candidates[0] is the
current behavior's pick).
``owned_titles`` the library artist's owned album titles.
``fetch_titles(candidate) -> list[str]`` that candidate's album titles;
called ONLY when disambiguation is actually needed (2+
candidates and we have owned albums), so the common
single-candidate path costs no extra API calls.
Falls back to candidates[0] (unchanged behavior) when there's nothing to
disambiguate or no candidate overlaps the owned catalog.
"""
candidates = list(candidates or [])
if not candidates:
return None, 0
if len(candidates) == 1:
return candidates[0], 0
owned = [t for t in (owned_titles or []) if t]
if not owned:
return candidates[0], 0
best, best_score = None, 0
for cand in candidates:
try:
titles = fetch_titles(cand) or []
except Exception as exc:
logger.debug("catalog disambiguation: fetch_titles failed: %s", exc)
titles = []
score = catalog_overlap_score(owned, titles)
if score > best_score:
best, best_score = cand, score
if best is not None and best_score > 0:
return best, best_score
return candidates[0], 0 # no overlap signal → keep the best-by-name pick
def owned_album_titles(database, artist_id) -> list:
"""The album titles the library actually has for this artist — the ground
truth used to disambiguate same-name source artists."""
try:
with database._get_connection() as conn:
rows = conn.execute(
"SELECT title FROM albums WHERE artist_id = ?", (artist_id,)
).fetchall()
return [r[0] for r in rows if r and r[0]]
except Exception as exc:
logger.debug("owned_album_titles(%s) failed: %s", artist_id, exc)
return []
def release_titles(albums) -> list:
"""Extract titles from a list of album objects/dicts (handles ``.title``/
``.name`` / dict keys) the candidate side of catalog disambiguation."""
out = []
for al in albums or []:
if isinstance(al, dict):
t = al.get('title') or al.get('name')
else:
t = getattr(al, 'title', None) or getattr(al, 'name', None)
if t:
out.append(t)
return out
def interruptible_sleep(stop_event: threading.Event, seconds: float, step: float = 0.5) -> bool: def interruptible_sleep(stop_event: threading.Event, seconds: float, step: float = 0.5) -> bool:
"""Sleep in chunks so shutdown can interrupt long waits.""" """Sleep in chunks so shutdown can interrupt long waits."""
if seconds <= 0: if seconds <= 0:

View file

@ -344,7 +344,29 @@ class MusicDatabase:
source_info TEXT -- JSON of source context (playlist name, album info, etc.) source_info TEXT -- JSON of source context (playlist name, album info, etc.)
) )
""") """)
# Wishlist ignore-list (#874): a TTL'd skip-gate. When a user
# removes a track from the wishlist or cancels an in-flight
# wishlist download, the track is recorded here so the automatic
# re-add paths (watchlist scan, failed-track capture, cancel
# re-add) skip it until the entry ages out (see core.wishlist.
# ignore.IGNORE_TTL_DAYS). Softer than `blocklist`: it expires
# and never blocks a manual force-download. Keyed on the bare
# track id; unique per (profile, track) so re-ignoring refreshes.
cursor.execute("""
CREATE TABLE IF NOT EXISTS wishlist_ignore (
id INTEGER PRIMARY KEY AUTOINCREMENT,
profile_id INTEGER NOT NULL DEFAULT 1,
track_id TEXT NOT NULL,
track_name TEXT DEFAULT '',
artist_name TEXT DEFAULT '',
reason TEXT DEFAULT 'removed', -- 'removed' | 'cancelled'
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(profile_id, track_id)
)
""")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_wishlist_ignore_profile ON wishlist_ignore (profile_id, track_id)")
# Watchlist table for storing artists to monitor for new releases # Watchlist table for storing artists to monitor for new releases
cursor.execute(""" cursor.execute("""
CREATE TABLE IF NOT EXISTS watchlist_artists ( CREATE TABLE IF NOT EXISTS watchlist_artists (
@ -729,6 +751,41 @@ class MusicDatabase:
cursor.execute("CREATE INDEX IF NOT EXISTS idx_aih_status ON auto_import_history (status)") cursor.execute("CREATE INDEX IF NOT EXISTS idx_aih_status ON auto_import_history (status)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_aih_folder_hash ON auto_import_history (folder_hash)") cursor.execute("CREATE INDEX IF NOT EXISTS idx_aih_folder_hash ON auto_import_history (folder_hash)")
# Re-identify hints (#889) — a user-designated, single-use answer to "which
# release does this track belong to". Written when the user picks a release in
# the Re-identify modal and the file is staged for auto-import; the import flow
# reads the hint at the TOP of matching (keyed by staged path, content_hash as a
# rename-proof fallback), expedites the match to these exact IDs, then consumes
# the row. `replace_track_id` (when set) is the library row to delete AFTER the
# re-import lands; `exempt_dedup` is always 1 because a re-identify is an explicit
# user action that must not be silently dropped by the quality dedup-skip.
cursor.execute("""
CREATE TABLE IF NOT EXISTS rematch_hints (
id INTEGER PRIMARY KEY AUTOINCREMENT,
staged_path TEXT NOT NULL,
content_hash TEXT,
source TEXT NOT NULL,
isrc TEXT,
track_id TEXT,
album_id TEXT,
artist_id TEXT,
track_title TEXT,
album_name TEXT,
artist_name TEXT,
album_type TEXT,
track_number INTEGER,
disc_number INTEGER,
replace_track_id INTEGER,
exempt_dedup INTEGER NOT NULL DEFAULT 1,
status TEXT NOT NULL DEFAULT 'pending',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
consumed_at TIMESTAMP
)
""")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_rmh_staged_path ON rematch_hints (staged_path)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_rmh_content_hash ON rematch_hints (content_hash)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_rmh_status ON rematch_hints (status)")
# Sync history table — tracks the last 100 sync operations with cached context for re-trigger # Sync history table — tracks the last 100 sync operations with cached context for re-trigger
cursor.execute(""" cursor.execute("""
CREATE TABLE IF NOT EXISTS sync_history ( CREATE TABLE IF NOT EXISTS sync_history (
@ -6888,11 +6945,26 @@ class MusicDatabase:
# STRATEGY 1: Try basic SQL LIKE search first (fastest) # STRATEGY 1: Try basic SQL LIKE search first (fastest)
basic_results = self._search_tracks_basic(cursor, title, artist, limit, server_source, rank_artist) basic_results = self._search_tracks_basic(cursor, title, artist, limit, server_source, rank_artist)
if basic_results: if basic_results:
logger.debug(f"Basic search found {len(basic_results)} results") logger.debug(f"Basic search found {len(basic_results)} results")
return basic_results return basic_results
# STRATEGY 1b: Spotify renders versions as "Title - Qualifier"
# ("Calma - Remix") but libraries usually store just the base
# ("Calma"), so the literal search misses. Retry on the base title
# BEFORE the OR-fuzzy fallback (which would flood on the common
# qualifier word — every "... remix" matches "remix"). #: Calma - Remix
if title:
from core.text.title_match import base_title_before_dash
base_title = base_title_before_dash(title)
if base_title and base_title != title:
base_results = self._search_tracks_basic(
cursor, base_title, artist, limit, server_source, rank_artist)
if base_results:
logger.debug("Base-title search matched '%s' via '%s'", title, base_title)
return base_results
# STRATEGY 2: Broader fuzzy search - splits into individual words with OR matching # STRATEGY 2: Broader fuzzy search - splits into individual words with OR matching
fuzzy_results = self._search_tracks_fuzzy_fallback(cursor, title, artist, limit, server_source) fuzzy_results = self._search_tracks_fuzzy_fallback(cursor, title, artist, limit, server_source)
if fuzzy_results: if fuzzy_results:
@ -6921,6 +6993,17 @@ class MusicDatabase:
if basic_rows: if basic_rows:
return [dict(r) for r in basic_rows] return [dict(r) for r in basic_rows]
# Base-title fallback for Spotify "Title - Qualifier" forms (see
# search_tracks STRATEGY 1b) before the OR-fuzzy flood.
if title:
from core.text.title_match import base_title_before_dash
base_title = base_title_before_dash(title)
if base_title and base_title != title:
base_rows = self._search_tracks_basic_rows(
cursor, base_title, artist, limit, server_source)
if base_rows:
return [dict(r) for r in base_rows]
fuzzy_rows = self._search_tracks_fuzzy_rows(cursor, title, artist, limit, server_source) fuzzy_rows = self._search_tracks_fuzzy_rows(cursor, title, artist, limit, server_source)
return [dict(r) for r in fuzzy_rows] return [dict(r) for r in fuzzy_rows]
except Exception as e: except Exception as e:
@ -8632,10 +8715,40 @@ class MusicDatabase:
], ],
# Keep qualities dict for backwards compat with any old code paths still reading it # Keep qualities dict for backwards compat with any old code paths still reading it
"qualities": { "qualities": {
"flac": {"enabled": True, "min_kbps": 500, "max_kbps": 10000, "priority": 1, "bit_depth": "any"}, "flac": {
"mp3_320": {"enabled": True, "min_kbps": 280, "max_kbps": 500, "priority": 2}, "enabled": True,
"mp3_256": {"enabled": True, "min_kbps": 200, "max_kbps": 400, "priority": 3}, "min_kbps": 500,
"mp3_192": {"enabled": False, "min_kbps": 150, "max_kbps": 300, "priority": 4}, "max_kbps": 10000,
"priority": 1,
"bit_depth": "any"
},
"mp3_320": {
"enabled": True,
"min_kbps": 280,
"max_kbps": 500,
"priority": 2
},
"mp3_256": {
"enabled": True,
"min_kbps": 200,
"max_kbps": 400,
"priority": 3
},
"mp3_192": {
"enabled": False,
"min_kbps": 150,
"max_kbps": 300,
"priority": 4
},
# AAC (incl. .m4a): opt-in, OFF by default. Priority 1.5 sits it
# above MP3 but below FLAC (AAC is more efficient than MP3); the
# min_kbps gate keeps junk-bitrate AAC from beating a good MP3.
"aac": {
"enabled": False,
"min_kbps": 128,
"max_kbps": 400,
"priority": 1.5
}
}, },
} }
@ -8764,6 +8877,21 @@ class MusicDatabase:
_blocked[0], _blocked[1]) _blocked[0], _blocked[1])
return False return False
# Ignore-list guard (#874): a user who removed or cancelled this
# track asked us to stop AUTO-requeuing it — every automatic
# re-add funnels through here, so one check covers them all.
# A *manual* add is explicit user intent → bypass the gate AND
# clear any stale ignore so it sticks. Fail-open: any error here
# must never block a legitimate wishlist add.
try:
if source_type == 'manual':
self.remove_from_wishlist_ignore(track_id, profile_id=profile_id)
elif self.is_track_ignored(track_id, profile_id=profile_id):
logger.info("Skipping wishlist add — track is on the ignore-list (#874): %s", track_id)
return False
except Exception as _ignore_exc:
logger.debug("Wishlist ignore-list check skipped (fail-open): %s", _ignore_exc)
from core.library import manual_library_match as _mlm from core.library import manual_library_match as _mlm
if _mlm.get_match_for_track(self, profile_id, spotify_track_data): if _mlm.get_match_for_track(self, profile_id, spotify_track_data):
logger.info( logger.info(
@ -8908,7 +9036,147 @@ class MusicDatabase:
except Exception as e: except Exception as e:
logger.error(f"Error removing track from wishlist: {e}") logger.error(f"Error removing track from wishlist: {e}")
return False return False
# ── Wishlist ignore-list (#874) ──────────────────────────────────────
# A TTL'd skip-gate consulted by add_to_wishlist so user-removed /
# user-cancelled tracks are not auto-re-queued. All methods fail-open
# (an error here must never block a legitimate wishlist add).
def add_to_wishlist_ignore(self, track_id: str, track_name: str = "",
artist_name: str = "", reason: str = "removed",
profile_id: int = 1) -> bool:
"""Record (or refresh) an ignore entry for a wishlist track id.
Keyed on the bare track id; UNIQUE(profile_id, track_id) means a
repeat ignore replaces the row and so refreshes its TTL clock.
"""
from core.wishlist.ignore import normalize_ignore_id
key = normalize_ignore_id(track_id)
if not key:
return False
try:
with self._get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
INSERT OR REPLACE INTO wishlist_ignore
(profile_id, track_id, track_name, artist_name, reason, created_at)
VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
""", (profile_id, key, track_name or "", artist_name or "", reason or "removed"))
conn.commit()
logger.info("Added track to wishlist ignore-list (%s): '%s' [%s]",
reason or "removed", track_name or key, key)
return True
except Exception as e:
logger.error("Error adding to wishlist ignore-list: %s", e)
return False
def is_track_ignored(self, track_id: str, profile_id: int = 1,
ttl_days: Optional[int] = None) -> bool:
"""Whether ``track_id`` has a non-expired ignore entry. Fail-open False."""
from core.wishlist.ignore import normalize_ignore_id, is_expired, IGNORE_TTL_DAYS
ttl = IGNORE_TTL_DAYS if ttl_days is None else ttl_days
key = normalize_ignore_id(track_id)
if not key:
return False
try:
with self._get_connection() as conn:
cursor = conn.cursor()
cursor.execute(
"SELECT created_at FROM wishlist_ignore WHERE profile_id = ? AND track_id = ?",
(profile_id, key))
row = cursor.fetchone()
if not row:
return False
return not is_expired(row["created_at"], datetime.now(), ttl)
except Exception as e:
logger.debug("is_track_ignored failed open: %s", e)
return False
def remove_from_wishlist_ignore(self, track_id: str, profile_id: int = 1) -> bool:
"""Un-ignore a track (manual override / UI action). Returns True if a row went."""
from core.wishlist.ignore import normalize_ignore_id
key = normalize_ignore_id(track_id)
if not key:
return False
try:
with self._get_connection() as conn:
cursor = conn.cursor()
cursor.execute(
"DELETE FROM wishlist_ignore WHERE profile_id = ? AND track_id = ?",
(profile_id, key))
conn.commit()
return cursor.rowcount > 0
except Exception as e:
logger.error("Error removing from wishlist ignore-list: %s", e)
return False
def get_wishlist_ignore(self, profile_id: int = 1,
ttl_days: Optional[int] = None) -> List[Dict[str, Any]]:
"""Active (non-expired) ignore entries, newest first; purges lapsed rows."""
from core.wishlist.ignore import is_expired, IGNORE_TTL_DAYS
ttl = IGNORE_TTL_DAYS if ttl_days is None else ttl_days
try:
with self._get_connection() as conn:
cursor = conn.cursor()
cursor.execute(
"SELECT track_id, track_name, artist_name, reason, created_at "
"FROM wishlist_ignore WHERE profile_id = ? ORDER BY created_at DESC",
(profile_id,))
rows = cursor.fetchall()
now = datetime.now()
active, expired_ids = [], []
for r in rows:
if is_expired(r["created_at"], now, ttl):
expired_ids.append(r["track_id"])
else:
active.append({
"track_id": r["track_id"],
"track_name": r["track_name"] or "",
"artist_name": r["artist_name"] or "",
"reason": r["reason"] or "removed",
"created_at": r["created_at"],
})
# Opportunistic housekeeping so the table can't grow unbounded.
if expired_ids:
cursor.executemany(
"DELETE FROM wishlist_ignore WHERE profile_id = ? AND track_id = ?",
[(profile_id, tid) for tid in expired_ids])
conn.commit()
return active
except Exception as e:
logger.error("Error reading wishlist ignore-list: %s", e)
return []
def clear_wishlist_ignore(self, profile_id: int = 1) -> int:
"""Drop every ignore entry for a profile. Returns rows removed."""
try:
with self._get_connection() as conn:
cursor = conn.cursor()
cursor.execute("DELETE FROM wishlist_ignore WHERE profile_id = ?", (profile_id,))
conn.commit()
return cursor.rowcount
except Exception as e:
logger.error("Error clearing wishlist ignore-list: %s", e)
return 0
def get_wishlist_spotify_data(self, track_id: str, profile_id: int = 1) -> Dict[str, Any]:
"""Parsed ``spotify_data`` for a wishlist row, or {}. Used to label an
ignore entry with the track's name/artist before the row is removed."""
try:
with self._get_connection() as conn:
cursor = conn.cursor()
cursor.execute(
"SELECT spotify_data FROM wishlist_tracks WHERE spotify_track_id = ? AND profile_id = ?",
(track_id, profile_id))
row = cursor.fetchone()
if not row or not row["spotify_data"]:
return {}
data = json.loads(row["spotify_data"])
return data if isinstance(data, dict) else {}
except Exception as e:
logger.debug("get_wishlist_spotify_data failed: %s", e)
return {}
def get_wishlist_tracks(self, limit: Optional[int] = None, profile_id: int = 1, def get_wishlist_tracks(self, limit: Optional[int] = None, profile_id: int = 1,
offset: int = 0, category: Optional[str] = None) -> List[Dict[str, Any]]: offset: int = 0, category: Optional[str] = None) -> List[Dict[str, Any]]:
"""Get tracks in the wishlist for the given profile, ordered by date added """Get tracks in the wishlist for the given profile, ordered by date added

View file

@ -1,116 +1,37 @@
# SoulSync 2.6.4 — Merge `dev``main` # soulsync 2.7.4 — `dev``main`
Patch release on top of 2.6.3. Headline: patch release on top of 2.7.3. headline is **re-identify** — re-file an already-imported track under the right release without re-downloading it.
- **#721 — Usenet album bundles stuck on "downloading release" when SAB History flips before storage lands.** Reported by @IamGroot60 against 2.6.3, validated on the `fix/usenet-bundle-save-path-handoff` branch, merged via PR #723.
Everything from 2.6.3 also rolls in unchanged (was bumped on dev but never tagged / published to main / docker, so this is the first time these changes reach users).
--- ---
## 2.6.4 — the patch ## what's new
### #721 — Usenet album bundle stuck on "downloading release" when SAB History flips before `storage` lands ### re-identify a track (#889)
Follow-up to the 2.6.3 queue→history handoff fix (#706). 2.6.3 covered the gap where SAB removes a job from the queue before adding it to history. **2.6.4** covers a second-stage gap: SAB flips `status` to `Completed` in History a few seconds **before** its post-processing writes the final `storage` field. filed a track under the wrong release (single vs ep vs album)? there's now a ⇄ button in the library Enhanced view that lets you fix it. search any configured source (tabs, defaults to your active one), see the same song across its single / ep / album with type badges, pick the right one, and soulsync re-files the file you already have under that release — correct year, in-album track number, and art. opt to replace the original entry or keep both.
Pre-fix: `poll_album_download` saw the first `Completed` read with `save_path=None` and bailed. The bundle plugin marked the batch failed, but the UI froze on the last `downloading progress=0.61` emit because the terminal `failed` emit never registered (renderer holds the last-known progress). built additively over 5 phases (hint store → import seam → multi-source search → modal → button), all riding the existing import pipeline so a no-hint import is byte-identical to before. and it can't lose your file: replace deletes the old entry only *after* the re-import lands, and never if you pick the release it's already in.
- **`poll_album_download`**: separate transient counter for "completed but no save_path." Tolerates up to `transient_miss_threshold` (default 5) consecutive reads in that state — gives SAB ~10s to land the path. When it arrives, return normally. When it doesn't, fail loudly with an explicit error pointing at the missing field. ### cleaner libraries & imports
- **Sticky save_path**: earlier `downloading` reads with a non-empty `save_path` (qBit / Transmission set this from the start) remain cached. So torrent flows aren't affected by the retry path. - **#890** — track titles no longer keep the "01 - " prefix from the filename when there's no embedded title tag (which made the real track read as a false "missing"). stripped conservatively so "7 Rings" / "1-800-273-8255" / "1979" are left alone.
- **SAB adapter (`_parse_history_slot`)**: widened the save_path fallback chain — `storage``path``download_path``dirname`. Covers SAB version differences (older builds populated `path`) and forks that expose `download_path` or `dirname`. Whitespace-only values skipped. `incomplete_path` intentionally NOT in the chain — it'd bypass the retry window and point at the in-progress staging dir. - **#891** — a Library Reorganize now sweeps the leftover cover.jpg / .lrc / sidecars from the old folder so it actually empties, plus an opt-in "Remove Residual Files" toggle on the Empty Folder Cleaner for the image-only folders you already have.
- **Diagnostic**: loud debug log when none of the known fields land, dumping the slot keys so we can grow `_HISTORY_SAVE_PATH_KEYS` if a fork ships a novel field name. - **Sokhi's batch** — same-album songs group under one canonical release id (no more split discographies / mixed cover art); a single can match its parent album; a mid-enrichment crash on an art-less file no longer leaves it untagged; and a sequel digit glued to a CJK title no longer matches the wrong album.
**9 new tests**: ### quality & sources
- `test_album_bundle.py` (3): late-save_path arrival recovers; threshold-exhausted fails cleanly; sticky save_path keeps torrent flows working. - **#886** — AAC (.m4a) as an opt-in soulseek quality tier, ranked above mp3 / below flac. off by default; existing profiles unchanged until you enable it.
- `test_usenet_client_adapters.py` (6): each fallback field tier, whitespace-only skip, all-empty returns None, `incomplete_path` ignored. - **#887** — enrichment on Spotify Free now reads "Running (Spotify Free)" instead of wrongly showing "Not Authenticated".
- **#884** — NZBGet imports from the finished location, not the incomplete "….#NZBID" folder.
- **#885** — setting the timezone to Australia/Sydney no longer makes the cache-maintenance job loop every 5 seconds.
132 album-bundle + usenet tests pass. Strictly additive — zero impact on users whose SAB returns `storage` on the first Completed read. ### polish
- the artist-detail header no longer bleeds the blurred artist photo behind it.
--- ---
## Everything else from 2.6.3 (carried forward) ## tests
strictly additive across the board — every new behavior is opt-in or gated so default flows are unchanged. ~100 new tests this cycle (re-identify seam, title-strip danger cases, the shared residual-file classifier, aac tier, tz scheduler, spotify-free status). full imports / matching / reorganize / auto-import suites green, ruff clean.
### Fixes ## post-merge
- [ ] tag `v2.7.4` on `main`
**#715 — Soulseek album downloads stuck on "failed" after slskd finished the release.** - [ ] docker-publish with `version_tag: 2.7.4`
`core/soulseek_client._resolve_downloaded_album_file` probed 3 hard-coded candidate paths. On the common slskd config `directories.downloads.username = true`, files land at `<download_dir>/<username>/<filename>` — none of the 3 candidates carried a username segment, so every file looked locally missing and the bundle poll silently spun for ~30 minutes before marking the batch failed. - [ ] discord announce (auto-fired by the workflow)
- [ ] reply on #889 / #890 / #891
- Lifted the per-track flow's recursive walk-by-basename helper into `core/downloads/file_finder.py` (`find_completed_audio_file`). Bundle resolver now delegates to it. Default-slskd users see zero behavior change (3-candidate fast path preserved).
- Bundle poll detects "slskd reports Completed but local file can't be resolved past a 45s grace window" → exits early with explicit log line pointing at the likely `soulseek.download_path` mismatch.
- Misleading `"(0 tracks, quality=)"` log on the preflight-reuse path fixed.
- **17 new tests** pin every slskd layout (flat, username-prefixed, full-tree-preserved, deep nested, dedup-suffix, quarantine-skip, YouTube/Tidal encoded, transfer-dir fallback, fuzzy variants).
**Auto-Sync ListenBrainz pipelines stuck on `Refreshing:` for 5+ minutes.**
Refresh path ran `_maybe_discover` inline AND Phase 2 ran the same matching engine via `run_playlist_discovery_worker`. LB tracks discovered twice; refresh-side run blocked with zero progress emission. Also: LB manager only exposed `update_all_playlists` (refreshing one playlist re-pulled all 12+ cached playlists). Also: LB adapter had a silent `except Exception: pass` masking real API failures.
- Pipeline sets `skip_discovery=True` on refresh config; Phase 2 handles discovery with proper progress emits.
- New `LBManager.refresh_playlist(mbid)` targeted refresh.
- LB adapter logs exceptions with traceback at warning level + returns `None`.
- **12 new tests**.
**Wishlist: harden Spotify backfill — poisoned `tn=1` can't mask a lean album.**
Spotify-API backfill that hydrates `release_date` / `total_tracks` was coupled to the "track_number missing" branch, so a poisoned default-1 track_number short-circuited it. Lifted to `core/downloads/track_metadata_backfill.py` with split concerns — track-number resolution keeps its precedence chain; album hydration runs whenever `release_date` / `total_tracks` missing, independent of track_number. Single API call still serves both. Also `core/wishlist/routes.py:_build_track_data` no longer defaults `track_number=1` / `disc_number=1` / `total_tracks=1` / `release_date=''`. **24 new tests**.
**Wishlist: fix three regressions causing all imports to land as track 01.**
Track→dict conversion in payload helpers dropped everything except `album.name`; Deezer-sourced discovery matches saved without `track_number`/`disc_number`; import pipeline only consulted `album_info.track_number` before falling to the filename. Track_number resolution chain lifted into `core/imports/track_number.py:resolve_track_number` with 18 unit tests.
**Wishlist: only engage album-bundle when several tracks from the same album are missing.**
New `core/wishlist/album_grouping.py`. Bundle path only engages when an album has ≥2 missing tracks; single-track items take the cheaper per-track path. Configurable via `wishlist.album_bundle_min_tracks`.
**Wishlist: distinguish Queued from Analyzing batches in the UI.**
**Album-bundle staging: clean Soulseek copies + sweep orphans at startup.**
Cleanup gate extended to include `soulseek` (was torrent/usenet only). New `sweep_orphan_album_bundle_staging` runs once at server boot. **12 new tests**.
**Usenet album poll: tolerate SAB queue→history handoff (#706).**
**Discogs: strip artist disambiguation suffixes everywhere (#634).**
**Library: Enhanced / Standard view toggle persists per browser.**
**Fix popup: manual matches survive Playlist Pipeline runs.**
**Fix popup: artist + track fields no longer surface unrelated covers.**
### UX overhauls
**Dashboard enrichment panel — equalizer-bar redesign.** 11 speedometer tiles → 11 vertical VU-meter equalizer bars in one symmetric flex row. Brand-logo avatar disc above each bar (Spotify/Apple Music/Deezer/Last.fm/Genius/MusicBrainz/AudioDB/Tidal/Qobuz/Discogs/Amazon with initial-letter CDN-fail fallback); peak-flash on cpm step-up; rolling counter; glass-surface reflection puddle. Last.fm circle-clipped; Tidal/Qobuz/Discogs/Amazon inverted to white silhouettes.
**Auto-Sync manager — full visual overhaul.** Selector-based override layer (zero JS/HTML changes). Every surface inside the modal restyled to match the dashboard's glassy / accent-radial aesthetic.
**Auto-Sync — weekly board cards now match the hourly board.** Same Run-now button, unschedule X, next-run countdown, health badge. Weekly cards now draggable between day columns.
**Auto-Sync sidebar — brand logo on each source-group header.**
**Sync page tabs — brand-logo chips with active label pill.** 14 tabs collapsed from cramped labeled pills to circular brand-logo chips; active tab swells into a pill with its label inline. `Link` variants (Spotify Link / Deezer Link / iTunes Link) carry a small chain-link badge bottom-right.
### Architectural lifts
**Unified Playlist Sources layer.** `PlaylistSource` ABC + registry in `core/playlists/sources/`. Refresh handler dropped from ~190 lines of if/elif to ~80 lines. ListenBrainz / Last.fm / SoulSync Discovery are now Sync-page tabs.
**Auto-Sync schedule types — weekday + time.** New Weekly Board tab on the Auto-Sync manager.
**iTunes / Apple Music link import.** New iTunes Link tab on the Sync page.
---
## Test plan
- [x] 132 album-bundle + usenet tests pass (the new #721 path)
- [x] 488 downloads tests pass (full suite)
- [x] ~90 new unit tests across the cycle, including 9 new for #721
- [x] Smoke: dashboard equalizer renders w/ brand logos, peak-flash on cpm increase
- [x] Smoke: Auto-Sync manager renders glass overhaul, hourly + weekly cards both have action rows
- [x] Smoke: Sync page tab strip renders as logo chips; active expands; Link variants show chain-link badge
- [ ] Live: @IamGroot60 to re-test Forty Licks usenet bundle on dev (build with the #721 fix applied)
- [ ] Live: Soulseek album download on a username-subdir slskd config completes cleanly (#715, user-validated post-merge)
- [ ] Live: bundle staging dir cleaned on completion (user-validated post-merge)
---
## Post-merge checklist
- [ ] Tag `v2.6.4` on `main`
- [ ] Trigger `docker-publish.yml` with `version_tag: 2.6.4` to push `boulderbadgedad/soulsync:2.6.4` + `ghcr.io/nezreka/soulsync:2.6.4` (default already updated)
- [ ] Discord release announcement (auto-fired by the workflow)
- [ ] Reply on #721 with the 2.6.4 release link

View file

@ -142,10 +142,7 @@ def _build_deps(engine, scan_mgr=None) -> AutomationDeps:
duplicate_cleaner_lock=threading.Lock(), duplicate_cleaner_lock=threading.Lock(),
duplicate_cleaner_executor=None, duplicate_cleaner_executor=None,
run_duplicate_cleaner=lambda: None, run_duplicate_cleaner=lambda: None,
get_quality_scanner_state=lambda: {}, run_repair_job_now=lambda *a, **k: True,
quality_scanner_lock=threading.Lock(),
quality_scanner_executor=None,
run_quality_scanner=lambda *a, **k: None,
download_orchestrator=None, download_orchestrator=None,
run_async=lambda coro: None, run_async=lambda coro: None,
tasks_lock=threading.Lock(), tasks_lock=threading.Lock(),
@ -360,7 +357,6 @@ class TestHandlerInvocation:
**{f.name: getattr(deps, f.name) for f in deps.__dataclass_fields__.values()}, **{f.name: getattr(deps, f.name) for f in deps.__dataclass_fields__.values()},
'get_db_update_state': lambda: running_state, 'get_db_update_state': lambda: running_state,
'get_duplicate_cleaner_state': lambda: running_state, 'get_duplicate_cleaner_state': lambda: running_state,
'get_quality_scanner_state': lambda: running_state,
'get_download_batches': lambda: active_batches, # forces clean_completed_downloads to skip 'get_download_batches': lambda: active_batches, # forces clean_completed_downloads to skip
'get_database': lambda: _StubDB(), 'get_database': lambda: _StubDB(),
'get_app': lambda: _StubApp(), 'get_app': lambda: _StubApp(),

View file

@ -89,10 +89,7 @@ def _build_deps(**overrides) -> AutomationDeps:
duplicate_cleaner_lock=threading.Lock(), duplicate_cleaner_lock=threading.Lock(),
duplicate_cleaner_executor=None, duplicate_cleaner_executor=None,
run_duplicate_cleaner=lambda: None, run_duplicate_cleaner=lambda: None,
get_quality_scanner_state=lambda: {}, run_repair_job_now=lambda *a, **k: True,
quality_scanner_lock=threading.Lock(),
quality_scanner_executor=None,
run_quality_scanner=lambda *a, **k: None,
download_orchestrator=None, download_orchestrator=None,
run_async=lambda coro: None, run_async=lambda coro: None,
tasks_lock=threading.Lock(), tasks_lock=threading.Lock(),
@ -185,11 +182,18 @@ class TestDuplicateCleaner:
class TestQualityScanner: class TestQualityScanner:
def test_already_running_returns_skipped(self): def test_triggers_quality_upgrade_repair_job(self):
state = {'status': 'running'} triggered = []
deps = _build_deps(get_quality_scanner_state=lambda: state) deps = _build_deps(run_repair_job_now=lambda job_id: triggered.append(job_id) or True)
result = auto_start_quality_scan({}, deps) result = auto_start_quality_scan({}, deps)
assert result == {'status': 'skipped', 'reason': 'Quality scan already running'} assert triggered == ['quality_upgrade']
assert result['status'] == 'completed'
assert result['triggered'] is True
def test_error_when_worker_unavailable(self):
deps = _build_deps(run_repair_job_now=lambda job_id: None)
result = auto_start_quality_scan({}, deps)
assert result['status'] == 'error'
# ─── clear_quarantine ──────────────────────────────────────────────── # ─── clear_quarantine ────────────────────────────────────────────────

View file

@ -63,10 +63,7 @@ def _build_deps(**overrides) -> AutomationDeps:
duplicate_cleaner_lock=threading.Lock(), duplicate_cleaner_lock=threading.Lock(),
duplicate_cleaner_executor=None, duplicate_cleaner_executor=None,
run_duplicate_cleaner=lambda: None, run_duplicate_cleaner=lambda: None,
get_quality_scanner_state=lambda: {}, run_repair_job_now=lambda *a, **k: True,
quality_scanner_lock=threading.Lock(),
quality_scanner_executor=None,
run_quality_scanner=lambda *a, **k: None,
download_orchestrator=None, download_orchestrator=None,
run_async=lambda coro: None, run_async=lambda coro: None,
tasks_lock=threading.Lock(), tasks_lock=threading.Lock(),

View file

@ -125,10 +125,7 @@ def _build_deps(**overrides) -> AutomationDeps:
duplicate_cleaner_lock=threading.Lock(), duplicate_cleaner_lock=threading.Lock(),
duplicate_cleaner_executor=None, duplicate_cleaner_executor=None,
run_duplicate_cleaner=lambda: None, run_duplicate_cleaner=lambda: None,
get_quality_scanner_state=lambda: {}, run_repair_job_now=lambda *a, **k: True,
quality_scanner_lock=threading.Lock(),
quality_scanner_executor=None,
run_quality_scanner=lambda *a, **k: None,
download_orchestrator=None, download_orchestrator=None,
run_async=lambda coro: None, run_async=lambda coro: None,
tasks_lock=threading.Lock(), tasks_lock=threading.Lock(),

View file

@ -76,10 +76,7 @@ def _build_deps(**overrides: Any) -> AutomationDeps:
duplicate_cleaner_lock=threading.Lock(), duplicate_cleaner_lock=threading.Lock(),
duplicate_cleaner_executor=None, duplicate_cleaner_executor=None,
run_duplicate_cleaner=lambda: None, run_duplicate_cleaner=lambda: None,
get_quality_scanner_state=lambda: {}, run_repair_job_now=lambda *a, **k: True,
quality_scanner_lock=threading.Lock(),
quality_scanner_executor=None,
run_quality_scanner=lambda *a, **k: None,
download_orchestrator=None, download_orchestrator=None,
run_async=lambda coro: None, run_async=lambda coro: None,
tasks_lock=threading.Lock(), tasks_lock=threading.Lock(),

View file

@ -41,10 +41,7 @@ def _minimal_deps(**overrides):
duplicate_cleaner_lock=threading.Lock(), duplicate_cleaner_lock=threading.Lock(),
duplicate_cleaner_executor=None, duplicate_cleaner_executor=None,
run_duplicate_cleaner=lambda: None, run_duplicate_cleaner=lambda: None,
get_quality_scanner_state=lambda: {}, run_repair_job_now=lambda *a, **k: True,
quality_scanner_lock=threading.Lock(),
quality_scanner_executor=None,
run_quality_scanner=lambda *a, **k: None,
download_orchestrator=None, download_orchestrator=None,
run_async=lambda coro: None, run_async=lambda coro: None,
tasks_lock=threading.Lock(), tasks_lock=threading.Lock(),

View file

@ -62,10 +62,7 @@ def _build_deps(**overrides) -> AutomationDeps:
duplicate_cleaner_lock=threading.Lock(), duplicate_cleaner_lock=threading.Lock(),
duplicate_cleaner_executor=None, duplicate_cleaner_executor=None,
run_duplicate_cleaner=lambda: None, run_duplicate_cleaner=lambda: None,
get_quality_scanner_state=lambda: {}, run_repair_job_now=lambda *a, **k: True,
quality_scanner_lock=threading.Lock(),
quality_scanner_executor=None,
run_quality_scanner=lambda *a, **k: None,
download_orchestrator=None, download_orchestrator=None,
run_async=lambda coro: None, run_async=lambda coro: None,
tasks_lock=threading.Lock(), tasks_lock=threading.Lock(),

View file

@ -113,12 +113,6 @@ _DEFAULT_STREAM_STATE = {
"error_message": None, "error_message": None,
} }
_DEFAULT_QUALITY_SCANNER_STATE = {
"status": "running", "phase": "Scanning...", "progress": 35,
"processed": 35, "total": 100, "quality_met": 30,
"low_quality": 5, "matched": 2, "error_message": "", "results": [],
}
_DEFAULT_DUPLICATE_CLEANER_STATE = { _DEFAULT_DUPLICATE_CLEANER_STATE = {
"status": "running", "phase": "Scanning...", "progress": 50, "status": "running", "phase": "Scanning...", "progress": 50,
"files_scanned": 500, "total_files": 1000, "duplicates_found": 10, "files_scanned": 500, "total_files": 1000, "duplicates_found": 10,
@ -257,7 +251,6 @@ enrichment_status = copy.deepcopy(_DEFAULT_ENRICHMENT_STATUS)
# Phase 4: Tool progress state # Phase 4: Tool progress state
stream_state = copy.deepcopy(_DEFAULT_STREAM_STATE) stream_state = copy.deepcopy(_DEFAULT_STREAM_STATE)
quality_scanner_state = copy.deepcopy(_DEFAULT_QUALITY_SCANNER_STATE)
duplicate_cleaner_state = copy.deepcopy(_DEFAULT_DUPLICATE_CLEANER_STATE) duplicate_cleaner_state = copy.deepcopy(_DEFAULT_DUPLICATE_CLEANER_STATE)
retag_state = copy.deepcopy(_DEFAULT_RETAG_STATE) retag_state = copy.deepcopy(_DEFAULT_RETAG_STATE)
db_update_state = copy.deepcopy(_DEFAULT_DB_UPDATE_STATE) db_update_state = copy.deepcopy(_DEFAULT_DB_UPDATE_STATE)
@ -350,13 +343,12 @@ ENRICHMENT_ENDPOINTS = {
# Phase 4 helpers # Phase 4 helpers
TOOL_NAMES = [ TOOL_NAMES = [
'stream', 'quality-scanner', 'duplicate-cleaner', 'stream', 'duplicate-cleaner',
'retag', 'db-update', 'metadata', 'logs', 'retag', 'db-update', 'metadata', 'logs',
] ]
TOOL_ENDPOINTS = { TOOL_ENDPOINTS = {
'stream': '/api/stream/status', 'stream': '/api/stream/status',
'quality-scanner': '/api/quality-scanner/status',
'duplicate-cleaner': '/api/duplicate-cleaner/status', 'duplicate-cleaner': '/api/duplicate-cleaner/status',
'retag': '/api/retag/status', 'retag': '/api/retag/status',
'db-update': '/api/database/update/status', 'db-update': '/api/database/update/status',
@ -374,10 +366,6 @@ def _build_stream_status():
} }
def _build_quality_scanner_status():
return dict(quality_scanner_state)
def _build_duplicate_cleaner_status(): def _build_duplicate_cleaner_status():
state_copy = duplicate_cleaner_state.copy() state_copy = duplicate_cleaner_state.copy()
state_copy["space_freed_mb"] = duplicate_cleaner_state["space_freed"] / (1024 * 1024) state_copy["space_freed_mb"] = duplicate_cleaner_state["space_freed"] / (1024 * 1024)
@ -419,7 +407,6 @@ def _build_tool_status(tool_name):
"""Dispatcher that returns the correct status payload for any tool.""" """Dispatcher that returns the correct status payload for any tool."""
builders = { builders = {
'stream': _build_stream_status, 'stream': _build_stream_status,
'quality-scanner': _build_quality_scanner_status,
'duplicate-cleaner': _build_duplicate_cleaner_status, 'duplicate-cleaner': _build_duplicate_cleaner_status,
'retag': _build_retag_status, 'retag': _build_retag_status,
'db-update': _build_db_update_status, 'db-update': _build_db_update_status,
@ -635,10 +622,6 @@ def test_app():
def stream_status_endpoint(): def stream_status_endpoint():
return jsonify(_build_stream_status()) return jsonify(_build_stream_status())
@app.route('/api/quality-scanner/status')
def quality_scanner_status_endpoint():
return jsonify(_build_quality_scanner_status())
@app.route('/api/duplicate-cleaner/status') @app.route('/api/duplicate-cleaner/status')
def duplicate_cleaner_status_endpoint(): def duplicate_cleaner_status_endpoint():
return jsonify(_build_duplicate_cleaner_status()) return jsonify(_build_duplicate_cleaner_status())
@ -961,7 +944,6 @@ def shared_state():
'enrichment_endpoints': ENRICHMENT_ENDPOINTS, 'enrichment_endpoints': ENRICHMENT_ENDPOINTS,
# Phase 4 state # Phase 4 state
'stream_state': stream_state, 'stream_state': stream_state,
'quality_scanner_state': quality_scanner_state,
'duplicate_cleaner_state': duplicate_cleaner_state, 'duplicate_cleaner_state': duplicate_cleaner_state,
'retag_state': retag_state, 'retag_state': retag_state,
'db_update_state': db_update_state, 'db_update_state': db_update_state,
@ -969,7 +951,6 @@ def shared_state():
'logs_activities': logs_activities, 'logs_activities': logs_activities,
'build_tool_status': _build_tool_status, 'build_tool_status': _build_tool_status,
'build_stream_status': _build_stream_status, 'build_stream_status': _build_stream_status,
'build_quality_scanner_status': _build_quality_scanner_status,
'build_duplicate_cleaner_status': _build_duplicate_cleaner_status, 'build_duplicate_cleaner_status': _build_duplicate_cleaner_status,
'build_retag_status': _build_retag_status, 'build_retag_status': _build_retag_status,
'build_db_update_status': _build_db_update_status, 'build_db_update_status': _build_db_update_status,
@ -1019,8 +1000,6 @@ def reset_state():
# Phase 4 resets # Phase 4 resets
stream_state.clear() stream_state.clear()
stream_state.update(copy.deepcopy(_DEFAULT_STREAM_STATE)) stream_state.update(copy.deepcopy(_DEFAULT_STREAM_STATE))
quality_scanner_state.clear()
quality_scanner_state.update(copy.deepcopy(_DEFAULT_QUALITY_SCANNER_STATE))
duplicate_cleaner_state.clear() duplicate_cleaner_state.clear()
duplicate_cleaner_state.update(copy.deepcopy(_DEFAULT_DUPLICATE_CLEANER_STATE)) duplicate_cleaner_state.update(copy.deepcopy(_DEFAULT_DUPLICATE_CLEANER_STATE))
retag_state.clear() retag_state.clear()
@ -1061,8 +1040,6 @@ def reset_state():
enrichment_status.update(copy.deepcopy(_DEFAULT_ENRICHMENT_STATUS)) enrichment_status.update(copy.deepcopy(_DEFAULT_ENRICHMENT_STATUS))
stream_state.clear() stream_state.clear()
stream_state.update(copy.deepcopy(_DEFAULT_STREAM_STATE)) stream_state.update(copy.deepcopy(_DEFAULT_STREAM_STATE))
quality_scanner_state.clear()
quality_scanner_state.update(copy.deepcopy(_DEFAULT_QUALITY_SCANNER_STATE))
duplicate_cleaner_state.clear() duplicate_cleaner_state.clear()
duplicate_cleaner_state.update(copy.deepcopy(_DEFAULT_DUPLICATE_CLEANER_STATE)) duplicate_cleaner_state.update(copy.deepcopy(_DEFAULT_DUPLICATE_CLEANER_STATE))
retag_state.clear() retag_state.clear()

View file

@ -1,531 +0,0 @@
"""Tests for core/discovery/quality_scanner.py — library quality scanner."""
from __future__ import annotations
import threading
from dataclasses import dataclass
import pytest
from core.discovery import quality_scanner as qs
# ---------------------------------------------------------------------------
# Fakes
# ---------------------------------------------------------------------------
@dataclass
class _FakeSpotifyTrack:
id: str = 'spt-1'
name: str = 'Found'
artists: list = None
album: str = 'Found Album'
duration_ms: int = 200000
popularity: int = 50
preview_url: str = ''
external_urls: dict = None
album_type: str = 'album'
release_date: str = '2024-01-01'
def __post_init__(self):
if self.artists is None:
self.artists = ['Found Artist']
if self.external_urls is None:
self.external_urls = {}
class _FakeMetadataClient:
def __init__(self, results=None):
self._results = results if results is not None else []
self.search_calls = []
def search_tracks(self, query, limit=5, allow_fallback=True):
self.search_calls.append((query, limit, allow_fallback))
return self._results
_TEST_PRIMARY_SOURCE = 'spotify'
_TEST_SOURCE_CLIENTS = {}
@pytest.fixture(autouse=True)
def _patch_source_resolution(monkeypatch):
monkeypatch.setattr(qs, 'get_primary_source', lambda: _TEST_PRIMARY_SOURCE)
monkeypatch.setattr(qs, 'get_client_for_source', lambda source, **_kwargs: _TEST_SOURCE_CLIENTS.get(source))
monkeypatch.setattr(qs.time, 'sleep', lambda *_args, **_kwargs: None)
yield
_TEST_SOURCE_CLIENTS.clear()
globals()['_TEST_PRIMARY_SOURCE'] = 'spotify'
class _FakeMatchingEngine:
def generate_download_queries(self, t):
return [f"{t.artists[0]} {t.name}"]
def normalize_string(self, s):
return (s or '').lower().strip()
def similarity_score(self, a, b):
if a == b:
return 1.0
if not a or not b:
return 0.0
return 0.95 if a in b or b in a else 0.0
class _MultiQueryMatchingEngine(_FakeMatchingEngine):
def generate_download_queries(self, t):
return [
f"{t.artists[0]} {t.name} first",
f"{t.artists[0]} {t.name} second",
]
class _FakeAutomationEngine:
def __init__(self):
self.events = []
def emit(self, event_type, data):
self.events.append((event_type, data))
class _FakeWishlistService:
def __init__(self):
self.added = []
def add_spotify_track_to_wishlist(self, **kwargs):
self.added.append(kwargs)
return True
class _FakeMusicDB:
def __init__(self, watchlist_artists=None, tracks=None, profile=None):
self._watchlist_artists = watchlist_artists if watchlist_artists is not None else []
self._tracks = tracks if tracks is not None else []
self._profile = profile or {'qualities': {'flac': {'enabled': True}}}
def get_quality_profile(self):
return self._profile
def get_watchlist_artists(self, profile_id=1):
return self._watchlist_artists
def _get_connection(self):
rows = self._tracks
return _FakeConn(rows)
class _FakeConn:
def __init__(self, rows):
self._rows = rows
def execute(self, query, params=None):
return _FakeCursor(self._rows)
def close(self):
pass
class _FakeCursor:
def __init__(self, rows):
self._rows = rows
def fetchall(self):
return self._rows
@dataclass
class _WatchlistArtist:
artist_name: str
def _build_deps(
*,
state=None,
source_clients=None,
primary_source='spotify',
quality_tier_result=('lossless', 1),
automation=None,
probe_fn=None,
):
globals()['_TEST_PRIMARY_SOURCE'] = primary_source
_TEST_SOURCE_CLIENTS.clear()
if source_clients is not None:
_TEST_SOURCE_CLIENTS.update(source_clients)
elif primary_source:
_TEST_SOURCE_CLIENTS[primary_source] = _FakeMetadataClient(results=[])
# Quality is now decided by probing the real file against the v3 targets.
# Derive a stand-in AudioQuality from the legacy tier param so existing
# tests keep their intent: 'lossless' → a FLAC that meets the default
# flac-enabled profile; anything else → an MP3 that doesn't.
from core.quality.model import AudioQuality
def _default_probe(_fp):
if quality_tier_result[0] == 'lossless':
return AudioQuality('flac', bit_depth=16, sample_rate=44100)
return AudioQuality('mp3', bitrate=128)
deps = qs.QualityScannerDeps(
quality_scanner_state=state if state is not None else {},
quality_scanner_lock=threading.Lock(),
QUALITY_TIERS={'lossless': {'tier': 1}, 'low_lossy': {'tier': 4}, 'lossy': {'tier': 3}},
matching_engine=_FakeMatchingEngine(),
automation_engine=automation or _FakeAutomationEngine(),
get_quality_tier_from_extension=lambda fp: quality_tier_result,
add_activity_item=lambda *a, **kw: None,
probe_audio_quality=probe_fn or _default_probe,
resolve_library_file_path=lambda fp: fp, # identity — tests use direct paths
)
return deps
def _track_row(track_id=1, title='Track', artist_id=1, album_id=1,
file_path='/x.mp3', bitrate=128, artist_name='Artist',
album_title='Album'):
return (track_id, title, artist_id, album_id, file_path, bitrate, artist_name, album_title)
@pytest.fixture
def mock_db_and_wishlist(monkeypatch):
"""Patches MusicDatabase and get_wishlist_service used inside the worker."""
db = _FakeMusicDB()
ws = _FakeWishlistService()
monkeypatch.setattr('database.music_database.MusicDatabase', lambda: db)
monkeypatch.setattr('core.wishlist_service.get_wishlist_service', lambda: ws)
return db, ws
# ---------------------------------------------------------------------------
# State init + DB load
# ---------------------------------------------------------------------------
def test_state_initialized_on_run(mock_db_and_wishlist):
"""Scanner resets state to running with cleared counters."""
db, _ = mock_db_and_wishlist
db._watchlist_artists = [] # no artists → exits early but after init
state = {}
deps = _build_deps(state=state)
qs.run_quality_scanner('watchlist', 1, deps)
assert state['status'] == 'finished' # exited early since no artists
assert state['error_message'] == 'Please add artists to watchlist first'
def test_no_watchlist_artists_short_circuit(mock_db_and_wishlist):
"""Scope=watchlist with no artists → status=finished, error message."""
db, _ = mock_db_and_wishlist
db._watchlist_artists = []
state = {}
deps = _build_deps(state=state)
qs.run_quality_scanner('watchlist', 1, deps)
assert state['status'] == 'finished'
assert 'add artists' in state['error_message']
# ---------------------------------------------------------------------------
# Provider availability gate
# ---------------------------------------------------------------------------
def test_no_available_provider_marks_error(mock_db_and_wishlist):
"""No available metadata providers → state['status']='error'."""
db, _ = mock_db_and_wishlist
db._watchlist_artists = [_WatchlistArtist('A')]
db._tracks = [_track_row()]
state = {}
deps = _build_deps(state=state, source_clients={}, quality_tier_result=('low_lossy', 4))
qs.run_quality_scanner('watchlist', 1, deps)
assert state['status'] == 'error'
assert 'metadata provider' in state['error_message'].lower()
# ---------------------------------------------------------------------------
# Quality tier check + skip
# ---------------------------------------------------------------------------
def test_high_quality_tracks_skipped(mock_db_and_wishlist):
"""Tracks meeting quality (tier_num <= min_acceptable) → quality_met += 1."""
db, _ = mock_db_and_wishlist
db._watchlist_artists = [_WatchlistArtist('A')]
db._tracks = [_track_row(file_path='/x.flac')]
state = {}
# Default min_acceptable is from {flac: enabled} → tier 1 (lossless)
# quality_tier_result=('lossless', 1) → 1 <= 1 → skip
deps = _build_deps(state=state, quality_tier_result=('lossless', 1))
qs.run_quality_scanner('watchlist', 1, deps)
assert state['quality_met'] == 1
assert state['low_quality'] == 0
def test_scanner_resolves_relative_path_before_probing(mock_db_and_wishlist):
"""DB stores RELATIVE paths (Artist/Album/Track.flac); the scanner must
resolve them to absolute via resolve_library_file_path before probing,
otherwise mutagen can't open the file and the whole library passes."""
from core.quality.model import AudioQuality
db, _ = mock_db_and_wishlist
db._watchlist_artists = [_WatchlistArtist('A')]
db._tracks = [_track_row(file_path='Artist/Album/Track.flac')]
state = {}
probed = []
def _probe(fp):
probed.append(fp)
return AudioQuality('flac', bit_depth=16, sample_rate=44100)
deps = _build_deps(state=state, probe_fn=_probe)
deps.resolve_library_file_path = lambda fp: '/music/' + fp
qs.run_quality_scanner('watchlist', 1, deps)
assert probed == ['/music/Artist/Album/Track.flac']
def test_low_quality_tracks_attempted(mock_db_and_wishlist):
"""Low-quality tracks (tier_num > min) trigger a metadata search."""
db, _ = mock_db_and_wishlist
db._watchlist_artists = [_WatchlistArtist('Artist')]
db._tracks = [_track_row(file_path='/x.mp3', artist_name='Artist', title='Track')]
state = {}
match = _FakeSpotifyTrack(name='Track', artists=['Artist'])
spotify_client = _FakeMetadataClient(results=[match])
deps = _build_deps(
state=state,
quality_tier_result=('low_lossy', 4),
source_clients={'spotify': spotify_client},
primary_source='spotify',
)
qs.run_quality_scanner('watchlist', 1, deps)
assert state['low_quality'] == 1
assert spotify_client.search_calls
assert spotify_client.search_calls[0][2] is False
def test_client_lookup_happens_per_query(mock_db_and_wishlist, monkeypatch):
"""Each generated query re-resolves the source client."""
db, _ = mock_db_and_wishlist
db._watchlist_artists = [_WatchlistArtist('Artist')]
db._tracks = [_track_row(file_path='/x.mp3', artist_name='Artist', title='Track')]
state = {}
spotify_client = _FakeMetadataClient(results=[])
lookups = []
monkeypatch.setattr(
qs,
'get_client_for_source',
lambda source, **_kwargs: (lookups.append(source), _TEST_SOURCE_CLIENTS.get(source))[1],
)
deps = _build_deps(
state=state,
quality_tier_result=('low_lossy', 4),
source_clients={'spotify': spotify_client},
primary_source='spotify',
)
deps.matching_engine = _MultiQueryMatchingEngine()
qs.run_quality_scanner('watchlist', 1, deps)
assert len(lookups) % 2 == 0
midpoint = len(lookups) // 2
assert lookups[:midpoint] == lookups[midpoint:]
assert len(spotify_client.search_calls) == 2
def test_low_quality_tracks_follow_source_priority(mock_db_and_wishlist):
"""Primary source is searched before Spotify."""
db, ws = mock_db_and_wishlist
db._watchlist_artists = [_WatchlistArtist('Artist')]
db._tracks = [_track_row(file_path='/x.mp3', artist_name='Artist', title='Track')]
state = {}
match = _FakeSpotifyTrack(name='Track', artists=['Artist'])
deezer_client = _FakeMetadataClient(results=[match])
spotify_client = _FakeMetadataClient(results=[])
deps = _build_deps(
state=state,
source_clients={'deezer': deezer_client, 'spotify': spotify_client},
primary_source='deezer',
quality_tier_result=('low_lossy', 4),
)
qs.run_quality_scanner('watchlist', 1, deps)
assert state['matched'] == 1
assert deezer_client.search_calls
assert spotify_client.search_calls == []
assert len(ws.added) == 1
add_args = ws.added[0]
assert add_args['track_data']['provider'] == 'deezer'
assert add_args['track_data']['source'] == 'deezer'
# ---------------------------------------------------------------------------
# Match → wishlist add
# ---------------------------------------------------------------------------
def test_match_adds_to_wishlist(mock_db_and_wishlist):
"""High-confidence match → wishlist_service.add_spotify_track_to_wishlist called."""
db, ws = mock_db_and_wishlist
db._watchlist_artists = [_WatchlistArtist('Artist')]
db._tracks = [_track_row(artist_name='Artist', title='Track', file_path='/x.mp3', bitrate=128)]
state = {}
match = _FakeSpotifyTrack(name='Track', artists=['Artist'])
deps = _build_deps(
state=state,
quality_tier_result=('low_lossy', 4),
source_clients={'spotify': _FakeMetadataClient(results=[match])},
primary_source='spotify',
)
qs.run_quality_scanner('watchlist', 1, deps)
assert state['matched'] == 1
assert len(ws.added) == 1
add_args = ws.added[0]
assert add_args['source_type'] == 'quality_scanner'
assert add_args['source_context']['original_file_path'] == '/x.mp3'
def test_match_preserves_album_and_artist_images(mock_db_and_wishlist):
"""Image metadata from the provider payload should survive the wishlist handoff."""
db, ws = mock_db_and_wishlist
db._watchlist_artists = [_WatchlistArtist('Artist')]
db._tracks = [_track_row(artist_name='Artist', title='Track', file_path='/x.mp3', bitrate=128)]
state = {}
match = {
'id': 'sp-1',
'name': 'Track',
'artists': [{'name': 'Artist', 'image_url': 'https://example.test/artist.jpg'}],
'album': 'Album',
'image_url': 'https://example.test/cover.jpg',
'duration_ms': 200000,
'popularity': 50,
'external_urls': {},
'album_type': 'album',
'release_date': '2024-01-01',
}
deps = _build_deps(
state=state,
quality_tier_result=('low_lossy', 4),
source_clients={'spotify': _FakeMetadataClient(results=[match])},
primary_source='spotify',
)
qs.run_quality_scanner('watchlist', 1, deps)
assert state['matched'] == 1
assert len(ws.added) == 1
add_args = ws.added[0]
assert add_args['track_data']['image_url'] == 'https://example.test/cover.jpg'
assert add_args['track_data']['album']['image_url'] == 'https://example.test/cover.jpg'
assert add_args['track_data']['album']['images'] == [{'url': 'https://example.test/cover.jpg'}]
assert add_args['track_data']['artists'][0]['image_url'] == 'https://example.test/artist.jpg'
def test_no_match_no_wishlist_add(mock_db_and_wishlist):
"""No match found → no wishlist add, matched stays 0."""
db, ws = mock_db_and_wishlist
db._watchlist_artists = [_WatchlistArtist('A')]
db._tracks = [_track_row(artist_name='A', title='Z', file_path='/x.mp3')]
state = {}
# No spotify results → no match
deps = _build_deps(
state=state,
quality_tier_result=('low_lossy', 4),
source_clients={'spotify': _FakeMetadataClient(results=[])},
primary_source='spotify',
)
qs.run_quality_scanner('watchlist', 1, deps)
assert state['matched'] == 0
assert ws.added == []
# ---------------------------------------------------------------------------
# Stop request gate
# ---------------------------------------------------------------------------
def test_stop_request_halts_loop(mock_db_and_wishlist):
"""Setting state['status'] != 'running' mid-loop halts processing."""
db, _ = mock_db_and_wishlist
db._watchlist_artists = [_WatchlistArtist('A')]
db._tracks = [_track_row(track_id=1), _track_row(track_id=2)]
state = {}
from core.quality.model import AudioQuality
# Probe is called once per track; use it to stop after the first.
call_count = [0]
def stop_after_first(fp):
call_count[0] += 1
if call_count[0] == 1:
# Set status to non-running BEFORE second track iter checks
with state_lock:
state['status'] = 'stopping'
return AudioQuality('flac', bit_depth=16, sample_rate=44100) # meets profile
state_lock = threading.Lock()
deps = _build_deps(state=state, quality_tier_result=('lossless', 1), probe_fn=stop_after_first)
qs.run_quality_scanner('watchlist', 1, deps)
# Only first track processed
assert state['quality_met'] == 1
# ---------------------------------------------------------------------------
# Completion
# ---------------------------------------------------------------------------
def test_completion_marks_finished(mock_db_and_wishlist):
"""All tracks processed → status='finished', progress=100."""
db, _ = mock_db_and_wishlist
db._watchlist_artists = [_WatchlistArtist('A')]
db._tracks = [_track_row()]
state = {}
deps = _build_deps(state=state)
qs.run_quality_scanner('watchlist', 1, deps)
assert state['status'] == 'finished'
assert state['progress'] == 100
def test_automation_event_emitted(mock_db_and_wishlist):
"""Successful completion emits 'quality_scan_completed' on automation engine."""
db, _ = mock_db_and_wishlist
db._watchlist_artists = [_WatchlistArtist('A')]
db._tracks = [_track_row()]
automation = _FakeAutomationEngine()
state = {}
deps = _build_deps(state=state, automation=automation)
qs.run_quality_scanner('watchlist', 1, deps)
assert any(name == 'quality_scan_completed' for name, _ in automation.events)
# ---------------------------------------------------------------------------
# All-library scope
# ---------------------------------------------------------------------------
def test_scope_all_loads_all_tracks(mock_db_and_wishlist):
"""scope != 'watchlist' loads all tracks (no watchlist filter)."""
db, _ = mock_db_and_wishlist
db._tracks = [_track_row(track_id=1), _track_row(track_id=2)]
state = {}
deps = _build_deps(state=state)
qs.run_quality_scanner('all', 1, deps)
assert state['total'] == 2

View file

@ -0,0 +1,106 @@
"""#886: AAC as an opt-in Soulseek quality tier.
The whole point is "purely additive": with AAC OFF (the default, and every
profile that predates this), an AAC candidate must behave EXACTLY as before
it lands in the 'other' bucket, which the waterfall never returns, so it's
dropped. Only a profile that explicitly enables AAC makes it a selectable tier,
ranked above MP3 and below FLAC.
filter_results_by_quality_preference reads db.get_quality_profile() and walks the
buckets; we stub the db + the quarantine sweep so it runs offline.
"""
from __future__ import annotations
import types
from pathlib import Path
from unittest.mock import patch
import pytest
from core.soulseek_client import SoulseekClient
from core.download_plugins.types import TrackResult
def _client():
c = SoulseekClient.__new__(SoulseekClient)
c.base_url = 'http://localhost:5030'
c.api_key = 'k'
c.download_path = Path('./test_downloads')
return c
def _cand(quality, size_mb, bitrate=None):
return TrackResult(
username='peer', filename=f'A/B/01 - Song.{quality}',
size=int(size_mb * 1024 * 1024), bitrate=bitrate, duration=None,
quality=quality, free_upload_slots=1, upload_speed=1_000_000,
queue_length=0, artist='A', title='Song', album='B', track_number=1)
def _q(enabled_flac=True, enabled_mp3=True, aac=None):
qualities = {
'flac': {'enabled': enabled_flac, 'min_kbps': 500, 'max_kbps': 10000, 'priority': 1, 'bit_depth': 'any'},
'mp3_320': {'enabled': enabled_mp3, 'min_kbps': 280, 'max_kbps': 500, 'priority': 2},
}
if aac is not None: # None => omit the tier entirely (pre-existing profile)
qualities['aac'] = {'enabled': aac, 'min_kbps': 128, 'max_kbps': 400, 'priority': 1.5}
return {'preset': 'custom', 'qualities': qualities, 'fallback_enabled': True}
def _filter(candidates, profile):
c = _client()
fake_db = types.SimpleNamespace(get_quality_profile=lambda: profile)
with patch('database.music_database.MusicDatabase', return_value=fake_db), \
patch.object(SoulseekClient, '_drop_quarantined_sources', lambda self, r: r):
return c.filter_results_by_quality_preference(candidates)
# ── additive proof: AAC off == today (dropped) ────────────────────────────────
def test_aac_dropped_when_tier_absent_pre_existing_profile():
# A profile saved before this feature has no 'aac' key at all.
out = _filter([_cand('aac', 5)], _q(aac=None))
assert out == [] # AAC went to 'other' -> never returned, exactly as before
def test_aac_dropped_when_tier_present_but_disabled():
out = _filter([_cand('aac', 5)], _q(aac=False))
assert out == []
def test_flac_mp3_selection_unchanged_when_aac_absent():
# The headline no-regression guard: a normal FLAC/MP3 mix is unaffected.
flac, mp3 = _cand('flac', 30), _cand('mp3', 5, bitrate=320)
out = _filter([mp3, flac], _q(aac=None))
assert out and out[0].quality == 'flac' # FLAC still wins, as before
# ── opt-in: AAC on makes it a real tier ───────────────────────────────────────
def test_aac_selected_when_enabled():
out = _filter([_cand('aac', 5)], _q(aac=True))
assert len(out) == 1 and out[0].quality == 'aac'
def test_flac_beats_aac_when_both_present():
flac, aac = _cand('flac', 30), _cand('aac', 5)
out = _filter([aac, flac], _q(aac=True))
assert out[0].quality == 'flac' # priority 1 < 1.5
def test_aac_beats_mp3_when_both_present():
mp3, aac = _cand('mp3', 5, bitrate=320), _cand('aac', 5)
out = _filter([mp3, aac], _q(aac=True))
assert out[0].quality == 'aac' # priority 1.5 < 2
def test_default_and_presets_ship_aac_disabled_above_mp3():
from database.music_database import MusicDatabase
db = MusicDatabase.__new__(MusicDatabase) # no DB init
profiles = [db._get_default_quality_profile()]
profiles += [db.get_quality_preset(p) for p in ('audiophile', 'balanced', 'space_saver')]
for prof in profiles:
aac = prof['qualities']['aac']
assert aac['enabled'] is False # opt-in everywhere
# above MP3: lower priority number than the best MP3 tier present
mp3_prios = [v['priority'] for k, v in prof['qualities'].items() if k.startswith('mp3')]
assert aac['priority'] < min(mp3_prios)

View file

@ -187,6 +187,24 @@ def test_reset_builder_nulls_status_not_just_attempted():
assert "WHERE spotify_match_status = 'not_found'" in sql assert "WHERE spotify_match_status = 'not_found'" in sql
def test_reset_builder_also_clears_artist_source_id():
# #868: a re-match must forget the stored id so the worker actually
# re-resolves (otherwise its existing-id short-circuit re-confirms the wrong
# same-name artist).
for service, col in [('spotify', 'spotify_artist_id'), ('itunes', 'itunes_artist_id'),
('deezer', 'deezer_id'), ('musicbrainz', 'musicbrainz_id')]:
sql, _ = build_reset_query(service, 'artist', 'item', entity_id=5)
assert f'{col} = NULL' in sql, f'{service}: expected {col} cleared'
assert f'{service}_match_status = NULL' in sql
def test_reset_builder_does_not_clear_track_id():
# Tracks have no source-id column (ids live in tags) — must not emit one.
sql, _ = build_reset_query('spotify', 'track', 'item', entity_id=5)
assert 'spotify_match_status = NULL' in sql
assert 'spotify_track_id = NULL' not in sql
def test_reset_item_requeues_to_pending(db): def test_reset_item_requeues_to_pending(db):
n = db.reset_enrichment('spotify', 'artist', 'item', entity_id='a2') # was not_found n = db.reset_enrichment('spotify', 'artist', 'item', entity_id='a2') # was not_found
assert n == 1 assert n == 1

View file

@ -0,0 +1,138 @@
"""Seam tests for canonical album grouping (Sokhi: split album rows -> mixed
cover art). Drives find_existing_soulsync_album_id against a real in-memory
SQLite albums table no app singletons, no I/O.
"""
from __future__ import annotations
import sqlite3
import pytest
from core.imports.album_grouping import (
find_existing_soulsync_album_id,
ALLOWED_ALBUM_SOURCE_COLS,
)
@pytest.fixture()
def cur():
conn = sqlite3.connect(":memory:")
conn.execute(
"""CREATE TABLE albums (
id TEXT PRIMARY KEY,
artist_id TEXT,
title TEXT,
server_source TEXT,
spotify_album_id TEXT,
itunes_album_id TEXT,
deezer_id TEXT,
soul_id TEXT,
discogs_id TEXT,
musicbrainz_release_id TEXT
)"""
)
yield conn.cursor()
conn.close()
def _add(cur, *, id, title, artist_id="art1", server_source="soulsync", **source_ids):
cols = ["id", "artist_id", "title", "server_source"] + list(source_ids)
vals = [id, artist_id, title, server_source] + list(source_ids.values())
cur.execute(
f"INSERT INTO albums ({', '.join(cols)}) VALUES ({', '.join(['?'] * len(cols))})",
vals,
)
def test_empty_db_returns_none(cur):
assert find_existing_soulsync_album_id(
cur, name_key_id="nk", artist_id="art1", album_name="Parachutes",
album_source_col="spotify_album_id", album_source_id="SP1") is None
def test_exact_name_hash_id_wins_first(cur):
_add(cur, id="nk", title="Parachutes")
assert find_existing_soulsync_album_id(
cur, name_key_id="nk", artist_id="art1", album_name="Parachutes") == "nk"
def test_canonical_source_id_unifies_differently_named_imports(cur):
# Existing row for release SP1 named "Parachutes". A second import of the
# SAME release id but a drifted name must JOIN it, not split.
_add(cur, id="existing", title="Parachutes", spotify_album_id="SP1")
got = find_existing_soulsync_album_id(
cur, name_key_id="different_hash", artist_id="art1",
album_name="Parachutes (Deluxe Edition)",
album_source_col="spotify_album_id", album_source_id="SP1")
assert got == "existing"
def test_different_release_id_stays_separate(cur):
# The single-vs-album case: a genuinely different release id must NOT merge
# (documents the known limit — single->album resolution is a separate step).
_add(cur, id="album_row", title="Parachutes", spotify_album_id="SP_ALBUM")
got = find_existing_soulsync_album_id(
cur, name_key_id="single_hash", artist_id="art1", album_name="Yellow",
album_source_col="spotify_album_id", album_source_id="SP_SINGLE")
assert got is None
def test_legacy_name_match_still_groups_without_a_source_id(cur):
_add(cur, id="byname", title="Parachutes")
got = find_existing_soulsync_album_id(
cur, name_key_id="other_hash", artist_id="art1", album_name="parachutes",
album_source_col=None, album_source_id=None)
assert got == "byname" # case-insensitive title + artist
def test_source_id_match_is_scoped_to_soulsync_rows(cur):
_add(cur, id="plexrow", title="Parachutes", server_source="plex", spotify_album_id="SP1")
got = find_existing_soulsync_album_id(
cur, name_key_id="nk", artist_id="art1", album_name="X",
album_source_col="spotify_album_id", album_source_id="SP1")
assert got is None # the matching row belongs to Plex, not soulsync
def test_non_allowlisted_column_is_ignored(cur):
# A column not on the allowlist must never be spliced into SQL.
assert "title" not in ALLOWED_ALBUM_SOURCE_COLS
_add(cur, id="row", title="Parachutes")
got = find_existing_soulsync_album_id(
cur, name_key_id="nk", artist_id="art1", album_name="nope",
album_source_col="title", album_source_id="Parachutes")
assert got is None # 'title' ignored as a source col; name 'nope' doesn't match
def test_empty_source_id_skips_canonical_match(cur):
_add(cur, id="row", title="Parachutes", spotify_album_id="")
got = find_existing_soulsync_album_id(
cur, name_key_id="nk", artist_id="art1", album_name="Other",
album_source_col="spotify_album_id", album_source_id="")
assert got is None
def test_missing_album_column_falls_through_not_raises(cur):
# Some sources (Deezer) don't have a dedicated album id column on the albums
# table; an allow-listed-but-absent column must NOT raise (it broke the whole
# import once) — it falls through to the name match.
cur.execute("CREATE TABLE albums_min (id TEXT, artist_id TEXT, title TEXT, server_source TEXT)")
cur.execute("INSERT INTO albums_min VALUES ('byname','art1','DZ Album','soulsync')")
# Point the helper at a table missing deezer_id by aliasing via a fresh cursor.
conn2 = sqlite3.connect(":memory:")
conn2.execute("CREATE TABLE albums (id TEXT, artist_id TEXT, title TEXT, server_source TEXT)")
conn2.execute("INSERT INTO albums VALUES ('byname','art1','DZ Album','soulsync')")
c2 = conn2.cursor()
got = find_existing_soulsync_album_id(
c2, name_key_id="nk", artist_id="art1", album_name="DZ Album",
album_source_col="deezer_id", album_source_id="67890")
conn2.close()
assert got == "byname" # deezer_id column absent -> fell through to name match
def test_musicbrainz_release_id_grouping(cur):
_add(cur, id="mbrow", title="Album", musicbrainz_release_id="mb-123")
got = find_existing_soulsync_album_id(
cur, name_key_id="nk2", artist_id="art1", album_name="Album (Remaster)",
album_source_col="musicbrainz_release_id", album_source_id="mb-123")
assert got == "mbrow"

View file

@ -5,9 +5,11 @@ from core.imports.quarantine import (
approve_quarantine_entry, approve_quarantine_entry,
delete_quarantine_entry, delete_quarantine_entry,
entry_id_from_quarantined_filename, entry_id_from_quarantined_filename,
find_quarantine_siblings,
get_quarantine_entry_stream_info, get_quarantine_entry_stream_info,
get_quarantined_source_keys, get_quarantined_source_keys,
list_quarantine_entries, list_quarantine_entries,
quarantine_group_key,
recover_to_staging, recover_to_staging,
serialize_quarantine_context, serialize_quarantine_context,
) )
@ -71,18 +73,20 @@ def test_serialize_round_trips_through_json():
# list_quarantine_entries # list_quarantine_entries
# ────────────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────────────
def _write_entry(quarantine_dir, entry_id, original_name, *, with_context=False, trigger="integrity", reason="boom", file_bytes=b"X" * 100): def _write_entry(quarantine_dir, entry_id, original_name, *, with_context=False, trigger="integrity", reason="boom", file_bytes=b"X" * 100, expected_track="Track", expected_artist="Artist", context=None):
qfile = quarantine_dir / f"{entry_id}_{original_name}.quarantined" qfile = quarantine_dir / f"{entry_id}_{original_name}.quarantined"
qfile.write_bytes(file_bytes) qfile.write_bytes(file_bytes)
sidecar = { sidecar = {
"original_filename": original_name, "original_filename": original_name,
"quarantine_reason": reason, "quarantine_reason": reason,
"expected_track": "Track", "expected_track": expected_track,
"expected_artist": "Artist", "expected_artist": expected_artist,
"timestamp": "2026-05-14T12:00:00", "timestamp": "2026-05-14T12:00:00",
"trigger": trigger, "trigger": trigger,
} }
if with_context: if context is not None:
sidecar["context"] = context
elif with_context:
sidecar["context"] = {"track_info": {"name": "Track"}, "context_key": entry_id} sidecar["context"] = {"track_info": {"name": "Track"}, "context_key": entry_id}
sidecar_path = quarantine_dir / f"{entry_id}_{os.path.splitext(original_name)[0]}.json" sidecar_path = quarantine_dir / f"{entry_id}_{os.path.splitext(original_name)[0]}.json"
sidecar_path.write_text(json.dumps(sidecar)) sidecar_path.write_text(json.dumps(sidecar))
@ -454,3 +458,95 @@ def test_move_with_retry_returns_false_on_missing_source(tmp_path):
# attempts=1 keeps the test fast (no retry sleeps) # attempts=1 keeps the test fast (no retry sleeps)
assert _move_with_retry(str(tmp_path / "nope.flac"), str(tmp_path / "dst.flac"), assert _move_with_retry(str(tmp_path / "nope.flac"), str(tmp_path / "dst.flac"),
attempts=1, delay=0) is False attempts=1, delay=0) is False
# ──────────────────────────────────────────────────────────────────────
# #876: grouping alternatives for one song — quarantine_group_key /
# find_quarantine_siblings, and the group_key field on list entries.
# ──────────────────────────────────────────────────────────────────────
def test_group_key_prefers_isrc_over_everything():
ctx = {"track_info": {"isrc": "USRC12345678", "id": "spid", "uri": "spotify:track:x"}}
assert quarantine_group_key("Artist", "Track", ctx) == "isrc:usrc12345678"
def test_group_key_falls_back_to_source_id_then_uri():
assert quarantine_group_key("A", "T", {"track_info": {"id": "abc123"}}) == "id:abc123"
assert quarantine_group_key("A", "T", {"track_info": {"uri": "spotify:track:z"}}) == "uri:spotify:track:z"
def test_group_key_falls_back_to_normalized_name_without_context():
# Trivial case/whitespace differences still collapse to one key.
k1 = quarantine_group_key("Kendrick Lamar", "DNA.")
k2 = quarantine_group_key("kendrick lamar", "dna.")
assert k1 == k2 == "nm:kendrick lamar|dna."
def test_group_key_none_when_nothing_identifies_target():
assert quarantine_group_key("", "", {}) is None
assert quarantine_group_key("", "", None) is None
def test_list_entries_carry_group_key(tmp_path):
_write_entry(tmp_path, "20260514_120000", "a.flac",
context={"track_info": {"isrc": "USABC1234567"}})
entries = list_quarantine_entries(str(tmp_path))
assert entries[0]["group_key"] == "isrc:usabc1234567"
def test_find_siblings_returns_same_target_attempts(tmp_path):
# Two failed source attempts at the SAME target track (same isrc) + an
# unrelated entry. Siblings of #2 = {#1}, never the unrelated one.
same = {"track_info": {"isrc": "USAAA0000001"}}
other = {"track_info": {"isrc": "USZZZ9999999"}}
q1, _ = _write_entry(tmp_path, "20260514_120000", "src1.flac", context=same)
q2, _ = _write_entry(tmp_path, "20260514_120001", "src2.flac", context=same)
_write_entry(tmp_path, "20260514_120002", "diff.flac", context=other)
id1 = entry_id_from_quarantined_filename(q1.name)
id2 = entry_id_from_quarantined_filename(q2.name)
assert find_quarantine_siblings(str(tmp_path), id2) == [id1]
def test_find_siblings_groups_by_intended_target_not_file_tags(tmp_path):
# Same intended target (isrc) even though the bad files differ — that's
# the whole point: the file metadata is wrong, the target is constant.
same = {"track_info": {"isrc": "USAAA0000001", "name": "Whatever"}}
q1, _ = _write_entry(tmp_path, "20260514_120000", "garbage_wrong_song.flac", context=same)
q2, _ = _write_entry(tmp_path, "20260514_120001", "another_bad_rip.flac", context=same)
id1 = entry_id_from_quarantined_filename(q1.name)
id2 = entry_id_from_quarantined_filename(q2.name)
assert find_quarantine_siblings(str(tmp_path), id1) == [id2]
def test_find_siblings_empty_for_ungroupable_entry(tmp_path):
# No id and blank expected fields -> None key -> never grouped.
q1, _ = _write_entry(tmp_path, "20260514_120000", "orphan.flac",
expected_track="", expected_artist="")
id1 = entry_id_from_quarantined_filename(q1.name)
assert find_quarantine_siblings(str(tmp_path), id1) == []
def test_find_siblings_empty_for_missing_entry(tmp_path):
_write_entry(tmp_path, "20260514_120000", "a.flac")
assert find_quarantine_siblings(str(tmp_path), "does_not_exist") == []
def test_siblings_must_be_captured_before_accepted_entry_leaves_quarantine(tmp_path):
# Regression for the approve-endpoint ordering: approving RESTORES (moves)
# the accepted entry out of quarantine, after which an id-based sibling
# lookup for that id can't resolve its group_key and returns []. The
# endpoint therefore captures siblings BEFORE approving. This pins that
# invariant: lookup-before == sibling found, lookup-after == empty.
same = {"track_info": {"isrc": "USAAA0000001"}}
q1, _ = _write_entry(tmp_path, "20260514_120000", "a.flac", context=same)
q2, _ = _write_entry(tmp_path, "20260514_120001", "b.flac", context=same)
id1 = entry_id_from_quarantined_filename(q1.name)
id2 = entry_id_from_quarantined_filename(q2.name)
captured = find_quarantine_siblings(str(tmp_path), id1) # while id1 present
assert captured == [id2]
delete_quarantine_entry(str(tmp_path), id1) # simulate approve restoring it
assert find_quarantine_siblings(str(tmp_path), id1) == [] # too late now

View file

@ -0,0 +1,71 @@
"""#889 Phase 4/5: apply a re-identify — stage the file (copy, not move) + build
the hint. Locks down: the original is never touched, the staged name is unique +
keeps the extension, the hint carries the chosen release, and replace_track_id is
set ONLY when 'replace' is ticked.
"""
from __future__ import annotations
from pathlib import Path
import pytest
from core.imports.rematch_apply import (
build_reidentify_hint,
stage_file_for_reidentify,
staged_destination,
)
_FIELDS = {
"source": "spotify", "track_id": "trk_1", "album_id": "alb_album1",
"artist_id": "art_1", "track_title": "Song", "album_name": "Album1",
"artist_name": "Artist", "album_type": "album", "track_number": 5,
"disc_number": 1, "isrc": "US1234567890",
}
def test_staged_destination_keeps_ext_and_is_traceable():
dest = staged_destination("/staging", "/lib/EP1/05 - Song.flac", 42)
assert dest.endswith(".flac")
assert "[reid-42]" in dest # traceable to the track + unique per track
assert dest.startswith("/staging/") # loose file in staging root → single candidate
def test_stage_copies_not_moves(tmp_path: Path):
src = tmp_path / "lib" / "EP1" / "05 - Song.flac"
src.parent.mkdir(parents=True)
src.write_bytes(b"audio-bytes")
staging = tmp_path / "Staging"
out = stage_file_for_reidentify(str(src), str(staging), 42,
signature_fn=lambda p: "sig123")
staged = Path(out["staged_path"])
assert staged.is_file() and staged.read_bytes() == b"audio-bytes"
assert src.is_file() # ORIGINAL untouched (copy, never move)
assert out["content_hash"] == "sig123"
def test_stage_missing_source_raises(tmp_path: Path):
with pytest.raises(FileNotFoundError):
stage_file_for_reidentify(str(tmp_path / "gone.flac"), str(tmp_path / "S"), 1)
def test_build_hint_sets_replace_when_ticked():
h = build_reidentify_hint(42, _FIELDS, "/staging/x.flac", "sig", replace=True)
assert h.replace_track_id == 42
assert h.album_id == "alb_album1" and h.source == "spotify"
assert h.track_number == 5 and h.isrc == "US1234567890"
assert h.exempt_dedup is True
assert h.staged_path == "/staging/x.flac" and h.content_hash == "sig"
def test_build_hint_no_replace_when_unticked():
h = build_reidentify_hint(42, _FIELDS, "/staging/x.flac", "sig", replace=False)
assert h.replace_track_id is None # keep original → no deletion
assert h.exempt_dedup is True # still bypasses dedup-skip (explicit action)
def test_build_hint_handles_non_numeric_track_id():
# Jellyfin-style GUID track ids must still round-trip as replace target.
h = build_reidentify_hint("abc-guid", _FIELDS, "/s/x.flac", None, replace=True)
assert h.replace_track_id == "abc-guid"

View file

@ -0,0 +1,155 @@
"""#889 Phase 1: the re-identify hint store — create / find / consume.
The hint is the single-use, user-designated "which release" answer the import
flow reads at the top of matching. These lock down: a hint round-trips, it's
found by staged path, found by content_hash when the path missed (rename-proof),
found by basename when the dir changed, consumed exactly once, and that a
consumed hint is never handed back.
"""
from __future__ import annotations
import sqlite3
import pytest
from core.imports.rematch_hints import (
RematchHint,
consume_hint,
create_hint,
find_hint_for_file,
list_pending_hints,
quick_file_signature,
)
# The slice of the real schema this module touches (kept in sync with
# database/music_database.py's rematch_hints CREATE).
_SCHEMA = """
CREATE TABLE rematch_hints (
id INTEGER PRIMARY KEY AUTOINCREMENT,
staged_path TEXT NOT NULL,
content_hash TEXT,
source TEXT NOT NULL,
isrc TEXT,
track_id TEXT,
album_id TEXT,
artist_id TEXT,
track_title TEXT,
album_name TEXT,
artist_name TEXT,
album_type TEXT,
track_number INTEGER,
disc_number INTEGER,
replace_track_id INTEGER,
exempt_dedup INTEGER NOT NULL DEFAULT 1,
status TEXT NOT NULL DEFAULT 'pending',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
consumed_at TIMESTAMP
)
"""
@pytest.fixture
def cur():
conn = sqlite3.connect(":memory:")
conn.row_factory = sqlite3.Row
conn.executescript(_SCHEMA)
yield conn.cursor()
conn.close()
def _hint(**kw):
base = dict(
staged_path="/staging/Song.flac",
source="spotify",
isrc="USABC1234567",
track_id="trk_1",
album_id="alb_album1",
artist_id="art_1",
track_title="Song",
album_name="Album1",
artist_name="Artist",
album_type="album",
track_number=5,
disc_number=1,
replace_track_id=42,
)
base.update(kw)
return RematchHint(**base)
def test_create_and_find_by_path_roundtrips(cur):
new_id = create_hint(cur, _hint())
assert new_id > 0
got = find_hint_for_file(cur, "/staging/Song.flac")
assert got is not None
assert got.id == new_id
assert got.album_id == "alb_album1" and got.album_type == "album"
assert got.isrc == "USABC1234567"
assert got.track_number == 5 and got.disc_number == 1
assert got.replace_track_id == 42
assert got.exempt_dedup is True # always set for a user-designated re-identify
assert got.status == "pending"
def test_find_by_content_hash_when_path_missed(cur):
create_hint(cur, _hint(content_hash="deadbeef"))
# Watcher renamed/moved the file → path lookup misses, hash rescues it.
assert find_hint_for_file(cur, "/totally/different.flac") is None
got = find_hint_for_file(cur, "/totally/different.flac", content_hash="deadbeef")
assert got is not None and got.album_name == "Album1"
def test_find_by_basename_when_dir_changed(cur):
create_hint(cur, _hint(staged_path="/staging/in/Song.flac"))
# Same filename, different directory (watcher moved it deeper).
got = find_hint_for_file(cur, "/staging/processing/Song.flac")
assert got is not None and got.track_id == "trk_1"
def test_consume_is_single_use(cur):
new_id = create_hint(cur, _hint())
assert find_hint_for_file(cur, "/staging/Song.flac") is not None
consume_hint(cur, new_id)
# Consumed → never handed back, by path or by hash.
assert find_hint_for_file(cur, "/staging/Song.flac") is None
assert find_hint_for_file(cur, "/x", content_hash=None) is None
def test_list_pending_excludes_consumed(cur):
a = create_hint(cur, _hint(staged_path="/staging/A.flac"))
create_hint(cur, _hint(staged_path="/staging/B.flac"))
assert len(list_pending_hints(cur)) == 2
consume_hint(cur, a)
pend = list_pending_hints(cur)
assert len(pend) == 1 and pend[0].staged_path == "/staging/B.flac"
def test_newest_pending_wins_on_duplicate_path(cur):
create_hint(cur, _hint(album_id="alb_old"))
create_hint(cur, _hint(album_id="alb_new")) # user re-picked for the same file
got = find_hint_for_file(cur, "/staging/Song.flac")
assert got.album_id == "alb_new"
def test_exempt_dedup_false_roundtrips(cur):
create_hint(cur, _hint(staged_path="/staging/Keep.flac", exempt_dedup=False))
got = find_hint_for_file(cur, "/staging/Keep.flac")
assert got.exempt_dedup is False
# ── content fingerprint ───────────────────────────────────────────────────────
def test_quick_file_signature_stable_and_distinct(tmp_path):
a = tmp_path / "a.bin"
b = tmp_path / "b.bin"
a.write_bytes(b"hello world" * 1000)
b.write_bytes(b"goodbye moon" * 1000)
sig_a1 = quick_file_signature(str(a))
sig_a2 = quick_file_signature(str(a))
sig_b = quick_file_signature(str(b))
assert sig_a1 and sig_a1 == sig_a2 # stable
assert sig_a1 != sig_b # distinct content → distinct sig
def test_quick_file_signature_missing_file_is_none():
assert quick_file_signature("/no/such/file.flac") is None

View file

@ -0,0 +1,217 @@
"""#889 Phase 2: the import seam — a hint short-circuits identification, and the
old library row is replaced only after the re-import succeeds.
Two layers:
* pure helpers (build_identification_from_hint, delete_replaced_track) exact
mapping + safe replacement against an in-memory DB, injectable unlink.
* the worker seam (_resolve_rematch_hint / _finalize_rematch_hint) proves the
NO-HINT path is untouched, the hint path returns a ready identification, the
lookup is fail-safe, and finalize consumes + replaces.
"""
from __future__ import annotations
import sqlite3
import types
import pytest
from core.auto_import_worker import AutoImportWorker, FolderCandidate
from core.imports.rematch_hints import (
RematchHint,
build_identification_from_hint,
consume_hint,
create_hint,
delete_replaced_track,
find_hint_for_file,
)
_SCHEMA = """
CREATE TABLE rematch_hints (
id INTEGER PRIMARY KEY AUTOINCREMENT, staged_path TEXT NOT NULL, content_hash TEXT,
source TEXT NOT NULL, isrc TEXT, track_id TEXT, album_id TEXT, artist_id TEXT,
track_title TEXT, album_name TEXT, artist_name TEXT, album_type TEXT,
track_number INTEGER, disc_number INTEGER, replace_track_id INTEGER,
exempt_dedup INTEGER NOT NULL DEFAULT 1, status TEXT NOT NULL DEFAULT 'pending',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, consumed_at TIMESTAMP
);
CREATE TABLE tracks (
id INTEGER PRIMARY KEY, album_id INTEGER, artist_id INTEGER, title TEXT,
track_number INTEGER, file_path TEXT
);
"""
@pytest.fixture
def conn():
c = sqlite3.connect(":memory:")
c.row_factory = sqlite3.Row
c.executescript(_SCHEMA)
yield c
c.close()
def _hint(**kw):
base = dict(staged_path="/staging/Song.flac", source="spotify", album_id="alb_album1",
artist_id="art_1", track_id="trk_1", track_title="Song", album_name="Album1",
artist_name="Artist", album_type="album", track_number=5, disc_number=1)
base.update(kw)
return RematchHint(**base)
# ── pure: identification mapping ──────────────────────────────────────────────
def test_build_identification_maps_hint_fields():
ident = build_identification_from_hint(_hint())
assert ident["album_id"] == "alb_album1"
assert ident["source"] == "spotify"
assert ident["album_name"] == "Album1"
assert ident["artist_id"] == "art_1"
assert ident["track_number"] == 5
assert ident["method"] == "rematch_hint"
assert ident["identification_confidence"] == 1.0
# album_type 'album' → not a single, and force_album_match makes the matcher
# fetch the real album (year/track#/art) instead of the singles stub.
assert ident["is_single"] is False
assert ident["force_album_match"] is True
def test_build_identification_single_release_still_forces_album_fetch():
# Even a chosen SINGLE release is fetched (it has a year too); is_single flags
# the type, force_album_match drives the album path regardless.
ident = build_identification_from_hint(_hint(album_type="single"))
assert ident["is_single"] is True
assert ident["force_album_match"] is True
# ── pure: safe replacement ────────────────────────────────────────────────────
def test_delete_replaced_track_removes_row_and_file(conn):
cur = conn.cursor()
cur.execute("INSERT INTO tracks (id, file_path) VALUES (7, '/lib/EP1/05 - Song.flac')")
removed = []
out = delete_replaced_track(cur, 7, unlink=lambda p: removed.append(p))
assert out == "/lib/EP1/05 - Song.flac"
assert removed == ["/lib/EP1/05 - Song.flac"] # file removed (we faked existence below)
cur.execute("SELECT 1 FROM tracks WHERE id = 7")
assert cur.fetchone() is None # row gone
def test_delete_replaced_track_keeps_file_if_another_row_points_at_it(conn):
cur = conn.cursor()
cur.execute("INSERT INTO tracks (id, file_path) VALUES (7, '/lib/shared.flac')")
cur.execute("INSERT INTO tracks (id, file_path) VALUES (8, '/lib/shared.flac')")
removed = []
out = delete_replaced_track(cur, 7, unlink=lambda p: removed.append(p))
assert out is None and removed == [] # row 8 still references it → no unlink
cur.execute("SELECT 1 FROM tracks WHERE id = 7")
assert cur.fetchone() is None # but row 7 still deleted
def test_delete_replaced_track_noops_on_missing_id(conn):
cur = conn.cursor()
assert delete_replaced_track(cur, None) is None
assert delete_replaced_track(cur, 999) is None # no such row
def test_delete_replaced_track_same_home_is_noop(conn):
# THE data-loss bug: re-identify to the release it's already in → the import
# reuses the same file/row, so deleting it would orphan the file. Guard: no-op.
cur = conn.cursor()
cur.execute("INSERT INTO tracks (id, file_path) VALUES (7, '/lib/Album1/05 - Song.flac')")
removed = []
out = delete_replaced_track(cur, 7, unlink=lambda p: removed.append(p),
new_paths=['/lib/Album1/05 - Song.flac'])
assert out is None and removed == [] # NOTHING unlinked
cur.execute("SELECT 1 FROM tracks WHERE id = 7")
assert cur.fetchone() is not None # row PRESERVED (it's the re-imported track)
def test_delete_replaced_track_different_home_still_deletes(conn):
# Genuinely re-homed (new path differs) → old row + file removed as intended.
cur = conn.cursor()
cur.execute("INSERT INTO tracks (id, file_path) VALUES (7, '/lib/EP1/05 - Song.flac')")
removed = []
out = delete_replaced_track(cur, 7, unlink=lambda p: removed.append(p),
new_paths=['/lib/Album1/05 - Song.flac'])
assert out == '/lib/EP1/05 - Song.flac'
assert removed == ['/lib/EP1/05 - Song.flac']
cur.execute("SELECT 1 FROM tracks WHERE id = 7")
assert cur.fetchone() is None
def test_delete_replaced_track_resolves_path_before_unlink(conn):
# The stored path is a server/Docker view this process can't read literally;
# resolve_fn maps it to the real file so we unlink the RIGHT path (not orphan it).
cur = conn.cursor()
cur.execute("INSERT INTO tracks (id, file_path) VALUES (7, '/mnt/serverview/Song.flac')")
removed = []
out = delete_replaced_track(cur, 7, unlink=lambda p: removed.append(p),
resolve_fn=lambda stored: '/real/local/Song.flac')
assert out == '/real/local/Song.flac'
assert removed == ['/real/local/Song.flac'] # unlinked the RESOLVED path
# patch os.path.exists so the unlink branch is reachable without real files
@pytest.fixture(autouse=True)
def _exists(monkeypatch):
monkeypatch.setattr("core.imports.rematch_hints.os.path.exists", lambda p: True)
# ── worker seam ───────────────────────────────────────────────────────────────
def _worker(conn):
# Production hands out a FRESH connection per call (the worker closes it);
# here we share one in-memory DB, so proxy close() to a no-op.
w = AutoImportWorker.__new__(AutoImportWorker)
proxy = types.SimpleNamespace(cursor=conn.cursor, commit=conn.commit, close=lambda: None)
w.database = types.SimpleNamespace(_get_connection=lambda: proxy)
return w
def test_resolve_returns_none_when_no_hint(conn):
w = _worker(conn)
cand = FolderCandidate(path="/staging", name="Song", audio_files=["/staging/Song.flac"])
assert w._resolve_rematch_hint(cand) == (None, None) # untouched → normal identify
def test_resolve_returns_identification_when_hinted(conn, monkeypatch):
# don't hash a real file
monkeypatch.setattr("core.imports.rematch_hints.quick_file_signature", lambda p: None)
create_hint(conn.cursor(), _hint(staged_path="/staging/Song.flac"))
conn.commit()
w = _worker(conn)
cand = FolderCandidate(path="/staging", name="Song", audio_files=["/staging/Song.flac"])
hint, ident = w._resolve_rematch_hint(cand)
assert hint is not None and hint.album_id == "alb_album1"
assert ident["album_id"] == "alb_album1" and ident["method"] == "rematch_hint"
def test_resolve_ignores_multi_file_candidates(conn):
create_hint(conn.cursor(), _hint(staged_path="/staging/Song.flac"))
conn.commit()
w = _worker(conn)
cand = FolderCandidate(path="/staging", name="Album",
audio_files=["/staging/Song.flac", "/staging/Other.flac"])
assert w._resolve_rematch_hint(cand) == (None, None) # re-identify is single-track only
def test_resolve_is_failsafe_on_db_error():
w = AutoImportWorker.__new__(AutoImportWorker)
def _boom():
raise RuntimeError("db down")
w.database = types.SimpleNamespace(_get_connection=_boom)
cand = FolderCandidate(path="/staging", name="Song", audio_files=["/staging/Song.flac"])
assert w._resolve_rematch_hint(cand) == (None, None) # error never breaks auto-import
def test_finalize_consumes_and_replaces(conn, monkeypatch):
monkeypatch.setattr("core.imports.rematch_hints.quick_file_signature", lambda p: None)
cur = conn.cursor()
cur.execute("INSERT INTO tracks (id, file_path) VALUES (42, '/lib/EP1/05 - Song.flac')")
hid = create_hint(cur, _hint(replace_track_id=42))
conn.commit()
w = _worker(conn)
hint = find_hint_for_file(conn.cursor(), "/staging/Song.flac")
w._finalize_rematch_hint(hint)
# old row deleted, hint consumed
cur.execute("SELECT 1 FROM tracks WHERE id = 42")
assert cur.fetchone() is None
assert find_hint_for_file(conn.cursor(), "/staging/Song.flac") is None # consumed

View file

@ -0,0 +1,121 @@
"""#889 Phase 3: re-identify search — normalize results across sources, infer the
release-type badge, and resolve the picked row's album_id.
Locks down: same song surfaces as multiple rows (single/EP/album), the EP
inference from a multi-track 'single', graceful empty on a dead source, and that
resolve_hint_fields pulls album_id (and refuses a result without one).
"""
from __future__ import annotations
import types
from core.imports.rematch_search import (
available_sources,
infer_release_type,
normalize_search_result,
resolve_hint_fields,
search_release_candidates,
)
# typed-Track-ish object (mirrors core.metadata.types.Track attrs the modal reads)
def _track(tid, title, album, album_type, total, isrc=None, year="2020"):
return types.SimpleNamespace(
id=tid, name=title, artists=["Artist"], album=album,
album_type=album_type, total_tracks=total, release_date=year + "-01-01",
image_url="http://img/" + tid, isrc=isrc, external_ids={},
)
# ── release-type inference ────────────────────────────────────────────────────
def test_infer_album_stays_album():
assert infer_release_type("album", 12) == "album"
def test_infer_single_one_track_is_single():
assert infer_release_type("single", 1) == "single"
def test_infer_multitrack_single_promoted_to_ep():
# Spotify labels EPs as album_type='single' — promote on track count.
assert infer_release_type("single", 5) == "ep"
def test_infer_compilation():
assert infer_release_type("compilation", 40) == "compilation"
def test_infer_unknown_falls_back_to_count():
assert infer_release_type(None, 10) == "album"
assert infer_release_type("", 4) == "ep"
assert infer_release_type(None, 1) == "single"
# ── normalization ─────────────────────────────────────────────────────────────
def test_normalize_builds_display_row():
row = normalize_search_result(_track("t1", "Song", "Album1", "album", 12, isrc="US1234567890"), "spotify")
assert row["track_id"] == "t1"
assert row["album_name"] == "Album1" and row["album_type"] == "album"
assert row["artist_name"] == "Artist"
assert row["year"] == "2020" and row["isrc"] == "US1234567890"
def test_normalize_skips_result_without_id_or_title():
assert normalize_search_result(types.SimpleNamespace(id="", name="X"), "spotify") is None
assert normalize_search_result(types.SimpleNamespace(id="t", name=""), "spotify") is None
def test_same_song_multiple_collections():
"""The headline case: one song, three releases, three distinct rows + badges."""
results = [
_track("t_alb", "Song", "Album1", "album", 12),
_track("t_ep", "Song", "EP1", "single", 5), # multi-track single → EP
_track("t_sgl", "Song", "Song (Single)", "single", 1),
]
client = types.SimpleNamespace(search_tracks=lambda q, limit=25: results)
rows = search_release_candidates("spotify", "Song", client_factory=lambda s: client)
badges = {r["album_name"]: r["album_type"] for r in rows}
assert badges == {"Album1": "album", "EP1": "ep", "Song (Single)": "single"}
def test_search_empty_on_missing_client():
assert search_release_candidates("spotify", "x", client_factory=lambda s: None) == []
def test_search_empty_on_blank_query():
called = []
search_release_candidates("spotify", " ", client_factory=lambda s: called.append(1))
assert called == [] # never even fetches a client for an empty query
def test_search_swallows_client_error():
def boom(q, limit=25):
raise RuntimeError("rate limited")
client = types.SimpleNamespace(search_tracks=boom)
assert search_release_candidates("spotify", "x", client_factory=lambda s: client) == []
# ── resolve on select ─────────────────────────────────────────────────────────
def test_resolve_pulls_album_id_and_fields():
details = {
"name": "Song", "track_number": 5, "disc_number": 1, "isrc": "US1234567890",
"album": {"id": "alb_album1", "name": "Album1", "album_type": "album", "total_tracks": 12},
"artists": [{"id": "art_1", "name": "Artist"}],
}
client = types.SimpleNamespace(get_track_details=lambda tid: details)
out = resolve_hint_fields("spotify", "t_alb", client_factory=lambda s: client)
assert out["album_id"] == "alb_album1"
assert out["artist_id"] == "art_1"
assert out["track_number"] == 5 and out["disc_number"] == 1
assert out["album_type"] == "album" and out["isrc"] == "US1234567890"
def test_resolve_refuses_result_without_album_id():
details = {"name": "Song", "album": {"name": "NoId Album"}} # no album id
client = types.SimpleNamespace(get_track_details=lambda tid: details)
assert resolve_hint_fields("spotify", "t", client_factory=lambda s: client) is None
def test_resolve_none_on_missing_client():
assert resolve_hint_fields("spotify", "t", client_factory=lambda s: None) is None

View file

@ -0,0 +1,206 @@
"""Seam tests for single -> parent-album resolution (Sokhi: single-matched track
splits from its album -> mixed cover art). The selector is pure; the resolver
takes injected fetchers, so neither needs a live metadata client.
"""
from __future__ import annotations
import pytest
from core.imports.single_to_album import (
select_parent_album,
resolve_single_to_album,
)
from core.imports.context import detect_album_info_web
# ── pure selector ─────────────────────────────────────────────────────────────
def _alb(name, tracks, album_type="album", **extra):
return {"name": name, "album_type": album_type, "tracks": tracks, **extra}
def test_picks_album_that_contains_the_track():
got = select_parent_album("Yellow", [
_alb("Parachutes", ["Don't Panic", "Shiver", "Yellow", "Trouble"]),
])
assert got and got["name"] == "Parachutes"
def test_returns_none_when_no_album_contains_the_track():
assert select_parent_album("Yellow", [
_alb("Some Other Album", ["Track A", "Track B"]),
]) is None
def test_never_promotes_onto_a_single_release():
# The single's own release (album_type 'single', name == track) must be ignored.
assert select_parent_album("Yellow", [
_alb("Yellow", ["Yellow"], album_type="single"),
]) is None
def test_ignores_ep_and_compilation_types():
assert select_parent_album("Yellow", [
_alb("Yellow EP", ["Yellow", "Yellow (Live)"], album_type="ep"),
_alb("Greatest Hits", ["Yellow", "Clocks"], album_type="compilation"),
]) is None
def test_skips_album_named_exactly_like_the_track():
# An 'album' whose name IS the track title is the single dressed as an album;
# don't treat it as the parent.
assert select_parent_album("Yellow", [
_alb("Yellow", ["Yellow"]),
]) is None
def test_matches_through_album_version_qualifier():
got = select_parent_album("Yellow", [
_alb("Parachutes", ["Shiver", "Yellow (Album Version)"]),
])
assert got and got["name"] == "Parachutes"
def test_first_qualifying_candidate_wins_deterministically():
got = select_parent_album("Yellow", [
_alb("Parachutes", ["Yellow"]),
_alb("Parachutes (Deluxe)", ["Yellow", "Bonus"]),
])
assert got["name"] == "Parachutes" # input order = priority
def test_empty_title_returns_none():
assert select_parent_album("", [_alb("Parachutes", ["Yellow"])]) is None
# ── injected-I/O resolver ─────────────────────────────────────────────────────
def test_resolver_finds_parent_album_lazily():
calls = {"tracks": 0}
albums = [
{"name": "Single Yellow", "album_type": "single", "id": "s1"}, # skipped (not album)
{"name": "Wrong Album", "album_type": "album", "id": "a1"},
{"name": "Parachutes", "album_type": "album", "id": "a2"},
]
def fetch_tracks(alb):
calls["tracks"] += 1
return {"a1": ["Other"], "a2": ["Yellow", "Shiver"]}.get(alb["id"], [])
got = resolve_single_to_album(
"Yellow",
fetch_album_candidates=lambda: albums,
fetch_album_tracks=fetch_tracks,
)
assert got and got["name"] == "Parachutes" and got["album_id"] == "a2"
assert calls["tracks"] == 2 # probed a1 then a2, stopped; never probed the single
def test_resolver_returns_none_when_nothing_contains_track():
got = resolve_single_to_album(
"Yellow",
fetch_album_candidates=lambda: [{"name": "X", "album_type": "album", "id": "a1"}],
fetch_album_tracks=lambda alb: ["Nope"],
)
assert got is None
def test_resolver_is_failsafe_on_candidate_fetch_error():
def boom():
raise RuntimeError("api down")
assert resolve_single_to_album(
"Yellow", fetch_album_candidates=boom, fetch_album_tracks=lambda a: []) is None
def test_resolver_is_failsafe_on_track_fetch_error():
def boom(alb):
raise RuntimeError("api down")
got = resolve_single_to_album(
"Yellow",
fetch_album_candidates=lambda: [{"name": "Parachutes", "album_type": "album", "id": "a1"}],
fetch_album_tracks=boom)
assert got is None
def test_resolver_caps_albums_probed():
albums = [{"name": f"A{i}", "album_type": "album", "id": str(i)} for i in range(20)]
probed = {"n": 0}
def fetch_tracks(alb):
probed["n"] += 1
return ["nope"]
resolve_single_to_album(
"Yellow",
fetch_album_candidates=lambda: albums,
fetch_album_tracks=fetch_tracks,
max_albums=5)
assert probed["n"] == 5 # never probes more than the cap
# ── gated wiring through detect_album_info_web (config gate + client shapes) ───
class _Cfg:
def __init__(self, on):
self._on = on
def get(self, key, default=None):
if key == "metadata_enhancement.single_to_album":
return self._on
return default
_SINGLE_CTX = {
"source": "spotify",
"artist": {"id": "art1", "name": "Coldplay"},
# album_type unset + name == track + total_tracks 1 -> is_album False, and the
# existing best-effort skips (album name == track), so the glue is reached.
"album": {"id": "s1", "name": "Yellow", "total_tracks": 1},
"track_info": {"id": "t1", "name": "Yellow", "track_number": 7},
"original_search_result": {"title": "Yellow", "album": "Yellow"},
}
def _patch_clients(monkeypatch, albums, tracks_by_id):
monkeypatch.setattr("core.metadata.album_tracks.get_artist_albums_for_source",
lambda *a, **k: albums)
monkeypatch.setattr("core.metadata.album_tracks.get_artist_album_tracks",
lambda album_id, **k: {"tracks": tracks_by_id.get(album_id, [])})
def test_glue_promotes_single_to_parent_album_when_enabled(monkeypatch):
monkeypatch.setattr("core.metadata.common.get_config_manager", lambda: _Cfg(True))
_patch_clients(
monkeypatch,
albums=[{"name": "Yellow", "album_type": "single", "id": "s1"},
{"name": "Parachutes", "album_type": "album", "id": "a2"}],
tracks_by_id={"a2": [{"title": "Shiver"}, {"title": "Yellow"}]},
)
out = detect_album_info_web(dict(_SINGLE_CTX), {"id": "art1", "name": "Coldplay"})
assert out and out["is_album"] is True
assert out["album_name"] == "Parachutes"
assert out["track_number"] == 7 # preserved
def test_glue_disabled_by_default_returns_none(monkeypatch):
monkeypatch.setattr("core.metadata.common.get_config_manager", lambda: _Cfg(False))
# Even with clients that WOULD match, the flag off => no promotion.
_patch_clients(monkeypatch,
albums=[{"name": "Parachutes", "album_type": "album", "id": "a2"}],
tracks_by_id={"a2": [{"title": "Yellow"}]})
assert detect_album_info_web(dict(_SINGLE_CTX), {"id": "art1", "name": "Coldplay"}) is None
def test_glue_no_match_returns_none(monkeypatch):
monkeypatch.setattr("core.metadata.common.get_config_manager", lambda: _Cfg(True))
_patch_clients(monkeypatch,
albums=[{"name": "Other Album", "album_type": "album", "id": "a9"}],
tracks_by_id={"a9": [{"title": "Different Song"}]})
assert detect_album_info_web(dict(_SINGLE_CTX), {"id": "art1", "name": "Coldplay"}) is None
def test_glue_failsafe_when_client_raises(monkeypatch):
monkeypatch.setattr("core.metadata.common.get_config_manager", lambda: _Cfg(True))
def boom(*a, **k):
raise RuntimeError("spotify down")
monkeypatch.setattr("core.metadata.album_tracks.get_artist_albums_for_source", boom)
assert detect_album_info_web(dict(_SINGLE_CTX), {"id": "art1", "name": "Coldplay"}) is None

View file

@ -14,7 +14,7 @@ from __future__ import annotations
from unittest.mock import patch from unittest.mock import patch
from core.imports.track_number import resolve_track_number from core.imports.track_number import read_embedded_track_number, resolve_track_number
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -226,3 +226,113 @@ def test_non_dict_track_info_treated_as_empty():
"""Defensive — non-dict track_info won't crash the resolver.""" """Defensive — non-dict track_info won't crash the resolver."""
result = resolve_track_number({}, 'not a dict', '/dir/file.flac') result = resolve_track_number({}, 'not a dict', '/dir/file.flac')
assert result is None assert result is None
# ---------------------------------------------------------------------------
# "Track 01" bug (Deezer single tracks): embedded file-tag source.
# ---------------------------------------------------------------------------
def test_embedded_tag_used_when_metadata_missing():
"""The Deezer single-track case: context carried no position (search
endpoint omits track_position), but the downloaded file did. With the
metadata-context de-poisoned to 0/None, the embedded tag is consulted
BEFORE the filename guess."""
result = resolve_track_number(
album_info={'track_number': 0}, # de-poisoned "unknown"
track_info={'track_number': 0},
file_path='/dl/Lenny Kravitz - Fly Away.flac', # no number prefix
embedded_track_number=2,
)
assert result == 2
def test_real_metadata_still_beats_embedded_tag():
"""No regression for album downloads: an authoritative album_info
position wins over the file tag (they normally agree, but the
context is the source of truth when present)."""
result = resolve_track_number(
album_info={'track_number': 5},
track_info={},
file_path='/dl/file.flac',
embedded_track_number=2,
)
assert result == 5
def test_filename_beats_embedded_tag_no_regression():
"""SAFETY: embedded tag is consulted LAST, so a correctly-named ripped
file ('05 - Song') with a stale/wrong embedded tag is NOT regressed
the filename still wins, exactly as before the fix."""
result = resolve_track_number(
album_info={},
track_info={},
file_path='/dl/09 - Mislabelled.flac',
embedded_track_number=2, # wrong/stale tag must NOT win
)
assert result == 9
def test_embedded_only_fills_the_floor_gap():
"""When metadata AND filename are both empty (the Deezer case), the
embedded tag fills what would otherwise be the blind default-1 floor."""
result = resolve_track_number(
album_info={}, track_info={},
file_path='/dl/Lenny Kravitz - Fly Away.flac', # no number prefix
embedded_track_number=2,
)
assert result == 2
def test_resolver_without_embedded_arg_is_backwards_compatible():
"""The new parameter is optional — existing 3-arg callers unaffected."""
assert resolve_track_number({}, {'track_number': 4}, '/x.flac') == 4
# read_embedded_track_number — mutagen-backed file tag reader.
def _fake_mutagen(tag_value):
"""Patch target factory: returns a callable standing in for
``mutagen.File`` that yields an object whose .get('tracknumber')
returns tag_value (mimicking easy=True list-valued tags)."""
class _Audio(dict):
pass
def _factory(path, easy=False):
a = _Audio()
if tag_value is not None:
a['tracknumber'] = tag_value
return a
return _factory
def test_read_embedded_handles_number_slash_total():
with patch('mutagen.File', _fake_mutagen(['2/15'])):
assert read_embedded_track_number('/x.flac') == 2
def test_read_embedded_handles_bare_number():
with patch('mutagen.File', _fake_mutagen(['7'])):
assert read_embedded_track_number('/x.flac') == 7
def test_read_embedded_none_when_tag_absent():
with patch('mutagen.File', _fake_mutagen(None)):
assert read_embedded_track_number('/x.flac') is None
def test_read_embedded_none_when_file_unreadable():
def _factory(path, easy=False):
return None
with patch('mutagen.File', _factory):
assert read_embedded_track_number('/x.flac') is None
def test_read_embedded_never_raises():
def _boom(path, easy=False):
raise RuntimeError('corrupt')
with patch('mutagen.File', _boom):
assert read_embedded_track_number('/x.flac') is None
def test_read_embedded_empty_path_returns_none():
assert read_embedded_track_number('') is None

View file

@ -0,0 +1,77 @@
"""#890: a leading track number leaking from a filename stem into the title
("01 - Sun It Rises") makes the track never match the canonical "Sun It Rises",
so it reads as a false "missing". strip_leading_track_number removes the prefix
conservatively, so titles that merely START with a number are left alone.
"""
from __future__ import annotations
import pytest
from core.imports.context import get_import_clean_title
from core.imports.paths import strip_leading_track_number
# ── the bug: track-number prefixes get stripped ───────────────────────────────
@pytest.mark.parametrize("dirty,clean", [
("01 - Sun It Rises", "Sun It Rises"), # the screenshot
("04 - Tiger Mountain Peasant Song", "Tiger Mountain Peasant Song"),
("05 - Quiet Houses", "Quiet Houses"),
("07 - Heard Them Stirring", "Heard Them Stirring"),
("01 Sun It Rises", "Sun It Rises"), # zero-padded, no separator
("3 - Title", "Title"), # plain number + separator + space
("12. Some Song", "Some Song"), # dot separator
("10 - Track Ten", "Track Ten"),
("09) Closing Time", "Closing Time"), # paren separator
(" 02 - Spaced Out ", "Spaced Out"), # messy whitespace
])
def test_strips_track_number_prefix(dirty, clean):
assert strip_leading_track_number(dirty) == clean
# ── the guard: titles that legitimately start with a number are UNTOUCHED ──────
@pytest.mark.parametrize("title", [
"7 Rings",
"99 Luftballons",
"50 Ways to Leave Your Lover",
"1-800-273-8255", # number-with-dashes is part of the title
"1979",
"9 to 5",
"4 Minutes",
"8 Mile",
"21 Guns",
"24 Hour Party People", # no separator → not a track number
"0 to 100",
"Sun It Rises", # no leading number at all
])
def test_preserves_real_titles(title):
assert strip_leading_track_number(title) == title
# ── degenerate inputs ─────────────────────────────────────────────────────────
def test_never_reduces_to_empty_or_bare_number():
assert strip_leading_track_number("01") == "01" # bare number → keep
assert strip_leading_track_number("01 - ") == "01 -" # nothing left → keep original (trimmed)
assert strip_leading_track_number("") == ""
assert strip_leading_track_number(None) == ""
def test_only_strips_one_prefix():
# A title that legitimately follows the number keeps its own leading number.
assert strip_leading_track_number("01 - 24 Hour Party People") == "24 Hour Party People"
# ── the chokepoint: every import path resolves its title through here ──────────
def test_get_import_clean_title_strips_filename_leak():
# original_search['title'] came from the filename stem (no embedded tag).
ctx = {"original_search_result": {"title": "01 - Sun It Rises"}}
assert get_import_clean_title(ctx) == "Sun It Rises"
def test_get_import_clean_title_leaves_clean_source_title():
ctx = {"original_search_result": {"title": "7 Rings"}}
assert get_import_clean_title(ctx) == "7 Rings"
def test_get_import_clean_title_default_untouched():
assert get_import_clean_title({}, default="Unknown Track") == "Unknown Track"

View file

@ -0,0 +1,51 @@
"""#891: the shared 'residual file' classifier — junk + cover/scan images +
lyric/metadata sidecars used by both the Reorganize cleanup and the Empty
Folder Cleaner, plus the reorganize sweep that uses it.
"""
from __future__ import annotations
from pathlib import Path
from core.library.residual_files import (
is_disposable,
is_image,
is_junk,
is_sidecar,
)
def test_images_classified():
for n in ('cover.jpg', 'Cover.JPEG', 'folder.png', 'back.webp', 'scan.tiff', 'art.gif'):
assert is_image(n) and is_disposable(n)
def test_sidecars_classified():
for n in ('lyrics.lrc', 'album.nfo', 'disc.cue', 'playlist.m3u', 'x.m3u8'):
assert is_sidecar(n) and is_disposable(n)
def test_junk_classified():
assert is_junk('.DS_Store') and is_disposable('Thumbs.db')
def test_real_content_not_disposable():
# Audio + anything unrecognized (booklet, video, a note) is real content.
for n in ('song.flac', 'track.mp3', 'booklet.pdf', 'movie.mkv', 'readme.txt', 'data.json'):
assert not is_disposable(n), n
# ── the reorganize sweep that uses the predicate ──────────────────────────────
def test_delete_album_sidecars_sweeps_all_residual_keeps_real(tmp_path: Path):
from core.library_reorganize import _delete_album_sidecars
d = tmp_path / 'Old Album'
d.mkdir()
for n in ('cover.jpg', 'back.jpg', 'disc.png', 'lyrics.lrc', 'album.nfo', '.DS_Store'):
(d / n).write_text('x')
(d / 'booklet.pdf').write_text('keep') # unrecognized → must survive
_delete_album_sidecars(str(d))
survivors = {p.name for p in d.iterdir()}
assert survivors == {'booklet.pdf'} # every residual swept, booklet kept

View file

@ -19,6 +19,12 @@ from core.musicbrainz_service import MusicBrainzService
VOL4 = "B小町 - TVアニメ「【推しの子】」キャラクターソングCD Vol.4" VOL4 = "B小町 - TVアニメ「【推しの子】」キャラクターソングCD Vol.4"
VOL45 = "B小町 - TVアニメ「【推しの子】」キャラクターソングCD Vol.4.5" VOL45 = "B小町 - TVアニメ「【推しの子】」キャラクターソングCD Vol.4.5"
# Sokhi #2: the sequel number is glued straight onto a CJK word ('…トラック2'),
# with the SAME digit already present elsewhere ('第2期' = season 2). Stripping
# to [a-z0-9] collapsed both titles to {'2'} and the wrong (cour-2) cover won.
OST = "『無職転生 〜異世界行ったら本気だす〜』 第2期 オリジナル・サウンドトラック"
OST2 = "『無職転生 〜異世界行ったら本気だす〜』 第2期 オリジナル・サウンドトラック2"
def test_helper_volume_and_sequel_differ(): def test_helper_volume_and_sequel_differ():
assert numeric_tokens_differ(VOL4, VOL45) assert numeric_tokens_differ(VOL4, VOL45)
@ -26,11 +32,20 @@ def test_helper_volume_and_sequel_differ():
assert numeric_tokens_differ("Now 99", "Now 100") assert numeric_tokens_differ("Now 99", "Now 100")
def test_helper_cjk_trailing_sequel_digit_differs():
# The trailing '2' must register as a difference even though '第2期' already
# puts a '2' on both sides.
assert numeric_tokens_differ(OST, OST2)
assert numeric_tokens_differ(OST2, OST)
def test_helper_shared_or_no_digits_match(): def test_helper_shared_or_no_digits_match():
assert not numeric_tokens_differ("1989", "1989 (Deluxe)") assert not numeric_tokens_differ("1989", "1989 (Deluxe)")
assert not numeric_tokens_differ(VOL4, VOL4) assert not numeric_tokens_differ(VOL4, VOL4)
assert not numeric_tokens_differ("IGOR", "IGOR (Deluxe)") assert not numeric_tokens_differ("IGOR", "IGOR (Deluxe)")
assert not numeric_tokens_differ("", "") assert not numeric_tokens_differ("", "")
# Same CJK album on both sides (incl. the shared 第2期) still matches.
assert not numeric_tokens_differ(OST, OST)
def _service_with_results(results): def _service_with_results(results):

View file

@ -202,6 +202,19 @@ def test_album_matches_rejects_numeric_difference():
assert art_lookup._album_matches("Taylor Swift", "1989", "Taylor Swift", "1989 (Deluxe)") assert art_lookup._album_matches("Taylor Swift", "1989", "Taylor Swift", "1989 (Deluxe)")
def test_album_matches_rejects_cjk_trailing_sequel_digit():
"""Sokhi #2: the sequel '2' is glued straight onto a CJK word
('…サウンドトラック2'), and '第2期' (season 2) already puts a '2' on both
sides so the digit-strip collapsed both to {'2'} and the cour-2
soundtrack's cover hung on the base soundtrack."""
ART = "藤澤慶昌"
OST = "『無職転生 〜異世界行ったら本気だす〜』 第2期 オリジナル・サウンドトラック"
assert not art_lookup._album_matches(ART, OST, ART, OST + "2")
assert not art_lookup._album_matches(ART, OST + "2", ART, OST)
# The genuine base-album hit still matches (incl. its shared 第2期).
assert art_lookup._album_matches(ART, OST, ART, OST)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# build_art_lookup — caching + guarding # build_art_lookup — caching + guarding
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View file

@ -731,3 +731,26 @@ def test_get_artist_discography_prefers_source_specific_artist_ids(monkeypatch):
) )
] ]
assert deezer.album_calls == [] assert deezer.album_calls == []
def test_get_artist_detail_discography_splits_eps_from_singles(monkeypatch):
"""#877: get_artist_detail_discography must put EPs in their OWN bucket (not
lumped into singles). The Download Discography modal now reads this split, so
its EPs filter has cards to act on and stays in sync with Artist Detail."""
spotify = _FakeSourceClient(
album_results=[
_album("a1", "An Album", "2024-01-01", album_type="album"),
_album("e1", "An EP", "2024-02-01", album_type="ep"),
_album("s1", "A Single", "2024-03-01", album_type="single"),
]
)
clients = {"deezer": _FakeSourceClient(), "spotify": spotify, "itunes": _FakeSourceClient()}
monkeypatch.setattr(metadata_registry, "get_primary_source", lambda spotify_client_factory=None: "deezer")
monkeypatch.setattr(metadata_registry, "get_source_priority", lambda primary: [primary, "spotify", "itunes"])
monkeypatch.setattr(metadata_registry, "get_client_for_source", lambda source, **kwargs: clients.get(source))
result = metadata_discography.get_artist_detail_discography("artist-1", "Artist One", MetadataLookupOptions())
assert [r["id"] for r in result["albums"]] == ["a1"]
assert [r["id"] for r in result["eps"]] == ["e1"]
assert [r["id"] for r in result["singles"]] == ["s1"]

View file

@ -0,0 +1,391 @@
"""Quality Upgrade Finder job — the findings-based replacement for the old
auto-acting Quality Scanner.
The old tool judged quality by file EXTENSION only and used min() of the enabled
tiers, so with the default profile (FLAC + MP3-320 + MP3-256 enabled) it flagged
EVERY non-lossless file a 320 kbps MP3 included and dumped them all into the
wishlist with no review. These tests pin the corrected behavior: bitrate-aware,
honors every enabled bucket, and only proposes (findings) rather than auto-acting.
"""
from __future__ import annotations
import types
import core.repair_jobs.quality_upgrade as qu
from core.repair_jobs.base import JobContext, JobResult
# Profiles ------------------------------------------------------------------
BALANCED = { # default: FLAC + MP3-320 + MP3-256 enabled, MP3-192 off
'qualities': {
'flac': {'enabled': True, 'min_kbps': 500},
'mp3_320': {'enabled': True, 'min_kbps': 280},
'mp3_256': {'enabled': True, 'min_kbps': 200},
'mp3_192': {'enabled': False, 'min_kbps': 150},
}
}
LOSSLESS_ONLY = {
'qualities': {
'flac': {'enabled': True, 'min_kbps': 500},
'mp3_320': {'enabled': False, 'min_kbps': 280},
'mp3_256': {'enabled': False, 'min_kbps': 200},
'mp3_192': {'enabled': False, 'min_kbps': 150},
}
}
NOTHING_ENABLED = {'qualities': {'flac': {'enabled': False}, 'mp3_320': {'enabled': False}}}
# --- pure quality decision -------------------------------------------------
def test_balanced_profile_accepts_320_mp3_REGRESSION():
"""The headline bug: with FLAC+320+256 enabled, a 320 kbps MP3 is acceptable.
The old min()-tier logic flagged it (and every other MP3) for re-download."""
assert meets('song.mp3', 320, BALANCED) is True
def test_balanced_profile_accepts_256_mp3():
assert meets('song.mp3', 256, BALANCED) is True
def test_balanced_profile_flags_low_bitrate_mp3():
assert meets('song.mp3', 128, BALANCED) is False
assert meets('song.mp3', 192, BALANCED) is False # below the 256 floor
def test_flac_always_meets_when_flac_enabled():
assert meets('song.flac', 900, BALANCED) is True
assert meets('song.flac', 900, LOSSLESS_ONLY) is True
def test_lossless_only_flags_every_lossy_regardless_of_bitrate():
assert meets('song.mp3', 320, LOSSLESS_ONLY) is False
assert meets('song.m4a', 256, LOSSLESS_ONLY) is False
def test_nothing_enabled_flags_nothing():
"""Empty/disabled profile must NOT flag the whole library."""
assert meets('song.mp3', 64, NOTHING_ENABLED) is True
def test_bitrate_in_bps_is_normalized():
"""Library bitrate stored as bps (320000) classifies the same as 320 kbps."""
assert qu.classify_track_quality('song.mp3', 320000) == qu.RANK_320
assert meets('song.mp3', 320000, BALANCED) is True
def test_unknown_lossy_bitrate_not_flagged_under_lossy_floor():
"""A lossy file with no bitrate can't be judged against a lossy floor → don't
flag (avoid false positives); but under a lossless floor it's clearly below."""
assert meets('song.mp3', None, BALANCED) is True
assert meets('song.mp3', None, LOSSLESS_ONLY) is False
def test_floor_is_worst_enabled_not_best():
# FLAC+320+256 enabled → floor is MP3-256 (rank 2), not FLAC.
assert qu.preferred_quality_floor(BALANCED) == qu.RANK_256
assert qu.preferred_quality_floor(LOSSLESS_ONLY) == qu.RANK_LOSSLESS
assert qu.preferred_quality_floor(NOTHING_ENABLED) is None
def meets(path, bitrate, profile):
return qu.meets_preferred_quality(path, bitrate, profile)
# --- scan produces a finding (seam) ----------------------------------------
class _FakeConn:
def __init__(self, rows, finding_ids=()):
self._rows = rows
self._finding_ids = list(finding_ids)
self._sql = ''
def execute(self, sql='', *a, **k):
self._sql = sql or ''
return self
def fetchall(self):
# The existing-findings query reads repair_findings; everything else is the
# track load.
if 'repair_findings' in self._sql:
return [(fid,) for fid in self._finding_ids]
return self._rows
def close(self):
pass
class _FakeDB:
def __init__(self, rows, profile, finding_ids=()):
self._rows = rows
self._profile = profile
self._finding_ids = finding_ids
def get_quality_profile(self):
return self._profile
def _get_connection(self):
return _FakeConn(self._rows, self._finding_ids)
def get_watchlist_artists(self, profile_id=1):
return [types.SimpleNamespace(artist_name='Artist A')]
def _ctx(db, findings):
return JobContext(
db=db,
transfer_folder='/tmp',
config_manager=None,
create_finding=lambda **kw: findings.append(kw) or True,
should_stop=lambda: False,
is_paused=lambda: False,
)
def _row(track_id=1, title='Song One', path='/music/a.mp3', bitrate=128, duration=180000,
artist='Artist A', album='Album X', album_id=10, track_number=6):
"""A track row in _TRACK_COLS order (album source-id columns default to None)."""
return (track_id, title, path, bitrate, duration, artist, album, album_id, track_number)
def _stub_engine(monkeypatch):
monkeypatch.setattr(qu, 'get_primary_source', lambda: 'spotify')
monkeypatch.setattr(qu, 'get_source_priority', lambda src: ['spotify'])
monkeypatch.setattr(
'core.matching_engine.MusicMatchingEngine',
lambda: types.SimpleNamespace(
generate_download_queries=lambda t: ['q'],
similarity_score=lambda a, b: 1.0,
normalize_string=lambda s: s,
),
)
def test_scan_creates_finding_for_low_quality_track(monkeypatch):
db = _FakeDB([_row(bitrate=128)], BALANCED)
_stub_engine(monkeypatch)
fake_match = {'id': 'sp1', 'name': 'Song One', 'artists': ['Artist A'],
'album': {'name': 'Album X', 'images': []}}
# No track-id / ISRC / album hit → exercise the search tier.
monkeypatch.setattr(qu, '_read_file_ids', lambda fp: {})
monkeypatch.setattr(qu, '_match_via_track_id', lambda *a, **k: (None, None))
monkeypatch.setattr(qu, '_match_via_album', lambda *a, **k: (None, None))
monkeypatch.setattr(qu, '_find_best_match',
lambda *a, **k: (fake_match, 0.95, 'spotify', True))
monkeypatch.setattr(qu, '_normalize_track_match', lambda track, src: dict(fake_match))
monkeypatch.setattr(qu, '_track_name', lambda t: 'Song One')
findings = []
result = qu.QualityUpgradeJob().scan(_ctx(db, findings))
assert result.findings_created == 1
assert len(findings) == 1
f = findings[0]
assert f['finding_type'] == 'quality_upgrade'
assert f['entity_id'] == '1'
# Album context + matched track carried for the apply step.
assert f['details']['matched_track_data']['id'] == 'sp1'
assert f['details']['album_title'] == 'Album X'
assert f['details']['provider'] == 'spotify'
def test_match_via_track_id_fetches_exact_by_id(monkeypatch):
"""Most-direct tier: a per-source track ID in the tags → get_track_details by ID."""
track = {'id': 'sp9', 'name': 'Song One', 'album': {'name': 'Album X'}}
client = types.SimpleNamespace(get_track_details=lambda tid: track if tid == 'sp9' else None)
monkeypatch.setattr(qu, 'get_client_for_source', lambda src: client)
best, source = qu._match_via_track_id({'spotify_track_id': 'sp9'}, ['spotify'])
assert best['id'] == 'sp9'
assert source == 'spotify'
assert qu._match_via_track_id({}, ['spotify']) == (None, None) # no ID → nothing
def test_duration_ok_guard():
assert qu._duration_ok(180000, 181000) is True # within 5s
assert qu._duration_ok(180000, 200000) is False # 20s off — wrong cut
assert qu._duration_ok(None, 200000) is True # unknown → lenient
assert qu._duration_ok(180000, 0) is True # unknown → lenient
def test_scan_prefers_track_id_tier(monkeypatch):
"""The source's own track ID (from file tags) wins over every other tier."""
db = _FakeDB([_row()], BALANCED)
_stub_engine(monkeypatch)
monkeypatch.setattr(qu, '_read_file_ids', lambda fp: {'spotify_track_id': 'sp9', 'isrc': 'X'})
fake = {'id': 'sp9', 'name': 'Song One', 'album': {'name': 'Album X'}}
monkeypatch.setattr(qu, '_match_via_track_id', lambda ids, sp: (fake, 'spotify'))
monkeypatch.setattr(qu, '_normalize_track_match', lambda t, s: dict(fake))
monkeypatch.setattr(qu, '_track_name', lambda t: 'Song One')
def _boom(*a, **k):
raise AssertionError("no lower tier should run when the track-ID tier matches")
monkeypatch.setattr(qu, '_match_via_isrc', _boom)
monkeypatch.setattr(qu, '_match_via_album', _boom)
monkeypatch.setattr(qu, '_find_best_match', _boom)
findings = []
result = qu.QualityUpgradeJob().scan(_ctx(db, findings))
assert result.findings_created == 1
assert findings[0]['details']['matched_via'] == 'track_id'
def test_scan_skips_already_proposed_tracks(monkeypatch):
"""A re-run must not re-resolve a track that already has a finding."""
db = _FakeDB([_row(track_id=1)], BALANCED, finding_ids=['1'])
monkeypatch.setattr(qu, 'get_primary_source', lambda: 'spotify')
monkeypatch.setattr(qu, 'get_source_priority', lambda src: ['spotify'])
def _boom(*a, **k):
raise AssertionError("no matching for an already-proposed track")
monkeypatch.setattr(qu, '_match_via_track_id', _boom)
monkeypatch.setattr(qu, '_find_best_match', _boom)
findings = []
result = qu.QualityUpgradeJob().scan(_ctx(db, findings))
assert findings == []
assert result.findings_skipped_dedup == 1
def test_match_via_isrc_accepts_exact_match(monkeypatch):
"""The guard accepts only a candidate whose own ISRC equals ours (dash/case
insensitive), so it survives a source returning unrelated hits first."""
monkeypatch.setattr(qu, 'get_client_for_source',
lambda src: types.SimpleNamespace(search_tracks=lambda *a, **k: []))
monkeypatch.setattr(qu, '_search_tracks_for_source', lambda *a, **k: [
{'id': 'x', 'name': 'Wrong', 'isrc': 'ZZISRC000000'},
{'id': 'sp1', 'name': 'Right', 'isrc': 'US-RC1-76-07839'}, # dashed form
])
best, source = qu._match_via_isrc('USRC17607839', ['spotify'])
assert best['id'] == 'sp1'
assert source == 'spotify'
def test_match_via_isrc_rejects_all_mismatches(monkeypatch):
monkeypatch.setattr(qu, 'get_client_for_source',
lambda src: types.SimpleNamespace(search_tracks=lambda *a, **k: []))
monkeypatch.setattr(qu, '_search_tracks_for_source', lambda *a, **k: [
{'id': 'x', 'name': 'Wrong', 'external_ids': {'isrc': 'ZZISRC000000'}},
])
assert qu._match_via_isrc('USRC17607839', ['spotify']) == (None, None)
def test_scan_prefers_isrc_exact_match_over_fuzzy(monkeypatch):
"""No track-ID, but the file carries an ISRC that resolves → use the exact match
and do NOT run the album/search tiers."""
db = _FakeDB([_row()], BALANCED)
_stub_engine(monkeypatch)
monkeypatch.setattr(qu, '_read_file_ids', lambda fp: {'isrc': 'USRC17607839'})
monkeypatch.setattr(qu, '_match_via_track_id', lambda *a, **k: (None, None))
fake = {'id': 'sp1', 'name': 'Song One', 'artists': ['Artist A'], 'album': {'name': 'Album X'}}
monkeypatch.setattr(qu, '_match_via_isrc', lambda isrc, sp: (fake, 'spotify'))
monkeypatch.setattr(qu, '_normalize_track_match', lambda t, s: dict(fake))
monkeypatch.setattr(qu, '_track_name', lambda t: 'Song One')
def _boom(*a, **k):
raise AssertionError("fuzzy search must not run when an ISRC match exists")
monkeypatch.setattr(qu, '_find_best_match', _boom)
findings = []
result = qu.QualityUpgradeJob().scan(_ctx(db, findings))
assert result.findings_created == 1
assert findings[0]['details']['matched_via'] == 'isrc'
assert findings[0]['details']['match_confidence'] == 1.0
def test_scan_falls_back_to_search_without_ids(monkeypatch):
"""No track-ID / ISRC / album hit → fall back to fuzzy search."""
db = _FakeDB([_row()], BALANCED)
_stub_engine(monkeypatch)
monkeypatch.setattr(qu, '_read_file_ids', lambda fp: {}) # un-enriched
monkeypatch.setattr(qu, '_match_via_track_id', lambda *a, **k: (None, None))
monkeypatch.setattr(qu, '_match_via_album', lambda *a, **k: (None, None))
fake = {'id': 'sp1', 'name': 'Song One', 'artists': ['Artist A'], 'album': {'name': 'Album X'}}
monkeypatch.setattr(qu, '_find_best_match', lambda *a, **k: (fake, 0.88, 'spotify', True))
monkeypatch.setattr(qu, '_normalize_track_match', lambda t, s: dict(fake))
monkeypatch.setattr(qu, '_track_name', lambda t: 'Song One')
findings = []
result = qu.QualityUpgradeJob().scan(_ctx(db, findings))
assert result.findings_created == 1
assert findings[0]['details']['matched_via'] == 'search'
def test_scan_uses_album_tier_when_no_ids(monkeypatch):
"""No track-ID / ISRC, but the album→track lookup resolves it → matched_via
'album', and the fuzzy search is never reached."""
db = _FakeDB([_row()], BALANCED)
_stub_engine(monkeypatch)
monkeypatch.setattr(qu, '_read_file_ids', lambda fp: {})
monkeypatch.setattr(qu, '_match_via_track_id', lambda *a, **k: (None, None))
fake = {'id': 'sp1', 'name': 'Song One', 'artists': ['Artist A'], 'album': {'name': 'Album X'}}
monkeypatch.setattr(qu, '_match_via_album', lambda *a, **k: (fake, 'spotify'))
monkeypatch.setattr(qu, '_normalize_track_match', lambda t, s: dict(fake))
monkeypatch.setattr(qu, '_track_name', lambda t: 'Song One')
def _boom(*a, **k):
raise AssertionError("fuzzy search must not run when the album tier matches")
monkeypatch.setattr(qu, '_find_best_match', _boom)
findings = []
result = qu.QualityUpgradeJob().scan(_ctx(db, findings))
assert result.findings_created == 1
assert findings[0]['details']['matched_via'] == 'album'
assert findings[0]['details']['match_confidence'] == 1.0
def test_find_track_in_album_exact_title_with_track_number(monkeypatch):
items = [
{'id': 'a', 'name': 'Intro', 'track_number': 1},
{'id': 'b', 'name': 'Karma Police', 'track_number': 6},
{'id': 'c', 'name': 'Karma Police (Live)', 'track_number': 12},
]
eng = types.SimpleNamespace(similarity_score=lambda a, b: 0.0, normalize_string=lambda s: s)
got = qu._find_track_in_album(items, 'Karma Police', 6, eng)
assert got['id'] == 'b'
def test_scan_skips_tracks_meeting_quality(monkeypatch):
# A 320 kbps MP3 meets the balanced profile → no finding, no metadata calls.
db = _FakeDB([_row(track_id=2, title='Good Song', bitrate=320)], BALANCED)
def _boom(*a, **k): # must never be called for an acceptable track
raise AssertionError("matching should not run for an acceptable track")
monkeypatch.setattr(qu, '_find_best_match', _boom)
findings = []
result = qu.QualityUpgradeJob().scan(_ctx(db, findings))
assert result.findings_created == 0
assert result.skipped == 1
assert findings == []
# --- fix handler adds to wishlist ------------------------------------------
def test_fix_handler_adds_matched_track_to_wishlist():
from core.repair_worker import RepairWorker
captured = {}
class _DB:
def add_to_wishlist(self, **kw):
captured.update(kw)
return True
worker = object.__new__(RepairWorker)
worker.db = _DB()
details = {
'matched_track_data': {'id': 'sp1', 'name': 'Song One',
'album': {'name': 'Album X'}},
'current_format': 'MP3 192', 'current_bitrate': 192,
'album_title': 'Album X', 'provider': 'spotify', 'match_confidence': 0.9,
}
res = worker._fix_quality_upgrade('track', '1', '/music/a.mp3', details)
assert res['success'] is True
assert captured['spotify_track_data']['id'] == 'sp1'
assert captured['source_type'] == 'repair'
assert captured['source_info']['job'] == 'quality_upgrade'
assert captured['source_info']['album_title'] == 'Album X'

View file

@ -0,0 +1,98 @@
"""Same-name artist disambiguation by owned-catalog overlap (#868).
Enrichment matched artists by NAME ONLY, so for a common name ("Rone" has ~5
artists) it grabbed whichever the source ranked first often the wrong one,
which then drove a wrong/sparse library discography. The fix: when several
candidates clear the name gate, pick the one whose catalog overlaps the albums
the user actually OWNS. These pin the source-agnostic selector.
"""
from __future__ import annotations
from core.worker_utils import (
catalog_overlap_score,
normalize_release_title,
pick_artist_by_catalog,
)
# --- normalization ---------------------------------------------------------
def test_normalize_strips_editions_and_punctuation():
assert normalize_release_title('Tohu Bohu (Deluxe Edition)') == 'tohu bohu'
assert normalize_release_title('Mirapolis - Remastered') == 'mirapolis'
assert normalize_release_title('Room with a View [2020]') == 'room with a view'
assert normalize_release_title('') == ''
# --- overlap scoring -------------------------------------------------------
def test_overlap_counts_matching_owned_titles():
owned = ['Tohu Bohu', 'Creatures', 'Mirapolis']
cand = ['Tohu Bohu (Deluxe)', 'Creatures', 'Spanish Breakfast', 'Motion']
assert catalog_overlap_score(owned, cand) == 2 # Tohu Bohu + Creatures
def test_overlap_zero_for_a_different_artists_catalog():
owned = ['Tohu Bohu', 'Creatures', 'Mirapolis']
cand = ['Some Other Record', 'Unrelated Album']
assert catalog_overlap_score(owned, cand) == 0
def test_overlap_zero_when_either_side_empty():
assert catalog_overlap_score([], ['A']) == 0
assert catalog_overlap_score(['A'], []) == 0
# --- the selector ----------------------------------------------------------
def _cand(cid, titles):
return {'id': cid, '_titles': titles}
def _fetch(cand):
return cand['_titles']
def test_single_candidate_returns_without_fetching():
calls = []
chosen, score = pick_artist_by_catalog(
[_cand('only', ['X'])], ['Tohu Bohu'],
lambda c: calls.append(c) or c['_titles'])
assert chosen['id'] == 'only'
assert calls == [] # never fetched — nothing to disambiguate
def test_no_owned_albums_keeps_name_order():
calls = []
chosen, score = pick_artist_by_catalog(
[_cand('first', ['A']), _cand('second', ['B'])], [],
lambda c: calls.append(c) or c['_titles'])
assert chosen['id'] == 'first' # candidates[0] — current behavior
assert calls == []
def test_picks_the_candidate_overlapping_owned_catalog():
# The WRONG Rone is ranked first; the right one overlaps the owned albums.
wrong = _cand('wrong', ['Rap Mixtape Vol 1', 'Some Single'])
right = _cand('right', ['Tohu Bohu', 'Creatures', 'Mirapolis'])
chosen, score = pick_artist_by_catalog(
[wrong, right], ['Tohu Bohu', 'Creatures', 'Spanish Breakfast'], _fetch)
assert chosen['id'] == 'right'
assert score == 2
def test_no_overlap_anywhere_falls_back_to_first():
a = _cand('a', ['Nope']); b = _cand('b', ['Also Nope'])
chosen, score = pick_artist_by_catalog([a, b], ['Tohu Bohu'], _fetch)
assert chosen['id'] == 'a'
assert score == 0
def test_fetch_failure_is_tolerated():
def _boom(_c):
raise RuntimeError('api down')
chosen, score = pick_artist_by_catalog(
[_cand('a', []), _cand('b', [])], ['Tohu Bohu'], _boom)
assert chosen['id'] == 'a' # both fail → fall back to first
assert score == 0

View file

@ -0,0 +1,88 @@
"""Find & Add: Spotify "Title - Qualifier" must find the base-titled library track.
wolf's report: Spotify shows "Calma - Remix", Find & Add searches that literal
string, the library stores the track as just "Calma" (only the duration marks it
as the remix) the literal search misses and the OR-fuzzy fallback floods 20
unrelated "... remix" hits. Dropping "- Remix" (searching "Calma") finds it.
Fix: search_tracks retries on the base title (before Spotify's " - " separator)
before the OR-fuzzy flood.
"""
from __future__ import annotations
import pytest
from core.text.title_match import base_title_before_dash
from database.music_database import MusicDatabase
# --- pure helper -----------------------------------------------------------
def test_base_title_before_dash_strips_spotify_version_suffix():
assert base_title_before_dash('Calma - Remix') == 'Calma'
assert base_title_before_dash('Closer - Radio Edit') == 'Closer'
assert base_title_before_dash('Crocodile Rock - Remastered 2014') == 'Crocodile Rock'
def test_base_title_before_dash_leaves_plain_titles_alone():
assert base_title_before_dash('Tom Sawyer') == 'Tom Sawyer'
assert base_title_before_dash('21st Century Schizoid Man') == '21st Century Schizoid Man'
assert base_title_before_dash('Up-Tight') == 'Up-Tight' # bare hyphen, not a separator
assert base_title_before_dash('') == ''
def test_base_title_before_dash_splits_first_separator_only():
assert base_title_before_dash('A - B - C') == 'A'
# --- integration: the real search path -------------------------------------
@pytest.fixture
def db(tmp_path):
return MusicDatabase(str(tmp_path / "music.db"))
def _insert(db, tid, title, artist_id, artist_name):
with db._get_connection() as conn:
conn.execute("INSERT OR IGNORE INTO artists (id, name) VALUES (?, ?)", (artist_id, artist_name))
conn.execute("INSERT OR IGNORE INTO albums (id, title, artist_id) VALUES (?, ?, ?)",
(artist_id, "Alb", artist_id))
conn.execute(
"INSERT INTO tracks (id, album_id, artist_id, title, track_number, duration, file_path) "
"VALUES (?, ?, ?, ?, 1, 238, ?)",
(tid, artist_id, artist_id, title, f"/m/{tid}.flac"),
)
conn.commit()
def test_spotify_dash_remix_finds_base_titled_track(db):
# Library stores the remix as just "Calma" (the wolf case).
_insert(db, 1, "Calma", 1, "Pedro Capó")
results = db.search_tracks(title="Calma - Remix", rank_artist="Pedro Capó")
titles = [t.title for t in results]
assert "Calma" in titles, "base-title fallback should find 'Calma' for 'Calma - Remix'"
def test_spotify_dash_remix_finds_parenthesized_remix(db):
# …and still matches when the library DID label it "(Remix)".
_insert(db, 1, "Calma (Remix)", 1, "Pedro Capó")
results = db.search_tracks(title="Calma - Remix", rank_artist="Pedro Capó")
assert any("Calma" in t.title for t in results)
def test_plain_title_unaffected_uses_basic_search(db):
_insert(db, 1, "Tom Sawyer", 1, "Rush")
results = db.search_tracks(title="Tom Sawyer")
assert [t.title for t in results] == ["Tom Sawyer"]
def test_dash_query_does_not_flood_when_base_matches(db):
# The base-title retry must short-circuit BEFORE the OR-fuzzy flood, so an
# unrelated "... Remix" track doesn't drown the real one.
_insert(db, 1, "Calma", 1, "Pedro Capó")
_insert(db, 2, "Some Other Song (KAIZ Remix)", 2, "Someone Else")
results = db.search_tracks(title="Calma - Remix", rank_artist="Pedro Capó")
titles = [t.title for t in results]
assert "Calma" in titles
assert "Some Other Song (KAIZ Remix)" not in titles

View file

@ -36,6 +36,23 @@ def test_is_junk():
assert is_junk('.DS_Store') and is_junk('thumbs.db') and not is_junk('cover.jpg') assert is_junk('.DS_Store') and is_junk('thumbs.db') and not is_junk('cover.jpg')
# ── #891: residual (image / sidecar only) folders ───────────────────────────
def test_image_only_dir_kept_by_default_removed_with_residual_opt():
# Default (junk only): a cover.jpg keeps the folder (the conservative behavior).
assert dir_is_removable(['cover.jpg'], []) is False
# Opt-in: image/sidecar-only folders become removable.
assert dir_is_removable(['cover.jpg'], [], ignore_disposable=True) is True
assert dir_is_removable(['back.jpg', 'lyrics.lrc', '.DS_Store'], [], ignore_disposable=True) is True
assert dir_is_removable(['folder.png', 'album.nfo'], [], ignore_disposable=True) is True
def test_residual_opt_still_keeps_real_content():
# Audio, or anything not recognized as a leftover (a booklet pdf), still blocks.
assert dir_is_removable(['cover.jpg', 'song.flac'], [], ignore_disposable=True) is False
assert dir_is_removable(['cover.jpg', 'booklet.pdf'], [], ignore_disposable=True) is False
assert dir_is_removable([], ['Album'], ignore_disposable=True) is False # surviving subdir
# ── apply re-check (real FS) ──────────────────────────────────────────────── # ── apply re-check (real FS) ────────────────────────────────────────────────
def _fx(): def _fx():
return dict(listdir=os.listdir, isdir=os.path.isdir, islink=os.path.islink, return dict(listdir=os.listdir, isdir=os.path.isdir, islink=os.path.islink,
@ -72,3 +89,34 @@ def test_apply_refuses_library_root(tmp_path):
res = remove_empty_folder(str(root), junk_files=[], remove_junk=True, root=str(root), **_fx()) res = remove_empty_folder(str(root), junk_files=[], remove_junk=True, root=str(root), **_fx())
assert res['removed'] is False and 'root' in res['error'].lower() assert res['removed'] is False and 'root' in res['error'].lower()
assert root.exists() assert root.exists()
def test_apply_sweeps_residual_then_folder_when_enabled(tmp_path):
root = tmp_path / 'lib'; root.mkdir()
d = root / 'Artist' / 'Old Album'; d.mkdir(parents=True)
(d / 'cover.jpg').write_text('img')
(d / 'back.jpg').write_text('img')
(d / 'lyrics.lrc').write_text('la')
res = remove_empty_folder(str(d), junk_files=[], remove_junk=True,
remove_disposable=True, root=str(root), **_fx())
assert res['removed'] is True and not d.exists()
def test_apply_without_residual_opt_leaves_image_folder(tmp_path):
# The default apply (no residual opt) must NOT delete a cover.jpg folder.
root = tmp_path / 'lib'; root.mkdir()
d = root / 'HasCover'; d.mkdir()
(d / 'cover.jpg').write_text('img')
res = remove_empty_folder(str(d), junk_files=[], remove_junk=True, root=str(root), **_fx())
assert res['removed'] is False and d.exists()
def test_apply_residual_opt_still_refuses_real_content(tmp_path):
root = tmp_path / 'lib'; root.mkdir()
d = root / 'Mixed'; d.mkdir()
(d / 'cover.jpg').write_text('img')
(d / 'booklet.pdf').write_text('pdf') # unrecognized → real content
res = remove_empty_folder(str(d), junk_files=[], remove_junk=True,
remove_disposable=True, root=str(root), **_fx())
assert res['removed'] is False and d.exists()
assert (d / 'booklet.pdf').exists() and (d / 'cover.jpg').exists() # nothing deleted

View file

@ -0,0 +1,100 @@
"""Sokhi: tracks occasionally land 'untagged' after a processing failure.
enhance_file_metadata clears the file's tags and saves it UP FRONT (so stale
tags never linger), then does the failure-prone enrichment (external source-id
embed, cover-art fetch) and saves again at the end. The core tags
(album/artist/title/track) come from the already-matched context and are written
to the in-memory object BEFORE those external steps but the on-disk file is
still the cleared one until the final save.
The #764 fix made the error handler restore ART, but it gated the re-save on
there being original art to restore. So a file with NO embedded art that hit a
mid-enrichment crash had its in-memory core tags thrown away and was left on disk
exactly as the up-front clear saved it: UNTAGGED.
These tests run the REAL enhance_file_metadata against a REAL art-less FLAC and
assert the core tags survive a crash in the external step.
"""
from __future__ import annotations
import os
import tempfile
from unittest.mock import patch
import pytest
pytest.importorskip("mutagen")
from mutagen.flac import FLAC # noqa: E402
import core.metadata.enrichment as enrichment # noqa: E402
class _Cfg:
def get(self, key, default=None):
return default
def _make_flac_no_art(path):
minimal = (
b"fLaC"
+ b"\x80\x00\x00\x22"
+ b"\x00\x10\x00\x10"
+ b"\x00\x00\x00\x00\x00\x00"
+ b"\x0a\xc4\x42\xf0\x00\x00\x00\x00"
+ b"\x00" * 16
)
with open(path, "wb") as f:
f.write(minimal)
FLAC(path).save() # valid FLAC, no tags, no pictures
@pytest.fixture
def flac_path():
fd, path = tempfile.mkstemp(suffix=".flac")
os.close(fd)
_make_flac_no_art(path)
yield path
try:
os.remove(path)
except OSError:
pass
_CORE = {"title": "Yellow", "artist": "Coldplay", "album_artist": "Coldplay",
"album": "Parachutes", "track_number": 1, "total_tracks": 9, "disc_number": 1}
def _run(flac_path, *, metadata, embed_side_effect):
with patch.object(enrichment, "get_config_manager", return_value=_Cfg()), \
patch.object(enrichment, "strip_all_non_audio_tags"), \
patch.object(enrichment, "extract_source_metadata", return_value=metadata), \
patch.object(enrichment, "embed_source_ids"), \
patch.object(enrichment, "verify_metadata_written", return_value=True), \
patch.object(enrichment, "embed_album_art_metadata", side_effect=embed_side_effect):
return enrichment.enhance_file_metadata(
flac_path, context={}, artist={"name": "Coldplay"}, album_info={},
)
def test_core_tags_survive_when_art_step_raises_on_artless_file(flac_path):
"""The regression: art-less file + a crash in the external art step must NOT
leave the file untagged the matched core tags must be on disk."""
def boom(audio_file, metadata):
raise RuntimeError("art backend exploded")
result = _run(flac_path, metadata=dict(_CORE), embed_side_effect=boom)
assert result is False # enrichment reported failure
f = FLAC(flac_path)
assert f.get("title") == ["Yellow"] # ...but core tags persisted
assert f.get("artist") == ["Coldplay"]
assert f.get("album") == ["Parachutes"] # the tag Rockbox buckets on
assert f.get("tracknumber") == ["1/9"]
def test_core_tags_written_on_happy_path_artless_file(flac_path):
result = _run(flac_path, metadata=dict(_CORE), embed_side_effect=lambda *a, **k: False)
assert result is True
f = FLAC(flac_path)
assert f.get("album") == ["Parachutes"]
assert f.get("artist") == ["Coldplay"]

View file

@ -0,0 +1,73 @@
"""#885: repair-job scheduling must be timezone-independent.
`finished_at` is written by SQLite's CURRENT_TIMESTAMP (always UTC), but the
scheduler compared it against `datetime.now()` (naive LOCAL). With TZ=Australia/
Sydney (UTC+11) every job looked ~11h stale and ran every poll; America/New_York
(behind UTC) masked it. The fix parses finished_at as UTC and compares against a
UTC now, so the machine timezone no longer leaks into elapsed time.
"""
from __future__ import annotations
import time
from datetime import datetime, timezone
import pytest
from core.repair_worker import RepairWorker
# ── pure helper ───────────────────────────────────────────────────────────────
def test_hours_since_treats_naive_timestamp_as_utc():
now = datetime(2026, 6, 18, 6, 0, 0, tzinfo=timezone.utc)
# SQLite CURRENT_TIMESTAMP style: UTC, no tz suffix.
assert RepairWorker._hours_since('2026-06-18 00:00:00', now) == pytest.approx(6.0)
def test_hours_since_handles_aware_timestamp():
now = datetime(2026, 6, 18, 6, 0, 0, tzinfo=timezone.utc)
assert RepairWorker._hours_since('2026-06-18T00:00:00+00:00', now) == pytest.approx(6.0)
def test_hours_since_recent_is_near_zero():
now = datetime(2026, 6, 18, 0, 0, 30, tzinfo=timezone.utc)
assert RepairWorker._hours_since('2026-06-18 00:00:00', now) == pytest.approx(30 / 3600, abs=1e-6)
# ── the #885 repro: a just-run job is never due, regardless of timezone ────────
def _set_tz(monkeypatch, tz):
monkeypatch.setenv('TZ', tz)
try:
time.tzset()
except AttributeError:
pytest.skip('time.tzset() unavailable on this platform')
def test_just_run_job_not_due_under_any_timezone(monkeypatch):
w = RepairWorker.__new__(RepairWorker)
w._jobs = {'cache_evictor': object()}
monkeypatch.setattr(RepairWorker, 'get_job_config',
lambda self, jid: {'enabled': True, 'interval_hours': 6})
# Job finished "now" in UTC (exactly how CURRENT_TIMESTAMP records it).
finished = datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S')
monkeypatch.setattr(RepairWorker, '_get_last_run',
lambda self, jid: {'finished_at': finished})
# Australia/Sydney is the exact repro; check the Americas + UTC too.
for tz in ('Australia/Sydney', 'America/New_York', 'UTC'):
_set_tz(monkeypatch, tz)
assert w._pick_next_job() is None, f"just-run job wrongly due under TZ={tz}"
def test_stale_job_is_still_picked_under_sydney(monkeypatch):
# Sanity: a genuinely-overdue job IS picked (we didn't break due-detection).
w = RepairWorker.__new__(RepairWorker)
w._jobs = {'cache_evictor': object()}
monkeypatch.setattr(RepairWorker, 'get_job_config',
lambda self, jid: {'enabled': True, 'interval_hours': 6})
# Finished ~10h ago in UTC.
old = datetime(2026, 1, 1, 0, 0, 0, tzinfo=timezone.utc).strftime('%Y-%m-%d %H:%M:%S')
monkeypatch.setattr(RepairWorker, '_get_last_run',
lambda self, jid: {'finished_at': old})
_set_tz(monkeypatch, 'Australia/Sydney')
assert w._pick_next_job() == 'cache_evictor'

View file

@ -0,0 +1,43 @@
"""ConfigManager.resolve_secret — test the EFFECTIVE secret, not the mask (#870).
The settings UI renders a saved-but-untouched secret as the redaction sentinel
(shown as dots). The Deezer ARL connection test was sending that sentinel as the
token, so Deezer rejected it ("Invalid ARL token — USER_ID=0") and it looked like
the ARL kept resetting even though the saved value was fine. resolve_secret maps
an empty/sentinel posted value back to the stored token.
"""
from __future__ import annotations
from config.settings import ConfigManager
S = ConfigManager.REDACTED_SENTINEL
def _cm(config_data):
cm = object.__new__(ConfigManager) # bypass __init__/DB — get() reads config_data
cm.config_data = config_data
return cm
def test_sentinel_resolves_to_stored():
cm = _cm({'deezer_download': {'arl': 'real_arl_123'}})
assert cm.resolve_secret('deezer_download.arl', S) == 'real_arl_123'
def test_empty_or_none_resolves_to_stored():
cm = _cm({'deezer_download': {'arl': 'real_arl_123'}})
assert cm.resolve_secret('deezer_download.arl', '') == 'real_arl_123'
assert cm.resolve_secret('deezer_download.arl', ' ') == 'real_arl_123'
assert cm.resolve_secret('deezer_download.arl', None) == 'real_arl_123'
def test_real_value_passes_through_trimmed():
cm = _cm({'deezer_download': {'arl': 'old'}})
assert cm.resolve_secret('deezer_download.arl', 'new_token') == 'new_token'
assert cm.resolve_secret('deezer_download.arl', ' spaced ') == 'spaced'
def test_sentinel_with_nothing_stored_returns_empty():
cm = _cm({})
assert cm.resolve_secret('deezer_download.arl', S) == ''

View file

@ -24,6 +24,23 @@ def _cm(config_data):
return cm return cm
# ── #879: the GET /api/settings handler calls config_manager.redacted_config().
# If that method is ever renamed/removed the endpoint 500s, the web UI treats the
# error body as settings, blanks the form to defaults, and autosaves over the
# user's real config. Pin the method's presence on the class so that can't ship.
def test_redacted_config_is_a_method_on_the_class():
assert callable(getattr(ConfigManager, 'redacted_config', None)), (
"GET /api/settings depends on ConfigManager.redacted_config(); removing or "
"renaming it 500s the settings endpoint and the UI then wipes the config (#879)")
def test_redacted_config_callable_on_instance_returns_dict():
cm = _cm({'spotify': {'client_secret': 'REAL'}})
out = cm.redacted_config()
assert isinstance(out, dict) and out.get('spotify', {}).get('client_secret') == S
# ── redacted_config: secrets out, everything else intact ──────────────────── # ── redacted_config: secrets out, everything else intact ────────────────────
def test_configured_secrets_are_masked(): def test_configured_secrets_are_masked():

View file

@ -0,0 +1,68 @@
"""Issue #887: the Spotify enrichment worker's get_stats() must report
``using_free`` when it's enriching via the no-creds Spotify Free source — even
with no official auth so the dashboard shows "Running (Spotify Free)" instead
of a misleading "Not Authenticated".
Builds the worker via __new__ (bypassing the real SpotifyClient()) and stubs the
db-querying helpers, so the get_stats() free/auth logic is tested in isolation.
"""
from __future__ import annotations
import types
from core.spotify_worker import SpotifyWorker
def _worker(*, serving_via_free, sp=None, rate_limited=False, budget_free=False):
w = SpotifyWorker.__new__(SpotifyWorker)
w.running = True
w.paused = False
w.thread = types.SimpleNamespace(is_alive=lambda: True)
w.current_item = None
w.stats = {'pending': 0, 'processed': 0}
w._serving_via_free = serving_via_free
w.client = types.SimpleNamespace(
sp=sp,
is_rate_limited=lambda: rate_limited,
get_rate_limit_info=lambda: None,
get_post_ban_cooldown_remaining=lambda: 0,
is_spotify_metadata_available=lambda: True,
_budget_exhausted_use_free=budget_free,
)
# db-backed helpers stubbed — we only exercise the auth/free reporting.
w._count_pending_items = lambda: 100
w._get_progress_breakdown = lambda: {}
w._get_daily_budget_info = lambda: {'exhausted': False}
return w
def test_no_auth_but_serving_via_free_reports_using_free():
# #887: no official auth (sp is None), worker enriching via Spotify Free.
stats = _worker(serving_via_free=True, sp=None).get_stats()
assert stats['authenticated'] is False # no official auth
assert stats['using_free'] is True # ...but Free is carrying it
def test_no_auth_and_not_serving_free_is_not_using_free():
# Genuinely can't enrich (no auth, free not active) -> Not Authenticated stands.
stats = _worker(serving_via_free=False, sp=None).get_stats()
assert stats['authenticated'] is False
assert stats['using_free'] is False
def test_rate_limit_bridge_still_reports_using_free():
# Pre-existing bridge path must still work (cache False, but rate-limited).
stats = _worker(serving_via_free=False, sp=object(), rate_limited=True).get_stats()
assert stats['using_free'] is True
def test_budget_bridge_still_reports_using_free():
stats = _worker(serving_via_free=False, sp=object(), budget_free=True).get_stats()
assert stats['using_free'] is True
def test_authed_and_not_on_free_reports_authenticated_not_free():
stats = _worker(serving_via_free=False, sp=object()).get_stats()
assert stats['authenticated'] is True
assert stats['using_free'] is False

View file

@ -252,6 +252,55 @@ class TestIterCollectionTrackIds:
assert ids == [] assert ids == []
def test_429_mid_walk_retries_and_completes(self):
"""Regression for #880: a 429 mid-pagination must RETRY the same
cursor page (with backoff), not truncate the collection. Before the
fix a transient 429 on page ~5 capped a 513-track favorites list at
~98 (the log showed `status=429` then `Retrieved 98/100`)."""
client = _make_authed_client()
# page1 (200) → 429 (transient) → page2 (200, end of chain)
responses = iter([
_FakeResp(200, _PAGE_ONE),
_FakeResp(429, text=""), # rate limited — retry, don't truncate
_FakeResp(200, _PAGE_TWO),
])
with patch.object(client, '_ensure_valid_token', return_value=True), \
patch('core.tidal_client.requests.get', side_effect=lambda *a, **kw: next(responses)), \
patch('core.tidal_client.time.sleep'):
ids = client._iter_collection_track_ids()
assert ids == ['1001', '1002', '1003', '1004', '1005'] # full chain, nothing dropped
def test_429_does_not_set_reconnect_flag(self):
"""A rate-limit is transient, NOT a scope problem — must not tell the
user to reconnect."""
client = _make_authed_client()
responses = iter([_FakeResp(200, _PAGE_ONE), _FakeResp(429, text=""), _FakeResp(200, _PAGE_TWO)])
with patch.object(client, '_ensure_valid_token', return_value=True), \
patch('core.tidal_client.requests.get', side_effect=lambda *a, **kw: next(responses)), \
patch('core.tidal_client.time.sleep'):
client._iter_collection_track_ids()
assert client.collection_needs_reconnect() is False
def test_429_exhausts_retries_returns_partial(self):
"""If the 429s never clear, give up after the retry budget and return
what we have (PARTIAL) rather than looping forever."""
client = _make_authed_client()
def gen():
yield _FakeResp(200, _PAGE_ONE)
while True:
yield _FakeResp(429, text="")
responses = gen()
with patch.object(client, '_ensure_valid_token', return_value=True), \
patch('core.tidal_client.requests.get', side_effect=lambda *a, **kw: next(responses)), \
patch('core.tidal_client.time.sleep'):
ids = client._iter_collection_track_ids()
assert ids == ['1001', '1002', '1003'] # page 1 survives; no hang
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# get_collection_tracks_count # get_collection_tracks_count

View file

@ -13,16 +13,15 @@ conftest is a different module instance. Use the ``shared_state`` fixture instea
import pytest import pytest
# All 7 tool progress pollers # Tool progress pollers
TOOLS = [ TOOLS = [
'stream', 'quality-scanner', 'duplicate-cleaner', 'stream', 'duplicate-cleaner',
'retag', 'db-update', 'metadata', 'logs', 'retag', 'db-update', 'metadata', 'logs',
] ]
# Endpoint URLs keyed by tool name # Endpoint URLs keyed by tool name
ENDPOINTS = { ENDPOINTS = {
'stream': '/api/stream/status', 'stream': '/api/stream/status',
'quality-scanner': '/api/quality-scanner/status',
'duplicate-cleaner': '/api/duplicate-cleaner/status', 'duplicate-cleaner': '/api/duplicate-cleaner/status',
'retag': '/api/retag/status', 'retag': '/api/retag/status',
'db-update': '/api/database/update/status', 'db-update': '/api/database/update/status',
@ -63,27 +62,6 @@ class TestToolDataShape:
assert 'error_message' in data assert 'error_message' in data
assert isinstance(data['progress'], (int, float)) assert isinstance(data['progress'], (int, float))
def test_quality_scanner_shape(self, test_app, shared_state):
"""Quality scanner has status, phase, progress, processed, total, quality_met."""
app, socketio = test_app
client = socketio.test_client(app)
build = shared_state['build_quality_scanner_status']
socketio.emit('tool:quality-scanner', build())
received = client.get_received()
events = [e for e in received if e['name'] == 'tool:quality-scanner']
assert len(events) >= 1
data = events[0]['args'][0]
assert 'status' in data
assert 'phase' in data
assert 'progress' in data
assert 'processed' in data
assert 'total' in data
assert 'quality_met' in data
assert 'low_quality' in data
assert 'matched' in data
def test_duplicate_cleaner_shape(self, test_app, shared_state): def test_duplicate_cleaner_shape(self, test_app, shared_state):
"""Duplicate cleaner has status, phase, progress, space_freed_mb.""" """Duplicate cleaner has status, phase, progress, space_freed_mb."""
app, socketio = test_app app, socketio = test_app
@ -255,11 +233,11 @@ class TestToolBackwardCompat:
client2 = socketio.test_client(app) client2 = socketio.test_client(app)
build = shared_state['build_tool_status'] build = shared_state['build_tool_status']
socketio.emit('tool:quality-scanner', build('quality-scanner')) socketio.emit('tool:duplicate-cleaner', build('duplicate-cleaner'))
for client in [client1, client2]: for client in [client1, client2]:
received = client.get_received() received = client.get_received()
events = [e for e in received if e['name'] == 'tool:quality-scanner'] events = [e for e in received if e['name'] == 'tool:duplicate-cleaner']
assert len(events) >= 1 assert len(events) >= 1
client1.disconnect() client1.disconnect()
@ -270,17 +248,15 @@ class TestToolBackwardCompat:
app, socketio = test_app app, socketio = test_app
client = socketio.test_client(app) client = socketio.test_client(app)
build = shared_state['build_tool_status'] build = shared_state['build_tool_status']
qs = shared_state['quality_scanner_state'] dc = shared_state['duplicate_cleaner_state']
# Mutate state # Mutate state
qs['status'] = 'finished' dc['status'] = 'finished'
qs['progress'] = 100 dc['progress'] = 100
qs['processed'] = 100
socketio.emit('tool:quality-scanner', build('quality-scanner')) socketio.emit('tool:duplicate-cleaner', build('duplicate-cleaner'))
received = client.get_received() received = client.get_received()
events = [e for e in received if e['name'] == 'tool:quality-scanner'] events = [e for e in received if e['name'] == 'tool:duplicate-cleaner']
data = events[-1]['args'][0] data = events[-1]['args'][0]
assert data['status'] == 'finished' assert data['status'] == 'finished'
assert data['progress'] == 100 assert data['progress'] == 100
assert data['processed'] == 100

View file

@ -714,6 +714,67 @@ def test_nzbget_parse_group_computes_progress() -> None:
assert status.download_speed == 500_000 assert status.download_speed == 500_000
def test_nzbget_queue_group_never_offers_a_save_path() -> None:
"""A queued group's DestDir is the in-progress '….#NZBID' dir, which is gone
after the move never expose it as a final save_path. Finalisation must come
from the HISTORY entry. (Swigs: imported from //incomplete/.#2141.)"""
adapter = _nzbget_with_config()
status = adapter._parse_group({
'NZBID': 2141, 'NZBName': 'xRepentancex-The.Sickness.Of.Eden',
'Status': 'DOWNLOADING',
'DestDir': '/data/usenet/incomplete/xRepentancex-The.Sickness.Of.Eden.#2141',
'Category': 'soulsync',
})
assert status.save_path is None
def test_nzbget_pp_finished_group_is_completed_but_has_no_path() -> None:
"""PP_FINISHED maps to 'completed' but is still a QUEUE group on the
in-progress dir it must report no save_path so the plugin waits for the
history entry instead of finalising on the incomplete folder."""
adapter = _nzbget_with_config()
status = adapter._parse_group({
'NZBID': 2141, 'NZBName': 'Album', 'Status': 'PP_FINISHED',
'DestDir': '/data/usenet/incomplete/Album.#2141', 'Category': 'soulsync',
})
assert status.state == 'completed'
assert status.save_path is None
def test_nzbget_history_prefers_finaldir_over_destdir() -> None:
"""After a post-processing move, FinalDir is the real location; DestDir can
still be the intermediate dir. Swigs' exact case."""
adapter = _nzbget_with_config()
status = adapter._parse_history({
'NZBID': 2141, 'Name': 'xRepentancex-The.Sickness.Of.Eden',
'Status': 'SUCCESS/ALL',
'DestDir': '/data/usenet/incomplete/xRepentancex-The.Sickness.Of.Eden.#2141',
'FinalDir': '/data/soulseek/xRepentancex-The.Sickness.Of.Eden-CD-FLAC-2015-CATARACT',
'Category': 'soulsync',
})
assert status.state == 'completed'
assert status.save_path == '/data/soulseek/xRepentancex-The.Sickness.Of.Eden-CD-FLAC-2015-CATARACT'
def test_nzbget_history_falls_back_to_destdir_when_no_finaldir() -> None:
"""No PP move -> FinalDir empty -> use DestDir (the final dest in that case)."""
adapter = _nzbget_with_config()
status = adapter._parse_history({
'NZBID': 7, 'Name': 'Album', 'Status': 'SUCCESS/HEALTH',
'DestDir': '/data/usenet/completed/Album', 'FinalDir': '', 'Category': 'c',
})
assert status.save_path == '/data/usenet/completed/Album'
def test_nzbget_history_empty_dirs_yield_none() -> None:
adapter = _nzbget_with_config()
status = adapter._parse_history({
'NZBID': 7, 'Name': 'Album', 'Status': 'SUCCESS/ALL',
'DestDir': ' ', 'FinalDir': '', 'Category': 'c',
})
assert status.save_path is None
def test_nzbget_remove_rejects_non_numeric_id() -> None: def test_nzbget_remove_rejects_non_numeric_id() -> None:
"""NZBGet IDs are ints; passing a string id like 'abc' must """NZBGet IDs are ints; passing a string id like 'abc' must
fail fast instead of corrupting the editqueue call.""" fail fast instead of corrupting the editqueue call."""

View file

@ -0,0 +1,186 @@
"""Per-worker same-name artist disambiguation (#868).
Each enrichment worker, when several same-name candidates clear the name gate,
must pick the one whose catalog overlaps the albums the library actually owns
not whichever the source ranked first. Covers Spotify (also the Spotify-Free
path, same client surface), iTunes, Deezer, and MusicBrainz.
"""
from __future__ import annotations
import types
# A query-aware fake DB: owned-albums query → owned titles; the source_id_conflict
# query (SELECT name FROM artists ...) → no conflict.
class _Cur:
def __init__(self, rows):
self._rows = rows
def fetchall(self):
return self._rows
class _Conn:
def __init__(self, owned):
self._owned = owned
def __enter__(self):
return self
def __exit__(self, *a):
return False
def execute(self, sql, params=()):
if 'FROM albums' in sql:
return _Cur([(t,) for t in self._owned])
return _Cur([]) # conflict check → none
def cursor(self):
return self
def commit(self):
pass
def close(self):
pass
class _DB:
def __init__(self, owned):
self._owned = owned
def _get_connection(self):
return _Conn(self._owned)
# The owned library: the RIGHT "Rone" made these.
OWNED = ['Tohu Bohu', 'Creatures', 'Mirapolis']
WRONG = types.SimpleNamespace(id='wrong_rone', name='Rone')
RIGHT = types.SimpleNamespace(id='right_rone', name='Rone')
ALBUMS = {
'wrong_rone': [{'title': 'Mixtape Vol 1'}, {'title': 'Random Single'}],
'right_rone': [{'title': 'Tohu Bohu (Deluxe)'}, {'title': 'Creatures'}],
}
def test_spotify_picks_artist_overlapping_owned_catalog():
from core.spotify_worker import SpotifyWorker
w = object.__new__(SpotifyWorker)
w.db = _DB(OWNED)
w.stats = {'matched': 0, 'not_found': 0, 'errors': 0}
w.client = types.SimpleNamespace(
search_artists=lambda name, limit=5: [WRONG, RIGHT], # wrong ranked first
get_artist_albums=lambda aid: ALBUMS.get(aid, []),
)
captured = {}
w._get_existing_id = lambda *a: None
w._mark_status = lambda *a: None
w._name_similarity = lambda a, b: 1.0 # both "Rone" clear the gate
w._is_spotify_id = lambda i: True
w._update_artist = lambda artist_id, obj: captured.update(id=obj.id)
w._process_artist({'id': 5, 'name': 'Rone'})
assert captured.get('id') == 'right_rone'
assert w.stats['matched'] == 1
def test_itunes_picks_artist_overlapping_owned_catalog():
from core.itunes_worker import iTunesWorker
w = object.__new__(iTunesWorker)
w.db = _DB(OWNED)
w.stats = {'matched': 0, 'not_found': 0, 'errors': 0}
w.client = types.SimpleNamespace(
search_artists=lambda name, limit=5: [WRONG, RIGHT],
get_artist_albums=lambda aid: ALBUMS.get(aid, []),
)
captured = {}
w._get_existing_id = lambda *a: None
w._mark_status = lambda *a: None
w._is_itunes_id = lambda i: True
w._update_artist = lambda artist_id, obj: captured.update(id=obj.id)
w._process_artist({'id': 5, 'name': 'Rone'})
assert captured.get('id') == 'right_rone'
assert w.stats['matched'] == 1
def test_deezer_picks_artist_overlapping_owned_catalog():
from core.deezer_worker import DeezerWorker
w = object.__new__(DeezerWorker)
w.db = _DB(OWNED)
w.stats = {'matched': 0, 'not_found': 0, 'errors': 0}
w.client = types.SimpleNamespace(
search_artists=lambda name, limit=5: [WRONG, RIGHT],
get_artist_albums_list=lambda aid: ALBUMS.get(aid, []),
get_artist_info=lambda aid: {'id': aid, 'name': 'Rone'},
)
captured = {}
w._get_existing_id = lambda *a: None
w._mark_status = lambda *a: None
w._update_artist = lambda artist_id, data: captured.update(id=data.get('id'))
w._process_artist(5, 'Rone')
assert captured.get('id') == 'right_rone'
assert w.stats['matched'] == 1
def _mb_service(owned_release_groups):
from core.musicbrainz_service import MusicBrainzService
s = object.__new__(MusicBrainzService)
s._check_cache = lambda *a, **k: None
s._save_to_cache = lambda *a, **k: None
s._calculate_similarity = lambda a, b: 1.0
s.mb_client = types.SimpleNamespace(
search_artist=lambda name, limit=5: [
{'id': 'wrong_mbid', 'name': 'Rone', 'score': 100}, # ranked first
{'id': 'right_mbid', 'name': 'Rone', 'score': 90},
],
get_artist=lambda mbid, includes=None: owned_release_groups.get(mbid),
)
return s
def test_musicbrainz_picks_artist_overlapping_owned_catalog():
rg = {
'wrong_mbid': {'release-groups': [{'title': 'Other Stuff'}]},
'right_mbid': {'release-groups': [{'title': 'Tohu Bohu'}, {'title': 'Creatures'}]},
}
result = _mb_service(rg).match_artist('Rone', owned_titles=OWNED)
assert result is not None
assert result['mbid'] == 'right_mbid'
def test_musicbrainz_without_owned_titles_keeps_legacy_behavior():
"""No owned titles → highest-confidence (name-order first) candidate, unchanged."""
rg = {'wrong_mbid': {'release-groups': []}, 'right_mbid': {'release-groups': []}}
result = _mb_service(rg).match_artist('Rone') # no owned_titles
assert result['mbid'] == 'wrong_mbid' # the top-confidence pick
def test_musicbrainz_cache_hit_used_when_catalog_overlaps():
"""A cached mbid whose catalog overlaps the owned albums is trusted (no re-resolve)."""
rg = {'cached_mbid': {'release-groups': [{'title': 'Tohu Bohu'}, {'title': 'Creatures'}]}}
s = _mb_service(rg)
s._check_cache = lambda *a, **k: {'musicbrainz_id': 'cached_mbid', 'confidence': 95}
s.mb_client.search_artist = lambda *a, **k: (_ for _ in ()).throw(
AssertionError("must not re-search when the cached match fits the library"))
result = s.match_artist('Rone', owned_titles=OWNED)
assert result['mbid'] == 'cached_mbid'
assert result['cached'] is True
def test_musicbrainz_stale_cache_is_bypassed_and_re_resolved():
"""A cached mbid with ZERO owned-catalog overlap (wrong same-name artist) is
bypassed fresh disambiguated resolve. This is what makes a re-match work for
MB despite the 90-day cache TTL (#868)."""
rg = {
'cached_wrong': {'release-groups': [{'title': 'Unrelated'}]}, # the stale cache
'wrong_mbid': {'release-groups': [{'title': 'Other Stuff'}]},
'right_mbid': {'release-groups': [{'title': 'Tohu Bohu'}, {'title': 'Creatures'}]},
}
s = _mb_service(rg)
s._check_cache = lambda *a, **k: {'musicbrainz_id': 'cached_wrong', 'confidence': 95}
result = s.match_artist('Rone', owned_titles=OWNED)
assert result['mbid'] == 'right_mbid' # re-resolved + disambiguated
assert result.get('cached') is not True

View file

@ -0,0 +1,190 @@
"""#874 — wishlist ignore-list: TTL skip-gate for user-removed/cancelled tracks.
Two layers:
* pure logic (core.wishlist.ignore) TTL, id-normalization, display extract
* DB seam (MusicDatabase on a temp db) add/check/remove/list/clear, the
add_to_wishlist gate, the manual-add bypass, and the regression that the
success-cleanup removal path does NOT ignore.
"""
from datetime import datetime, timedelta
import pytest
from core.wishlist.ignore import (
IGNORE_TTL_DAYS,
REASON_CANCELLED,
REASON_REMOVED,
active_ignored_ids,
extract_display,
is_expired,
is_ignored,
normalize_ignore_id,
)
from database.music_database import MusicDatabase
# ── pure logic ──────────────────────────────────────────────────────────
def test_normalize_strips_composite_album_suffix():
assert normalize_ignore_id("track123::album456") == "track123"
assert normalize_ignore_id(" track123 ") == "track123"
assert normalize_ignore_id("") == ""
assert normalize_ignore_id(None) == ""
def test_is_expired_true_past_ttl_false_within():
now = datetime(2026, 6, 15, 12, 0, 0)
fresh = (now - timedelta(days=5)).strftime("%Y-%m-%d %H:%M:%S")
stale = (now - timedelta(days=40)).strftime("%Y-%m-%d %H:%M:%S")
assert is_expired(fresh, now) is False
assert is_expired(stale, now) is True
def test_is_expired_unparseable_is_treated_expired_fail_open():
# Corrupt timestamp must lapse (never wedge a track out of the wishlist).
assert is_expired("not-a-date", datetime(2026, 6, 15)) is True
assert is_expired("", datetime(2026, 6, 15)) is True
assert is_expired(None, datetime(2026, 6, 15)) is True
def test_is_ignored_matches_composite_and_bare_ids():
now = datetime(2026, 6, 15, 12, 0, 0)
created = (now - timedelta(days=1)).strftime("%Y-%m-%d %H:%M:%S")
rows = [{"track_id": "abc", "created_at": created}]
# Stored bare, queried with composite (and vice-versa) — both match.
assert is_ignored(rows, "abc::album9", now) is True
rows2 = [{"track_id": "abc", "created_at": created}]
assert is_ignored(rows2, "abc", now) is True
assert is_ignored(rows2, "different", now) is False
def test_active_ignored_ids_drops_expired():
now = datetime(2026, 6, 15, 12, 0, 0)
fresh = (now - timedelta(days=2)).strftime("%Y-%m-%d %H:%M:%S")
stale = (now - timedelta(days=99)).strftime("%Y-%m-%d %H:%M:%S")
rows = [
{"track_id": "keep", "created_at": fresh},
{"track_id": "drop", "created_at": stale},
]
assert active_ignored_ids(rows, now) == {"keep"}
def test_extract_display_handles_dict_and_string_artists():
assert extract_display({"name": "Song", "artists": [{"name": "A"}]}) == ("Song", "A")
assert extract_display({"name": "Song", "artists": ["B"]}) == ("Song", "B")
assert extract_display({}) == ("", "")
assert extract_display(None) == ("", "")
# ── DB seam (temp database — never the live db) ─────────────────────────
@pytest.fixture
def db(tmp_path):
return MusicDatabase(str(tmp_path / "m.db"))
def _track(track_id="t1", name="Some Song", artist="Some Artist", album_id="alb1"):
return {
"id": track_id,
"name": name,
"artists": [{"name": artist}],
"album": {"id": album_id, "name": "Some Album", "images": []},
}
def test_add_check_remove_roundtrip(db):
assert db.is_track_ignored("t1") is False
assert db.add_to_wishlist_ignore("t1", "Song", "Artist", REASON_REMOVED) is True
assert db.is_track_ignored("t1") is True
# Composite id of the same base track is also considered ignored.
assert db.is_track_ignored("t1::albX") is True
assert db.remove_from_wishlist_ignore("t1") is True
assert db.is_track_ignored("t1") is False
def test_get_and_clear_ignore_list(db):
db.add_to_wishlist_ignore("a", "SongA", "ArtA", REASON_REMOVED)
db.add_to_wishlist_ignore("b", "SongB", "ArtB", REASON_CANCELLED)
entries = db.get_wishlist_ignore()
assert {e["track_id"] for e in entries} == {"a", "b"}
assert any(e["reason"] == "cancelled" for e in entries)
assert db.clear_wishlist_ignore() == 2
assert db.get_wishlist_ignore() == []
def test_expired_entry_is_not_active_and_gets_purged(db):
db.add_to_wishlist_ignore("old", "Old", "Art", REASON_REMOVED)
# Backdate it well past the TTL.
with db._get_connection() as conn:
conn.execute(
"UPDATE wishlist_ignore SET created_at = ? WHERE track_id = ?",
((datetime.now() - timedelta(days=IGNORE_TTL_DAYS + 5)).strftime("%Y-%m-%d %H:%M:%S"), "old"),
)
conn.commit()
assert db.is_track_ignored("old") is False # lapsed
assert db.get_wishlist_ignore() == [] # and purged on read
with db._get_connection() as conn:
remaining = conn.execute("SELECT COUNT(*) c FROM wishlist_ignore").fetchone()["c"]
assert remaining == 0
# ── the gate: add_to_wishlist honours the ignore-list ───────────────────
def test_gate_blocks_auto_readd_but_manual_bypasses_and_clears(db):
track = _track("t1")
# Auto add works first time.
assert db.add_to_wishlist(track, source_type="playlist") is True
# User removes + ignores it.
db.remove_from_wishlist("t1")
db.add_to_wishlist_ignore("t1", "Some Song", "Some Artist", REASON_REMOVED)
# Auto re-add (watchlist / failed-capture / cancel) is now blocked.
assert db.add_to_wishlist(track, source_type="playlist") is False
assert db.is_track_ignored("t1") is True
# A MANUAL add bypasses the gate AND clears the ignore so it sticks.
assert db.add_to_wishlist(track, source_type="manual") is True
assert db.is_track_ignored("t1") is False
def test_gate_failopen_when_ignore_table_errors(db, monkeypatch):
# If the ignore check raises, the add must still succeed (never block).
monkeypatch.setattr(db, "is_track_ignored", lambda *a, **k: (_ for _ in ()).throw(RuntimeError("boom")))
assert db.add_to_wishlist(_track("t9"), source_type="playlist") is True
def test_route_remove_track_records_ignore(db, monkeypatch):
# Pin the route → ignore wiring (not just the DB layer): a user-initiated
# remove via the route function must drop the row AND ignore the track.
import types
from core.wishlist import routes as routes_module
db.add_to_wishlist(_track("rt1", name="Route Song", artist="Route Artist"),
source_type="playlist")
class _Svc:
database = db
def remove_track_from_wishlist(self, tid, profile_id=1):
return db.remove_from_wishlist(tid, profile_id=profile_id)
monkeypatch.setattr(routes_module, "get_wishlist_service", lambda: _Svc())
runtime = types.SimpleNamespace(profile_id=1, logger=routes_module.module_logger)
payload, status = routes_module.remove_track_from_wishlist(runtime, "rt1")
assert status == 200
assert db.is_track_ignored("rt1") is True
# The ignore carries the captured label.
entry = db.get_wishlist_ignore()[0]
assert entry["track_name"] == "Route Song"
assert entry["reason"] == REASON_REMOVED
def test_regression_success_cleanup_does_not_ignore(db):
# The post-download success path calls remove_from_wishlist directly — it
# must NOT add anything to the ignore-list (only user remove/cancel do).
track = _track("t5")
assert db.add_to_wishlist(track, source_type="playlist") is True
db.remove_from_wishlist("t5") # simulate success cleanup
assert db.is_track_ignored("t5") is False # NOT ignored
# And so a later legitimate auto-add still works.
assert db.add_to_wishlist(track, source_type="playlist") is True

View file

@ -40,7 +40,7 @@ logger = setup_logging(_log_level, _log_path)
# App version — single source of truth for backup metadata, system-info, update check, etc. # App version — single source of truth for backup metadata, system-info, update check, etc.
# Semver: MAJOR.MINOR.PATCH. Bump at each dev→main release. # Semver: MAJOR.MINOR.PATCH. Bump at each dev→main release.
_SOULSYNC_BASE_VERSION = "2.7.2" _SOULSYNC_BASE_VERSION = "2.7.4"
def _build_version_string(): def _build_version_string():
"""Append short commit hash to version when available (e.g. 2.35+abc1234).""" """Append short commit hash to version when available (e.g. 2.35+abc1234)."""
@ -990,21 +990,8 @@ def _set_db_update_automation_id(value):
global _db_update_automation_id global _db_update_automation_id
_db_update_automation_id = value _db_update_automation_id = value
# Quality Scanner state # Quality scanning is now the 'quality_upgrade' library-maintenance repair job
quality_scanner_state = { # (core/repair_jobs/quality_upgrade.py) — no standalone state/executor here.
"status": "idle", # idle, running, finished, error
"phase": "Ready to scan",
"progress": 0,
"processed": 0,
"total": 0,
"quality_met": 0,
"low_quality": 0,
"matched": 0,
"error_message": "",
"results": [], # List of low quality tracks with match status
}
quality_scanner_lock = threading.Lock()
quality_scanner_executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="QualityScanner")
# Duplicate Cleaner state # Duplicate Cleaner state
duplicate_cleaner_state = { duplicate_cleaner_state = {
@ -1253,10 +1240,7 @@ def _register_automation_handlers():
duplicate_cleaner_lock=duplicate_cleaner_lock, duplicate_cleaner_lock=duplicate_cleaner_lock,
duplicate_cleaner_executor=duplicate_cleaner_executor, duplicate_cleaner_executor=duplicate_cleaner_executor,
run_duplicate_cleaner=_run_duplicate_cleaner, run_duplicate_cleaner=_run_duplicate_cleaner,
get_quality_scanner_state=lambda: quality_scanner_state, run_repair_job_now=lambda job_id: repair_worker.run_job_now(job_id) if repair_worker else None,
quality_scanner_lock=quality_scanner_lock,
quality_scanner_executor=quality_scanner_executor,
run_quality_scanner=_run_quality_scanner,
download_orchestrator=download_orchestrator, download_orchestrator=download_orchestrator,
run_async=run_async, run_async=run_async,
tasks_lock=tasks_lock, tasks_lock=tasks_lock,
@ -1859,7 +1843,6 @@ def _shutdown_runtime_components():
for executor, name in [ for executor, name in [
(stream_executor, "stream executor"), (stream_executor, "stream executor"),
(db_update_executor, "db update executor"), (db_update_executor, "db update executor"),
(quality_scanner_executor, "quality scanner executor"),
(duplicate_cleaner_executor, "duplicate cleaner executor"), (duplicate_cleaner_executor, "duplicate cleaner executor"),
(sync_executor, "sync executor"), (sync_executor, "sync executor"),
(missing_download_executor, "missing download executor"), (missing_download_executor, "missing download executor"),
@ -7573,6 +7556,18 @@ def approve_quarantine_item(entry_id):
docker_resolve_path(config_manager.get('soulseek.download_path', './downloads')), docker_resolve_path(config_manager.get('soulseek.download_path', './downloads')),
'Transfer', 'Transfer',
) )
_req = request.get_json(silent=True) or {}
# #876: capture the sibling alternatives BEFORE approving — the approve
# restores (moves) this entry's file out of quarantine, after which its
# own group_key can no longer be looked up by id. Read-only here; the
# actual deletion happens only after the re-import is safely kicked off.
_sibling_ids = []
if _req.get('remove_siblings'):
from core.imports.quarantine import find_quarantine_siblings
try:
_sibling_ids = find_quarantine_siblings(_get_quarantine_dir(), entry_id)
except Exception as sib_exc:
logger.warning(f"[Quarantine] Sibling lookup for {entry_id} failed: {sib_exc}")
result = approve_quarantine_entry(_get_quarantine_dir(), entry_id, restore_dir) result = approve_quarantine_entry(_get_quarantine_dir(), entry_id, restore_dir)
if result is None: if result is None:
return jsonify({ return jsonify({
@ -7591,7 +7586,6 @@ def approve_quarantine_item(entry_id):
# context lost task_id/batch_id (the wrapper pops them before quarantine), # context lost task_id/batch_id (the wrapper pops them before quarantine),
# so we re-supply them here. Manager-tab approvals (no task_id) keep the # so we re-supply them here. Manager-tab approvals (no task_id) keep the
# original inner-pipeline path. # original inner-pipeline path.
_req = request.get_json(silent=True) or {}
_task_id = (_req.get('task_id') or '').strip() or None _task_id = (_req.get('task_id') or '').strip() or None
_batch_id = None _batch_id = None
if _task_id: if _task_id:
@ -7611,7 +7605,28 @@ def approve_quarantine_item(entry_id):
_reprocess = lambda: _post_process_matched_download(context_key, context, restored_path) _reprocess = lambda: _post_process_matched_download(context_key, context, restored_path)
threading.Thread(target=_reprocess, daemon=True).start() threading.Thread(target=_reprocess, daemon=True).start()
logger.info(f"[Quarantine] Approved {entry_id} (original_trigger={trigger}, bypass=all, task={_task_id}) → re-running pipeline") logger.info(f"[Quarantine] Approved {entry_id} (original_trigger={trigger}, bypass=all, task={_task_id}) → re-running pipeline")
return jsonify({"success": True, "trigger_bypassed": "all", "original_trigger": trigger}) # #876: once one alternative for a song is accepted, the other
# quarantined attempts at the SAME intended target are redundant
# failed downloads of a track the user now owns. Delete the siblings
# captured above (scoped to the quarantine manager via `remove_siblings`
# — the download-modal chooser passes no flag and is unaffected).
removed_siblings = []
if _sibling_ids:
from core.imports.quarantine import delete_quarantine_entry
try:
for sib_id in _sibling_ids:
if delete_quarantine_entry(_get_quarantine_dir(), sib_id):
removed_siblings.append(sib_id)
if removed_siblings:
logger.info(f"[Quarantine] Auto-removed {len(removed_siblings)} sibling alternative(s) of {entry_id}: {removed_siblings}")
except Exception as sib_exc:
logger.warning(f"[Quarantine] Sibling cleanup for {entry_id} failed: {sib_exc}")
return jsonify({
"success": True,
"trigger_bypassed": "all",
"original_trigger": trigger,
"removed_siblings": removed_siblings,
})
except Exception as e: except Exception as e:
logger.error(f"[Quarantine] Error approving {entry_id}: {e}") logger.error(f"[Quarantine] Error approving {entry_id}: {e}")
return jsonify({"success": False, "error": str(e)}), 500 return jsonify({"success": False, "error": str(e)}), 500
@ -9131,7 +9146,11 @@ def get_artist_discography(artist_id):
effective_override_source = 'spotify' effective_override_source = 'spotify'
from core.metadata.lookup import MetadataLookupOptions from core.metadata.lookup import MetadataLookupOptions
from core.metadata_service import get_artist_discography as _get_artist_discography # #877: use the artist-DETAIL discography so the Download Discography modal
# gets the SAME release-type split (albums / eps / singles) the Artist
# Detail view shows — EPs were being lumped into singles before, leaving
# the modal's EPs toggle dead.
from core.metadata.discography import get_artist_detail_discography as _get_artist_discography
# Server-side per-source ID resolution. Look up the library row # Server-side per-source ID resolution. Look up the library row
# by ANY of the IDs the frontend might send: library DB id, # by ANY of the IDs the frontend might send: library DB id,
@ -9209,6 +9228,7 @@ def get_artist_discography(artist_id):
) )
album_list = discography['albums'] album_list = discography['albums']
eps_list = discography.get('eps', [])
singles_list = discography['singles'] singles_list = discography['singles']
active_source = discography['source'] active_source = discography['source']
source_priority = discography['source_priority'] source_priority = discography['source_priority']
@ -9320,6 +9340,7 @@ def get_artist_discography(artist_id):
return jsonify({ return jsonify({
"albums": album_list, "albums": album_list,
"eps": eps_list,
"singles": singles_list, "singles": singles_list,
"source": active_source or (source_priority[0] if source_priority else "unknown"), "source": active_source or (source_priority[0] if source_priority else "unknown"),
"artist_info": artist_info, "artist_info": artist_info,
@ -10023,6 +10044,147 @@ def get_artist_enhanced_detail(artist_id):
except Exception as e: except Exception as e:
return jsonify({"success": False, "error": str(e)}), 500 return jsonify({"success": False, "error": str(e)}), 500
# ── Re-identify an imported track (#889) ──
@app.route('/api/reidentify/sources', methods=['GET'])
def reidentify_sources():
"""Source tabs for the Re-identify modal — every metadata source with a live
client, the active one flagged so the UI selects it by default."""
try:
from core.imports.rematch_search import available_sources
return jsonify({"success": True, "sources": available_sources()})
except Exception as e:
return jsonify({"success": False, "error": str(e), "sources": []}), 500
@app.route('/api/reidentify/search', methods=['GET'])
def reidentify_search():
"""Search one metadata source for the releases a track appears on.
Query params: ``source`` (defaults to the active source), ``q`` (the query),
``limit``. Returns display rows the SAME song across single/EP/album, each
with a type badge for the user to pick. album_id is resolved later, only for
the chosen row."""
try:
from core.imports.rematch_search import available_sources, search_release_candidates
query = (request.args.get('q') or '').strip()
if not query:
return jsonify({"success": True, "results": []})
source = (request.args.get('source') or '').strip()
if not source:
actives = [s for s in available_sources() if s.get('active')]
source = actives[0]['source'] if actives else 'spotify'
try:
limit = max(1, min(50, int(request.args.get('limit', 25))))
except (TypeError, ValueError):
limit = 25
rows = search_release_candidates(source, query, limit=limit)
return jsonify({"success": True, "source": source, "results": rows})
except Exception as e:
logger.error(f"Re-identify search error: {e}")
return jsonify({"success": False, "error": str(e), "results": []}), 500
@app.route('/api/reidentify/apply', methods=['POST'])
def reidentify_apply():
"""Apply a re-identify: stage the track's library file + write a single-use hint
so the auto-import worker re-files it under the chosen release (Phase 2).
Body: ``{library_track_id, source, track_id, replace}``. Admin-only (mutates the
library). COPIES the file the original is removed only after the re-import
succeeds, and only when ``replace`` is true."""
try:
database = get_database()
pid = get_current_profile_id()
prof = database.get_profile(pid) if pid else None
if not prof or not prof.get('is_admin'):
return jsonify({"success": False, "error": "Admin only"}), 403
data = request.get_json(silent=True) or {}
library_track_id = data.get('library_track_id')
source = (data.get('source') or '').strip()
track_id = (data.get('track_id') or '').strip()
replace = bool(data.get('replace', True))
if not library_track_id or not source or not track_id:
return jsonify({"success": False, "error": "library_track_id, source and track_id are required"}), 400
from core.imports.rematch_search import resolve_hint_fields
from core.imports.rematch_apply import stage_file_for_reidentify, build_reidentify_hint
from core.imports.rematch_hints import create_hint
# 1) Resolve the picked release → the IDs the hint needs (album_id critically).
hint_fields = resolve_hint_fields(source, track_id)
if not hint_fields:
return jsonify({"success": False, "error": "Could not resolve the selected release (no album id)"}), 400
# 2) Locate the library file for this track.
conn = database._get_connection()
try:
cur = conn.cursor()
cur.execute("SELECT file_path FROM tracks WHERE id = ?", (str(library_track_id),))
row = cur.fetchone()
finally:
conn.close()
if not row or not row['file_path']:
return jsonify({"success": False, "error": "Library track has no file on disk"}), 404
stored_path = row['file_path']
# Resolve the stored DB path to a file THIS process can actually read, using
# the SAME strong resolver the rest of the app uses (transfer/download/library/
# Plex search + #833 confusable folding via find_on_disk).
real_path = _resolve_library_file_path(stored_path)
if not real_path:
# On a miss, run the diagnostic variant purely to tell us (and the user)
# what was tried — instead of failing on the raw, possibly-stale path.
from core.library.path_resolver import resolve_library_file_path_with_diagnostic
try:
_plex = media_server_engine.client('plex') if media_server_engine else None
except Exception:
_plex = None
_, attempt = resolve_library_file_path_with_diagnostic(
stored_path, config_manager=config_manager, plex_client=_plex)
searched = ", ".join(attempt.base_dirs_tried) or "(no library/transfer/download dirs configured)"
logger.warning("[Re-identify] could not locate track %s file — stored=%s raw_exists=%s searched=[%s]",
library_track_id, stored_path, attempt.raw_path_existed, searched)
return jsonify({"success": False, "error": (
f"SoulSync couldn't find this track's file on disk.\nStored path: {stored_path}\n"
f"Searched: {searched}.\nIf the file lives on a media server SoulSync can't read directly "
f"(or the stored path is stale), re-identify isn't available for it.")}), 404
# 3) Copy into staging + fingerprint the copy.
staging_dir = docker_resolve_path(config_manager.get('import.staging_path', './Staging'))
staged = stage_file_for_reidentify(real_path, staging_dir, library_track_id)
# 4) Persist the single-use hint.
hint = build_reidentify_hint(library_track_id, hint_fields,
staged['staged_path'], staged['content_hash'], replace=replace)
conn = database._get_connection()
try:
cur = conn.cursor()
hint_id = create_hint(cur, hint)
conn.commit()
finally:
conn.close()
# 5) Nudge the worker so it doesn't wait for the next timer tick.
try:
if auto_import_worker is not None:
auto_import_worker.trigger_scan()
except Exception as _e:
logger.debug("Re-identify: scan nudge failed (worker will catch it on its timer): %s", _e)
logger.info("[Re-identify] staged track %s%s '%s' (%s), replace=%s",
library_track_id, hint.album_type or 'release', hint.album_name or '?',
source, replace)
return jsonify({"success": True, "hint_id": hint_id, "staged_path": staged['staged_path'],
"album_name": hint.album_name, "album_type": hint.album_type})
except FileNotFoundError as e:
return jsonify({"success": False, "error": f"Source file not found: {e}"}), 404
except Exception as e:
logger.error(f"Re-identify apply error: {e}")
return jsonify({"success": False, "error": str(e)}), 500
@app.route('/api/library/artist/<artist_id>/quality-analysis') @app.route('/api/library/artist/<artist_id>/quality-analysis')
def get_artist_quality_analysis(artist_id): def get_artist_quality_analysis(artist_id):
"""Analyze track quality for an artist — returns tier classification for each track.""" """Analyze track quality for an artist — returns tier classification for each track."""
@ -16845,6 +17007,48 @@ def remove_batch_from_wishlist():
logger.error(f"Error batch removing from wishlist: {e}") logger.error(f"Error batch removing from wishlist: {e}")
return jsonify({"success": False, "error": str(e)}), 500 return jsonify({"success": False, "error": str(e)}), 500
@app.route('/api/wishlist/ignore-list', methods=['GET'])
def get_wishlist_ignore_list():
"""#874: active (non-expired) wishlist ignore entries for this profile."""
try:
from core.wishlist_service import get_wishlist_service
runtime = _build_wishlist_route_runtime()
entries = get_wishlist_service().database.get_wishlist_ignore(profile_id=runtime.profile_id)
from core.wishlist.ignore import IGNORE_TTL_DAYS
return jsonify({"success": True, "entries": entries, "ttl_days": IGNORE_TTL_DAYS})
except Exception as e:
logger.error(f"Error reading wishlist ignore-list: {e}")
return jsonify({"success": False, "error": str(e), "entries": []}), 500
@app.route('/api/wishlist/ignore-list/remove', methods=['POST'])
def remove_from_wishlist_ignore_list():
"""#874: un-ignore a track so it can be auto-acquired again."""
try:
data = request.get_json() or {}
track_id = data.get('track_id') or data.get('spotify_track_id')
if not track_id:
return jsonify({"success": False, "error": "No track_id provided"}), 400
from core.wishlist_service import get_wishlist_service
runtime = _build_wishlist_route_runtime()
ok = get_wishlist_service().database.remove_from_wishlist_ignore(
track_id, profile_id=runtime.profile_id)
return jsonify({"success": True, "removed": ok})
except Exception as e:
logger.error(f"Error removing from wishlist ignore-list: {e}")
return jsonify({"success": False, "error": str(e)}), 500
@app.route('/api/wishlist/ignore-list/clear', methods=['POST'])
def clear_wishlist_ignore_list():
"""#874: clear the entire wishlist ignore-list for this profile."""
try:
from core.wishlist_service import get_wishlist_service
runtime = _build_wishlist_route_runtime()
count = get_wishlist_service().database.clear_wishlist_ignore(profile_id=runtime.profile_id)
return jsonify({"success": True, "cleared": count})
except Exception as e:
logger.error(f"Error clearing wishlist ignore-list: {e}")
return jsonify({"success": False, "error": str(e)}), 500
@app.route('/api/add-album-to-wishlist', methods=['POST']) @app.route('/api/add-album-to-wishlist', methods=['POST'])
def add_album_track_to_wishlist(): def add_album_track_to_wishlist():
"""Endpoint to add a single track from an album to the wishlist.""" """Endpoint to add a single track from an album to the wishlist."""
@ -17686,30 +17890,7 @@ def _get_quality_tier_from_extension(file_path):
return ('unknown', 999) return ('unknown', 999)
# Quality scanner worker logic lives in core/discovery/quality_scanner.py. # (Quality scanning moved to the 'quality_upgrade' library-maintenance repair job.)
from core.discovery import quality_scanner as _discovery_quality_scanner
def _build_quality_scanner_deps():
"""Build the QualityScannerDeps bundle from web_server.py globals on each call."""
return _discovery_quality_scanner.QualityScannerDeps(
quality_scanner_state=quality_scanner_state,
quality_scanner_lock=quality_scanner_lock,
QUALITY_TIERS=QUALITY_TIERS,
matching_engine=matching_engine,
automation_engine=automation_engine,
get_quality_tier_from_extension=_get_quality_tier_from_extension,
add_activity_item=add_activity_item,
probe_audio_quality=_probe_audio_quality,
resolve_library_file_path=_resolve_library_file_path,
)
def _run_quality_scanner(scope='watchlist', profile_id=1):
return _discovery_quality_scanner.run_quality_scanner(
scope, profile_id, _build_quality_scanner_deps()
)
from core.library.duplicate_cleaner import ( from core.library.duplicate_cleaner import (
_run_duplicate_cleaner, _run_duplicate_cleaner,
@ -17722,60 +17903,6 @@ _init_duplicate_cleaner(
engine=automation_engine, engine=automation_engine,
) )
@app.route('/api/quality-scanner/start', methods=['POST'])
def start_quality_scan():
"""Start the quality scanner"""
with quality_scanner_lock:
if quality_scanner_state["status"] == "running":
return jsonify({"success": False, "error": "A scan is already in progress"}), 409
data = request.get_json() or {}
scope = data.get('scope', 'watchlist') # 'watchlist' or 'all'
logger.info(f"[Quality Scanner API] Starting scan with scope: {scope}")
# Re-arm the path-resolve diagnostic so each scan logs the searched dirs
# once (otherwise it only ever fires on the first scan after a restart).
global _resolve_library_diag_logged
_resolve_library_diag_logged = False
# Reset state
quality_scanner_state["status"] = "running"
quality_scanner_state["phase"] = "Initializing..."
quality_scanner_state["progress"] = 0
quality_scanner_state["processed"] = 0
quality_scanner_state["total"] = 0
quality_scanner_state["quality_met"] = 0
quality_scanner_state["low_quality"] = 0
quality_scanner_state["matched"] = 0
quality_scanner_state["results"] = []
quality_scanner_state["error_message"] = ""
# Submit worker (capture profile_id before thread)
scan_profile_id = get_current_profile_id()
quality_scanner_executor.submit(_run_quality_scanner, scope, scan_profile_id)
add_activity_item("", "Quality Scan Started", f"Scanning {scope} tracks", "Now")
return jsonify({"success": True, "message": "Quality scan started"})
@app.route('/api/quality-scanner/status', methods=['GET'])
def get_quality_scanner_status():
"""Get current quality scanner status"""
with quality_scanner_lock:
return jsonify(quality_scanner_state)
@app.route('/api/quality-scanner/stop', methods=['POST'])
def stop_quality_scan():
"""Stop the quality scanner (sets a stop flag)"""
with quality_scanner_lock:
if quality_scanner_state["status"] == "running":
quality_scanner_state["status"] = "finished"
quality_scanner_state["phase"] = "Scan stopped by user"
return jsonify({"success": True, "message": "Stop request sent"})
else:
return jsonify({"success": False, "error": "No scan is currently running"}), 404
@app.route('/api/duplicate-cleaner/start', methods=['POST']) @app.route('/api/duplicate-cleaner/start', methods=['POST'])
def start_duplicate_cleaner(): def start_duplicate_cleaner():
"""Start the duplicate cleaner""" """Start the duplicate cleaner"""
@ -19060,26 +19187,44 @@ def _check_batch_completion_v2(batch_id):
def _add_cancelled_task_to_wishlist(task): def _add_cancelled_task_to_wishlist(task):
""" """Handle a user-cancelled download's wishlist state.
Helper function to add cancelled task to wishlist.
Separated for clarity and error isolation. #874: a manual cancel means "stop auto-retrying this release". The
previous behaviour re-added the cancelled track to the wishlist, which
the auto-processor then re-downloaded re-cancelled re-added,
forever. Instead we record a TTL'd ignore entry (so the watchlist /
auto-processor skip it) and drop it from the active wishlist. The user
can still force-download it manually (manual paths bypass the gate),
and the ignore lapses after core.wishlist.ignore.IGNORE_TTL_DAYS so it
is reconsidered later. Error-isolated; never raises into the caller.
""" """
if not task: if not task:
return return
try: try:
from core.wishlist_service import get_wishlist_service from core.wishlist_service import get_wishlist_service
from core.wishlist.ignore import extract_display, REASON_CANCELLED
wishlist_service = get_wishlist_service() wishlist_service = get_wishlist_service()
payload = _build_cancelled_task_wishlist_payload(task, profile_id=get_current_profile_id()) profile_id = get_current_profile_id()
success = wishlist_service.add_spotify_track_to_wishlist(**payload) track_info = task.get('track_info', {}) or {}
track_id = track_info.get('id')
if success: name = track_info.get('name')
logger.info(f"[Atomic Cancel] Added '{task.get('track_info', {}).get('name')}' to wishlist")
else: if not track_id:
logger.error(f"[Atomic Cancel] Failed to add '{task.get('track_info', {}).get('name')}' to wishlist") logger.warning("[Atomic Cancel] No track id on cancelled task — cannot ignore '%s'", name)
return
disp_name, disp_artist = extract_display(track_info)
wishlist_service.database.add_to_wishlist_ignore(
track_id, track_name=disp_name, artist_name=disp_artist,
reason=REASON_CANCELLED, profile_id=profile_id)
# Drop it from the active wishlist so the auto-processor stops
# re-attempting it; the gate then blocks any automatic re-add.
wishlist_service.remove_track_from_wishlist(track_id, profile_id=profile_id)
logger.info("[Atomic Cancel] Ignored (TTL) + removed from wishlist: '%s'", name or track_id)
except Exception as e: except Exception as e:
logger.error(f"[Atomic Cancel] Critical error adding to wishlist: {e}") logger.error(f"[Atomic Cancel] Critical error handling cancelled task: {e}")
@app.route('/api/playlists/<batch_id>/cancel_batch', methods=['POST']) @app.route('/api/playlists/<batch_id>/cancel_batch', methods=['POST'])
def cancel_batch(batch_id): def cancel_batch(batch_id):
@ -21720,7 +21865,10 @@ def deezer_download_test():
"""Test Deezer ARL token authentication.""" """Test Deezer ARL token authentication."""
try: try:
data = request.get_json() or {} data = request.get_json() or {}
arl = data.get('arl', '') # An empty/redaction-sentinel value means "test the SAVED token" — the
# settings field round-trips a mask for a saved-but-untouched secret, so
# testing it must use the stored ARL, not the mask (#870).
arl = config_manager.resolve_secret('deezer_download.arl', data.get('arl'))
if not arl: if not arl:
return jsonify({'success': False, 'error': 'No ARL token provided'}) return jsonify({'success': False, 'error': 'No ARL token provided'})
@ -37396,12 +37544,6 @@ def _emit_tool_progress_loop():
# (which skipped HTTP polling while the socket was up) never learned # (which skipped HTTP polling while the socket was up) never learned
# its stream was ready. Each client polls /api/stream/status instead, # its stream was ready. Each client polls /api/stream/status instead,
# which resolves its own session from the cookie. # which resolves its own session from the cookie.
# Quality Scanner
try:
with quality_scanner_lock:
socketio.emit('tool:quality-scanner', dict(quality_scanner_state))
except Exception as e:
logger.debug(f"Error emitting quality scanner status: {e}")
# Duplicate Cleaner (add computed space_freed_mb) # Duplicate Cleaner (add computed space_freed_mb)
try: try:
with duplicate_cleaner_lock: with duplicate_cleaner_lock:

View file

@ -5629,6 +5629,13 @@
<small class="settings-hint">Order the sources to choose whose cover art is used. The first source that has a cover wins; misses fall through to the next, and if none match, your download's own art is kept. Only sources you're connected to are shown — leave all off to keep current behavior.</small> <small class="settings-hint">Order the sources to choose whose cover art is used. The first source that has a cover wins; misses fall through to the next, and if none match, your download's own art is kept. Only sources you're connected to are shown — leave all off to keep current behavior.</small>
<div class="hybrid-source-list" id="art-source-list"></div> <div class="hybrid-source-list" id="art-source-list"></div>
</div> </div>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" id="single-to-album-enabled">
Match singles to their parent album
</label>
<small class="settings-hint">When a track matches a single release, look up the album that contains it and tag it as that album — so every song in an album gets the same (album) cover instead of some getting the single's art. Off by default: adds an extra metadata lookup per single-matched track.</small>
</div>
<div class="form-group"> <div class="form-group">
<label class="checkbox-label"> <label class="checkbox-label">
<input type="checkbox" id="lrclib-enabled" checked> <input type="checkbox" id="lrclib-enabled" checked>
@ -6754,49 +6761,6 @@
</div> </div>
</div> </div>
<div class="tool-card" id="quality-scanner-card">
<div class="tool-card-header">
<h4 class="tool-card-title">Quality Scanner</h4>
<button class="tool-help-button" data-tool="quality-scanner"
title="Learn more about this tool">?</button>
</div>
<p class="tool-card-info">Scan library for tracks below quality preferences</p>
<div class="tool-card-stats">
<div class="stat-item">
<span class="stat-item-label">Processed:</span>
<span class="stat-item-value" id="quality-stat-processed">0</span>
</div>
<div class="stat-item">
<span class="stat-item-label">Quality Met:</span>
<span class="stat-item-value" id="quality-stat-met">0</span>
</div>
<div class="stat-item">
<span class="stat-item-label">Low Quality:</span>
<span class="stat-item-value" id="quality-stat-low">0</span>
</div>
<div class="stat-item">
<span class="stat-item-label">Matched:</span>
<span class="stat-item-value" id="quality-stat-matched">0</span>
</div>
</div>
<div class="tool-card-controls">
<select id="quality-scan-scope">
<option value="watchlist">Watchlist Artists Only</option>
<option value="all">All Library Tracks</option>
</select>
<button id="quality-scan-button">Scan Library</button>
</div>
<div class="tool-card-progress-section">
<p class="progress-phase-label" id="quality-phase-label">Ready to scan</p>
<div class="progress-bar-container">
<div class="progress-bar-fill" id="quality-progress-bar" style="width: 0%;">
</div>
</div>
<p class="progress-details-label" id="quality-progress-label">0 / 0 tracks scanned
(0.0%)</p>
</div>
</div>
<div class="tool-card" id="reconcile-ids-card"> <div class="tool-card" id="reconcile-ids-card">
<div class="tool-card-header"> <div class="tool-card-header">
<h4 class="tool-card-title">Import IDs from File Tags</h4> <h4 class="tool-card-title">Import IDs from File Tags</h4>
@ -7671,6 +7635,50 @@
</div> </div>
</div> </div>
<!-- Re-identify Track Modal (#889) -->
<div class="modal-overlay hidden" id="reid-modal-overlay" onclick="if(event.target===this)closeReidentifyModal()">
<div class="reid-modal" id="reid-modal">
<div class="reid-hero">
<div class="reid-hero-decor">
<div class="reid-hero-bg" id="reid-hero-bg"></div>
<div class="reid-hero-overlay"></div>
</div>
<span class="reid-close" onclick="closeReidentifyModal()">&times;</span>
<div class="reid-hero-content">
<div class="reid-hero-art" id="reid-hero-art"></div>
<div class="reid-hero-meta">
<div class="reid-hero-eyebrow">Re-identify track</div>
<div class="reid-hero-title" id="reid-hero-title">Track</div>
<div class="reid-hero-sub" id="reid-hero-sub"></div>
</div>
</div>
</div>
<div class="reid-tabs" id="reid-tabs"></div>
<div class="reid-search">
<svg class="reid-search-icon" viewBox="0 0 24 24" width="18" height="18"><path fill="currentColor" d="M15.5 14h-.79l-.28-.27a6.5 6.5 0 1 0-.7.7l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0A4.5 4.5 0 1 1 14 9.5 4.5 4.5 0 0 1 9.5 14z"/></svg>
<input type="text" id="reid-search-input" class="reid-search-input"
placeholder="Search for the track…" onkeydown="if(event.key==='Enter')runReidentifySearch()" />
<button class="reid-search-btn" id="reid-search-btn" onclick="runReidentifySearch()">Search</button>
</div>
<div class="reid-results" id="reid-results"></div>
<div class="reid-footer">
<label class="reid-replace" title="When on, the original file is deleted after the track is re-filed under the new release.">
<input type="checkbox" id="reid-replace" checked />
<span class="reid-replace-box"></span>
<span class="reid-replace-text">Replace the original file <em>(recommended)</em></span>
</label>
<div class="reid-footer-actions">
<button class="btn btn--secondary" onclick="closeReidentifyModal()">Cancel</button>
<button class="btn btn--primary" id="reid-confirm-btn" disabled onclick="confirmReidentify()">Re-identify</button>
</div>
</div>
</div>
</div>
<!-- Watchlist Artist Config Modal --> <!-- Watchlist Artist Config Modal -->
<div class="modal-overlay hidden" id="watchlist-artist-config-modal-overlay"> <div class="modal-overlay hidden" id="watchlist-artist-config-modal-overlay">
<div class="watchlist-artist-config-modal" id="watchlist-artist-config-modal"> <div class="watchlist-artist-config-modal" id="watchlist-artist-config-modal">

View file

@ -29,7 +29,6 @@ let isSortReversed = false;
let searchAbortController = null; let searchAbortController = null;
let dbStatsInterval = null; let dbStatsInterval = null;
let dbUpdateStatusInterval = null; let dbUpdateStatusInterval = null;
let qualityScannerStatusInterval = null;
let duplicateCleanerStatusInterval = null; let duplicateCleanerStatusInterval = null;
let wishlistCountInterval = null; let wishlistCountInterval = null;
let wishlistCountdownInterval = null; // Countdown timer for wishlist overview modal let wishlistCountdownInterval = null; // Countdown timer for wishlist overview modal
@ -487,7 +486,6 @@ function initializeWebSocket() {
// 'tool:stream' is intentionally NOT wired: stream state is per-listener // 'tool:stream' is intentionally NOT wired: stream state is per-listener
// (session cookie), so the global broadcast could only carry the DEFAULT // (session cookie), so the global broadcast could only carry the DEFAULT
// session's eternal "stopped" — the player polls /api/stream/status instead. // session's eternal "stopped" — the player polls /api/stream/status instead.
socket.on('tool:quality-scanner', (data) => { if (_qaToolBusy(data)) qaSignal('tools'); updateQualityScanProgressFromData(data); });
socket.on('tool:duplicate-cleaner', (data) => { if (_qaToolBusy(data)) qaSignal('tools'); updateDuplicateCleanProgressFromData(data); }); socket.on('tool:duplicate-cleaner', (data) => { if (_qaToolBusy(data)) qaSignal('tools'); updateDuplicateCleanProgressFromData(data); });
socket.on('tool:db-update', (data) => { if (_qaToolBusy(data)) qaSignal('tools'); updateDbProgressFromData(data); }); socket.on('tool:db-update', (data) => { if (_qaToolBusy(data)) qaSignal('tools'); updateDbProgressFromData(data); });
socket.on('tool:metadata', (data) => { if (_qaToolBusy(data)) qaSignal('tools'); updateMetadataStatusFromData(data); }); socket.on('tool:metadata', (data) => { if (_qaToolBusy(data)) qaSignal('tools'); updateMetadataStatusFromData(data); });

View file

@ -1186,6 +1186,9 @@ async function openWishlistOverviewModal() {
<button class="playlist-modal-btn playlist-modal-btn-warning" onclick="cleanupWishlistOverview()"> <button class="playlist-modal-btn playlist-modal-btn-warning" onclick="cleanupWishlistOverview()">
🧹 Cleanup Wishlist 🧹 Cleanup Wishlist
</button> </button>
<button class="playlist-modal-btn playlist-modal-btn-secondary" onclick="openWishlistIgnoreModal()" title="Tracks you removed or cancelled — auto-skipped until they expire">
🚫 Ignored
</button>
</div> </div>
<div class="playlist-modal-footer-right"> <div class="playlist-modal-footer-right">
<button class="playlist-modal-btn playlist-modal-btn-secondary" onclick="closeWishlistOverviewModal()">Close</button> <button class="playlist-modal-btn playlist-modal-btn-secondary" onclick="closeWishlistOverviewModal()">Close</button>
@ -1312,6 +1315,117 @@ function closeWishlistOverviewModal() {
console.log('✅ Modal closed'); console.log('✅ Modal closed');
} }
// ── #874: Wishlist ignore-list ("Ignored") modal ────────────────────────
// Tracks the user removed from the wishlist or cancelled mid-download are
// auto-skipped (not re-queued) until they expire. This modal lets the user
// see what's currently ignored and lift the skip (un-ignore / clear all).
async function openWishlistIgnoreModal() {
let modal = document.getElementById('wishlist-ignore-modal');
if (modal) modal.remove();
modal = document.createElement('div');
modal.id = 'wishlist-ignore-modal';
modal.className = 'modal-overlay';
modal.style.cssText = 'display:flex;position:fixed;inset:0;z-index:10050;align-items:center;justify-content:center;background:rgba(0,0,0,0.6);';
modal.innerHTML = `
<div class="playlist-modal-content" style="max-width:560px;width:90%;max-height:80vh;display:flex;flex-direction:column;">
<div class="playlist-modal-header">
<h2 style="margin:0;">🚫 Ignored Tracks</h2>
<button class="playlist-modal-btn playlist-modal-btn-secondary" onclick="closeWishlistIgnoreModal()">Close</button>
</div>
<p style="opacity:0.7;font-size:13px;margin:8px 16px 0;">Removed or cancelled tracks are skipped by auto-download until they expire. Un-ignore to allow auto-download again (you can always download manually).</p>
<div id="wishlist-ignore-list" class="playlist-tracks-scroll" style="flex:1;overflow-y:auto;padding:12px 16px;">
<div class="loading-indicator">Loading...</div>
</div>
<div class="playlist-modal-footer">
<div class="playlist-modal-footer-left">
<button id="wishlist-ignore-clear-btn" class="playlist-modal-btn playlist-modal-btn-danger" onclick="clearWishlistIgnoreList()" style="display:none;">Clear All</button>
</div>
</div>
</div>`;
document.body.appendChild(modal);
modal.addEventListener('click', (e) => { if (e.target === modal) closeWishlistIgnoreModal(); });
await loadWishlistIgnoreList();
}
function closeWishlistIgnoreModal() {
const modal = document.getElementById('wishlist-ignore-modal');
if (modal) modal.remove();
}
async function loadWishlistIgnoreList() {
const list = document.getElementById('wishlist-ignore-list');
const clearBtn = document.getElementById('wishlist-ignore-clear-btn');
if (!list) return;
try {
const resp = await fetch('/api/wishlist/ignore-list');
const data = await resp.json();
const entries = (data && data.entries) || [];
if (clearBtn) clearBtn.style.display = entries.length ? '' : 'none';
if (!entries.length) {
list.innerHTML = '<div class="playlist-empty-state" style="text-align:center;opacity:0.6;padding:30px 0;">🎉<br><br>Nothing ignored.</div>';
return;
}
const ttl = (data && data.ttl_days) || 30;
list.innerHTML = entries.map(e => {
const title = escapeHtml(e.track_name || e.track_id || 'Unknown');
const artist = escapeHtml(e.artist_name || '');
const reason = e.reason === 'cancelled' ? 'Cancelled' : 'Removed';
const tid = escapeHtml(String(e.track_id || ''));
return `<div class="wishlist-ignore-row" style="display:flex;align-items:center;gap:10px;padding:8px 4px;border-bottom:1px solid rgba(255,255,255,0.06);">
<div style="flex:1;min-width:0;">
<div style="font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">${title}</div>
<div style="opacity:0.6;font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">${artist}${artist ? ' · ' : ''}${reason} · skips ${ttl}d</div>
</div>
<button class="playlist-modal-btn playlist-modal-btn-secondary" style="flex-shrink:0;" data-track-id="${tid}" onclick="unignoreWishlistTrack(this.dataset.trackId)">Un-ignore</button>
</div>`;
}).join('');
} catch (err) {
list.innerHTML = '<div class="playlist-empty-state" style="text-align:center;opacity:0.6;padding:30px 0;">Error loading ignored tracks</div>';
}
}
async function unignoreWishlistTrack(trackId) {
if (!trackId) return;
try {
const resp = await fetch('/api/wishlist/ignore-list/remove', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ track_id: trackId }),
});
const data = await resp.json();
if (data && data.success) {
showToast('Track un-ignored — it can be auto-downloaded again.', 'success');
await loadWishlistIgnoreList();
} else {
showToast(`Un-ignore failed: ${(data && data.error) || 'unknown'}`, 'error');
}
} catch (err) {
showToast(`Un-ignore failed: ${err.message}`, 'error');
}
}
async function clearWishlistIgnoreList() {
if (!await showConfirmDialog({
title: 'Clear Ignored List',
message: 'Allow all currently-ignored tracks to be auto-downloaded again?',
confirmText: 'Clear All',
cancelText: 'Cancel',
})) return;
try {
const resp = await fetch('/api/wishlist/ignore-list/clear', { method: 'POST' });
const data = await resp.json();
if (data && data.success) {
showToast(`Cleared ${data.cleared || 0} ignored track(s).`, 'success');
await loadWishlistIgnoreList();
} else {
showToast(`Clear failed: ${(data && data.error) || 'unknown'}`, 'error');
}
} catch (err) {
showToast(`Clear failed: ${err.message}`, 'error');
}
}
async function cleanupWishlistOverview() { async function cleanupWishlistOverview() {
console.log('🧹 cleanupWishlistOverview() called'); console.log('🧹 cleanupWishlistOverview() called');

View file

@ -455,11 +455,14 @@ function updateSpotifyEnrichmentStatusFromData(data) {
const button = document.getElementById('spotify-enrich-button'); const button = document.getElementById('spotify-enrich-button');
if (!button) return; if (!button) return;
const notAuthenticated = data.authenticated === false;
const isRateLimited = data.rate_limited === true; const isRateLimited = data.rate_limited === true;
// The real API is banned but the worker is still matching via the no-creds // The real API is unauthed/banned but the worker is still matching via the
// Spotify Free source — treat it as running, not stuck (#798 bridge). // no-creds Spotify Free source — treat it as running, not stuck (#798/#887).
const bridgingFree = data.using_free === true; const bridgingFree = data.using_free === true;
// #887: a no-auth user whose enrichment runs on Spotify Free is NOT "not
// authenticated" for status purposes — the worker IS enriching. Only flag
// Not Authenticated when Free isn't carrying it.
const notAuthenticated = data.authenticated === false && !bridgingFree;
const rateLimitedStuck = isRateLimited && !bridgingFree; const rateLimitedStuck = isRateLimited && !bridgingFree;
// Budget is a real-API cap; when bridging to free it no longer applies, so // Budget is a real-API cap; when bridging to free it no longer applies, so
// only treat the budget as a stop when we're NOT serving via free (#798). // only treat the budget as a stop when we're NOT serving via free (#798).

View file

@ -328,16 +328,6 @@ const HELPER_CONTENT = {
], ],
docsId: 'dashboard' docsId: 'dashboard'
}, },
'#quality-scanner-card': {
title: 'Quality Scanner',
description: 'Analyzes audio files for quality integrity. Calculates bitrate density to detect transcodes (e.g., an MP3 re-encoded as FLAC). Scope options: Full Library, New Only, or Single Artist.',
tips: [
'"Quality Met" = file quality matches its format claims',
'"Low Quality" = suspicious file flagged for review',
'Matched count shows tracks with verified metadata'
],
docsId: 'dashboard'
},
'#duplicate-cleaner-card': { '#duplicate-cleaner-card': {
title: 'Duplicate Cleaner', title: 'Duplicate Cleaner',
description: 'Scans your library for duplicate tracks by comparing title, artist, album, and file characteristics. Reviews duplicates before taking any action.', description: 'Scans your library for duplicate tracks by comparing title, artist, album, and file characteristics. Reviews duplicates before taking any action.',
@ -2358,7 +2348,6 @@ const HELPER_TOURS = {
// Tools — in page order // Tools — in page order
{ page: 'dashboard', selector: '#db-updater-card', title: 'Database Updater', description: 'Syncs your media server\'s library into SoulSync\'s database. Three modes: Incremental (fast, new content only), Full Refresh (rebuilds everything), Deep Scan (finds and removes stale entries).' }, { page: 'dashboard', selector: '#db-updater-card', title: 'Database Updater', description: 'Syncs your media server\'s library into SoulSync\'s database. Three modes: Incremental (fast, new content only), Full Refresh (rebuilds everything), Deep Scan (finds and removes stale entries).' },
{ page: 'dashboard', selector: '#metadata-updater-card', title: 'Metadata Enrichment', description: 'Background workers that enrich your library from 9 services — Spotify, MusicBrainz, Deezer, Last.fm, iTunes, AudioDB, Genius, Tidal, Qobuz. Runs automatically at the configured interval.' }, { page: 'dashboard', selector: '#metadata-updater-card', title: 'Metadata Enrichment', description: 'Background workers that enrich your library from 9 services — Spotify, MusicBrainz, Deezer, Last.fm, iTunes, AudioDB, Genius, Tidal, Qobuz. Runs automatically at the configured interval.' },
{ page: 'dashboard', selector: '#quality-scanner-card', title: 'Quality Scanner', description: 'Analyzes audio files for quality integrity. Calculates bitrate density to detect transcodes (e.g., an MP3 re-encoded as FLAC). Scan by Full Library, New Only, or Single Artist.' },
{ page: 'dashboard', selector: '#duplicate-cleaner-card', title: 'Duplicate Cleaner', description: 'Finds and removes duplicate tracks by comparing title, artist, album, and audio characteristics. Always reviews before deleting.' }, { page: 'dashboard', selector: '#duplicate-cleaner-card', title: 'Duplicate Cleaner', description: 'Finds and removes duplicate tracks by comparing title, artist, album, and audio characteristics. Always reviews before deleting.' },
{ page: 'dashboard', selector: '#discovery-pool-card', title: 'Discovery Pool', description: 'Tracks from similar artists found during watchlist scans. Matched tracks feed the Discover page playlists and genre browser. Fix failed matches manually.' }, { page: 'dashboard', selector: '#discovery-pool-card', title: 'Discovery Pool', description: 'Tracks from similar artists found during watchlist scans. Matched tracks feed the Discover page playlists and genre browser. Fix failed matches manually.' },
{ page: 'dashboard', selector: '#retag-tool-card', title: 'Retag Tool', description: 'Queue of tracks needing metadata corrections. When enrichment detects better tags than what\'s in your files, they appear here for batch review.' }, { page: 'dashboard', selector: '#retag-tool-card', title: 'Retag Tool', description: 'Queue of tracks needing metadata corrections. When enrichment detects better tags than what\'s in your files, they appear here for batch review.' },
@ -3386,7 +3375,7 @@ function _guessPageFromSelector(selector) {
'import': ['import-page-'], 'import': ['import-page-'],
'settings': ['settings-', 'stg-tab', 'api-service', 'server-toggle', 'save-button', 'spotify-client', 'soulseek-url', 'quality-profile'], 'settings': ['settings-', 'stg-tab', 'api-service', 'server-toggle', 'save-button', 'spotify-client', 'soulseek-url', 'quality-profile'],
'issues': ['issues-'], 'issues': ['issues-'],
'dashboard': ['dashboard-', 'service-card', 'watchlist-button', 'wishlist-button', 'db-updater', 'metadata-updater', 'quality-scanner', 'duplicate-cleaner', 'discovery-pool-card', 'retag-tool', 'media-scan', 'backup-manager', 'metadata-cache'], 'dashboard': ['dashboard-', 'service-card', 'watchlist-button', 'wishlist-button', 'db-updater', 'metadata-updater', 'duplicate-cleaner', 'discovery-pool-card', 'retag-tool', 'media-scan', 'backup-manager', 'metadata-cache'],
}; };
const selectorLower = selector.toLowerCase(); const selectorLower = selector.toLowerCase();
@ -3415,21 +3404,16 @@ function closeHelperSearch() {
const WHATS_NEW = { const WHATS_NEW = {
// Convention: keep only the CURRENT release here, plus a single brief // Convention: keep only the CURRENT release here, plus a single brief
// "Earlier versions" summary entry. Don't accumulate old per-version blocks. // "Earlier versions" summary entry. Don't accumulate old per-version blocks.
'2.7.2': [ '2.7.4': [
{ date: 'June 2026 — 2.7.2 release' }, { date: 'June 2026 — 2.7.4 release' },
{ title: 'Organize playlists into folders', desc: 'optionally mirror each playlist into its own folder on disk — symlink (no extra space) or copy — so external players (plex / jellyfin / music assistant) see your soulsync playlists as real folders. rebuilds itself after every sync, prunes removed tracks, and there\'s a manual rebuild button in settings.', page: 'settings' }, { title: 'Re-identify a track (#889)', desc: 'filed a track under the wrong release? a new ⇄ button in the library Enhanced view lets you re-identify it — search any source (tabs, defaults to your active one), see the same song across its single / EP / album with type badges, pick the right one, and soulsync re-files the file you already have under that release with the correct year, in-album track number, and art. replace the original or keep both.', page: 'artists' },
{ title: 'Export server playlists as M3U', desc: 'one-click "Export M3U" button in the Server Playlists compare/editor toolbar — writes a standard .m3u of the playlist\'s tracks and downloads it to your browser. handy for music assistant and friends.', page: 'sync' }, { title: 'Track titles no longer keep the "01 - " (#890)', desc: 'files with no embedded title tag used to import as "01 - Song Title" (the filename stem) — which never matched the canonical "Song Title", so the real track showed as a false "missing". the number prefix is now stripped, conservatively, so titles like "7 Rings" or "1-800-273-8255" are left alone.', page: 'library' },
{ title: 'Follow-only watchlist', desc: 'each watchlist artist now has an "auto-download" toggle (on by default). turn it off to just follow an artist — scans still discover and surface new releases, they just don\'t get auto-added to the wishlist.', page: 'artists' }, { title: 'Clear dead cover-art folders (#891)', desc: 'a Library Reorganize that moves an album now sweeps the leftover cover.jpg / .lrc / sidecars from the old folder so it actually empties. plus an opt-in "Remove Residual Files" toggle on the Empty Folder Cleaner clears the image-/sidecar-only folders you already have.', page: 'tools' },
{ title: 'Download from a SoundCloud link (#865)', desc: 'paste a soundcloud track url — including unlisted / private share links — into manual search and it resolves + downloads directly.', page: 'downloads' }, { title: 'AAC as an opt-in quality tier (#886)', desc: 'soulseek downloads can now include AAC (.m4a) as a selectable quality tier, ranked above MP3 and below FLAC. purely additive — off by default; every existing profile behaves exactly as before until you enable it.', page: 'settings' },
{ title: 'YouTube playlists keep the artist (#863)', desc: 'youtube / youtube-music playlists that used to import as "Unknown Artist" now recover the real artist from the track\'s music metadata, the "Artist - Title" pattern, or the uploading channel — and fall back to the matched artist when youtube gives nothing.', page: 'sync' }, { title: 'Spotify Free enrichment status (#887)', desc: 'if you run enrichment on Spotify Free (no spotify auth), the dashboard button now reads "Running (Spotify Free)" instead of wrongly showing "Not Authenticated".', page: 'dashboard' },
{ title: 'Rename mirrored playlists', desc: 'a mirrored playlist now has a rename (✏️) button — set a custom name that changes how it shows in soulsync and how it syncs, survives upstream refreshes, and still tracks the same server playlist.', page: 'sync' }, { title: 'Cleaner album imports (Sokhi)', desc: 'songs from the same album now group under one canonical release id (no more split discographies or mixed cover art), a single can be matched to its parent album, a mid-enrichment crash on an art-less file no longer leaves it untagged, and a sequel digit glued to a CJK title no longer matches the wrong album.', page: 'library' },
{ title: 'New maintenance jobs', desc: 'ReplayGain Filler (#437) computes + writes missing replaygain tags across your library, and Empty Folder Cleaner sweeps out leftover empty directories. both under Tools → library maintenance.', page: 'tools' }, { title: 'More fixes', desc: 'NZBGet imports from the finished location instead of the incomplete "….#NZBID" folder (#884); setting your timezone to Australia/Sydney no longer makes the cache-maintenance job loop every 5 seconds (#885); and the artist-detail header no longer bleeds the blurred artist photo behind it.', page: 'downloads' },
{ title: 'Export your watchlist + library', desc: 'one Export button with a scope selector dumps your watchlist roster and/or whole library roster to JSON / CSV / text.', page: 'artists' }, { title: 'Earlier versions', desc: '2.7.3 added the Quality Upgrade Finder (find + upgrade tracks you own in worse quality than available), a wishlist ignore-list (#874), quarantine duplicate-grouping (#876), the "Track 01" position fix, and Tidal discovery/favorites fixes (#867, #880). 2.7.2 brought playlist-folder mirroring + server-playlist M3U export and ReplayGain / Empty-Folder maintenance jobs; 2.7.1 added download verification + a review queue and closed the websocket login-bypass (#852); 2.7.0 made multi-user real — per-profile streaming accounts, opt-in login, reverse-proxy support.' },
{ title: 'Custom completed-download path for Torrent/Usenet (#857)', desc: 'point soulsync at the in-container folder your torrent/usenet client drops finished files into (e.g. a category subfolder), so completed grabs are found instead of going missing.', page: 'settings' },
{ title: 'HiFi instances: restore + new working instance', desc: 'a "Restore Defaults" button re-adds any built-in instances you removed (keeps your own), the ✔/✖ controls have bigger tap targets, and a freshly-confirmed working instance is auto-pushed to everyone (thanks Sokhi).', page: 'settings' },
{ title: 'Artist DB Record inspector', desc: 'an artist\'s detail page can now show the raw "DB Record" — everything the database knows about that artist — for debugging metadata.', page: 'library' },
{ title: 'Fixes', desc: 'a hung database update self-heals now instead of wedging on "Starting..." forever (#859); Library Reorganize finally works on media-server libraries by falling back to tag mode (#862); spotify (no-auth) shows as connected and the dashboard test reports it correctly instead of claiming deezer; navidrome reconnects itself instead of latching disconnected; the orphan detector hard-bails on a mass-orphan flood; plus more #852 lock-screen hardening and login-password management in Manage Profiles.', page: 'settings' },
{ title: 'Earlier versions', desc: '2.7.1 added download verification & a review queue (acoustid fingerprint-checks every download), closed the websocket login-bypass (#852), and the acoustid Relocate fix. 2.7.0 brought multi-user for real — per-profile streaming accounts, opt-in login, reverse-proxy support. before that the 2.6.x cycle brought the blocklist, the download-retry overhaul, Download Origins, Spotify-no-auth metadata, and Library Re-tag.' },
], ],
}; };
@ -3460,58 +3444,38 @@ const WHATS_NEW = {
// usage_note?: 'optional hint shown at the bottom' } // usage_note?: 'optional hint shown at the bottom' }
const VERSION_MODAL_SECTIONS = [ const VERSION_MODAL_SECTIONS = [
{ {
title: "Organize playlists into folders", title: "Re-identify a track (#889)",
description: "soulsync can now mirror each of your playlists into its own folder on disk, so external players (plex / jellyfin / music assistant) see them as real folders instead of just living inside soulsync.", description: "filed a track under the wrong release? re-identify it from the library without re-downloading — soulsync re-files the file you already have under the release you pick.",
features: [ features: [
"symlink (no extra disk space) or copy — your choice", "a ⇄ button in the Enhanced library view opens a search across any configured source (tabs, defaults to your active one)",
"rebuilds itself after every sync and prunes tracks you removed", "see the same song across its single / EP / album, each with a type badge, and pick the right collection",
"separate output root + a manual rebuild button in settings", "it re-files under that release with the correct year, in-album track number, and album art",
"replace the original entry or keep both — and it can never delete the file if you pick the release it's already in",
], ],
usage_note: "Settings → Playlists → organize into folders", usage_note: "Library → an artist → Enhanced view → ⇄ on a track",
}, },
{ {
title: "Better YouTube & SoundCloud imports", title: "Cleaner libraries & imports",
description: "the two weak spots in playlist/link importing got fixed.", description: "a batch of fixes that keep the library tidy and matchable.",
features: [ features: [
"#863 — youtube / youtube-music playlists that imported as \"Unknown Artist\" now recover the real artist from music metadata, the \"Artist - Title\" pattern, or the uploading channel (and fall back to the matched artist)", "#890 — track titles no longer keep the \"01 - \" from a filename (which caused false \"missing\" tracks); stripped conservatively so \"7 Rings\" / \"1-800-273-8255\" are left alone",
"#865 — paste a soundcloud track link, including unlisted / private share urls, into manual search to download it directly", "#891 — a reorganize now sweeps leftover cover.jpg / .lrc from the old folder, plus an opt-in \"Remove Residual Files\" toggle clears image-only folders you already have",
"Sokhi — same-album songs group under one canonical release (no split discographies / mixed cover art); a single can match its parent album; a mid-enrichment crash no longer leaves a file untagged; a CJK-title sequel-digit no longer matches the wrong album",
], ],
}, },
{ {
title: "Export server playlists as M3U", title: "Quality & sources",
description: "a one-click \"Export M3U\" button now sits in the Server Playlists compare/editor toolbar — writes a standard .m3u of the playlist and downloads it to your browser. great for music assistant.", description: "more control over downloads, plus a couple of source fixes.",
features: [],
usage_note: "Sync → Server Playlists → Export M3U",
},
{
title: "Follow-only watchlist + more",
description: "a grab-bag of requested features.",
features: [ features: [
"per-artist \"auto-download\" toggle: turn it off to just follow an artist — scans still surface new releases, they just don't auto-add to the wishlist", "#886 — AAC (.m4a) as an opt-in soulseek quality tier, ranked above MP3 and below FLAC; off by default so nothing changes until you enable it",
"rename mirrored playlists (✏️): a custom name that changes the display + sync name, survives refreshes, still tracks the server playlist", "#887 — enrichment on Spotify Free now reads \"Running (Spotify Free)\" instead of \"Not Authenticated\"",
"export your watchlist and/or whole library roster to JSON / CSV / text", "#884 — NZBGet imports from the finished location, not the incomplete \"….#NZBID\" folder",
"ReplayGain Filler (#437) and Empty Folder Cleaner library-maintenance jobs", "#885 — Australia/Sydney timezone no longer makes the cache-maintenance job loop every 5 seconds",
"custom in-container completed-download path for Torrent / Usenet sources (#857)",
"HiFi instances: Restore Defaults button, bigger tap targets, and a new confirmed-working instance pushed to everyone",
"Artist detail \"DB Record\" inspector for debugging metadata",
], ],
}, },
{ {
title: "Fixes this release", title: "Earlier in 2.7.3 / 2.7.2 / 2.7.1 / 2.7.0",
description: "a stack of issue fixes on top of 2.7.1.", description: "2.7.3 added the Quality Upgrade Finder (find + upgrade tracks you own in worse quality than available), a wishlist ignore-list (#874), quarantine duplicate-grouping (#876), the \"Track 01\" position fix, and Tidal discovery/favorites fixes (#867, #880). 2.7.2 brought playlist-folder mirroring + server-playlist M3U export and ReplayGain / Empty-Folder maintenance jobs; 2.7.1 added download verification (acoustid checks every download) + a review queue and closed the websocket login-bypass (#852); 2.7.0 made multi-user real — per-profile streaming accounts, opt-in login, reverse-proxy support.",
features: [
"#859 — a hung database update self-heals instead of wedging on \"Starting...\" forever",
"#862 — Library Reorganize now works on media-server libraries (falls back to tag mode when there are no source IDs)",
"spotify (no-auth) shows as connected and the dashboard test reports it correctly, instead of claiming a deezer fallback",
"navidrome reconnects itself instead of latching \"disconnected\"",
"the orphan detector hard-bails on a mass-orphan flood instead of plowing ahead",
"more #852 lock-screen hardening + login-password management in Manage Profiles",
"Aria2 added to the torrent client list",
],
},
{
title: "Earlier in 2.7.1 / 2.7.0",
description: "2.7.1 added download verification & an unverified review queue (acoustid fingerprint-checks every download against what you asked for), closed the websocket login-bypass (#852), and added the acoustid Relocate fix action. 2.7.0 made multi-user real: per-profile streaming accounts (My Accounts), auto-sync running as its owner, opt-in username/password login with recovery, and reverse-proxy support. before that, the 2.6.x cycle brought the blocklist, the download-retry overhaul, Download Origins, Spotify-no-auth metadata, and Library Re-tag.",
features: [], features: [],
}, },
]; ];

View file

@ -1116,6 +1116,15 @@ function updateProfileIndicator() {
const statusSection = document.querySelector('.status-section--clickable'); const statusSection = document.querySelector('.status-section--clickable');
if (statusSection) statusSection.classList.toggle('status-section--locked', !currentProfile.is_admin); if (statusSection) statusSection.classList.toggle('status-section--locked', !currentProfile.is_admin);
// My Accounts (per-profile streaming OAuth) and My Settings (per-profile
// server library) are inert for admin — admin uses the global app account
// for every service and the full Settings page. Hide both for admin; keep
// them for non-admins, who actually get a connect/library UI.
const myAccountsBtn = document.getElementById('my-accounts-btn');
const personalSettingsBtn = document.getElementById('personal-settings-btn');
if (myAccountsBtn) myAccountsBtn.style.display = currentProfile.is_admin ? 'none' : '';
if (personalSettingsBtn) personalSettingsBtn.style.display = currentProfile.is_admin ? 'none' : '';
indicator.onclick = async () => { indicator.onclick = async () => {
const res = await fetch('/api/profiles'); const res = await fetch('/api/profiles');
const data = await res.json(); const data = await res.json();

View file

@ -1891,16 +1891,12 @@ function createReleaseCard(release) {
// Store mutable reference so stream updates propagate to click handler // Store mutable reference so stream updates propagate to click handler
card._releaseData = release; card._releaseData = release;
// Tag card for content-type filtering // Tag card for content-type filtering (shared classifier — #877, so Artist
const livePattern = /\b(live)\b|\(live[^)]*\)|\[live[^]]*\]/i; // Detail and the Download Discography modal never drift apart).
const compilationPattern = /\b(greatest hits|best of|collection|anthology|essential)\b/i; const cc = _classifyReleaseContent(release);
const featuredPattern = /\(?\bfeat\.?\s|\bft\.?\s|\bfeaturing\b/i; card.setAttribute("data-is-live", cc.isLive ? "true" : "false");
const isLive = livePattern.test(release.title || '') || (release.album_type === 'compilation' && livePattern.test(release.title || '')); card.setAttribute("data-is-compilation", cc.isCompilation ? "true" : "false");
const isCompilation = (release.album_type === 'compilation') || compilationPattern.test(release.title || ''); card.setAttribute("data-is-featured", cc.isFeatured ? "true" : "false");
const isFeatured = featuredPattern.test(release.title || '');
card.setAttribute("data-is-live", isLive ? "true" : "false");
card.setAttribute("data-is-compilation", isCompilation ? "true" : "false");
card.setAttribute("data-is-featured", isFeatured ? "true" : "false");
// Background image — use data-bg-src for IntersectionObserver lazy loading // Background image — use data-bg-src for IntersectionObserver lazy loading
// (observeLazyBackgrounds is called by the caller after appending the grid). // (observeLazyBackgrounds is called by the caller after appending the grid).
@ -2518,8 +2514,8 @@ async function openDiscographyModal() {
const data = await res.json(); const data = await res.json();
if (!data.error) { if (!data.error) {
discography = { albums: data.albums || [], singles: data.singles || [] }; discography = { albums: data.albums || [], eps: data.eps || [], singles: data.singles || [] };
if (discography.albums.length > 0 || discography.singles.length > 0) { if (discography.albums.length > 0 || discography.eps.length > 0 || discography.singles.length > 0) {
artistsPageState.artistDiscography = discography; artistsPageState.artistDiscography = discography;
artistsPageState.sourceOverride = data.source || artistsPageState.sourceOverride || null; artistsPageState.sourceOverride = data.source || artistsPageState.sourceOverride || null;
// Use metadata source ID for the modal (needed for download API calls) // Use metadata source ID for the modal (needed for download API calls)
@ -2569,6 +2565,9 @@ async function openDiscographyModal() {
<button class="discog-filter active" data-type="album" onclick="toggleDiscogFilter(this)">Albums</button> <button class="discog-filter active" data-type="album" onclick="toggleDiscogFilter(this)">Albums</button>
<button class="discog-filter active" data-type="ep" onclick="toggleDiscogFilter(this)">EPs</button> <button class="discog-filter active" data-type="ep" onclick="toggleDiscogFilter(this)">EPs</button>
<button class="discog-filter active" data-type="single" onclick="toggleDiscogFilter(this)">Singles</button> <button class="discog-filter active" data-type="single" onclick="toggleDiscogFilter(this)">Singles</button>
<button class="discog-filter active" data-content="live" onclick="toggleDiscogFilter(this)">Live</button>
<button class="discog-filter active" data-content="compilations" onclick="toggleDiscogFilter(this)">Compilations</button>
<button class="discog-filter active" data-content="featured" onclick="toggleDiscogFilter(this)">Featured</button>
</div> </div>
<div class="discog-select-actions"> <div class="discog-select-actions">
<button class="discog-select-btn" onclick="discogSelectAll(true)">Select All</button> <button class="discog-select-btn" onclick="discogSelectAll(true)">Select All</button>
@ -2605,21 +2604,36 @@ async function openDiscographyModal() {
function _esc(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; } function _esc(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
// #877: single source of truth for content-type classification, shared by the
// Artist Detail cards and the Download Discography modal so they can't drift.
function _classifyReleaseContent(release) {
const t = (release && (release.title || release.name)) || '';
const livePattern = /\b(live)\b|\(live[^)]*\)|\[live[^\]]*\]/i;
const compilationPattern = /\b(greatest hits|best of|collection|anthology|essential)\b/i;
const featuredPattern = /\(?\bfeat\.?\s|\bft\.?\s|\bfeaturing\b/i;
return {
isLive: livePattern.test(t),
isCompilation: (release && release.album_type === 'compilation') || compilationPattern.test(t),
isFeatured: featuredPattern.test(t),
};
}
function _renderDiscogCard(release, index, completionData) { function _renderDiscogCard(release, index, completionData) {
const comp = completionData?.albums?.find(c => c.id === release.id) || completionData?.singles?.find(c => c.id === release.id); const comp = completionData?.albums?.find(c => c.id === release.id) || completionData?.singles?.find(c => c.id === release.id);
const status = comp?.status || 'unknown'; const status = comp?.status || 'unknown';
const isOwned = status === 'completed'; const isOwned = status === 'completed';
const isPartial = status === 'partial' || status === 'nearly_complete'; const isPartial = status === 'partial' || status === 'nearly_complete';
const year = release.release_date ? release.release_date.substring(0, 4) : ''; const year = release.release_date ? release.release_date.substring(0, 4) : '';
const tracks = release.total_tracks || 0; const tracks = release.total_tracks || release.track_count || 0;
const img = release.image_url || ''; const img = release.image_url || '';
const cc = _classifyReleaseContent(release);
const checked = !isOwned; const checked = !isOwned;
const statusClass = isOwned ? 'owned' : isPartial ? 'partial' : ''; const statusClass = isOwned ? 'owned' : isPartial ? 'partial' : '';
const statusIcon = isOwned ? '✓' : isPartial ? '◐' : ''; const statusIcon = isOwned ? '✓' : isPartial ? '◐' : '';
const albumName = release.name || release.title || ''; const albumName = release.name || release.title || '';
return ` return `
<label class="discog-card ${statusClass}" data-type="${release._type}" style="animation-delay:${index * 0.03}s"> <label class="discog-card ${statusClass}" data-type="${release._type}" data-is-live="${cc.isLive}" data-is-compilation="${cc.isCompilation}" data-is-featured="${cc.isFeatured}" style="animation-delay:${index * 0.03}s">
<input type="checkbox" class="discog-card-cb" data-album-id="${release.id}" data-album-name="${_esc(albumName)}" data-tracks="${tracks}" ${checked ? 'checked' : ''} onchange="_updateDiscogFooterCount()"> <input type="checkbox" class="discog-card-cb" data-album-id="${release.id}" data-album-name="${_esc(albumName)}" data-tracks="${tracks}" ${checked ? 'checked' : ''} onchange="_updateDiscogFooterCount()">
<div class="discog-card-art"> <div class="discog-card-art">
${img ? `<img src="${img}" alt="" loading="lazy">` : '<div class="discog-card-art-placeholder">🎵</div>'} ${img ? `<img src="${img}" alt="" loading="lazy">` : '<div class="discog-card-art-placeholder">🎵</div>'}
@ -2636,9 +2650,29 @@ function _renderDiscogCard(release, index, completionData) {
function toggleDiscogFilter(btn) { function toggleDiscogFilter(btn) {
btn.classList.toggle('active'); btn.classList.toggle('active');
const type = btn.dataset.type; _applyDiscogFilters();
document.querySelectorAll(`.discog-card[data-type="${type}"]`).forEach(card => { }
card.style.display = btn.classList.contains('active') ? '' : 'none';
// #877: combined category (Albums/EPs/Singles) + content (Live/Compilations/
// Featured) filtering, mirroring the Artist Detail filter logic. A card is
// hidden if its category is off OR any active content exclusion applies — and
// because the download payload is built from VISIBLE checked cards, every
// toggle now actually changes what gets downloaded.
function _applyDiscogFilters() {
const typeActive = {};
document.querySelectorAll('.discog-filter[data-type]').forEach(b => {
typeActive[b.dataset.type] = b.classList.contains('active');
});
const contentActive = {};
document.querySelectorAll('.discog-filter[data-content]').forEach(b => {
contentActive[b.dataset.content] = b.classList.contains('active');
});
document.querySelectorAll('.discog-card').forEach(card => {
let hidden = typeActive[card.getAttribute('data-type')] === false;
if (!hidden && contentActive.live === false && card.getAttribute('data-is-live') === 'true') hidden = true;
if (!hidden && contentActive.compilations === false && card.getAttribute('data-is-compilation') === 'true') hidden = true;
if (!hidden && contentActive.featured === false && card.getAttribute('data-is-featured') === 'true') hidden = true;
card.style.display = hidden ? 'none' : '';
}); });
_updateDiscogFooterCount(); _updateDiscogFooterCount();
} }
@ -4156,6 +4190,7 @@ function _buildTrackRow(track, album, admin) {
actionsTd.innerHTML = ` actionsTd.innerHTML = `
<div class="enhanced-track-actions-group"> <div class="enhanced-track-actions-group">
<button class="enhanced-source-info-btn" title="View download source info"></button> <button class="enhanced-source-info-btn" title="View download source info"></button>
<button class="enhanced-reidentify-btn" title="Re-identify — file this track under a different release">&#8644;</button>
<button class="enhanced-redownload-btn" title="Redownload this track">&#8635;</button> <button class="enhanced-redownload-btn" title="Redownload this track">&#8635;</button>
<button class="enhanced-delete-btn" title="Delete track from library">&#10005;</button> <button class="enhanced-delete-btn" title="Delete track from library">&#10005;</button>
</div> </div>
@ -4353,6 +4388,15 @@ function _attachTableDelegation(table, album) {
return; return;
} }
// Re-identify button (admin) — #889
if (target.closest('.enhanced-reidentify-btn')) {
e.stopPropagation();
const artistName = artistDetailPageState.enhancedData ? artistDetailPageState.enhancedData.artist.name : '';
openReidentifyModal(track.id, track.title || 'Unknown', artistName,
album.title || '', album.thumb_url || '');
return;
}
// Redownload button (admin) // Redownload button (admin)
if (target.closest('.enhanced-redownload-btn')) { if (target.closest('.enhanced-redownload-btn')) {
e.stopPropagation(); e.stopPropagation();
@ -9140,3 +9184,198 @@ async function openArtistExportModal(initialScope) {
refresh(); refresh();
} }
// ==================== Re-identify Track Modal (#889) ====================
// Lets an admin re-file an already-imported track under a different release
// (single / EP / album). Searches any configured metadata source (tabs, default
// active), and on confirm stages the file + writes a single-use hint the
// auto-import worker consumes (see core/imports/rematch_*.py).
const reidState = { trackId: null, source: null, sources: [], rows: [], selected: null };
function openReidentifyModal(trackId, title, artist, albumTitle, imageUrl) {
reidState.trackId = trackId;
reidState.source = null;
reidState.rows = [];
reidState.selected = null;
const overlay = document.getElementById('reid-modal-overlay');
if (!overlay) return;
// Hero
document.getElementById('reid-hero-title').textContent = title || 'Track';
const sub = document.getElementById('reid-hero-sub');
sub.textContent = (artist || '') + (albumTitle ? ` · currently in “${albumTitle}` : '');
const art = document.getElementById('reid-hero-art');
const bg = document.getElementById('reid-hero-bg');
if (imageUrl) {
art.style.backgroundImage = `url('${imageUrl}')`;
art.classList.remove('empty');
bg.style.backgroundImage = `url('${imageUrl}')`;
} else {
art.style.backgroundImage = '';
art.classList.add('empty');
bg.style.backgroundImage = '';
}
document.getElementById('reid-search-input').value = `${title || ''} ${artist || ''}`.trim();
document.getElementById('reid-replace').checked = true;
_reidUpdateConfirm();
_reidRenderState('idle');
overlay.classList.remove('hidden');
_reidLoadTabs();
}
function closeReidentifyModal() {
const overlay = document.getElementById('reid-modal-overlay');
if (overlay) overlay.classList.add('hidden');
}
async function _reidLoadTabs() {
const tabsEl = document.getElementById('reid-tabs');
tabsEl.innerHTML = '';
try {
const resp = await fetch('/api/reidentify/sources');
const data = await resp.json();
reidState.sources = (data && data.sources) || [];
} catch (_) {
reidState.sources = [];
}
if (!reidState.sources.length) {
tabsEl.innerHTML = '<span class="reid-tab active">No metadata sources available</span>';
_reidRenderState('empty', 'No configured metadata source to search.');
return;
}
const active = reidState.sources.find(s => s.active) || reidState.sources[0];
reidState.source = active.source;
reidState.sources.forEach(s => {
const tab = document.createElement('div');
tab.className = 'reid-tab' + (s.source === reidState.source ? ' active' : '');
tab.textContent = s.label || s.source;
tab.onclick = () => _reidSelectTab(s.source);
tabsEl.appendChild(tab);
});
runReidentifySearch(); // auto-search the active source on open
}
function _reidSelectTab(source) {
if (source === reidState.source) return;
reidState.source = source;
document.querySelectorAll('#reid-tabs .reid-tab').forEach(t => {
t.classList.toggle('active', t.textContent ===
(reidState.sources.find(s => s.source === source) || {}).label);
});
runReidentifySearch();
}
async function runReidentifySearch() {
const query = (document.getElementById('reid-search-input').value || '').trim();
if (!query || !reidState.source) return;
reidState.selected = null;
_reidUpdateConfirm();
_reidRenderState('loading');
try {
const url = `/api/reidentify/search?source=${encodeURIComponent(reidState.source)}&q=${encodeURIComponent(query)}`;
const resp = await fetch(url);
const data = await resp.json();
reidState.rows = (data && data.results) || [];
_reidRenderResults();
} catch (e) {
_reidRenderState('empty', 'Search failed. Try another source.');
}
}
function _reidRenderResults() {
const el = document.getElementById('reid-results');
if (!reidState.rows.length) {
_reidRenderState('empty', 'No releases found. Try refining the search or another source tab.');
return;
}
// ISRC-bearing rows first (provably the same recording), then the rest.
const ranked = reidState.rows
.map((r, i) => ({ r, i }))
.sort((a, b) => (b.r.isrc ? 1 : 0) - (a.r.isrc ? 1 : 0));
el.innerHTML = '';
ranked.forEach(({ r }, n) => {
const badge = (r.album_type || 'album').toLowerCase();
const bits = [];
if (r.year) bits.push(r.year);
if (r.total_tracks) bits.push(`${r.total_tracks} track${r.total_tracks === 1 ? '' : 's'}`);
const row = document.createElement('div');
row.className = 'reid-result';
row.style.animationDelay = `${Math.min(n * 0.03, 0.3)}s`;
row.onclick = () => _reidSelectResult(r, row);
row.innerHTML = `
<div class="reid-result-art" ${r.image_url ? `style="background-image:url('${encodeURI(r.image_url)}')"` : ''}>
${r.image_url ? '' : '<span>♪</span>'}
</div>
<div class="reid-result-info">
<div class="reid-result-title">${escapeHtml(r.track_title || '')}</div>
<div class="reid-result-release">${escapeHtml(r.album_name || 'Unknown release')}${r.artist_name ? ' · ' + escapeHtml(r.artist_name) : ''}</div>
</div>
<div class="reid-result-meta">
<span class="reid-badge ${badge}">${escapeHtml(badge)}</span>
${bits.length ? `<span class="reid-result-detail">${escapeHtml(bits.join(' · '))}</span>` : ''}
<span class="reid-result-check"></span>
</div>`;
el.appendChild(row);
});
}
function _reidSelectResult(r, rowEl) {
reidState.selected = r;
document.querySelectorAll('#reid-results .reid-result').forEach(x => x.classList.remove('selected'));
rowEl.classList.add('selected');
_reidUpdateConfirm();
}
function _reidUpdateConfirm() {
const btn = document.getElementById('reid-confirm-btn');
if (btn) btn.disabled = !reidState.selected;
}
function _reidRenderState(kind, msg) {
const el = document.getElementById('reid-results');
if (!el) return;
if (kind === 'loading') {
el.innerHTML = '<div class="reid-state"><div class="reid-spinner"></div><p>Searching…</p></div>'
+ '<div class="reid-skel"></div><div class="reid-skel"></div><div class="reid-skel"></div>';
} else if (kind === 'empty') {
el.innerHTML = `<div class="reid-state"><div class="reid-state-icon">🔍</div><p>${escapeHtml(msg || 'No results.')}</p></div>`;
} else { // idle
el.innerHTML = '<div class="reid-state"><div class="reid-state-icon">💿</div>'
+ '<p>Pick the release this track should be filed under — the same song may appear on a single, an EP, and an album.</p></div>';
}
}
async function confirmReidentify() {
if (!reidState.selected || !reidState.trackId) return;
const btn = document.getElementById('reid-confirm-btn');
const replace = document.getElementById('reid-replace').checked;
btn.disabled = true;
const prev = btn.textContent;
btn.textContent = 'Staging…';
try {
const resp = await fetch('/api/reidentify/apply', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
library_track_id: reidState.trackId,
source: reidState.selected.source,
track_id: reidState.selected.track_id,
replace: replace,
}),
});
const data = await resp.json();
if (!resp.ok || !data.success) throw new Error(data.error || 'Re-identify failed');
showToast(`Re-filing under “${data.album_name || 'the chosen release'}” — it'll update after the next import pass.`, 'success');
closeReidentifyModal();
} catch (e) {
showToast(e.message || 'Re-identify failed', 'error');
btn.disabled = false;
btn.textContent = prev;
}
}

View file

@ -45,6 +45,9 @@ function debouncedAutoSaveSettings() {
// fields on load — those aren't user edits and must not trigger a full // fields on load — those aren't user edits and must not trigger a full
// save (which re-initializes every backend service client). // save (which re-initializes every backend service client).
if (window._suppressSettingsAutoSave) return; if (window._suppressSettingsAutoSave) return;
// #879: never auto-save while the last settings load failed — the form is
// showing defaults, not the real config, so saving would wipe it.
if (window._settingsLoadFailed) return;
// #827: the Logs tab has no savable settings — its live-viewer controls // #827: the Logs tab has no savable settings — its live-viewer controls
// (source picker, filters, auto-scroll) were tripping the auto-save and // (source picker, filters, auto-scroll) were tripping the auto-save and
// flooding app.log with "Settings saved" lines, drowning out the logs the // flooding app.log with "Settings saved" lines, drowning out the logs the
@ -976,6 +979,18 @@ async function loadSettingsData() {
const response = await fetch(API.settings); const response = await fetch(API.settings);
const settings = await response.json(); const settings = await response.json();
// #879: a failed GET /api/settings returns an error body (e.g. {"error":
// "..."} on a 500), NOT real settings. Populating from it blanks every
// field to its default ('settings.spotify?.x || ""'), and the next
// (auto)save then overwrites the user's real config. Abort BEFORE
// touching any field and flag it so saves stay blocked until a good load.
if (!response.ok || !settings || typeof settings !== 'object' || settings.error) {
window._settingsLoadFailed = true;
throw new Error('settings load failed (HTTP ' + response.status + '): ' +
((settings && settings.error) || 'unexpected response'));
}
window._settingsLoadFailed = false; // good load → saving is safe again
// Populate Spotify settings // Populate Spotify settings
document.getElementById('spotify-client-id').value = settings.spotify?.client_id || ''; document.getElementById('spotify-client-id').value = settings.spotify?.client_id || '';
document.getElementById('spotify-client-secret').value = settings.spotify?.client_secret || ''; document.getElementById('spotify-client-secret').value = settings.spotify?.client_secret || '';
@ -1217,6 +1232,7 @@ async function loadSettingsData() {
document.getElementById('embed-album-art').checked = settings.metadata_enhancement?.embed_album_art !== false; document.getElementById('embed-album-art').checked = settings.metadata_enhancement?.embed_album_art !== false;
document.getElementById('cover-art-download').checked = settings.metadata_enhancement?.cover_art_download !== false; document.getElementById('cover-art-download').checked = settings.metadata_enhancement?.cover_art_download !== false;
document.getElementById('prefer-caa-art').checked = settings.metadata_enhancement?.prefer_caa_art === true; document.getElementById('prefer-caa-art').checked = settings.metadata_enhancement?.prefer_caa_art === true;
document.getElementById('single-to-album-enabled').checked = settings.metadata_enhancement?.single_to_album === true;
document.getElementById('lrclib-enabled').checked = settings.metadata_enhancement?.lrclib_enabled !== false; document.getElementById('lrclib-enabled').checked = settings.metadata_enhancement?.lrclib_enabled !== false;
document.getElementById('replaygain-enabled').checked = settings.post_processing?.replaygain_enabled === true; document.getElementById('replaygain-enabled').checked = settings.post_processing?.replaygain_enabled === true;
document.getElementById('duration-tolerance-seconds').value = settings.post_processing?.duration_tolerance_seconds ?? 0; document.getElementById('duration-tolerance-seconds').value = settings.post_processing?.duration_tolerance_seconds ?? 0;
@ -1460,7 +1476,10 @@ async function loadSettingsData() {
} catch (error) { } catch (error) {
console.error('Error loading settings:', error); console.error('Error loading settings:', error);
showToast('Failed to load settings', 'error'); // #879: any load failure → block saves so a blank/partial form can't be
// written over the real config. Cleared on the next successful load.
window._settingsLoadFailed = true;
showToast('Failed to load settings — reload the page before saving (your saved config is untouched)', 'error');
} }
} }
@ -2799,6 +2818,16 @@ function _getTagConfig(path) {
} }
async function saveSettings(quiet = false) { async function saveSettings(quiet = false) {
// #879: refuse to save if the settings never loaded successfully — the form
// is showing defaults, not the user's real config, so saving would wipe it.
// Cleared automatically on the next successful load (reload the page).
if (window._settingsLoadFailed) {
if (!quiet && typeof showToast === 'function') {
showToast("Settings didn't load — reload the page before saving (your config is untouched)", 'error');
}
return;
}
// Validate file organization templates before saving // Validate file organization templates before saving
const validationErrors = validateFileOrganizationTemplates(); const validationErrors = validateFileOrganizationTemplates();
if (validationErrors.length > 0) { if (validationErrors.length > 0) {
@ -3010,6 +3039,7 @@ async function saveSettings(quiet = false) {
cover_art_download: document.getElementById('cover-art-download').checked, cover_art_download: document.getElementById('cover-art-download').checked,
prefer_caa_art: document.getElementById('prefer-caa-art').checked, prefer_caa_art: document.getElementById('prefer-caa-art').checked,
album_art_order: getArtOrder(), album_art_order: getArtOrder(),
single_to_album: document.getElementById('single-to-album-enabled').checked,
lrclib_enabled: document.getElementById('lrclib-enabled').checked, lrclib_enabled: document.getElementById('lrclib-enabled').checked,
tags: { tags: {
quality_tag: _getTagConfig('metadata_enhancement.tags.quality_tag'), quality_tag: _getTagConfig('metadata_enhancement.tags.quality_tag'),
@ -4240,13 +4270,11 @@ async function testDeezerDownloadConnection() {
statusEl.textContent = 'Checking...'; statusEl.textContent = 'Checking...';
statusEl.style.color = '#aaa'; statusEl.style.color = '#aaa';
try { try {
// Save the ARL first so the backend can use it let arl = document.getElementById('deezer-download-arl')?.value || '';
const arl = document.getElementById('deezer-download-arl')?.value || ''; // An untouched field holds the redaction sentinel (a token IS saved) —
if (!arl) { // send empty so the backend tests the SAVED token instead of the mask,
statusEl.textContent = 'No ARL token provided'; // which the source would reject (#870).
statusEl.style.color = '#ff9800'; if (arl === REDACTED_SECRET_SENTINEL) arl = '';
return;
}
const resp = await fetch('/api/deezer-download/test', { const resp = await fetch('/api/deezer-download/test', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },

View file

@ -80,7 +80,6 @@ body {
/* Soft translucent borders */ /* Soft translucent borders */
border-right: 1px solid rgba(255, 255, 255, 0.08); border-right: 1px solid rgba(255, 255, 255, 0.08);
border-top: 1px solid rgba(255, 255, 255, 0.12); border-top: 1px solid rgba(255, 255, 255, 0.12);
border-top-right-radius: 24px;
border-bottom-right-radius: 24px; border-bottom-right-radius: 24px;
/* Soft floating shadow with inner glow */ /* Soft floating shadow with inner glow */
@ -291,17 +290,16 @@ body.reduce-effects .sidebar::after {
.sidebar-header { .sidebar-header {
min-height: 115px; min-height: 115px;
/* Opaque base layered under the accent gradient so nav items scrolling /* Translucent so the backdrop-filter blur below is visible the dark tint
past the sticky header don't bleed through the translucent stops. */ keeps the header readable while nav items scrolling behind it read as a
soft frosted blur instead of a sharp bleed-through. */
background: background:
linear-gradient(180deg, linear-gradient(180deg,
rgba(var(--accent-rgb), 0.14) 0%, rgba(var(--accent-rgb), 0.16) 0%,
rgba(var(--accent-rgb), 0.08) 30%, rgba(var(--accent-rgb), 0.10) 30%,
rgba(var(--accent-rgb), 0.03) 70%, rgba(24, 24, 24, 0.62) 70%,
rgba(18, 18, 18, 1) 100%), rgba(18, 18, 18, 0.72) 100%);
rgb(18, 18, 18);
border-bottom: 1px solid rgba(255, 255, 255, 0.08); border-bottom: 1px solid rgba(255, 255, 255, 0.08);
border-top-right-radius: 20px;
padding: 20px 24px; padding: 20px 24px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -309,9 +307,21 @@ body.reduce-effects .sidebar::after {
gap: 8px; gap: 8px;
position: sticky; position: sticky;
top: 0; top: 0;
/* Above the nav (the sidebar gives its children z-index:1) so nav items
scrolling up sit BEHIND the header i.e. in its backdrop, where the
blur below can actually act on them. Without this they paint in front
and backdrop-filter has nothing to blur. */
z-index: 2;
overflow: hidden; overflow: hidden;
flex-shrink: 0; flex-shrink: 0;
/* Intense frosted-glass blur: nav items scrolling up behind the translucent
accent-gradient top now read as a soft blur instead of bleeding through
sharply. (Only visible where the background is translucent the opaque
base toward the bottom still stops any bleed.) */
backdrop-filter: blur(28px) saturate(1.3);
-webkit-backdrop-filter: blur(28px) saturate(1.3);
/* Subtle inner glow */ /* Subtle inner glow */
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.1); box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.1);
} }
@ -10664,6 +10674,63 @@ body.helper-mode-active #dashboard-activity-feed:hover {
gap: 4px; gap: 4px;
} }
/* #876: quarantine "alternatives for one song" group a collapsible parent
row wrapping the standard entry rows for each failed source attempt. */
.lh-quarantine-group {
border-left: 3px solid rgba(var(--accent-rgb), 0.35);
border-radius: 8px;
margin-bottom: 4px;
background: rgba(var(--accent-rgb), 0.03);
}
.lh-quarantine-group-header {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 12px;
cursor: pointer;
border-radius: 8px;
transition: background 0.15s ease;
}
.lh-quarantine-group-header:hover {
background: rgba(var(--accent-rgb), 0.06);
}
.lh-quarantine-group-text {
flex: 1;
min-width: 0;
}
.lh-quarantine-group-count {
flex-shrink: 0;
font-size: 12px;
color: rgba(var(--accent-rgb), 0.95);
border: 1px solid rgba(var(--accent-rgb), 0.4);
border-radius: 999px;
padding: 2px 10px;
white-space: nowrap;
}
.lh-quarantine-group-header .lh-expand-btn {
transition: transform 0.18s ease;
}
.lh-quarantine-group:not(.lh-qgroup-collapsed) .lh-quarantine-group-header .lh-expand-btn {
transform: rotate(180deg);
}
.lh-quarantine-group-members {
padding: 0 8px 8px 16px;
display: flex;
flex-direction: column;
gap: 4px;
}
.lh-quarantine-group.lh-qgroup-collapsed .lh-quarantine-group-members {
display: none;
}
.library-history-entry-row1 { .library-history-entry-row1 {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
@ -59698,6 +59765,7 @@ tr:hover .enhanced-track-actions-group { opacity: 1; }
.enhanced-track-actions-group.visible { opacity: 1; } .enhanced-track-actions-group.visible { opacity: 1; }
.enhanced-source-info-btn, .enhanced-source-info-btn,
.enhanced-reidentify-btn,
.enhanced-redownload-btn { .enhanced-redownload-btn {
background: none; border: none; cursor: pointer; background: none; border: none; cursor: pointer;
width: 24px; height: 24px; border-radius: 5px; width: 24px; height: 24px; border-radius: 5px;
@ -59708,6 +59776,9 @@ tr:hover .enhanced-track-actions-group { opacity: 1; }
.enhanced-source-info-btn { color: rgba(255,255,255,0.35); font-size: 13px; } .enhanced-source-info-btn { color: rgba(255,255,255,0.35); font-size: 13px; }
.enhanced-source-info-btn:hover { color: rgba(100,181,246,0.9); background: rgba(100,181,246,0.1); } .enhanced-source-info-btn:hover { color: rgba(100,181,246,0.9); background: rgba(100,181,246,0.1); }
.enhanced-reidentify-btn { color: rgba(255,255,255,0.35); font-size: 15px; }
.enhanced-reidentify-btn:hover { color: rgb(var(--accent-light-rgb)); background: rgba(var(--accent-rgb),0.1); }
.enhanced-redownload-btn { color: rgba(255,255,255,0.35); font-size: 15px; } .enhanced-redownload-btn { color: rgba(255,255,255,0.35); font-size: 15px; }
.enhanced-redownload-btn:hover { color: var(--accent); background: rgba(var(--accent-rgb),0.1); } .enhanced-redownload-btn:hover { color: var(--accent); background: rgba(var(--accent-rgb),0.1); }
@ -60293,8 +60364,9 @@ body.reduce-effects #page-particles-canvas {
.dl-nav-badge { .dl-nav-badge {
position: absolute; position: absolute;
top: 4px; top: 50%;
right: 8px; right: 8px;
transform: translateY(-50%);
background: rgb(var(--accent-rgb)); background: rgb(var(--accent-rgb));
color: #fff; color: #fff;
font-size: 0.6rem; font-size: 0.6rem;
@ -62639,6 +62711,10 @@ body[data-artist-source="source"] #artist-detail-page #library-artist-enhance-bt
} }
#artist-detail-page .artist-detail-hero-bg { #artist-detail-page .artist-detail-hero-bg {
/* Blurred artist-cover backdrop retired Boulder didn't want the artist
image bleeding behind the header. The element stays in the DOM (JS still
sets its background-image) but is hidden; flip display back on to restore. */
display: none;
position: absolute; position: absolute;
inset: -20px; inset: -20px;
background-size: cover; background-size: cover;
@ -68427,3 +68503,201 @@ body.app-locked > *:not(#launch-pin-overlay):not(#login-overlay):not(script):not
} }
.wlx-opt input { accent-color: #7aa2f7; } .wlx-opt input { accent-color: #7aa2f7; }
.watchlist-export-btn .watchlist-all-icon { font-weight: 700; } .watchlist-export-btn .watchlist-all-icon { font-weight: 700; }
/* =========================================================================
Re-identify Track Modal (#889)
A focused, vibey chooser: "which release does this track belong to?"
========================================================================= */
#reid-modal-overlay { backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); }
.reid-modal {
position: relative;
width: min(760px, 94vw);
max-height: 88vh;
display: flex;
flex-direction: column;
overflow: hidden;
border-radius: 20px;
background: linear-gradient(180deg, #14161d 0%, #0e0f15 100%);
border: 1px solid rgba(var(--accent-rgb), 0.18);
box-shadow:
0 30px 90px rgba(0, 0, 0, 0.6),
0 0 80px rgba(var(--accent-rgb), 0.08),
inset 0 1px 0 rgba(255, 255, 255, 0.05);
animation: reidPop 0.34s cubic-bezier(0.16, 1, 0.3, 1);
}
@keyframes reidPop {
from { opacity: 0; transform: translateY(18px) scale(0.97); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
/* Hero
No overflow:hidden on .reid-hero itself (it was clipping the header content).
Instead the decorative blurred bg + overlay live in .reid-hero-decor, an
absolutely-positioned clip layer: it contains the blur (so it can't bleed into
the tabs) and is pointer-events:none (so it can never steal clicks from the
source tabs). The actual content is a SIBLING of the decor layer, so it's never
clipped. */
.reid-hero { position: relative; padding: 26px 28px; }
.reid-hero-decor {
position: absolute; inset: 0; z-index: 0;
overflow: hidden; pointer-events: none;
border-radius: 20px 20px 0 0;
}
.reid-hero-bg {
position: absolute; inset: -30px;
background-size: cover; background-position: center;
filter: blur(46px) brightness(0.4) saturate(1.5);
transform: scale(1.25); opacity: 0.9;
transition: background-image 0.4s ease;
}
.reid-hero-overlay {
position: absolute; inset: 0;
background: linear-gradient(135deg, rgba(10,11,16,0.55) 0%, rgba(10,11,16,0.8) 100%);
}
.reid-hero-content { position: relative; z-index: 2; display: flex; gap: 18px; align-items: center; }
.reid-hero-art {
width: 76px; height: 76px; flex: 0 0 76px; border-radius: 12px;
background: rgba(255,255,255,0.05) center/cover no-repeat;
box-shadow: 0 8px 24px rgba(0,0,0,0.5); border: 1px solid rgba(255,255,255,0.08);
}
.reid-hero-art.empty { display: grid; place-items: center; }
.reid-hero-art.empty::after { content: '♪'; font-size: 30px; color: rgba(var(--accent-rgb), 0.7); }
.reid-hero-meta { min-width: 0; }
.reid-hero-eyebrow {
font: 600 11px/1 'JetBrains Mono', ui-monospace, monospace;
letter-spacing: 0.14em; text-transform: uppercase;
color: rgb(var(--accent-light-rgb)); margin-bottom: 7px;
}
.reid-hero-title {
font-size: 23px; font-weight: 800; color: #fff; line-height: 1.15;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.reid-hero-sub { margin-top: 4px; color: #aab; font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.reid-close {
position: absolute; top: 14px; right: 18px; z-index: 3;
font-size: 26px; line-height: 1; color: #aab; cursor: pointer;
width: 34px; height: 34px; display: grid; place-items: center; border-radius: 50%;
transition: all 0.18s ease;
}
.reid-close:hover { color: #fff; background: rgba(255,255,255,0.1); transform: rotate(90deg); }
/* ── Source tabs ── */
.reid-tabs {
display: flex; gap: 7px; padding: 14px 28px 0; flex-wrap: wrap;
}
.reid-tab {
padding: 7px 15px; border-radius: 999px; cursor: pointer;
font-size: 12.5px; font-weight: 600; color: #9aa3bd;
background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.06);
transition: all 0.18s ease; white-space: nowrap;
}
.reid-tab:hover { color: #fff; background: rgba(255,255,255,0.08); }
.reid-tab.active {
color: #06210f; background: linear-gradient(180deg, rgb(var(--accent-light-rgb)), rgb(var(--accent-rgb)));
border-color: transparent; box-shadow: 0 4px 14px rgba(var(--accent-rgb), 0.35);
}
/* ── Search ── */
.reid-search { display: flex; align-items: center; gap: 10px; padding: 16px 28px 12px; position: relative; }
.reid-search-icon { position: absolute; left: 42px; color: #6b7390; pointer-events: none; }
.reid-search-input {
flex: 1; padding: 12px 14px 12px 40px; border-radius: 12px;
background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.08);
color: #fff; font-size: 14px; transition: all 0.18s ease;
}
.reid-search-input:focus {
outline: none; border-color: rgba(var(--accent-rgb), 0.5);
background: rgba(255,255,255,0.06); box-shadow: 0 0 0 3px rgba(var(--accent-rgb), 0.12);
}
.reid-search-btn {
padding: 11px 20px; border-radius: 12px; cursor: pointer; font-weight: 700; font-size: 13px;
color: #06210f; background: linear-gradient(180deg, rgb(var(--accent-light-rgb)), rgb(var(--accent-rgb)));
border: none; transition: all 0.18s ease;
}
.reid-search-btn:hover { filter: brightness(1.08); transform: translateY(-1px); }
/* ── Results ── */
.reid-results { flex: 1; overflow-y: auto; padding: 4px 22px 10px; min-height: 220px; }
.reid-result {
display: flex; align-items: center; gap: 14px; padding: 12px 14px; margin: 6px 0;
border-radius: 14px; cursor: pointer;
background: rgba(255,255,255,0.025); border: 1.5px solid transparent;
transition: transform 0.16s ease, background 0.16s ease, border-color 0.16s ease, box-shadow 0.16s ease;
animation: reidRowIn 0.3s ease backwards;
}
@keyframes reidRowIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
.reid-result:hover { background: rgba(255,255,255,0.05); transform: translateY(-2px); }
.reid-result.selected {
border-color: rgba(var(--accent-rgb), 0.65);
background: rgba(var(--accent-rgb), 0.1);
box-shadow: 0 6px 20px rgba(var(--accent-rgb), 0.18);
}
.reid-result-art {
width: 52px; height: 52px; flex: 0 0 52px; border-radius: 9px;
background: rgba(255,255,255,0.06) center/cover no-repeat; border: 1px solid rgba(255,255,255,0.07);
display: grid; place-items: center;
}
.reid-result-art span { font-size: 20px; color: rgba(var(--accent-rgb), 0.6); }
.reid-result-info { min-width: 0; flex: 1; }
.reid-result-title { font-weight: 700; color: #fff; font-size: 14.5px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.reid-result-release { color: #9aa3bd; font-size: 12.5px; margin-top: 3px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.reid-result-meta { display: flex; align-items: center; gap: 9px; flex: 0 0 auto; }
.reid-result-detail { color: #6b7390; font-size: 11.5px; font-family: 'JetBrains Mono', ui-monospace, monospace; text-align: right; }
.reid-result-check {
width: 22px; height: 22px; border-radius: 50%; flex: 0 0 22px;
border: 2px solid rgba(255,255,255,0.14); display: grid; place-items: center; transition: all 0.18s ease;
}
.reid-result.selected .reid-result-check {
border-color: rgb(var(--accent-rgb)); background: rgb(var(--accent-rgb));
}
.reid-result.selected .reid-result-check::after { content: '✓'; color: #06210f; font-size: 13px; font-weight: 900; }
/* Type badges */
.reid-badge {
font: 700 10px/1 'JetBrains Mono', ui-monospace, monospace;
letter-spacing: 0.08em; text-transform: uppercase;
padding: 4px 8px; border-radius: 6px; white-space: nowrap;
}
.reid-badge.album { color: #22ff6b; background: rgba(34,255,107,0.12); border: 1px solid rgba(34,255,107,0.25); }
.reid-badge.ep { color: #5aa9ff; background: rgba(90,169,255,0.12); border: 1px solid rgba(90,169,255,0.25); }
.reid-badge.single { color: #ffc24b; background: rgba(255,194,75,0.12); border: 1px solid rgba(255,194,75,0.25); }
.reid-badge.compilation { color: #c98bff; background: rgba(201,139,255,0.12); border: 1px solid rgba(201,139,255,0.25); }
/* Idle / loading / empty */
.reid-state { display: grid; place-items: center; gap: 14px; min-height: 200px; text-align: center; color: #6b7390; padding: 20px; }
.reid-state .reid-state-icon { font-size: 38px; opacity: 0.5; }
.reid-state p { font-size: 13.5px; max-width: 360px; }
.reid-spinner {
width: 38px; height: 38px; border-radius: 50%;
border: 3px solid rgba(var(--accent-rgb), 0.15); border-top-color: rgb(var(--accent-rgb));
animation: reidSpin 0.8s linear infinite;
}
@keyframes reidSpin { to { transform: rotate(360deg); } }
.reid-skel {
height: 76px; margin: 6px 0; border-radius: 14px;
background: linear-gradient(90deg, rgba(255,255,255,0.03) 25%, rgba(255,255,255,0.07) 37%, rgba(255,255,255,0.03) 63%);
background-size: 400% 100%; animation: reidShimmer 1.4s ease infinite;
}
@keyframes reidShimmer { 0% { background-position: 100% 0; } 100% { background-position: -100% 0; } }
/* ── Footer ── */
.reid-footer {
display: flex; align-items: center; justify-content: space-between; gap: 16px;
padding: 16px 28px; border-top: 1px solid rgba(255,255,255,0.06);
background: rgba(0,0,0,0.2); flex-wrap: wrap;
}
.reid-replace { display: inline-flex; align-items: center; gap: 10px; cursor: pointer; user-select: none; }
.reid-replace input { display: none; }
.reid-replace-box {
width: 20px; height: 20px; border-radius: 6px; flex: 0 0 20px;
border: 2px solid rgba(255,255,255,0.18); transition: all 0.18s ease; display: grid; place-items: center;
}
.reid-replace input:checked + .reid-replace-box {
background: linear-gradient(180deg, rgb(var(--accent-light-rgb)), rgb(var(--accent-rgb))); border-color: transparent;
}
.reid-replace input:checked + .reid-replace-box::after { content: '✓'; color: #06210f; font-size: 13px; font-weight: 900; }
.reid-replace-text { color: #c2c9de; font-size: 13px; }
.reid-replace-text em { color: #7f879e; font-style: normal; font-size: 11.5px; }
.reid-footer-actions { display: flex; gap: 10px; }
#reid-confirm-btn:disabled { opacity: 0.4; cursor: not-allowed; filter: grayscale(0.4); }

View file

@ -2811,132 +2811,8 @@ function stopDbUpdatePolling() {
} }
} }
// =================================================================== // (Quality Scanner tool removed — quality scanning is now the 'Quality Upgrade
// QUALITY SCANNER TOOL // Finder' job in Library Maintenance / Tools → repair jobs.)
// ===================================================================
async function handleQualityScanButtonClick() {
const button = document.getElementById('quality-scan-button');
const currentAction = button.textContent;
// The quality check now runs as a proper Library Maintenance job: it probes
// each library file's REAL audio quality (same pipeline as the download
// gate) and reports below-profile tracks as FINDINGS you can re-download /
// delete / ignore — instead of silently adding to the wishlist. Triggering
// here just kicks off a "Run Now" of that job; results land under
// Library Maintenance → Findings.
try {
button.disabled = true;
button.textContent = 'Starting...';
const response = await fetch('/api/repair/jobs/quality_upgrade_scanner/run', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({})
});
if (response.ok) {
showToast('Quality scan started — below-quality tracks will appear under Library Maintenance → Findings.', 'success');
} else {
let msg = 'Error starting quality scan';
try { msg = (await response.json()).error || msg; } catch {}
showToast(msg, 'error');
}
} catch (error) {
showToast('Failed to start quality scan.', 'error');
} finally {
button.disabled = false;
button.textContent = 'Scan Library';
}
}
async function checkAndUpdateQualityScanProgress() {
if (socketConnected) return; // WebSocket handles this
try {
const response = await fetch('/api/quality-scanner/status', {
signal: AbortSignal.timeout(10000) // 10 second timeout
});
if (!response.ok) return;
const state = await response.json();
console.debug('🔍 Quality Scanner Status:', state.status, `${state.processed}/${state.total}`, `${state.progress.toFixed(1)}%`);
updateQualityScanProgressUI(state);
// Start polling only if not already polling and status is running
if (state.status === 'running' && !qualityScannerStatusInterval) {
console.log('🔄 Starting quality scanner polling (1 second interval)');
qualityScannerStatusInterval = setInterval(checkAndUpdateQualityScanProgress, 1000);
}
} catch (error) {
console.warn('Could not fetch quality scanner status:', error);
// Don't stop polling on network errors - keep trying
}
}
function updateQualityScanProgressFromData(data) {
const prev = _lastToolStatus['quality-scanner'];
_lastToolStatus['quality-scanner'] = data.status;
if (prev !== undefined && data.status === prev && data.status !== 'running') return;
updateQualityScanProgressUI(data);
}
function updateQualityScanProgressUI(state) {
const button = document.getElementById('quality-scan-button');
const phaseLabel = document.getElementById('quality-phase-label');
const progressLabel = document.getElementById('quality-progress-label');
const progressBar = document.getElementById('quality-progress-bar');
const scopeSelect = document.getElementById('quality-scan-scope');
// Stats
const processedStat = document.getElementById('quality-stat-processed');
const metStat = document.getElementById('quality-stat-met');
const lowStat = document.getElementById('quality-stat-low');
const matchedStat = document.getElementById('quality-stat-matched');
if (!button || !phaseLabel || !progressLabel || !progressBar || !scopeSelect) return;
// Update stats
if (processedStat) processedStat.textContent = state.processed || 0;
if (metStat) metStat.textContent = state.quality_met || 0;
if (lowStat) lowStat.textContent = state.low_quality || 0;
if (matchedStat) matchedStat.textContent = state.matched || 0;
if (state.status === 'running') {
button.textContent = 'Stop Scan';
button.disabled = false;
scopeSelect.disabled = true;
phaseLabel.textContent = state.phase || 'Scanning...';
progressLabel.textContent = `${state.processed} / ${state.total} tracks scanned (${state.progress.toFixed(1)}%)`;
progressBar.style.width = `${state.progress}%`;
} else { // idle, finished, or error
stopQualityScannerPolling();
button.textContent = 'Scan Library';
button.disabled = false;
scopeSelect.disabled = false;
if (state.status === 'error') {
phaseLabel.textContent = `Error: ${state.error_message}`;
progressBar.style.backgroundColor = '#ff4444'; // Red for error
} else {
phaseLabel.textContent = state.phase || 'Ready to scan';
progressBar.style.backgroundColor = 'rgb(var(--accent-rgb))'; // Green for normal
}
if (state.status === 'finished') {
// Show completion toast with results
showToast(`Scan complete! ${state.matched} tracks added to wishlist`, 'success');
}
}
}
function stopQualityScannerPolling() {
if (qualityScannerStatusInterval) {
console.log('⏹️ Stopping quality scanner polling');
clearInterval(qualityScannerStatusInterval);
qualityScannerStatusInterval = null;
}
}
// =================================================================== // ===================================================================
// IMPORT IDS FROM FILE TAGS (reconcile embedded provider IDs) // IMPORT IDS FROM FILE TAGS (reconcile embedded provider IDs)
@ -3377,9 +3253,23 @@ function openLibraryHistoryModal() {
overlay.classList.remove('hidden'); overlay.classList.remove('hidden');
_libraryHistoryState.page = 1; _libraryHistoryState.page = 1;
loadLibraryHistory(); loadLibraryHistory();
_refreshQuarantineTabCount(); // #876: count correct on open, not only after clicking the tab
} }
} }
// #876: keep the Quarantine tab badge accurate the moment the modal opens. The
// full count was previously only set by loadQuarantineList() (i.e. after the tab
// was clicked), so it showed a stale 0 until then.
async function _refreshQuarantineTabCount() {
const el = document.getElementById('history-quarantine-count');
if (!el) return;
try {
const resp = await fetch('/api/quarantine/list');
const data = await resp.json();
el.textContent = (data.entries || []).length;
} catch (e) { /* leave the existing value on error */ }
}
// ────────────────────────────────────────────────────────────────────── // ──────────────────────────────────────────────────────────────────────
// Quarantine tab — rendered inside the Library History modal as a third // Quarantine tab — rendered inside the Library History modal as a third
// tab next to Downloads + Server Imports. Reuses the existing list + // tab next to Downloads + Server Imports. Reuses the existing list +
@ -3410,13 +3300,66 @@ async function loadQuarantineList() {
list.innerHTML = '<div class="library-history-empty">🛡️<br><br>No quarantined files. Nice and clean.</div>'; list.innerHTML = '<div class="library-history-empty">🛡️<br><br>No quarantined files. Nice and clean.</div>';
return; return;
} }
list.innerHTML = entries.map(renderQuarantineEntry).join(''); list.innerHTML = _groupQuarantineEntries(entries).map(renderQuarantineGroupOrEntry).join('');
} catch (err) { } catch (err) {
console.error('Error loading quarantine entries:', err); console.error('Error loading quarantine entries:', err);
list.innerHTML = '<div class="library-history-empty">Error loading quarantine</div>'; list.innerHTML = '<div class="library-history-empty">Error loading quarantine</div>';
} }
} }
// #876: bucket entries that are alternatives for the SAME intended target
// (same backend group_key — derived from expected_artist/expected_track, the
// track SoulSync was trying to fetch, not the bad file's own tags). Entries
// with a null group_key (legacy/orphan, ungroupable) each stand alone.
// Preserves the newest-first order the backend already sorted by.
function _groupQuarantineEntries(entries) {
const groups = [];
const byKey = new Map();
for (const entry of entries) {
const key = entry.group_key;
if (!key) { groups.push({ key: null, members: [entry] }); continue; }
let g = byKey.get(key);
if (!g) { g = { key, members: [] }; byKey.set(key, g); groups.push(g); }
g.members.push(entry);
}
return groups;
}
function renderQuarantineGroupOrEntry(group) {
if (group.members.length === 1) return renderQuarantineEntry(group.members[0]);
return renderQuarantineGroup(group);
}
// A collapsible parent row for multiple alternatives of one song. Header shows
// the shared track/artist + an alternatives count; members reuse the standard
// entry markup so per-row Approve/Recover/Delete keep working unchanged.
function renderQuarantineGroup(group) {
const first = group.members[0] || {};
const title = first.expected_track || first.original_filename || 'Unknown';
const artist = first.expected_artist || '';
const n = group.members.length;
const sub = artist ? escapeHtml(artist) : '';
// Reuse the album art the sidecar context carried, when any member has it.
const thumb = (group.members.find(m => m.thumb_url) || {}).thumb_url || '';
const thumbHtml = thumb
? `<img class="library-history-thumb" src="${escapeHtml(thumb)}" alt="" onerror="this.style.display='none'">`
: '<div class="library-history-thumb-placeholder">🛡️</div>';
return `<div class="lh-quarantine-group lh-qgroup-collapsed">
<div class="lh-quarantine-group-header" onclick="this.parentElement.classList.toggle('lh-qgroup-collapsed')">
${thumbHtml}
<div class="lh-quarantine-group-text">
<div class="library-history-entry-title">${escapeHtml(title)}</div>
<div class="library-history-entry-meta">${sub}</div>
</div>
<span class="lh-quarantine-group-count">${n} alternatives</span>
<span class="lh-expand-btn">&#x25BE;</span>
</div>
<div class="lh-quarantine-group-members">
${group.members.map(renderQuarantineEntry).join('')}
</div>
</div>`;
}
function renderQuarantineEntry(entry) { function renderQuarantineEntry(entry) {
const triggerLabels = { integrity: 'Duration / Integrity', acoustid: 'AcoustID Mismatch', bit_depth: 'Bit Depth Filter', unknown: 'Unknown' }; const triggerLabels = { integrity: 'Duration / Integrity', acoustid: 'AcoustID Mismatch', bit_depth: 'Bit Depth Filter', unknown: 'Unknown' };
const triggerColors = { integrity: '#facc15', acoustid: '#ef5350', bit_depth: '#fb923c', unknown: '#888' }; const triggerColors = { integrity: '#facc15', acoustid: '#ef5350', bit_depth: '#fb923c', unknown: '#888' };
@ -3498,12 +3441,20 @@ async function approveQuarantineEntry(entryId) {
}); });
if (!ok) return; if (!ok) return;
try { try {
const r = await fetch(`/api/quarantine/${encodeURIComponent(entryId)}/approve`, { method: 'POST' }); const r = await fetch(`/api/quarantine/${encodeURIComponent(entryId)}/approve`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
// #876: clear the other quarantined alternatives for this same song
// once one is accepted — they're redundant failed attempts now.
body: JSON.stringify({ remove_siblings: true }),
});
const data = await r.json(); const data = await r.json();
if (!data.success) { if (!data.success) {
showToast(`Approve failed: ${data.error}`, 'error'); showToast(`Approve failed: ${data.error}`, 'error');
} else { } else {
showToast(`Approved — skipped ${data.trigger_bypassed} check, re-running pipeline.`, 'success'); const removed = (data.removed_siblings || []).length;
const extra = removed ? ` — removed ${removed} other option${removed === 1 ? '' : 's'}.` : '';
showToast(`Approved — skipped ${data.trigger_bypassed} check, re-running pipeline.${extra}`, 'success');
} }
} catch (err) { } catch (err) {
showToast(`Approve failed: ${err.message}`, 'error'); showToast(`Approve failed: ${err.message}`, 'error');
@ -5617,43 +5568,6 @@ const TOOL_HELP_CONTENT = {
<p>Available for <strong>Plex</strong> and <strong>Jellyfin</strong> media servers. Each enrichment worker only runs if its service is authenticated.</p> <p>Available for <strong>Plex</strong> and <strong>Jellyfin</strong> media servers. Each enrichment worker only runs if its service is authenticated.</p>
` `
}, },
'quality-scanner': {
title: 'Quality Scanner',
content: `
<h4>What does this tool do?</h4>
<p>The Quality Scanner identifies tracks in your library that don't meet your preferred quality settings and automatically matches them to Spotify to add to your wishlist for re-downloading.</p>
<h4>Scan Scope</h4>
<ul>
<li><strong>Watchlist Artists Only:</strong> Only scans tracks from artists you're watching. Faster and more focused.</li>
<li><strong>All Library Tracks:</strong> Scans your entire music library. Comprehensive but takes longer.</li>
</ul>
<h4>How it works</h4>
<ol>
<li>Scans tracks and checks file format against your quality preferences</li>
<li>Identifies tracks below your quality threshold (e.g., MP3 when you prefer FLAC)</li>
<li>Uses fuzzy matching to find the track on Spotify (70% confidence minimum)</li>
<li>Automatically adds matched tracks to your wishlist for re-download</li>
</ol>
<h4>Quality Tiers</h4>
<ul>
<li><strong>Tier 1 (Best):</strong> FLAC, WAV, ALAC, AIFF - Lossless formats</li>
<li><strong>Tier 2:</strong> OPUS, OGG - High quality lossy</li>
<li><strong>Tier 3:</strong> M4A, AAC - Standard lossy</li>
<li><strong>Tier 4:</strong> MP3, WMA - Lower quality lossy</li>
</ul>
<h4>Stats Explained</h4>
<ul>
<li><strong>Processed:</strong> Total tracks scanned so far</li>
<li><strong>Quality Met:</strong> Tracks that meet your quality standards</li>
<li><strong>Low Quality:</strong> Tracks below your quality threshold</li>
<li><strong>Matched:</strong> Low quality tracks successfully matched to Spotify and added to wishlist</li>
</ul>
`
},
'duplicate-cleaner': { 'duplicate-cleaner': {
title: 'Duplicate Cleaner', title: 'Duplicate Cleaner',
content: ` content: `
@ -7553,11 +7467,6 @@ async function initializeToolsPage() {
metadataButton._toolsWired = true; metadataButton._toolsWired = true;
} }
const qualityScanButton = document.getElementById('quality-scan-button');
if (qualityScanButton && !qualityScanButton._toolsWired) {
qualityScanButton.addEventListener('click', handleQualityScanButtonClick);
qualityScanButton._toolsWired = true;
}
const duplicateCleanButton = document.getElementById('duplicate-clean-button'); const duplicateCleanButton = document.getElementById('duplicate-clean-button');
if (duplicateCleanButton && !duplicateCleanButton._toolsWired) { if (duplicateCleanButton && !duplicateCleanButton._toolsWired) {
@ -7602,7 +7511,6 @@ async function initializeToolsPage() {
// Check for ongoing operations // Check for ongoing operations
await checkAndUpdateDbProgress(); await checkAndUpdateDbProgress();
await checkAndUpdateQualityScanProgress();
await checkAndUpdateDuplicateCleanProgress(); await checkAndUpdateDuplicateCleanProgress();
// Initialize library maintenance section // Initialize library maintenance section