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:
commit
b761229a00
93 changed files with 7128 additions and 1860 deletions
4
.github/workflows/docker-publish.yml
vendored
4
.github/workflows/docker-publish.yml
vendored
|
|
@ -9,9 +9,9 @@ on:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
version_tag:
|
version_tag:
|
||||||
description: 'Version tag (e.g. 2.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:
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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', {})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -660,7 +660,12 @@ 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.
|
||||||
|
# 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)
|
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,
|
||||||
|
|
@ -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 ──
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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(
|
deps.update_progress(
|
||||||
automation_id, log_line=f'Quality scan started (scope: {scope})', log_type='info',
|
automation_id, status='error', phase='Unavailable',
|
||||||
)
|
log_line='Quality Upgrade job could not be triggered (library worker unavailable)',
|
||||||
|
|
||||||
# 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(
|
|
||||||
automation_id,
|
|
||||||
phase=state.get('phase', 'Scanning...'),
|
|
||||||
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(
|
deps.update_progress(
|
||||||
automation_id, status='error', progress=100,
|
automation_id, status='finished', progress=100, phase='Triggered',
|
||||||
phase='Error', log_line=err, log_type='error',
|
log_line='Quality Upgrade scan queued — findings appear in Library Maintenance',
|
||||||
)
|
|
||||||
return {'status': 'error', 'reason': err, '_manages_own_progress': True}
|
|
||||||
|
|
||||||
issues = state.get('low_quality', 0)
|
|
||||||
deps.update_progress(
|
|
||||||
automation_id, status='finished', progress=100,
|
|
||||||
phase='Complete',
|
|
||||||
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),
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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)}"
|
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
98
core/imports/album_grouping.py
Normal file
98
core/imports/album_grouping.py
Normal 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
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 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
|
return None
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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'),
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
92
core/imports/rematch_apply.py
Normal file
92
core/imports/rematch_apply.py
Normal 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",
|
||||||
|
]
|
||||||
334
core/imports/rematch_hints.py
Normal file
334
core/imports/rematch_hints.py
Normal 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 + file→track 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",
|
||||||
|
]
|
||||||
247
core/imports/rematch_search.py
Normal file
247
core/imports/rematch_search.py
Normal 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":
|
||||||
|
# 1–3 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",
|
||||||
|
]
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
124
core/imports/single_to_album.py
Normal file
124
core/imports/single_to_album.py
Normal 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
|
||||||
|
|
@ -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
|
||||||
return _coerce_positive(from_filename)
|
ff = _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)
|
||||||
|
|
|
||||||
|
|
@ -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')
|
||||||
|
|
|
||||||
53
core/library/residual_files.py
Normal file
53
core/library/residual_files.py
Normal 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',
|
||||||
|
]
|
||||||
|
|
@ -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:
|
try:
|
||||||
os.remove(sidecar)
|
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:
|
||||||
|
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:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,9 +118,26 @@ 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
|
||||||
|
|
@ -127,13 +145,24 @@ class MusicBrainzService:
|
||||||
# Check cache first
|
# Check cache first
|
||||||
cached = self._check_cache('artist', artist_name)
|
cached = self._check_cache('artist', artist_name)
|
||||||
if cached:
|
if cached:
|
||||||
|
cached_mbid = cached.get('musicbrainz_id')
|
||||||
|
# Don't trust a cached mbid whose catalog has ZERO overlap with the
|
||||||
|
# albums this library owns — that's the wrong same-name artist (and a
|
||||||
|
# re-match would otherwise be blocked for up to the 90-day cache TTL,
|
||||||
|
# #868). Fall through to a fresh, disambiguated resolve in that case.
|
||||||
|
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}'")
|
logger.debug(f"Cache hit for artist '{artist_name}'")
|
||||||
return {
|
return {
|
||||||
'mbid': cached['musicbrainz_id'],
|
'mbid': cached_mbid,
|
||||||
'name': artist_name,
|
'name': artist_name,
|
||||||
'confidence': cached['confidence'],
|
'confidence': cached['confidence'],
|
||||||
'cached': True
|
'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,24 +173,29 @@ 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))
|
||||||
|
scored.sort(key=lambda s: s[0], reverse=True)
|
||||||
|
|
||||||
if confidence > best_confidence:
|
# Among the strong (>=70) candidates, disambiguate same-name artists by
|
||||||
best_confidence = confidence
|
# which one's release groups overlap the albums this library owns.
|
||||||
best_match = result
|
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:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
if ignore_junk:
|
||||||
return all(is_junk(f) for f in files)
|
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,18 +196,20 @@ 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, j))
|
remove_file(os.path.join(folder_path, e))
|
||||||
except Exception: # noqa: S110 — junk best-effort; rmdir below fails loudly if blocked
|
except Exception: # noqa: S110 — leftover best-effort; rmdir below fails loudly if blocked
|
||||||
pass
|
pass
|
||||||
try:
|
try:
|
||||||
rmdir(folder_path)
|
rmdir(folder_path)
|
||||||
|
|
|
||||||
720
core/repair_jobs/quality_upgrade.py
Normal file
720
core/repair_jobs/quality_upgrade.py
Normal 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
|
||||||
|
|
@ -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 local↔UTC 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,
|
||||||
|
|
|
||||||
|
|
@ -427,7 +427,10 @@ 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)
|
||||||
|
|
@ -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),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
162
core/wishlist/ignore.py
Normal 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
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -345,6 +345,28 @@ class MusicDatabase:
|
||||||
)
|
)
|
||||||
""")
|
""")
|
||||||
|
|
||||||
|
# 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 (
|
||||||
|
|
@ -6893,6 +6950,21 @@ class MusicDatabase:
|
||||||
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(
|
||||||
|
|
@ -8909,6 +9037,146 @@ class MusicDatabase:
|
||||||
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
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
|
|
@ -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 ────────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
106
tests/downloads/test_soulseek_aac_quality.py
Normal file
106
tests/downloads/test_soulseek_aac_quality.py
Normal 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)
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
138
tests/imports/test_album_grouping.py
Normal file
138
tests/imports/test_album_grouping.py
Normal 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"
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
71
tests/imports/test_rematch_apply.py
Normal file
71
tests/imports/test_rematch_apply.py
Normal 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"
|
||||||
155
tests/imports/test_rematch_hints.py
Normal file
155
tests/imports/test_rematch_hints.py
Normal 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
|
||||||
217
tests/imports/test_rematch_hints_seam.py
Normal file
217
tests/imports/test_rematch_hints_seam.py
Normal 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
|
||||||
121
tests/imports/test_rematch_search.py
Normal file
121
tests/imports/test_rematch_search.py
Normal 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
|
||||||
206
tests/imports/test_single_to_album.py
Normal file
206
tests/imports/test_single_to_album.py
Normal 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
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
77
tests/imports/test_track_number_strip.py
Normal file
77
tests/imports/test_track_number_strip.py
Normal 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"
|
||||||
51
tests/library/test_residual_files.py
Normal file
51
tests/library/test_residual_files.py
Normal 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
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -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"]
|
||||||
|
|
|
||||||
391
tests/repair_jobs/test_quality_upgrade.py
Normal file
391
tests/repair_jobs/test_quality_upgrade.py
Normal 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'
|
||||||
98
tests/test_artist_catalog_disambiguation.py
Normal file
98
tests/test_artist_catalog_disambiguation.py
Normal 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
|
||||||
88
tests/test_base_title_search_fallback.py
Normal file
88
tests/test_base_title_search_fallback.py
Normal 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
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
100
tests/test_enrichment_tag_preservation.py
Normal file
100
tests/test_enrichment_tag_preservation.py
Normal 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"]
|
||||||
73
tests/test_repair_scheduler_tz.py
Normal file
73
tests/test_repair_scheduler_tz.py
Normal 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'
|
||||||
43
tests/test_resolve_secret.py
Normal file
43
tests/test_resolve_secret.py
Normal 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) == ''
|
||||||
|
|
@ -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():
|
||||||
|
|
|
||||||
68
tests/test_spotify_worker_status.py
Normal file
68
tests/test_spotify_worker_status.py
Normal 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
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
|
||||||
|
|
@ -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."""
|
||||||
|
|
|
||||||
186
tests/test_worker_artist_disambiguation.py
Normal file
186
tests/test_worker_artist_disambiguation.py
Normal 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
|
||||||
190
tests/wishlist/test_wishlist_ignore.py
Normal file
190
tests/wishlist/test_wishlist_ignore.py
Normal 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
|
||||||
380
web_server.py
380
web_server.py
|
|
@ -40,7 +40,7 @@ logger = setup_logging(_log_level, _log_path)
|
||||||
|
|
||||||
# App version — single source of truth for backup metadata, system-info, update check, etc.
|
# App version — single source of truth for backup metadata, system-info, update check, etc.
|
||||||
# Semver: MAJOR.MINOR.PATCH. Bump at each dev→main release.
|
# Semver: MAJOR.MINOR.PATCH. Bump at each dev→main release.
|
||||||
_SOULSYNC_BASE_VERSION = "2.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')
|
||||||
|
name = track_info.get('name')
|
||||||
|
|
||||||
if success:
|
if not track_id:
|
||||||
logger.info(f"[Atomic Cancel] Added '{task.get('track_info', {}).get('name')}' to wishlist")
|
logger.warning("[Atomic Cancel] No track id on cancelled task — cannot ignore '%s'", name)
|
||||||
else:
|
return
|
||||||
logger.error(f"[Atomic Cancel] Failed to add '{task.get('track_info', {}).get('name')}' to wishlist")
|
|
||||||
|
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:
|
||||||
|
|
|
||||||
|
|
@ -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()">×</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">
|
||||||
|
|
|
||||||
|
|
@ -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); });
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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).
|
||||||
|
|
|
||||||
|
|
@ -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: [],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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">⇄</button>
|
||||||
<button class="enhanced-redownload-btn" title="Redownload this track">↻</button>
|
<button class="enhanced-redownload-btn" title="Redownload this track">↻</button>
|
||||||
<button class="enhanced-delete-btn" title="Delete track from library">✕</button>
|
<button class="enhanced-delete-btn" title="Delete track from library">✕</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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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' },
|
||||||
|
|
|
||||||
|
|
@ -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); }
|
||||||
|
|
|
||||||
|
|
@ -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">▾</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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue