Merge pull request #761 from Nezreka/dev

Dev
This commit is contained in:
BoulderBadgeDad 2026-06-01 14:53:04 -07:00 committed by GitHub
commit 117f52bb25
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
82 changed files with 7485 additions and 823 deletions

View file

@ -605,7 +605,16 @@ class ConfigManager:
"metadata_enhancement": {
"enabled": True,
"embed_album_art": True,
"post_process_order": ["musicbrainz", "deezer", "audiodb", "tidal", "qobuz", "lastfm", "genius"]
"post_process_order": ["musicbrainz", "deezer", "audiodb", "tidal", "qobuz", "lastfm", "genius"],
# Ordered preferred cover-art sources (empty = use the
# download's own art, i.e. today's behavior). Resolved + walked
# with fallback by core/metadata/art_sources.py.
"album_art_order": [],
# Minimum cover-art resolution (shortest side, px). A preferred
# source whose art is smaller is skipped so the next source is
# tried — stops a low-res Cover Art Archive upload from winning.
# 0 disables the size gate.
"min_art_size": 1000
},
"musicbrainz": {
"embed_tags": True

View file

@ -282,8 +282,9 @@ class AcoustIDClient:
def test_api_key(self) -> Tuple[bool, str]:
"""
Validate the API key by fingerprinting a real audio file and looking it up.
Falls back to a direct API call if no audio files are available.
Validate the API key with a direct AcoustID lookup call. An invalid key
is reported as invalid (error code 4); any other error means the key was
accepted.
Returns:
Tuple of (success, message)
@ -294,24 +295,12 @@ class AcoustIDClient:
import requests
try:
# Try to find a real audio file to fingerprint for an end-to-end test
test_file = self._find_test_audio_file()
if test_file and CHROMAPRINT_AVAILABLE:
logger.info(f"Testing API key with real audio file: {test_file}")
try:
result = self.fingerprint_and_lookup(test_file)
# If we get here without exception, the API key is valid
# (invalid keys raise or return error before results)
return True, "AcoustID API key is valid"
except Exception as e:
error_str = str(e).lower()
if 'invalid' in error_str and 'api' in error_str:
return False, "Invalid AcoustID API key - get one from https://acoustid.org/new-application"
# Fingerprint/lookup failed for non-key reasons, fall through to direct test
logger.warning(f"Real file test failed ({e}), trying direct API call")
# Fallback: direct API call with minimal fingerprint
# Authoritative key check: a direct API lookup with a dummy
# fingerprint. AcoustID validates the client key first, so an
# invalid key returns error code 4 regardless of the fingerprint.
# (The previous real-file path trusted "no exception = valid", but
# fingerprint_and_lookup swallows the invalid-key error and returns
# None — so it reported broken keys as valid. #756-adjacent.)
url = 'https://api.acoustid.org/v2/lookup'
params = {
'client': self.api_key,
@ -326,7 +315,6 @@ class AcoustIDClient:
if data.get('status') == 'error':
error = data.get('error', {})
error_code = error.get('code', 0)
error_msg = error.get('message', 'Unknown error')
# Error code 4 is specifically "invalid API key"
if error_code == 4:
@ -346,33 +334,33 @@ class AcoustIDClient:
logger.error(f"Error testing AcoustID API key: {e}")
return False, f"Error: {str(e)}"
def fingerprint_and_lookup(self, audio_file: str) -> Optional[Dict[str, Any]]:
"""
Generate fingerprint and look up recording in AcoustID.
def lookup_with_status(self, audio_file: str) -> Dict[str, Any]:
"""Fingerprint + AcoustID lookup returning a STRUCTURED result.
This is the main method - combines fingerprinting and lookup in one call.
Unlike fingerprint_and_lookup() (which collapses every outcome into
dict-or-None), this distinguishes a genuine no-match from an actual
error an invalid API key, rate limit, missing chromaprint, or a
fingerprint failure. That distinction is what lets the UI show "AcoustID
Error" (something is broken — fix it) instead of a benign-looking
"Skipped" that silently hides a dead key.
Args:
audio_file: Path to the audio file
Returns:
Dict with:
'recordings': list of dicts with 'mbid', 'title', 'artist', 'score'
'best_score': float (highest score across all results)
'recording_mbids': list of unique MBIDs (for backward compat)
Or None on error.
Returns dict with:
'status': 'ok' | 'no_match' | 'error' | 'no_backend'
| 'fingerprint_error' | 'unsupported' | 'unavailable'
| 'not_found'
'recordings': list (meaningful only for 'ok')
'best_score': float
'recording_mbids': list
'error': human-readable detail for any non-'ok' status
'invalid_key': bool (True when the API specifically rejected the key)
"""
if not ACOUSTID_AVAILABLE:
logger.debug("Cannot lookup: pyacoustid not available")
return None
return {'status': 'unavailable', 'recordings': [], 'error': 'pyacoustid library not installed'}
if not self.api_key:
logger.debug("Cannot lookup: no API key")
return None
return {'status': 'unavailable', 'recordings': [], 'error': 'No AcoustID API key configured'}
if not os.path.isfile(audio_file):
logger.warning(f"Cannot lookup: file not found: {audio_file}")
return None
return {'status': 'not_found', 'recordings': [], 'error': f'File not found: {audio_file}'}
# Check channel count — chromaprint crashes (SIGABRT) on >2 channel files (e.g. 5.1 surround)
try:
@ -382,7 +370,8 @@ class AcoustIDClient:
channels = getattr(mf.info, 'channels', 2)
if channels and channels > 2:
logger.warning(f"Skipping AcoustID: file has {channels} channels (surround audio): {audio_file}")
return None
return {'status': 'unsupported', 'recordings': [],
'error': f'{channels}-channel (surround) audio not supported by chromaprint'}
except Exception as e:
logger.debug(f"Could not check channel count, proceeding anyway: {e}")
@ -392,17 +381,12 @@ class AcoustIDClient:
api_key_preview = f"{self.api_key[:8]}..." if self.api_key and len(self.api_key) > 8 else "NOT SET"
logger.info(f"Fingerprinting and looking up: {audio_file} (API key: {api_key_preview})")
# Use match() which handles fingerprinting + lookup + parsing
logger.debug("Running acoustid.match()...")
recordings = []
seen_mbids = set()
best_score = 0.0
for result in acoustid.match(
self.api_key,
audio_file,
parse=True
):
for result in acoustid.match(self.api_key, audio_file, parse=True):
# match() with parse=True returns (score, recording_id, title, artist)
if not isinstance(result, tuple) or len(result) < 2:
logger.warning(f"Unexpected result format: {result}")
@ -420,45 +404,57 @@ class AcoustIDClient:
if recording_id and recording_id not in seen_mbids:
seen_mbids.add(recording_id)
recordings.append({
'mbid': recording_id,
'title': title,
'artist': artist,
'score': score,
})
recordings.append({'mbid': recording_id, 'title': title, 'artist': artist, 'score': score})
logger.debug(f"Found match: {title} by {artist} (MBID: {recording_id}, score: {score})")
if not recordings:
logger.info(f"No AcoustID matches found for: {audio_file}")
return None
return {'status': 'no_match', 'recordings': [], 'best_score': best_score,
'recording_mbids': [], 'error': 'Track not found in AcoustID database'}
logger.info(f"AcoustID found {len(recordings)} recording(s) (best score: {best_score:.2f})")
return {
'recordings': recordings,
'best_score': best_score,
'recording_mbids': list(seen_mbids),
}
return {'status': 'ok', 'recordings': recordings, 'best_score': best_score,
'recording_mbids': list(seen_mbids)}
except acoustid.NoBackendError:
logger.error("Chromaprint library not found and fpcalc not available")
return None
return {'status': 'no_backend', 'recordings': [],
'error': 'Chromaprint/fpcalc not installed (install libchromaprint1)'}
except acoustid.FingerprintGenerationError as e:
logger.warning(f"Failed to fingerprint {audio_file}: {e}")
return None
return {'status': 'fingerprint_error', 'recordings': [], 'error': f'Could not fingerprint file: {e}'}
except acoustid.WebServiceError as e:
# Log more details about the API error
api_key_preview = f"{self.api_key[:8]}..." if self.api_key and len(self.api_key) > 8 else "???"
logger.warning(f"AcoustID API error (key: {api_key_preview}): {e}")
# Check for common errors
error_str = str(e).lower()
if 'invalid' in error_str or 'unknown' in error_str:
logger.error("API key appears to be invalid - check your AcoustID settings")
# Old pyacoustid reports an invalid key as the bare "status: error"
# (it drops the detail), so treat that as an invalid-key signal too.
invalid = ('invalid' in error_str or 'unknown' in error_str or 'status: error' in error_str)
if invalid:
logger.error("AcoustID API key appears to be invalid — check your AcoustID settings")
elif 'rate' in error_str or 'limit' in error_str:
logger.warning("Rate limited by AcoustID - will retry later")
return None
logger.warning("Rate limited by AcoustID — will retry later")
return {'status': 'error', 'recordings': [], 'invalid_key': invalid,
'error': f'AcoustID API error: {e}'}
except Exception as e:
logger.error(f"Unexpected error in AcoustID lookup: {e}", exc_info=True)
return None
return {'status': 'error', 'recordings': [], 'error': f'Unexpected error: {e}'}
def fingerprint_and_lookup(self, audio_file: str) -> Optional[Dict[str, Any]]:
"""Legacy dict-or-None lookup. Returns the recordings dict on a confirmed
match, else None. Kept for callers that only need "did we identify it"
(library scanner, auto-import). Callers that must report WHY a lookup
didn't match (verification badge, key test) should use
``lookup_with_status`` so an error isn't mistaken for a no-match.
"""
res = self.lookup_with_status(audio_file)
if res.get('status') == 'ok':
return {
'recordings': res['recordings'],
'best_score': res.get('best_score', 0.0),
'recording_mbids': res.get('recording_mbids', []),
}
return None
def refresh_config(self):
"""Refresh cached config values (call after settings change)."""

View file

@ -50,8 +50,9 @@ class VerificationResult(Enum):
"""Possible outcomes of audio verification."""
PASS = "pass" # Title/artist match - file is correct
FAIL = "fail" # Title/artist mismatch - wrong file downloaded
SKIP = "skip" # Could not verify (error or unavailable) - continue normally
SKIP = "skip" # Genuinely couldn't verify (no match in DB) - continue normally
DISABLED = "disabled" # Verification not enabled
ERROR = "error" # Lookup errored (invalid key / rate limit / no backend) - continue, but flag it
def _normalize(text: str) -> str:
@ -399,18 +400,33 @@ class AcoustIDVerification:
logger.debug(f"AcoustID verification skipped: {reason}")
return VerificationResult.SKIP, reason
# Step 2: Fingerprint and lookup in AcoustID
# Step 2: Fingerprint and lookup in AcoustID (structured so an
# actual error — invalid key / rate limit / no chromaprint — is
# reported distinctly from a genuine no-match, instead of both
# silently surfacing as "Skipped").
logger.info(f"Fingerprinting and looking up: {audio_file_path}")
acoustid_result = self.acoustid_client.fingerprint_and_lookup(audio_file_path)
lookup = self.acoustid_client.lookup_with_status(audio_file_path) or {}
status = lookup.get('status')
# Infer status by content when absent (a caller/stub that returned
# just recordings): recordings => matched, none => no match.
if status is None:
status = 'ok' if lookup.get('recordings') else 'no_match'
if not acoustid_result:
return VerificationResult.SKIP, "Track not found in AcoustID database"
if status in ('error', 'no_backend', 'fingerprint_error', 'unavailable'):
# Something is broken (not the track's fault) — never quarantine
# on this; surface it so the user can fix it.
return VerificationResult.ERROR, lookup.get('error', 'AcoustID lookup failed')
if status != 'ok':
# no_match / unsupported / not_found — genuinely could not verify.
return VerificationResult.SKIP, lookup.get('error', 'No match in AcoustID database')
acoustid_result = lookup
recordings = acoustid_result.get('recordings', [])
best_score = acoustid_result.get('best_score', 0)
if not recordings:
return VerificationResult.SKIP, "AcoustID returned no recordings"
return VerificationResult.SKIP, "No match in AcoustID database"
logger.debug(
f"AcoustID returned {len(recordings)} recording(s) "

View file

@ -70,7 +70,16 @@ _meta_cache_lock = threading.Lock()
class AmazonClientError(RuntimeError):
"""Raised on unrecoverable T2Tunes API errors."""
"""Raised on unrecoverable T2Tunes API errors.
Carries the HTTP ``status_code`` when the failure was an HTTP error, so
callers (the worker's outage detection) can tell a source outage (5xx) from
a per-item miss without parsing the message.
"""
def __init__(self, *args, status_code=None):
super().__init__(*args)
self.status_code = status_code
# ---------------------------------------------------------------------------
@ -703,7 +712,8 @@ class AmazonClient:
)
continue
raise AmazonClientError(
f"HTTP {exc.response.status_code} for {url} — body: {body!r}"
f"HTTP {exc.response.status_code} for {url} — body: {body!r}",
status_code=exc.response.status_code,
) from exc
except requests.RequestException as exc:
raise AmazonClientError(f"Request failed for {url}: {exc}") from exc

61
core/amazon_outage.py Normal file
View file

@ -0,0 +1,61 @@
"""Amazon enrichment outage detection + back-off — pure, importable, testable.
The Amazon worker enriches via a public T2Tunes proxy instance. When that
instance is down (HTTP 5xx, "Amazon Music API is not initialized", or an
unreachable host), the worker must NOT treat every album as an individual
failure: doing so floods the logs with an error per item, churns network + DB
continuously, and permanently marks the whole library ``error`` (which the
retry tiers never re-attempt) for what is really a transient outage.
Instead it recognizes "the whole source is down", leaves the item untouched so
it's retried once the instance recovers, and backs off hard. These two pure
helpers carry that logic so it can be unit-tested without the worker, the DB,
or the network.
"""
from __future__ import annotations
import re
# HTTP statuses that mean "the source/proxy is unhealthy", not "no match".
_OUTAGE_STATUS = {500, 502, 503, 504}
# Substrings (lower-cased) in an error message that indicate a source outage
# rather than a per-item miss: proxy not ready, gateway errors, the host being
# unreachable, or an error page returned instead of JSON.
_OUTAGE_PHRASES = (
"not initialized", "not configured", "service unavailable",
"bad gateway", "gateway time", "request failed", "response not json",
"max retries", "connection", "timed out", "temporarily unavailable",
)
# Back-off schedule while the source is down.
_NORMAL_DELAY = 2 # seconds between items when healthy
_OUTAGE_BASE = 30 # first back-off step
_OUTAGE_CAP = 1800 # 30 minutes max
def is_source_outage(exc: Exception) -> bool:
"""True when ``exc`` indicates the Amazon source/proxy is down (transient,
whole-source), as opposed to a normal per-item error.
Robust to how the error is surfaced: an explicit ``status_code`` attribute,
an ``HTTP <code>`` prefix in the message, or an outage phrase (covers
connection failures and non-JSON error pages that carry no status code)."""
code = getattr(exc, "status_code", None)
if isinstance(code, int) and code in _OUTAGE_STATUS:
return True
msg = str(exc).lower()
m = re.search(r"http\s+(\d{3})", msg)
if m and int(m.group(1)) in _OUTAGE_STATUS:
return True
return any(p in msg for p in _OUTAGE_PHRASES)
def next_poll_delay_seconds(outage_streak: int) -> int:
"""Seconds to wait before the next item. Normal cadence when healthy;
escalating back-off (30s, 60s, 120s, capped at 30 min) the longer the
source has been down, so a dead instance can't flood logs/CPU/DB."""
if outage_streak <= 0:
return _NORMAL_DELAY
return min(_OUTAGE_BASE * (2 ** min(outage_streak - 1, 6)), _OUTAGE_CAP)

View file

@ -9,6 +9,7 @@ from database.music_database import MusicDatabase
from core.amazon_client import AmazonClient
from core.worker_utils import interruptible_sleep, set_album_api_track_count
from core.enrichment.manual_match_honoring import honor_stored_match
from core.amazon_outage import is_source_outage, next_poll_delay_seconds
logger = get_logger("amazon_worker")
@ -39,6 +40,11 @@ class AmazonWorker:
self.retry_days = 30
self.name_similarity_threshold = 0.80
# Source-outage circuit breaker: counts consecutive whole-source
# failures (proxy down / "not initialized" / unreachable) so the loop
# backs off instead of grinding the whole library item-by-item.
self._outage_streak = 0
logger.info("Amazon background worker initialized")
def _ensure_amazon_schema(self, cursor) -> None:
@ -151,7 +157,9 @@ class AmazonWorker:
continue
self._process_item(item)
interruptible_sleep(self._stop_event, 2)
# Normal 2s cadence when healthy; escalating back-off (up to
# 30 min) while the source is in an outage streak.
interruptible_sleep(self._stop_event, next_poll_delay_seconds(self._outage_streak))
except Exception as e:
logger.error(f"Error in worker loop: {e}")
@ -275,7 +283,32 @@ class AmazonWorker:
elif item_type == 'track':
self._process_track(item_id, item_name, item.get('artist', ''), item)
# The source answered (match or not_found) — clear any outage streak.
if self._outage_streak:
logger.info("Amazon source recovered after %d outage(s), resuming",
self._outage_streak)
self._outage_streak = 0
except Exception as e:
if is_source_outage(e):
# The whole source is down (proxy 5xx / "not initialized" /
# unreachable). Do NOT mark the item 'error' — that would burn
# the entire library to a state the retry tiers never re-attempt
# for a transient outage. Leave it untouched so it's retried once
# the instance recovers, and let the loop back off. Log once per
# streak to avoid flooding.
self._outage_streak += 1
if self._outage_streak == 1:
logger.warning("Amazon source unavailable — pausing enrichment "
"until it recovers: %s", e)
else:
logger.debug("Amazon source still unavailable (streak=%d): %s",
self._outage_streak, e)
return
# A non-outage error means the source actually answered (e.g. a
# 404/parse error on a real response), so the outage is over —
# clear the streak and handle this as a normal per-item error.
self._outage_streak = 0
logger.error(f"Error processing {item['type']} #{item['id']}: {e}")
self.stats['errors'] += 1
try:

View file

@ -49,6 +49,90 @@ class SyncDeps:
sync_lock: Any # threading.Lock
async def _database_only_find_track(spotify_track, candidate_pool=None):
"""Database-only track matcher used when no media server is connected.
Patched onto sync_service._find_track_in_media_server. Accepts
``candidate_pool`` for interface parity with the real matcher (sync_service
calls it with candidate_pool=...); the DB path queries the library directly
via check_track_exists, so it doesn't need the per-artist candidate cache —
but it MUST accept the kwarg or sync raises "unexpected keyword argument
'candidate_pool'". Module-level (not a nested closure) so it's importable
and unit-tested.
"""
logger.info(f"Database-only search for: '{spotify_track.name}' by {spotify_track.artists}")
try:
from database.music_database import MusicDatabase
from config.settings import config_manager
db = MusicDatabase()
active_server = config_manager.get_active_media_server()
original_title = spotify_track.name
spotify_id = getattr(spotify_track, 'id', '') or ''
# --- Sync match cache fast-path ---
if spotify_id:
try:
cached = db.read_sync_match_cache(spotify_id, active_server)
if cached:
db_track_check = db.get_track_by_id(cached['server_track_id'])
if db_track_check:
class DatabaseTrackCached:
def __init__(self, db_t):
self.ratingKey = db_t.id
self.title = db_t.title
self.id = db_t.id
logger.debug(f"Sync cache hit: '{original_title}' → server track {cached['server_track_id']}")
return DatabaseTrackCached(db_track_check), cached['confidence']
logger.warning(f"Sync cache stale for '{original_title}' — track gone")
except Exception as e:
logger.debug("sync match cache fast-path failed: %s", e)
# --- End cache fast-path ---
# Try each artist
for artist in spotify_track.artists:
if isinstance(artist, str):
artist_name = artist
elif isinstance(artist, dict) and 'name' in artist:
artist_name = artist['name']
else:
artist_name = str(artist)
db_track, confidence = db.check_track_exists(
original_title, artist_name,
confidence_threshold=0.80,
server_source=active_server
)
if db_track and confidence >= 0.80:
logger.info(f"Database match: '{db_track.title}' (confidence: {confidence:.2f})")
if spotify_id:
try:
from core.matching_engine import MusicMatchingEngine
me = MusicMatchingEngine()
db.save_sync_match_cache(
spotify_id, me.clean_title(original_title), me.clean_artist(artist_name),
active_server, db_track.id, db_track.title, confidence
)
except Exception as e:
logger.debug("save sync match cache failed: %s", e)
class DatabaseTrackMock:
def __init__(self, db_track):
self.ratingKey = db_track.id
self.title = db_track.title
self.id = db_track.id
return DatabaseTrackMock(db_track), confidence
logger.warning(f"No database match found for: '{original_title}'")
return None, 0.0
except Exception as e:
logger.error(f"Database search error: {e}")
return None, 0.0
def run_sync_task(playlist_id, playlist_name, tracks_json, automation_id=None, profile_id=1, playlist_image_url='', deps: SyncDeps = None, sync_mode: str = 'replace'):
"""The actual sync function that runs in the background thread."""
sync_states = deps.sync_states
@ -260,89 +344,9 @@ def run_sync_task(playlist_id, playlist_name, tracks_json, automation_id=None, p
if media_client is None or not media_client.is_connected():
logger.info("Media client not connected - patching sync service for database-only matching")
# Store original method
original_find_track = sync_service._find_track_in_media_server
# Create database-only replacement method
async def database_only_find_track(spotify_track):
logger.info(f"Database-only search for: '{spotify_track.name}' by {spotify_track.artists}")
try:
from database.music_database import MusicDatabase
from config.settings import config_manager
db = MusicDatabase()
active_server = config_manager.get_active_media_server()
original_title = spotify_track.name
spotify_id = getattr(spotify_track, 'id', '') or ''
# --- Sync match cache fast-path ---
if spotify_id:
try:
cached = db.read_sync_match_cache(spotify_id, active_server)
if cached:
db_track_check = db.get_track_by_id(cached['server_track_id'])
if db_track_check:
class DatabaseTrackCached:
def __init__(self, db_t):
self.ratingKey = db_t.id
self.title = db_t.title
self.id = db_t.id
logger.debug(f"Sync cache hit: '{original_title}' → server track {cached['server_track_id']}")
return DatabaseTrackCached(db_track_check), cached['confidence']
logger.warning(f"Sync cache stale for '{original_title}' — track gone")
except Exception as e:
logger.debug("sync match cache fast-path failed: %s", e)
# --- End cache fast-path ---
# Try each artist (same logic as original)
for artist in spotify_track.artists:
# Extract artist name from both string and dict formats
if isinstance(artist, str):
artist_name = artist
elif isinstance(artist, dict) and 'name' in artist:
artist_name = artist['name']
else:
artist_name = str(artist)
db_track, confidence = db.check_track_exists(
original_title, artist_name,
confidence_threshold=0.80,
server_source=active_server
)
if db_track and confidence >= 0.80:
logger.info(f"Database match: '{db_track.title}' (confidence: {confidence:.2f})")
# Save to sync match cache
if spotify_id:
try:
from core.matching_engine import MusicMatchingEngine
me = MusicMatchingEngine()
db.save_sync_match_cache(
spotify_id, me.clean_title(original_title), me.clean_artist(artist_name),
active_server, db_track.id, db_track.title, confidence
)
except Exception as e:
logger.debug("save sync match cache failed: %s", e)
# Create mock track object for playlist creation
class DatabaseTrackMock:
def __init__(self, db_track):
self.ratingKey = db_track.id
self.title = db_track.title
self.id = db_track.id
return DatabaseTrackMock(db_track), confidence
logger.warning(f"No database match found for: '{original_title}'")
return None, 0.0
except Exception as e:
logger.error(f"Database search error: {e}")
return None, 0.0
# Patch the method
sync_service._find_track_in_media_server = database_only_find_track
# Patch the matcher to the module-level database-only implementation
# (importable + unit-tested; accepts candidate_pool for parity).
sync_service._find_track_in_media_server = _database_only_find_track
logger.info("Patched sync service to use database-only matching")
sync_start_time = time.time()

View file

@ -21,8 +21,10 @@ folder scan.
from __future__ import annotations
import re
import shutil
import time
import unicodedata
import uuid
from pathlib import Path
from typing import Any, Callable, Iterable, Optional
@ -32,6 +34,13 @@ from utils.logging_config import get_logger
logger = get_logger("download_plugins.album_bundle")
# Minimum album-title relevance a Prowlarr candidate must clear to be eligible
# for an album-bundle download (#730). Prowlarr returns broad fuzzy matches — a
# "Heroes" search also returns other Bowie albums — so without this gate the
# most-popular result wins regardless of whether it's the right album. Below
# this floor we refuse the bundle and let the caller fall back to per-track.
_ALBUM_TITLE_RELEVANCE_FLOOR = 0.6
# Album-pick size floor / ceiling. Single-track torrents (~10 MB)
# are rejected when bigger candidates exist; anything past 3 GB is
@ -92,10 +101,96 @@ def quality_score(title: str, quality_guess) -> int:
return _QUALITY_SCORE.get(quality_guess(title) or '', 0)
def pick_best_album_release(candidates, quality_guess) -> Optional[object]:
def _normalize_release_text(text: str) -> str:
"""Lowercase, fold accents (Björk -> bjork), strip punctuation to spaces.
NFKD-decompose then drop combining marks so accented characters fold to
their base letter instead of fragmenting (the naive approach turned
'Björk' into 'bj rk'). Collapses runs of whitespace.
"""
if not text:
return ""
decomposed = unicodedata.normalize("NFKD", text)
stripped = "".join(c for c in decomposed if not unicodedata.combining(c))
lowered = stripped.lower()
# Punctuation -> space (so "heroes" matches "heroes:" / "heroes -"),
# then collapse whitespace.
cleaned = re.sub(r"[^a-z0-9]+", " ", lowered)
return re.sub(r"\s+", " ", cleaned).strip()
# Edition / format / qualifier words that appear in stored album names or
# release titles but say nothing about WHICH album it is. Stripped before
# scoring so "Currents" matches "Currents (Deluxe)" and "Heroes" matches
# "Heroes (2017 Remaster)" — the #730 fix must not reject the RIGHT album just
# because the DB name carries an edition suffix the torrent title lacks.
_ALBUM_NOISE_WORDS = frozenset({
"deluxe", "edition", "remaster", "remastered", "remasters", "remix",
"expanded", "anniversary", "bonus", "version", "explicit", "clean",
"reissue", "special", "limited", "collectors", "collector", "the",
"ep", "lp", "album", "single", "disc", "cd", "vol", "volume",
"flac", "mp3", "aac", "ogg", "wav", "alac", "m4a", "320", "256", "192",
"web", "vinyl", "hi", "res", "hires", "24bit", "16bit", "original",
"soundtrack", "ost",
})
def _significant_words(normalized: str) -> list:
"""Words that actually identify an album: drop pure-digit tokens (years,
bitrates) and edition/format noise. Keeps at least the raw words if the
filter would empty it (e.g. an album literally named '1989' or 'Deluxe')."""
words = [w for w in normalized.split()
if w not in _ALBUM_NOISE_WORDS and not w.isdigit()]
return words or normalized.split()
def album_title_relevance(candidate_title: str, album_name: str) -> float:
"""How well a release title matches the requested album, 0.01.0.
Scores the fraction of the album's SIGNIFICANT words (edition/format/year
noise removed) that appear as whole words in the candidate title.
Word-boundary, not substring, so "Heroes" does NOT match "Superheroes" and
a different album sharing no significant words scores 0 while "Currents"
still matches "Currents (Deluxe)" and "Heroes" matches the "2017 Remaster".
Returns 1.0 when there's no album name to check (can't gate on nothing
preserves old behavior for callers that don't pass a title).
"""
norm_album = _normalize_release_text(album_name)
if not norm_album:
return 1.0
norm_title = _normalize_release_text(candidate_title)
if not norm_title:
return 0.0
album_words = _significant_words(norm_album)
title_words = set(norm_title.split())
if not album_words:
return 1.0
matched = sum(1 for w in album_words if w in title_words)
coverage = matched / len(album_words)
# Full-phrase bonus (idea from contributor PR #731): when the album's core
# phrase appears intact in the title, we're highly confident it's the right
# release even if token-coverage is dragged down by a long multi-word name.
# MUST be word-boundary anchored, NOT a raw substring — a naive
# `phrase in norm_title` lets "heroes" match "superheroes" and reintroduces
# the exact wrong-album bug #730 fixes (PR #731's version has this flaw).
core_phrase = " ".join(album_words)
if core_phrase and re.search(rf"(?:^| ){re.escape(core_phrase)}(?: |$)", norm_title):
coverage = max(coverage, 0.9)
return coverage
def pick_best_album_release(candidates, quality_guess,
album_name: str = "") -> Optional[object]:
"""Pick the single best torrent / NZB for an album-bundle download.
Heuristic, in priority order:
0. Album-TITLE relevance gate (#730): drop candidates whose title doesn't
sufficiently match the requested album. Prowlarr returns broad fuzzy
matches, so without this the most-popular result wins even when it's a
different album. When ``album_name`` is given and NOTHING clears the
relevance floor, return None the caller then falls back to per-track
rather than downloading a confident mismatch.
1. Reasonable album-ish size (40 MB 3 GB) drops single-track
releases that snuck in and quarantines suspicious giants.
2. Higher seeders > lower (dead torrents = dead downloads).
@ -106,6 +201,24 @@ def pick_best_album_release(candidates, quality_guess) -> Optional[object]:
"""
if not candidates:
return None
# 0. Title-relevance gate. Only applied when we know the album name; with
# no name we can't judge relevance, so we don't gate (old behavior).
if album_name:
relevant = [
c for c in candidates
if album_title_relevance(c.title or "", album_name) >= _ALBUM_TITLE_RELEVANCE_FLOOR
]
if not relevant:
logger.warning(
"[Album Bundle] No candidate cleared the title-relevance floor "
"for '%s' (%d candidates rejected as wrong album) — refusing the "
"bundle so the caller falls back to per-track.",
album_name, len(candidates),
)
return None
candidates = relevant
sized = [c for c in candidates
if ALBUM_PICK_MIN_BYTES <= (c.size or 0) <= ALBUM_PICK_MAX_BYTES]
pool = sized or list(candidates)

View file

@ -494,12 +494,25 @@ class TorrentDownloadPlugin(DownloadSourcePlugin):
candidates = [r for r in search_results
if r.protocol == 'torrent' and (r.magnet_uri or r.download_url)]
if not candidates:
# Album isn't available on this source. Mark the failure as
# fallback-eligible so the dispatch returns to the per-track flow
# instead of hard-failing the batch — in hybrid mode that lets the
# next configured source take over. Without this flag a torrent-first
# hybrid would get stuck at "searching" forever when Prowlarr
# returns nothing, never trying the other sources.
result['error'] = f'No torrent results found for "{query}"'
result['fallback'] = True
return result
picked = pick_best_album_release(candidates, _guess_quality_from_title)
picked = pick_best_album_release(
candidates, _guess_quality_from_title, album_name=album_name,
)
if picked is None:
result['error'] = 'No suitable torrent candidate after filtering'
# No candidate matched the requested album (or none passed filtering).
# Fall back to the per-track flow rather than downloading a wrong
# album (#730) — per-track searches each track individually.
result['error'] = 'No torrent candidate matched the requested album'
result['fallback'] = True
return result
download_url = picked.magnet_uri or picked.download_url

View file

@ -465,12 +465,22 @@ class UsenetDownloadPlugin(DownloadSourcePlugin):
candidates = [r for r in search_results
if r.protocol == 'usenet' and r.download_url]
if not candidates:
# Album isn't available on this source — fall back to the per-track
# flow (next configured source in hybrid mode) rather than hard-
# failing the whole batch. Mirrors the torrent plugin + soulseek's
# default fallback contract.
result['error'] = f'No usenet results found for "{query}"'
result['fallback'] = True
return result
picked = pick_best_album_release(candidates, _guess_quality_from_title)
picked = pick_best_album_release(
candidates, _guess_quality_from_title, album_name=album_name,
)
if picked is None:
result['error'] = 'No suitable NZB candidate after filtering'
# No candidate matched the requested album (or none passed filtering).
# Fall back to per-track rather than grabbing a wrong album (#730).
result['error'] = 'No NZB candidate matched the requested album'
result['fallback'] = True
return result
logger.info("[Usenet album] Picked '%s' (size=%.1fMB grabs=%s indexer=%s)",

View file

@ -173,7 +173,22 @@ def try_dispatch(
)
except Exception as exc:
logger.exception("[Album Bundle] %s plugin raised: %s", mode, exc)
outcome = {'success': False, 'error': f'Plugin error: {exc}'}
# An OSError means an I/O step failed after the source already had the
# album — most importantly the staging dir not being writable (#760),
# but also any transient filesystem error. Treat it as fallback-eligible
# so we return to the per-track flow instead of hard-failing the whole
# batch (the #715 symptom: files download, then the batch fails).
# Programming errors (TypeError, KeyError, …) are NOT OSError and stay
# terminal, so genuine bugs still fail loudly. (requests' network
# exceptions also subclass OSError, but plugins normally catch those
# internally and return an outcome rather than raising; if one does
# surface here, falling back to per-track is still the safe choice.)
is_io_failure = isinstance(exc, OSError)
outcome = {
'success': False,
'error': f'Plugin error: {exc}',
'fallback': is_io_failure,
}
if not outcome.get('success'):
err = outcome.get('error', 'Album bundle download failed')

View file

@ -34,11 +34,22 @@ logger = logging.getLogger(__name__)
def _private_album_bundle_staging_miss_reason(batch_id: Optional[str], deps: Any) -> Optional[str]:
"""Return a user-facing miss reason when per-track search should stop.
Torrent / usenet / Soulseek album batches first download one private staged release,
Torrent / usenet album batches first download one private staged release,
then each track claims the matching staged file. If that claim fails after
the release is already staged, falling through to the normal per-track
search only retries release-level sources N times and can keep re-adding
the same torrent. Treat the staged release as authoritative for this pass.
the same torrent/NZB. For those two sources we treat the staged release as
authoritative for this pass.
Soulseek is deliberately NOT short-circuited. A Soulseek album bundle stages
whichever single folder scored best, and ``album_bundle_partial`` only
reflects whether the files found IN that folder downloaded not whether the
folder actually contained every track the album needs. So a track the album
needs but that wasn't in the chosen folder would otherwise be marked
not_found with no fallback (#743). Unlike torrent/usenet, Soulseek per-track
search is a genuine per-file network search it doesn't re-add a release —
so letting these misses fall through to the normal per-track flow (and, in
hybrid mode, onward to the next source) is correct and cheap.
"""
if not batch_id:
return None
@ -60,7 +71,7 @@ def _private_album_bundle_staging_miss_reason(batch_id: Optional[str], deps: Any
batch.get('album_bundle_private_staging')
and batch.get('album_bundle_state') == 'staged'
and not batch.get('album_bundle_partial')
and source in ('torrent', 'usenet', 'soulseek')
and source in ('torrent', 'usenet')
and (mode == source or (mode == 'hybrid' and hybrid_first == source))
):
return f'Track was not found in the staged {source} album release'

View file

@ -0,0 +1,106 @@
"""Assemble a per-track detail view for the download-modal "track detail" modal.
Merges a live download task with its ``library_history`` record (the same data
the Download History cards render) into one dict the frontend modal consumes.
Kept pure + importable so the merge + status classification are unit-tested
without Flask or the DB; the web endpoint is thin glue around build_track_detail.
"""
from __future__ import annotations
from typing import Any, Dict, Optional
# error_message substrings that mean "quarantined" (file recoverable) rather
# than a plain failure. Mirrors the download-modal status renderer.
_QUARANTINE_KEYWORDS = (
'integrity check failed',
'bit depth filter',
'verification failed',
'quarantin',
)
def classify_status_kind(status: str, error_message: str = '') -> str:
"""Map a raw task status to a UI 'kind' that drives the modal layout:
completed / quarantined / failed / not_found / in_progress.
"""
s = (status or '').lower()
if s == 'completed':
return 'completed'
if s in ('failed', 'cancelled'):
em = (error_message or '').lower()
if any(k in em for k in _QUARANTINE_KEYWORDS):
return 'quarantined'
return 'failed'
if s == 'not_found':
return 'not_found'
return 'in_progress'
def _first_artist(track_info: Dict[str, Any]) -> str:
artists = track_info.get('artists') or []
if isinstance(artists, list) and artists:
first = artists[0]
if isinstance(first, dict):
return (first.get('name') or '').strip()
return str(first).strip()
return (track_info.get('artist') or track_info.get('artist_name') or '').strip()
def _album_name(track_info: Dict[str, Any]) -> str:
album = track_info.get('album')
if isinstance(album, dict):
return (album.get('name') or '').strip()
return (album or '').strip() if isinstance(album, str) else ''
def build_track_detail(task: Dict[str, Any], history: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
"""Merge a download task (+ optional library_history row) into one detail dict.
The task supplies live status/source/reason/quarantine id; the history row
(when found) supplies the durable provenance final file path, quality,
AcoustID verdict, source, and the expected-vs-downloaded comparison.
"""
ti = task.get('track_info') if isinstance(task.get('track_info'), dict) else {}
status = task.get('status', '') or ''
kind = classify_status_kind(status, task.get('error_message', '') or '')
detail: Dict[str, Any] = {
'task_id': task.get('task_id') or task.get('id') or '',
'status': status,
'status_kind': kind,
'title': (ti.get('name') or '').strip(),
'artist': _first_artist(ti),
'album': _album_name(ti),
'source': (task.get('username') or '').strip(),
'reason': (task.get('error_message') or '').strip(),
'quarantine_entry_id': task.get('quarantine_entry_id') or '',
'file_path': (task.get('filename') or '').strip(),
'quality': '',
'acoustid_result': '',
'thumb_url': '',
'expected': {},
'downloaded': {},
}
if history:
detail['file_path'] = (history.get('file_path') or detail['file_path'])
detail['quality'] = history.get('quality') or ''
detail['acoustid_result'] = history.get('acoustid_result') or ''
detail['source'] = history.get('download_source') or detail['source']
detail['thumb_url'] = history.get('thumb_url') or ''
detail['downloaded'] = {
'title': history.get('title') or '',
'artist': history.get('artist_name') or '',
'album': history.get('album_name') or '',
}
detail['expected'] = {
'title': history.get('source_track_title') or '',
'artist': history.get('source_artist') or '',
}
# Fall back to history values when the task had none.
detail['title'] = detail['title'] or detail['downloaded']['title']
detail['artist'] = detail['artist'] or detail['downloaded']['artist']
detail['album'] = detail['album'] or detail['downloaded']['album']
return detail

View file

@ -172,11 +172,14 @@ class ImageCache:
raise ImageCacheError(f"Upstream response is not an image: {mime_type}")
declared_size = response.headers.get("Content-Length")
expected_bytes = None
try:
if declared_size and int(declared_size) > self.max_download_bytes:
raise ImageCacheError("Image exceeds configured size limit")
if declared_size:
expected_bytes = int(declared_size)
if expected_bytes > self.max_download_bytes:
raise ImageCacheError("Image exceeds configured size limit")
except ValueError:
pass
expected_bytes = None
ext = mimetypes.guess_extension(mime_type) or ".img"
if ext == ".jpe":
@ -205,6 +208,22 @@ class ImageCache:
if total <= 0:
raise ImageCacheError("Image response was empty")
# Truncation guard (#750): a dropped/short connection makes
# iter_content end early WITHOUT raising, so a partial image would
# otherwise be committed as status='ok' and cached permanently —
# rendering as a half-decoded cover (top strip, rest grey). If the
# server declared a Content-Length and we got fewer bytes, treat it
# as a failed download: discard the tmp file and don't cache it, so
# the next request retries fresh instead of serving a broken file.
if expected_bytes is not None and total < expected_bytes:
try:
tmp_path.unlink(missing_ok=True)
except Exception as cleanup_exc:
logger.debug("image_cache tmp cleanup failed: %s", cleanup_exc)
raise ImageCacheError(
f"Truncated image download: got {total} of {expected_bytes} bytes"
)
os.replace(tmp_path, path)
expires_at = now + self.ttl_seconds
with self._db_lock:

View file

@ -183,11 +183,29 @@ def check_audio_integrity(
checks["actual_length_s"] = actual_length_s
if actual_length_s <= 0:
# Length 0 is NOT proof of corruption here: the file already passed the
# size gate, was identified as a real audio format, and has a valid
# info block. A genuinely empty/truncated/stub file fails one of those
# earlier checks instead. The real cause of a clean-but-zero-length
# parse is "length unknown" — fragmented / streamed FLAC carries
# total_samples=0 in its STREAMINFO even though every audio frame is
# present and the file plays fine. HiFi is the common trigger: it
# assembles FLAC from HLS segments and demuxes with `ffmpeg -c copy`,
# which preserves total_samples=0, so mutagen computes length 0 and the
# file was wrongly quarantined (#756). Treat it as unknown length:
# accept the file and skip the duration cross-check we can't perform
# without a length. mutagen never decoded/validated frame data anyway,
# so accepting here doesn't weaken real corruption detection.
logger.warning(
"[Integrity] %s parsed cleanly (%d bytes, format=%s) but reports "
"length 0 — treating as unknown length (likely streamed/fragmented "
"FLAC), not rejecting",
os.path.basename(file_path), size, type(audio).__name__,
)
return IntegrityResult(
ok=False,
reason="Mutagen reports zero-length audio — file has no playable "
"audio data",
checks={**checks, "mutagen_parse": "zero_length"},
ok=True,
checks={**checks, "mutagen_parse": "zero_length_unknown",
"length_check": "skipped_unknown_length"},
)
# --- Check 3: duration agreement (optional) ---

View file

@ -92,12 +92,22 @@ def _should_skip_quarantine_check(context: dict, check_name: str) -> bool:
def _mark_task_quarantined(context: dict, quarantine_path: str | None) -> None:
if not quarantine_path:
return
entry_id = entry_id_from_quarantined_filename(quarantine_path)
# Always stash the entry id on the context. The verification wrapper
# (post_process_matched_download_with_verification) pops task_id/batch_id
# OUT of the context before running the inner pipeline, so when a quarantine
# happens inside that inner run context.get('task_id') is None here and the
# direct write below no-ops. The wrapper restores task_id afterward and
# applies this stashed id to the real task — without this, downloads that go
# through the wrapper (album-bundle / staging path) quarantined with no
# quarantine_entry_id, so the UI had no way to manage the file (#756-adjacent).
context['_quarantine_entry_id'] = entry_id
task_id = context.get('task_id')
if not task_id:
return
with tasks_lock:
if task_id in download_tasks:
download_tasks[task_id]['quarantine_entry_id'] = entry_id_from_quarantined_filename(quarantine_path)
download_tasks[task_id]['quarantine_entry_id'] = entry_id
def build_import_pipeline_runtime(
@ -948,6 +958,9 @@ def post_process_matched_download_with_verification(context_key, context, file_p
if task_id in download_tasks:
download_tasks[task_id]['status'] = 'failed'
download_tasks[task_id]['error_message'] = f"AcoustID verification failed: {failure_msg}"
_eid = context.get('_quarantine_entry_id')
if _eid:
download_tasks[task_id]['quarantine_entry_id'] = _eid
with matched_context_lock:
if context_key in matched_downloads_context:
del matched_downloads_context[context_key]
@ -1001,6 +1014,9 @@ def post_process_matched_download_with_verification(context_key, context, file_p
download_tasks[task_id]['error_message'] = (
f"File integrity check failed: {failure_msg}"
)
_eid = context.get('_quarantine_entry_id')
if _eid:
download_tasks[task_id]['quarantine_entry_id'] = _eid
with matched_context_lock:
if context_key in matched_downloads_context:
del matched_downloads_context[context_key]

View file

@ -14,6 +14,7 @@ from __future__ import annotations
import json
import os
import shutil
import time
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
@ -289,6 +290,56 @@ def _restore_filename(quarantined_filename: str, sidecar_original: Optional[str]
return base
def get_quarantine_entry_stream_info(
quarantine_dir: str, entry_id: str
) -> Optional[Tuple[str, str]]:
"""Resolve a quarantined entry to ``(file_path, original_extension)`` for
in-app playback.
The on-disk file carries a ``.quarantined`` suffix, so its own extension is
useless for picking an audio MIME type. Recover the real extension from the
sidecar's ``original_filename`` when present, else from the quarantine
filename convention. Returns None when the entry's file can't be found.
"""
file_path, sidecar_path = _resolve_entry_paths(quarantine_dir, entry_id)
if not file_path or not os.path.isfile(file_path):
return None
sidecar_original: Optional[str] = None
if sidecar_path:
try:
with open(sidecar_path, encoding="utf-8") as f:
sidecar_original = json.load(f).get("original_filename")
except Exception as exc:
logger.debug("stream-info: sidecar read failed for %s: %s", entry_id, exc)
original_name = _restore_filename(os.path.basename(file_path), sidecar_original)
extension = os.path.splitext(original_name)[1].lower()
return file_path, extension
def _move_with_retry(src: str, dst: str, attempts: int = 4, delay: float = 0.4) -> bool:
"""Move a file, retrying briefly on transient OS locks.
On Windows a still-open read handle (e.g. the in-app quarantine preview
player that just streamed the file) makes shutil.move raise
PermissionError / WinError 32 "file in use". The handle is released a beat
after playback stops, so a few short retries clear the common case without
failing the whole Approve/Recover. Returns True on success.
"""
last_exc: Optional[BaseException] = None
for i in range(attempts):
try:
shutil.move(src, dst)
return True
except OSError as exc:
last_exc = exc
if i < attempts - 1:
time.sleep(delay)
logger.error("move failed after %d attempts: %s -> %s: %s", attempts, src, dst, last_exc)
return False
def approve_quarantine_entry(
quarantine_dir: str,
entry_id: str,
@ -334,10 +385,9 @@ def approve_quarantine_entry(
restored_path = os.path.join(restore_dir, original_name)
restored_path = _ensure_unique_path(restored_path)
try:
shutil.move(file_path, restored_path)
except OSError as exc:
logger.error("approve: failed to restore %s -> %s: %s", file_path, restored_path, exc)
if not _move_with_retry(file_path, restored_path):
logger.error("approve: failed to restore %s -> %s (file may still be in use)",
file_path, restored_path)
return None
try:
@ -377,10 +427,8 @@ def recover_to_staging(
os.makedirs(staging_dir, exist_ok=True)
target = _ensure_unique_path(os.path.join(staging_dir, restored_name))
try:
shutil.move(file_path, target)
except OSError as exc:
logger.error("recover: failed to move %s -> %s: %s", file_path, target, exc)
if not _move_with_retry(file_path, target):
logger.error("recover: failed to move %s -> %s (file may still be in use)", file_path, target)
return None
if sidecar_path and os.path.isfile(sidecar_path):

View file

@ -0,0 +1,57 @@
"""Choosing which copy of a duplicate track to keep.
The keeper is the highest-quality copy, and **format/lossless is the primary
criterion**: a lossless FLAC must beat a lossy MP3 regardless of the recorded
bitrate number a FLAC whose bitrate the library scan never populated (a
common gap) still has to win over a 282 kbps MP3. Only *within* the same format
tier do bitrate, then duration, then track number break the tie.
This was lifted out of the repair worker so the deletion path and the UI's
"Keep Best" recommendation rank identically (previously both ranked by bitrate
first, which kept the MP3 when the FLAC's bitrate was missing), and so the
ranking is unit-testable in isolation.
"""
from __future__ import annotations
import os
from typing import Dict, List, Optional, Tuple
# Format quality rank by file extension (higher = better). Mirrors
# ``auto_import_worker._quality_rank`` so lossless always outranks lossy.
_FORMAT_RANK = {
".flac": 10, ".wav": 9, ".aiff": 9, ".aif": 9, ".ape": 8,
".m4a": 7, ".ogg": 6, ".opus": 6, ".mp3": 5, ".aac": 5, ".wma": 3,
}
def format_rank_for_path(file_path: Optional[str]) -> int:
"""Quality rank for a file by extension (higher = better, unknown = 1)."""
if not file_path:
return 1
ext = os.path.splitext(str(file_path))[1].lower()
return _FORMAT_RANK.get(ext, 1)
def duplicate_keep_sort_key(track: Dict) -> Tuple[int, int, float, int]:
"""Sort key for picking the keeper — the higher tuple wins.
Order of precedence: format/lossless tier, then bitrate, then duration,
then track number (a real number over a placeholder ``01``). Putting format
first is the whole point it makes FLAC beat MP3 even when the FLAC's
bitrate is 0/missing in the DB.
"""
return (
format_rank_for_path(track.get("file_path")),
track.get("bitrate") or 0,
track.get("duration") or 0,
track.get("track_number") or 0,
)
def pick_duplicate_to_keep(tracks: List[Dict]) -> Optional[Dict]:
"""Return the copy to keep from a duplicate group (highest sort key), or
``None`` if the group is empty."""
if not tracks:
return None
return max(tracks, key=duplicate_keep_sort_key)

View file

@ -14,12 +14,27 @@ from utils.logging_config import get_logger
logger = get_logger("library.manual_library_match")
def normalize_library_track_id(value: Any) -> Optional[str]:
"""Normalize an incoming library_track_id to the opaque string id we store.
Library track ids are ``str(ratingKey)`` numeric strings for Plex, but
GUIDs/hashes for Jellyfin, Navidrome, and other Subsonic servers (see
``tracks.id`` which is TEXT). They must NOT be coerced to int: doing so
rejected every non-numeric id with "Invalid library track id". We only
reject empty/None here; the caller validates existence against the DB.
"""
if value is None:
return None
text = str(value).strip()
return text or None
def save_match(
db,
profile_id: int,
source: str,
source_track_id: str,
library_track_id: int,
library_track_id: str,
**meta,
) -> bool:
"""Save (insert or replace) a manual match."""

291
core/metadata/art_lookup.py Normal file
View file

@ -0,0 +1,291 @@
"""Per-source album-art lookups + availability for the cover-art picker.
Bridges the pure resolver in ``art_sources.py`` to the real metadata clients.
Each supported source contributes two things:
- **availability** is this source usable for the current user right now?
Free sources (CAA, Deezer, iTunes, AudioDB) are always available; account
sources (Spotify) only when connected. This is what powers "not everybody
has access to every source": the UI offers only available sources and the
resolver skips the rest.
- **lookup** ``(artist, album, metadata) -> cover_url | None``, calling an
EXISTING client method. Every lookup is individually guarded so any error or
miss degrades to ``None`` (the resolver then falls through to the next
source, finally to the download's own art) — a flaky source can never raise
into, or break, a download.
Lookups are cached per album via ``build_art_lookup`` so resolving art for a
16-track album hits each source at most once.
Client accessors are imported lazily inside each function to keep this module
import-light (the pure resolver + its tests never pull a network client).
"""
from __future__ import annotations
import re
from typing import Callable, Dict, List, Optional
from utils.logging_config import get_logger
from core.metadata.art_sources import ART_CAPABLE_SOURCES
logger = get_logger("metadata.art_lookup")
# Sources that need no account/config — always offered.
_FREE_SOURCES = ("caa", "deezer", "itunes", "audiodb")
# ---------------------------------------------------------------------------
# Availability
# ---------------------------------------------------------------------------
def _spotify_available() -> bool:
"""Spotify art is only usable when the user is connected. Reuse the
canonical accessor, which returns a client only when authenticated."""
try:
from core.metadata.registry import get_client_for_source
return get_client_for_source("spotify") is not None
except Exception:
return False
_AVAILABILITY: Dict[str, Callable[[], bool]] = {
"spotify": _spotify_available,
}
def is_art_source_available(source: str) -> bool:
"""Is ``source`` usable right now? Free sources are always available;
account sources defer to their connection check. Unknown/unsupported
sources are never available."""
name = (source or "").strip().lower()
if name not in ART_CAPABLE_SOURCES:
return False
if name in _FREE_SOURCES:
return True
check = _AVAILABILITY.get(name)
return bool(check()) if check else False
def available_art_sources() -> List[str]:
"""The supported art sources currently usable for this user, in the
default priority order for populating the settings UI."""
return [s for s in ART_CAPABLE_SOURCES if is_art_source_available(s)]
# ---------------------------------------------------------------------------
# Album-match validation. Every client's search returns its top hit
# unvalidated (results[0]), so a source that lacks the album could hand back a
# DIFFERENT one — embedding wrong-album art, which is worse than the download's
# own cover. We therefore confirm the returned album matches the request before
# trusting its art; a mismatch returns None so the resolver falls through,
# preserving the "worst case = the cover you'd get today" guarantee.
# ---------------------------------------------------------------------------
_STOPWORDS = {"the", "a", "an"}
def _norm(s) -> str:
return re.sub(r"[^a-z0-9]+", " ", str(s or "").lower()).strip()
def _significant_tokens(s) -> set:
return {t for t in _norm(s).split() if t not in _STOPWORDS}
def _result_album_artist(obj):
"""Best-effort (album_name, artist_name) from a search result — handles
both raw dicts (Deezer/AudioDB) and dataclasses (iTunes/Spotify)."""
if isinstance(obj, dict):
name = obj.get("title") or obj.get("name") or obj.get("strAlbum") or ""
artist = obj.get("strArtist") or ""
a = obj.get("artist")
if not artist and isinstance(a, dict):
artist = a.get("name", "")
elif not artist and isinstance(a, str):
artist = a
return name, artist
name = getattr(obj, "name", None) or getattr(obj, "title", None) or ""
arts = getattr(obj, "artists", None)
if arts is None:
arts = getattr(obj, "artist", None)
if isinstance(arts, (list, tuple)):
artist = ", ".join(str(x) for x in arts if x)
else:
artist = str(arts) if arts else ""
return name, artist
def _album_matches(req_artist, req_album, got_artist, got_album) -> bool:
"""True when the returned album plausibly IS the requested one.
Both album and artist are compared by significant-token subset (stopwords
dropped), which tolerates leading articles, word order, "(Deluxe)"/
"- Remastered" suffixes, punctuation, and "feat."/"&"/multi-artist ordering.
Lenient enough to never reject a genuine hit a false reject just falls
back to today's art — yet strict enough to drop a different album (the
generic-title case, e.g. two "Greatest Hits", is caught by the artist gate)."""
ra, ga = _significant_tokens(req_album), _significant_tokens(got_album)
if not ra or not ga:
return False
if not (ra <= ga or ga <= ra):
return False
ta, tg = _significant_tokens(req_artist), _significant_tokens(got_artist)
if not ta:
return True # requested artist unknown -> album match suffices
if not tg:
return False # asked for an artist, none returned -> can't confirm
return ta <= tg or tg <= ta or bool(ta & tg)
# ---------------------------------------------------------------------------
# Per-source lookups — each returns a cover URL or None, never raises.
# Non-CAA sources validate the returned album before trusting its art.
# ---------------------------------------------------------------------------
def _caa_art(artist: str, album: str, metadata: dict) -> Optional[str]:
# Cover Art Archive is keyed by the MusicBrainz release id, so it's THE
# release's art by definition — no fuzzy match to validate. Resolves only
# once MusicBrainz enrichment has found the release.
mbid = (metadata or {}).get("musicbrainz_release_id")
if not mbid:
return None
return f"https://coverartarchive.org/release/{mbid}/front-1200"
def _deezer_art(artist: str, album: str, metadata: dict) -> Optional[str]:
from core.metadata.registry import get_deezer_client
client = get_deezer_client()
if not client:
return None
data = client.search_album(artist, album)
if not data:
return None
got_album, got_artist = _result_album_artist(data)
if not _album_matches(artist, album, got_artist, got_album):
return None
url = data.get("cover_xl") or data.get("cover_big") or data.get("cover_medium")
if not url:
return None
try:
from core.deezer_client import _upgrade_deezer_cover_url
return _upgrade_deezer_cover_url(url)
except Exception:
return url
def _itunes_art(artist: str, album: str, metadata: dict) -> Optional[str]:
from core.metadata.registry import get_itunes_client
client = get_itunes_client()
if not client:
return None
for alb in (client.search_albums(f"{artist} {album}") or []):
url = getattr(alb, "image_url", None)
if not url:
continue
got_album, got_artist = _result_album_artist(alb)
if _album_matches(artist, album, got_artist, got_album):
# iTunes serves any size via the WxH segment — request the max so
# iTunes contributes high-res art, not the 600px default.
return re.sub(r"/\d+x\d+bb\.", "/3000x3000bb.", url)
return None
def _audiodb_art(artist: str, album: str, metadata: dict) -> Optional[str]:
from core.audiodb_client import AudioDBClient
data = AudioDBClient().search_album(artist, album)
if not data:
return None
got_album, got_artist = _result_album_artist(data)
if not _album_matches(artist, album, got_artist, got_album):
return None
return data.get("strAlbumThumb") or None
def _spotify_art(artist: str, album: str, metadata: dict) -> Optional[str]:
from core.metadata.registry import get_client_for_source
client = get_client_for_source("spotify")
if not client:
return None
for alb in (client.search_albums(f"{artist} {album}") or []):
url = getattr(alb, "image_url", None)
if not url:
continue
got_album, got_artist = _result_album_artist(alb)
if _album_matches(artist, album, got_artist, got_album):
return url
return None
_LOOKUPS: Dict[str, Callable[[str, str, dict], Optional[str]]] = {
"caa": _caa_art,
"deezer": _deezer_art,
"itunes": _itunes_art,
"audiodb": _audiodb_art,
"spotify": _spotify_art,
}
def select_preferred_art_url(
artist: Optional[str],
album: Optional[str],
metadata: Optional[dict],
configured_order,
validate: Optional[Callable[[str, str], bool]] = None,
) -> Optional[str]:
"""Pick a cover-art URL from the user's configured source order, or None.
``None`` means "feature off, or nothing in the list resolved" the caller
then keeps its existing art (today's behavior), so the worst case is simply
the cover you'd get today. This is the single entry point the art pipeline
calls; it's a no-op (returns ``None`` immediately) unless ``album_art_order``
is an explicit non-empty list, which keeps every existing install untouched.
``validate(source, url)`` is an optional gate forwarded to the resolver the
art pipeline passes one that fetches the candidate and rejects images below a
minimum resolution, so a too-small cover (e.g. a low-res Cover Art Archive
upload) is skipped and the next source is tried instead of winning by
priority alone.
"""
if not isinstance(configured_order, (list, tuple)) or not configured_order:
return None
from core.metadata.art_sources import effective_art_order, resolve_cover_art
order = [s for s in effective_art_order(configured_order) if is_art_source_available(s)]
if not order:
return None
lookup = build_art_lookup(artist or "", album or "", metadata or {})
url, _src = resolve_cover_art(order, lookup, validate=validate)
return url
def build_art_lookup(
artist: str,
album: str,
metadata: Optional[dict] = None,
) -> Callable[[str], Optional[str]]:
"""Return a ``source_name -> cover_url | None`` callable for one album,
suitable to pass straight to ``art_sources.resolve_cover_art``. Results are
cached per source so re-resolving across an album's tracks costs at most one
lookup per source, and every lookup is guarded (errors None)."""
meta = metadata or {}
cache: Dict[str, Optional[str]] = {}
def lookup(source: str) -> Optional[str]:
name = (source or "").strip().lower()
if name in cache:
return cache[name]
fn = _LOOKUPS.get(name)
url: Optional[str] = None
if fn is not None:
try:
url = fn(artist, album, meta)
except Exception as exc:
logger.debug("[art] %s lookup failed: %s", name, exc)
url = None
cache[name] = url
return url
return lookup

View file

@ -0,0 +1,106 @@
"""Cover-art source selection.
Picks album cover art from a user-ordered list of sources, falling back down
the list and finally to the download's own art when nothing in the list
resolves. This generalizes the legacy single ``prefer_caa_art`` toggle into an
ordered, mix-and-match preference (Sokhi's request) while preserving today's
behavior byte-for-byte when no order is configured.
The module is deliberately pure and import-light: the ordering + fallback
contract is unit-testable without network, config, or a DB. The actual
per-source art lookups are injected as callables (a registry the caller
builds), so this module never imports a metadata client that keeps the
selection logic fast to test and impossible to break with a client-side
network change.
"""
from __future__ import annotations
from typing import Callable, Optional, Sequence, Tuple
# Sources we can reliably pull album cover art from, in a sensible default
# priority. This is the set the UI offers and that ``effective_art_order``
# accepts — anything else in a saved order is filtered out.
#
# Genius (lyrics) and Last.fm (deprecated/unreliable images) are excluded — no
# dependable album covers. Tidal/Qobuz/HiFi are deferred: their album lookups
# return IDs that need cover-URL construction and they lack a clean core-side
# client accessor, so rather than ship extraction that silently yields nothing
# we add them once that's verified. The current set covers the universally
# available free sources plus Spotify (the common connected account source).
ART_CAPABLE_SOURCES: Tuple[str, ...] = (
"caa", "deezer", "itunes", "spotify", "audiodb",
)
# Minimum byte size for a fetched image to count as a real cover. Mirrors the
# existing Cover Art Archive guard in ``artwork.py`` (a 1x1 pixel, a
# placeholder, or a truncated download is a miss, not a hit).
MIN_VALID_ART_BYTES = 1000
def effective_art_order(
order,
*,
prefer_caa_art: bool = False,
) -> list:
"""Resolve the configured art-source order into a concrete priority list.
Rules (in priority):
- A configured non-empty list wins. It's lower-cased, trimmed, filtered to
known art-capable sources, and de-duplicated (first occurrence kept).
- An empty / missing / all-invalid list preserves **legacy behavior**:
``['caa']`` when ``prefer_caa_art`` is on (Cover Art Archive first, then
the download's own art), else ``[]`` (use the download's own art only).
The empty-list case is what makes the feature non-breaking: an install that
has never touched the new setting resolves to exactly today's logic.
"""
if isinstance(order, (list, tuple)):
seen = set()
deduped = []
for raw in order:
name = str(raw).strip().lower()
if not name or name not in ART_CAPABLE_SOURCES or name in seen:
continue
seen.add(name)
deduped.append(name)
if deduped:
return deduped
return ["caa"] if prefer_caa_art else []
def resolve_cover_art(
order: Sequence[str],
lookup: Callable[[str], Optional[str]],
*,
validate: Optional[Callable[[str, str], bool]] = None,
) -> Tuple[Optional[str], Optional[str]]:
"""Walk ``order`` and return ``(art_url, source_name)`` for the first source
whose ``lookup(source)`` yields a URL that passes ``validate`` (if given).
Returns ``(None, None)`` when nothing in the list resolves the caller then
falls back to the download's own art (today's default), so art is never
*worse* than before.
Robustness contract:
- ``lookup`` is ``source_name -> url | None``. A source that returns a
falsy value is skipped.
- An exception raised by ``lookup`` or ``validate`` for one source is
swallowed and treated as a miss, so a single flaky source can never
abort the whole chain (or the download).
"""
for source in order:
try:
url = lookup(source)
except Exception:
url = None
if not url:
continue
if validate is not None:
try:
if not validate(source, url):
continue
except Exception:
continue
return url, source
return None, None

View file

@ -8,7 +8,7 @@ import urllib.request
from ipaddress import ip_address
from urllib.parse import quote, urlparse
from core.imports.context import get_import_context_album
from core.imports.context import get_import_context_album, get_import_context_artist
from core.metadata.common import (
get_config_manager,
get_image_dimensions,
@ -330,6 +330,35 @@ def _fetch_art_bytes(art_url: str):
return None, None
def _min_size_art_validator(min_px):
"""Build a ``(validate, cache)`` pair for the preferred-art resolver.
``validate(source, url)`` fetches the candidate cover, caches its bytes (so
the winning source isn't fetched twice), and accepts it only when its
shortest side is at least ``min_px``. A too-small cover e.g. a low-res
Cover Art Archive upload is rejected so the resolver falls through to the
next source instead of letting it win on priority alone. Images whose
dimensions can't be read are accepted (don't over-reject; the fallback is
still today's art). ``min_px <= 0`` disables the size gate entirely.
"""
cache = {}
def validate(_source, url):
res = _fetch_art_bytes(url)
cache[url] = res
data = res[0] if res else None
if not data:
return False
if not min_px or min_px <= 0:
return True
dims = get_image_dimensions(data)
if not dims:
return True
return min(dims[0] or 0, dims[1] or 0) >= min_px
return validate, cache
def embed_album_art_metadata(audio_file, metadata: dict):
cfg = get_config_manager()
symbols = get_mutagen_symbols()
@ -340,8 +369,31 @@ def embed_album_art_metadata(audio_file, metadata: dict):
image_data = None
mime_type = None
# User-preferred cover-art source. When album_art_order is a non-empty
# list it is the SOLE authority for preferred art (put 'caa' in it to use
# Cover Art Archive), and the legacy prefer_caa_art toggle below is
# skipped. With no list this is a no-op and behavior is exactly as before.
album_art_order = cfg.get("metadata_enhancement.album_art_order")
art_list_active = isinstance(album_art_order, (list, tuple)) and len(album_art_order) > 0
try:
from core.metadata.art_lookup import select_preferred_art_url
_validate, _art_cache = _min_size_art_validator(
cfg.get("metadata_enhancement.min_art_size", 1000))
preferred_url = select_preferred_art_url(
metadata.get("album_artist") or metadata.get("artist"),
metadata.get("album"),
metadata,
album_art_order,
validate=_validate,
)
if preferred_url:
cached = _art_cache.get(preferred_url)
image_data, mime_type = cached if (cached and cached[0]) else _fetch_art_bytes(preferred_url)
except Exception as exc:
logger.debug("Preferred art-source selection failed: %s", exc)
release_mbid = metadata.get("musicbrainz_release_id")
if release_mbid and cfg.get("metadata_enhancement.prefer_caa_art", False):
if not image_data and not art_list_active and release_mbid and cfg.get("metadata_enhancement.prefer_caa_art", False):
try:
# 1200px CDN thumbnail, not the flaky bare /front original.
caa_url = f"https://coverartarchive.org/release/{release_mbid}/front-1200"
@ -395,7 +447,12 @@ def download_cover_art(album_info: dict, target_dir: str, context: dict = None):
cover_path = os.path.join(target_dir, "cover.jpg")
album_info = album_info or {}
release_mbid = album_info.get("musicbrainz_release_id")
prefer_caa = cfg.get("metadata_enhancement.prefer_caa_art", False)
# When a preferred-art priority list is configured it is the sole
# authority, so the legacy CAA toggle is neutralized for this whole
# function (it gates the existing-file upgrade logic too).
_art_order = cfg.get("metadata_enhancement.album_art_order")
_art_list_active = isinstance(_art_order, (list, tuple)) and len(_art_order) > 0
prefer_caa = cfg.get("metadata_enhancement.prefer_caa_art", False) and not _art_list_active
if os.path.exists(cover_path):
if release_mbid and prefer_caa:
@ -412,7 +469,31 @@ def download_cover_art(album_info: dict, target_dir: str, context: dict = None):
is_upgrade = False
image_data = None
if release_mbid and prefer_caa:
# User-preferred cover-art source (no-op unless album_art_order is set).
# cover.jpg only supports the artist+album sources here (no MBID in
# album_info), which matches today's CAA-only special-casing.
try:
from core.metadata.art_lookup import select_preferred_art_url
artist_ctx = get_import_context_artist(context) if context else {}
_validate, _art_cache = _min_size_art_validator(
cfg.get("metadata_enhancement.min_art_size", 1000))
preferred_url = select_preferred_art_url(
(artist_ctx or {}).get("name"),
album_info.get("album_name"),
album_info,
cfg.get("metadata_enhancement.album_art_order"),
validate=_validate,
)
if preferred_url:
cached = _art_cache.get(preferred_url)
pref_data = cached[0] if (cached and cached[0]) else _fetch_art_bytes(preferred_url)[0]
if pref_data and len(pref_data) > 1000:
image_data = pref_data
except Exception as exc:
logger.debug("Preferred art-source selection failed: %s", exc)
if not image_data and release_mbid and prefer_caa:
try:
# 1200px CDN thumbnail, not the flaky bare /front original.
caa_url = f"https://coverartarchive.org/release/{release_mbid}/front-1200"

View file

@ -14,6 +14,26 @@ from typing import Optional, Dict, List, Tuple
logger = logging.getLogger(__name__)
# Hard ceiling on cached entity rows. TTL-only eviction has no upper bound, so
# heavy discovery/enrichment within the TTL window let this table reach ~1.8M
# rows / 7.6 GB (and helped bloat the main DB toward a corruption incident).
# Beyond this cap we evict least-recently-ACCESSED rows (LRU) — the data to do
# it (last_accessed_at) is already stored. Tunable via config; 0 disables.
_DEFAULT_MAX_CACHE_ENTITIES = 250_000
def entities_to_evict_for_capacity(total_rows: int, max_rows: int) -> int:
"""Pure decision: how many LRU rows to drop to bring the cache to ``max_rows``.
Separated from SQL so it's unit-testable (mirrors core.db_integrity.
prune_backups). ``max_rows <= 0`` means "no cap" -> never evict. Never
returns negative.
"""
if max_rows <= 0:
return 0
return max(0, total_rows - max_rows)
# Singleton
_cache_instance = None
_cache_lock = threading.Lock()
@ -619,6 +639,43 @@ class MetadataCache:
return self._run_maintenance_write("Cache eviction", _operation)
def evict_over_capacity(self, max_rows: int = _DEFAULT_MAX_CACHE_ENTITIES) -> int:
"""Enforce a hard row ceiling: if the entity cache exceeds ``max_rows``,
delete the least-recently-ACCESSED rows down to the cap (LRU). This is
the bound TTL-only eviction lacked. Returns the count evicted.
Runs AFTER evict_expired in the maintenance job, so TTL-expired and junk
rows are already gone this only trims a still-oversized healthy cache.
"""
def _operation(conn):
cursor = conn.cursor()
total = cursor.execute(
"SELECT COUNT(*) FROM metadata_cache_entities"
).fetchone()[0]
to_evict = entities_to_evict_for_capacity(total, max_rows)
if to_evict <= 0:
return 0
# Delete the oldest-accessed rows. NULL last_accessed_at sorts first
# (evicted earliest) — those were never touched since insert.
cursor.execute("""
DELETE FROM metadata_cache_entities
WHERE id IN (
SELECT id FROM metadata_cache_entities
ORDER BY last_accessed_at ASC
LIMIT ?
)
""", (to_evict,))
evicted = cursor.rowcount
conn.commit()
if evicted > 0:
logger.info(
"Cache over capacity (%d > %d) — evicted %d least-recently-used entities",
total, max_rows, evicted,
)
return evicted
return self._run_maintenance_write("Cache capacity eviction", _operation)
def clean_junk_entities(self) -> int:
"""Delete cached entities with empty/placeholder names."""
def _operation(conn):

View file

@ -7,6 +7,19 @@ from utils.logging_config import get_logger
logger = get_logger("musicbrainz_client")
# Lucene query-syntax characters that must be backslash-escaped when a
# user-supplied value is interpolated into a query (e.g. artist names like
# "Sunn O)))", "Anthony Green (Saosin)", "Therapy?", "!!!"). Without this an
# unbalanced paren or a stray ?/* either breaks the field group (returning
# unrelated results) or yields zero hits.
_LUCENE_SPECIAL = set('+-&|!(){}[]^"~*?:\\/')
def _escape_lucene(text: str) -> str:
"""Backslash-escape Lucene special characters in a user-supplied term."""
return ''.join('\\' + ch if ch in _LUCENE_SPECIAL else ch for ch in text)
# Global rate limiting variables
_last_api_call_time = 0
_api_call_lock = threading.Lock()
@ -158,14 +171,14 @@ class MusicBrainzClient:
safe_artist = artist_name.replace('\\', '\\\\').replace('"', '\\"')
query += f' AND artist:"{safe_artist}"'
else:
# Bare query — MB tokenizes against title + artist credit +
# alias + sortname indexes together with diacritic folding.
# Recovers cases like "Bjork" → "Björk" that strict phrase
# queries miss.
parts = [album_name]
if artist_name:
parts.append(artist_name)
query = ' '.join(p for p in parts if p)
# Loose title terms (no phrase quotes → diacritic folding and
# alias/sortname recall, e.g. "Bjork" → "Björk"), but
# FIELD-SCOPE the artist so it constrains rather than floating
# as a free fuzzy term that lets unrelated releases whose titles
# echo the artist name rank first (same root cause as #754).
query = album_name
if artist_name and artist_name.strip():
query += f' AND artist:({_escape_lucene(artist_name.strip())})'
params = {
'query': query,
@ -223,18 +236,24 @@ class MusicBrainzClient:
safe_artist = artist_name.replace('\\', '\\\\').replace('"', '\\"')
query += f' AND artist:"{safe_artist}"'
else:
# Bare query — see search_release for rationale.
parts = [track_name]
if artist_name:
parts.append(artist_name)
query = ' '.join(p for p in parts if p)
# Loose track terms (no phrase quotes → tolerant of bracketed
# suffixes and diacritics, hitting MB's alias/sortname indexes),
# but FIELD-SCOPE the artist so it actually constrains results.
# A bare "track artist" blob let the artist float as a free
# fuzzy term, so covers/karaoke whose TITLES contain the artist
# name outranked the real recording (#754: "Sweet Child O Mine"
# / "Guns N Roses" returned only covers). Scoping still folds
# diacritics — artist:(Bjork) matches "Björk".
query = track_name
if artist_name and artist_name.strip():
query += f' AND artist:({_escape_lucene(artist_name.strip())})'
params = {
'query': query,
'fmt': 'json',
'limit': limit
}
response = self.session.get(
f"{self.BASE_URL}/recording",
params=params,

View file

@ -0,0 +1 @@
"""Playback helpers (web-player play logging)."""

65
core/playback/play_log.py Normal file
View file

@ -0,0 +1,65 @@
"""Build a listening-history event from a SoulSync web-player play.
Pure, DB-agnostic. ``listening_history`` is otherwise populated only from the
media server (Plex/Jellyfin/Navidrome) by ``listening_stats_worker``; this lets
the WEB PLAYER record its own plays too, so "recently played" and the Phase-2
smart-radio recency signal reflect what was actually played in SoulSync.
Kept as a pure function so it's unit-testable without a DB or Flask: it
normalizes the player's track payload into the exact event shape
``MusicDatabase.insert_listening_events`` expects.
"""
from __future__ import annotations
from typing import Any, Dict, Optional
# Marks rows that came from the SoulSync web player (vs a media server), so they
# can be distinguished in queries / dedup if ever needed.
WEB_PLAYER_SOURCE = "soulsync_web"
def build_play_event(track: Dict[str, Any], played_at: str,
duration_ms: int = 0) -> Optional[Dict[str, Any]]:
"""Normalize a player track payload into a listening_history event.
``played_at`` MUST be supplied by the caller (an ISO timestamp string)
this module never reads the clock, so it stays pure/testable. Returns None
when there's nothing worth logging (no title), so callers can skip cleanly.
The event shape matches insert_listening_events():
track_id, title, artist, album, played_at, duration_ms, server_source,
db_track_id.
"""
if not isinstance(track, dict):
return None
title = (track.get("title") or "").strip()
if not title:
return None
# db_track_id is the local tracks.id when it's a real library track (a
# plain integer id). Streamed/search results may carry a composite id —
# keep it only when it's a clean int so the FK-ish join stays valid.
raw_id = track.get("id")
db_track_id = int(raw_id) if _is_int_like(raw_id) else None
return {
"track_id": str(raw_id) if raw_id is not None else None,
"title": title,
"artist": (track.get("artist") or "").strip(),
"album": (track.get("album") or "").strip(),
"played_at": played_at,
"duration_ms": int(duration_ms) if _is_int_like(duration_ms) else 0,
"server_source": WEB_PLAYER_SOURCE,
"db_track_id": db_track_id,
}
def _is_int_like(v: Any) -> bool:
if isinstance(v, bool):
return False
if isinstance(v, int):
return True
if isinstance(v, str):
return v.isdigit()
return False

28
core/radio/__init__.py Normal file
View file

@ -0,0 +1,28 @@
"""Radio / auto-play recommendation logic.
Pure, DB-agnostic helpers that decide *what* radio should play. The SQL
execution stays in ``database.music_database.get_radio_tracks``; this package
owns the decisions (tag parsing, tier caps, dedup/collection, LIKE-condition
building) so they're unit-testable without a live DB — the seam Phase 2's
smarter ranking will plug into.
"""
from core.radio.selection import (
RadioCollector,
build_like_conditions,
merge_tags,
parse_tags,
rank_candidates,
same_artist_cap,
score_candidate,
)
__all__ = [
"RadioCollector",
"build_like_conditions",
"merge_tags",
"parse_tags",
"rank_candidates",
"same_artist_cap",
"score_candidate",
]

222
core/radio/selection.py Normal file
View file

@ -0,0 +1,222 @@
"""Pure radio-selection decisions, lifted out of the DB layer.
``database.music_database.get_radio_tracks`` used to inline all of this between
``cursor.execute`` calls, so the algorithm couldn't be tested without a live DB
(which also happens to throw in the dev sandbox). These helpers carry the same
behavior as before they're a faithful extraction, not a rewrite — but as
plain functions they're unit-testable and give Phase 2 (smart ranking) a clean
place to evolve the logic.
Nothing here touches sqlite; callers pass already-fetched rows (as dicts) and
get back decisions.
"""
from __future__ import annotations
import hashlib
import json
import math
from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple
def parse_tags(raw_val: Any) -> List[str]:
"""Parse a genre/mood/style field into a list of tags.
The field may be a JSON array (canonical) or a legacy comma-separated
string. Mirrors the inline ``_parse_tags`` the DB method used.
"""
if not raw_val:
return []
try:
parsed = json.loads(raw_val)
return parsed if isinstance(parsed, list) else [str(parsed)]
except (json.JSONDecodeError, ValueError, TypeError):
return [t.strip() for t in str(raw_val).split(",") if t.strip()]
def same_artist_cap(limit: int) -> int:
"""How many same-artist tracks tier 1 may contribute.
Capped so radio doesn't become an all-one-artist playlist: 30% of the
limit, floored at 5 (matches the original ``max(5, limit * 3 // 10)``).
"""
return max(5, limit * 3 // 10)
def merge_tags(*tag_groups: Iterable[str]) -> List[str]:
"""Concatenate tag lists, dedupe, preserve first-seen order.
Mirrors ``list(dict.fromkeys(a + b))`` used for genre/mood/style merges.
"""
merged: List[str] = []
for group in tag_groups:
for tag in group:
merged.append(tag)
return list(dict.fromkeys(merged))
def build_like_conditions(
tags: Sequence[str], columns: Sequence[str]
) -> Tuple[str, List[str]]:
"""Build an OR-of-LIKEs SQL fragment + params for matching ``tags``
against each of ``columns``.
Returns ``(sql_fragment, params)`` where the fragment is
``"col1 LIKE ? OR col1 LIKE ? OR col2 LIKE ? ..."`` (one LIKE per
column per tag) and params are the ``%tag%`` wildcards in matching
order. Returns ``("", [])`` when there are no tags or no columns, so
callers can skip the tier cleanly.
This reproduces the original per-tier condition building, which paired
every tag against album-level and artist-level columns.
"""
if not tags or not columns:
return "", []
conditions: List[str] = []
params: List[str] = []
# Group by column (all tags for column A, then all tags for column B) to
# match the original ordering: it emitted every ``al.<f> LIKE ?`` then
# every ``ar.<f> LIKE ?``, with params being ``[%tag%...] * 2``.
for col in columns:
for tag in tags:
conditions.append(f"{col} LIKE ?")
params.append(f"%{tag}%")
return " OR ".join(conditions), params
class RadioCollector:
"""Accumulates radio candidates across tiers with dedup + cap logic.
Replaces the inline ``collected`` list + ``seen_ids`` set + ``_collect``
closure the DB method used. Construct with the overall ``limit`` and the
set of IDs to exclude up front (seed track + caller-supplied), then feed
each tier's fetched rows through :meth:`collect`.
"""
def __init__(self, limit: int, exclude_ids: Optional[Iterable[Any]] = None):
self.limit = limit
self._collected: List[Dict[str, Any]] = []
# seen_ids seeds with the exclude set so excluded tracks never collect
# AND so the placeholders/values used in WHERE ... NOT IN stay in sync.
self._seen: set[str] = {str(e) for e in (exclude_ids or [])}
@property
def tracks(self) -> List[Dict[str, Any]]:
return self._collected
@property
def filled(self) -> bool:
"""True once we've reached the overall limit."""
return len(self._collected) >= self.limit
def exclude_placeholders(self) -> str:
"""SQL ``?,?,...`` placeholder string sized to the current seen set."""
return ",".join("?" * len(self._seen))
def exclude_values(self) -> List[str]:
"""Param values for the placeholders above (current seen set)."""
return list(self._seen)
def remaining(self) -> int:
"""How many more tracks are needed to hit the limit."""
return max(0, self.limit - len(self._collected))
def collect(
self,
rows: Iterable[Dict[str, Any]],
cap: Optional[int] = None,
*,
rank: bool = False,
) -> bool:
"""Append ``rows`` (dict-like) to the result, skipping already-seen IDs.
``cap`` bounds how many THIS call may add (on top of what's already
collected); ``None`` means bounded only by the overall limit. Returns
True once the overall limit is reached. Mirrors the original
``_collect`` closure exactly.
When ``rank`` is True the (still-unseen) rows are scored and sorted by
:func:`rank_candidates` before being appended so a tier hands the DB
a generous random pool and this picks the best of it (Phase 2 smart
radio). When False the rows are taken in the order given (the original
behavior, preserved for any caller that wants it).
"""
target = min(self.limit, len(self._collected) + cap) if cap else self.limit
fresh = [dict(r) for r in rows if str(r["id"]) not in self._seen]
if rank:
fresh = rank_candidates(fresh)
for r in fresh:
rid = str(r["id"])
if rid in self._seen:
continue
self._seen.add(rid)
self._collected.append(r)
if len(self._collected) >= target:
return True
return self.filled
# ── Phase 2: smart ranking ─────────────────────────────────────────────────
#
# The old radio was pure ``ORDER BY RANDOM()``. We now fetch a generous random
# POOL per tier (so the SQL stays cheap and varied) and rank it here by a
# weighted score. All the intelligence is in these pure functions so it's
# unit-testable and tunable without touching SQL.
# Weights are deliberately modest so popularity guides but doesn't dominate —
# radio should still surface lesser-played tracks, just less often.
_W_PLAY_COUNT = 1.0 # local play_count (log-damped)
_W_LASTFM = 0.5 # lastfm_playcount (log-damped) — global popularity hint
_W_RECENCY_PENALTY = 2.0 # subtracted for very-recently-played (avoid repeats)
_W_JITTER = 1.0 # deterministic per-track noise so runs vary a little
def _log_damp(value: Any) -> float:
"""log1p of a non-negative count, 0 for missing/invalid. Damps so a track
with 10000 plays doesn't bury one with 50 — popularity is a nudge."""
try:
v = float(value)
except (TypeError, ValueError):
return 0.0
if v <= 0:
return 0.0
return math.log1p(v)
def _stable_jitter(track_id: Any) -> float:
"""Deterministic [0,1) pseudo-random per track id.
Math.random / Date are unavailable / nondeterministic-unfriendly here and
we want runs to be reproducible in tests, so derive jitter from a hash of
the id. Two different tracks get different jitter; the same track is stable
within a ranking pass.
"""
h = hashlib.sha1(str(track_id).encode("utf-8")).hexdigest()
return int(h[:8], 16) / 0xFFFFFFFF
def score_candidate(row: Dict[str, Any]) -> float:
"""Weighted desirability score for a radio candidate row.
Signals (all optional absent columns score 0, so this is safe on a DB
that hasn't recorded listening data yet):
+ play_count local plays (log-damped)
+ lastfm_playcount global popularity hint (log-damped)
- recently_played caller-flagged repeat-risk penalty
+ jitter stable per-track noise for run-to-run variety
"""
score = 0.0
score += _W_PLAY_COUNT * _log_damp(row.get("play_count"))
score += _W_LASTFM * _log_damp(row.get("lastfm_playcount"))
if row.get("_recently_played"):
score -= _W_RECENCY_PENALTY
score += _W_JITTER * _stable_jitter(row.get("id"))
return score
def rank_candidates(rows: Sequence[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""Sort candidate rows best-first by :func:`score_candidate`.
Stable sort; ties keep input order. Does not mutate the input rows.
"""
return sorted(rows, key=score_candidate, reverse=True)

View file

@ -89,4 +89,21 @@ class CacheEvictorJob(RepairJob):
logger.error("MB null cleanup failed: %s", e, exc_info=True)
result.errors += 1
if context.check_stop():
return result
# Phase 5: Hard capacity cap (LRU). Runs LAST so TTL/junk/orphan/null
# rows are already gone; this only trims a still-oversized HEALTHY cache
# down to the row ceiling — the bound TTL-only eviction never had (the
# cache previously reached ~1.8M rows / 7.6 GB).
try:
over = cache.evict_over_capacity()
result.auto_fixed += over
result.scanned += over
if over > 0:
logger.info("Phase 5 — capacity cap: evicted %d LRU entries", over)
except Exception as e:
logger.error("Capacity eviction failed: %s", e, exc_info=True)
result.errors += 1
return result

View file

@ -1364,8 +1364,11 @@ class RepairWorker:
return {'success': False, 'error': f'Selected track ID {keep_id} not found in duplicates'}
best_id = keep_id
else:
# Auto-pick: highest bitrate, then longest duration, then highest track number (correct > 01)
best = max(tracks, key=lambda t: (t.get('bitrate', 0) or 0, t.get('duration', 0) or 0, t.get('track_number', 0) or 0))
# Auto-pick the keeper: lossless format first (so a FLAC beats an
# MP3 even when the FLAC's bitrate is missing in the DB), then
# bitrate, duration, and track number as tie-breakers.
from core.library.duplicate_keep import pick_duplicate_to_keep
best = pick_duplicate_to_keep(tracks)
best_id = best.get('track_id') or best.get('id')
if not best_id:

140
core/streaming/state.py Normal file
View file

@ -0,0 +1,140 @@
"""Stream playback state — testable store, foundation for multi-listener.
Today ``web_server.py`` keeps ONE module-global ``stream_state`` dict + one
``stream_lock`` (``web_server.py:747``). That means the whole server has a
single "currently playing" every browser tab/device is a remote for the same
playback, and two listeners collide. Fixing that (the player-revamp Phase 3
goal) requires per-session state, but the global is woven through ~22 call
sites and isn't unit-testable where it lives.
This module lifts the state into a small, tested abstraction WITHOUT yet
changing behavior:
* ``StreamSession`` one playback's state. Behaves like the old dict
(``s["status"]``, ``s.get(...)``, ``s.update({...})``) so existing call sites
work unchanged, but each carries its OWN lock so distinct sessions never
block or clobber each other.
* ``StreamStateStore`` a registry of named sessions. ``DEFAULT_SESSION`` is
the single shared session that reproduces today's exact behavior; wiring the
web server through it is a no-op refactor. When Phase 3 adds a per-request
session id (browser/device), the store already supports it that step is the
only remaining (browser-side, unprovable-here) piece.
Pure Python, no Flask/DB. Fully unit-testable.
"""
from __future__ import annotations
import threading
from typing import Any, Dict, Iterator, List, Optional
DEFAULT_SESSION = "default"
def _fresh_state() -> Dict[str, Any]:
"""The stopped/empty baseline — matches web_server.py's original literal."""
return {
"status": "stopped", # stopped | loading | queued | ready | error
"progress": 0,
"track_info": None,
"file_path": None,
"error_message": None,
}
class StreamSession:
"""One playback session's state, with its own lock.
Dict-compatible for the operations the existing call sites use
(``__getitem__``, ``__setitem__``, ``get``, ``update``) so lifting the
global is a drop-in. ``lock`` is exposed so callers that did
``with stream_lock:`` keep that exact guard now per-session.
"""
def __init__(self, initial: Optional[Dict[str, Any]] = None):
self._state: Dict[str, Any] = _fresh_state()
if initial:
self._state.update(initial)
self.lock = threading.RLock()
# -- dict-compatible surface (matches old stream_state usage) --
def __getitem__(self, key: str) -> Any:
return self._state[key]
def __setitem__(self, key: str, value: Any) -> None:
self._state[key] = value
def __contains__(self, key: str) -> bool:
return key in self._state
def get(self, key: str, default: Any = None) -> Any:
return self._state.get(key, default)
def update(self, values: Dict[str, Any]) -> None:
self._state.update(values)
def snapshot(self) -> Dict[str, Any]:
"""A shallow copy — for emitting to clients without leaking the live
dict (the old code read individual keys under the lock; a snapshot is
the safe equivalent for the whole thing)."""
return dict(self._state)
def reset(self) -> None:
"""Return to the stopped/empty baseline (used by stop)."""
self._state = _fresh_state()
def replace(self, new_state: Dict[str, Any]) -> None:
"""Wholesale replace the backing dict (mirrors the old
``_set_stream_state`` global reassignment)."""
self._state = dict(new_state)
class StreamStateStore:
"""Registry of named :class:`StreamSession` objects.
``get()`` lazily creates a session on first reference, so a brand-new
session id just works. The default session reproduces the single-global
behavior the app has today.
"""
def __init__(self):
self._sessions: Dict[str, StreamSession] = {}
self._registry_lock = threading.RLock()
def get(self, session_id: str = DEFAULT_SESSION) -> StreamSession:
with self._registry_lock:
session = self._sessions.get(session_id)
if session is None:
session = StreamSession()
self._sessions[session_id] = session
return session
def has(self, session_id: str) -> bool:
with self._registry_lock:
return session_id in self._sessions
def drop(self, session_id: str) -> bool:
"""Remove a session (e.g. on disconnect). Returns True if one existed.
The default session is never dropped it's the always-present shared
playback."""
if session_id == DEFAULT_SESSION:
return False
with self._registry_lock:
return self._sessions.pop(session_id, None) is not None
def session_ids(self) -> List[str]:
with self._registry_lock:
return list(self._sessions.keys())
def active_ids(self) -> List[str]:
"""Session ids whose status is not 'stopped' — i.e. currently doing
something. The signal multi-listener UI / cleanup will key off of."""
with self._registry_lock:
return [
sid for sid, s in self._sessions.items()
if s.get("status") != "stopped"
]
def __iter__(self) -> Iterator[StreamSession]:
with self._registry_lock:
return iter(list(self._sessions.values()))

View file

@ -3654,6 +3654,36 @@ class MusicDatabase:
if conn:
conn.close()
def record_web_player_play(self, event):
"""Record a single SoulSync web-player play: insert the listening_history
row AND bump tracks.play_count / last_played for the smart-radio recency
signal. ``event`` is the dict from core.playback.play_log.build_play_event.
Returns True if the history row was newly inserted.
"""
if not event:
return False
inserted = self.insert_listening_events([event])
db_id = event.get('db_track_id')
if db_id is not None:
conn = None
try:
conn = self._get_connection()
cursor = conn.cursor()
cursor.execute("""
UPDATE tracks
SET play_count = COALESCE(play_count, 0) + 1,
last_played = ?
WHERE id = ?
""", (event.get('played_at'), db_id))
conn.commit()
except Exception as e:
logger.error(f"Error bumping play_count for track {db_id}: {e}")
finally:
if conn:
conn.close()
return inserted > 0
def update_track_play_counts(self, counts):
"""Update play_count and last_played on the tracks table.
@ -4237,9 +4267,17 @@ class MusicDatabase:
def _add_metadata_cache_tables(self, cursor):
"""Create metadata_cache_entities and metadata_cache_searches tables for universal API response caching"""
try:
# Skip only when the marker is set AND the tables actually exist.
# A marker-only guard is fragile: if the `metadata` table survives a
# corruption-recovery but the (large) cache tables don't, the stale
# marker would permanently short-circuit creation and the metadata
# cache would silently never work again (nothing lands in the
# browser). Verifying the table presence makes this self-heal.
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='metadata_cache_entities'")
tables_present = cursor.fetchone() is not None
cursor.execute("SELECT value FROM metadata WHERE key = 'metadata_cache_v1' LIMIT 1")
if cursor.fetchone():
return # Already migrated
if cursor.fetchone() and tables_present:
return # Already migrated and tables present
logger.info("Creating metadata cache tables...")
@ -4410,7 +4448,7 @@ class MusicDatabase:
logger.error(f"Error creating manual_library_track_matches table: {e}")
def save_manual_library_match(self, profile_id: int, source: str, source_track_id: str,
library_track_id: int, **meta) -> bool:
library_track_id: str, **meta) -> bool:
"""Insert or replace a manual match. meta keys: source_title, source_artist,
source_album, source_context_json, server_source."""
try:
@ -12796,24 +12834,53 @@ class MusicDatabase:
seed = dict(seed)
artist_name = seed['artist_name']
# Build the set of IDs to exclude (seed + caller-supplied)
excluded = {str(track_id)}
# Selection decisions (dedup, caps, tag parsing, condition
# building) live in core.radio.selection so they're unit-
# testable without a live DB. The cursor work stays here.
from core.radio.selection import (
RadioCollector,
build_like_conditions,
merge_tags,
parse_tags,
same_artist_cap,
)
# Seed + caller-supplied IDs to exclude (seeds the collector's
# seen-set so excluded tracks never collect and the NOT IN
# placeholders/values stay in sync).
exclude_seed = [str(track_id)]
if exclude_ids:
excluded.update(str(eid) for eid in exclude_ids)
exclude_seed.extend(str(eid) for eid in exclude_ids)
collector = RadioCollector(limit, exclude_ids=exclude_seed)
collected: list[dict] = []
seen_ids: set[str] = set(excluded)
# Phase 2 smart radio: each tier pulls a generous RANDOM pool,
# then core.radio.selection ranks it (play_count + lastfm
# popularity, recency penalty, stable jitter) and the collector
# keeps the best. Pool factor keeps SQL cheap while giving the
# ranker real choice; bumped, then floored so small tiers still
# over-fetch a little.
_POOL_FACTOR = 4
def _exclude_placeholders():
return ','.join('?' * len(seen_ids))
def _pool(n):
return max(n * _POOL_FACTOR, n + 10)
def _exclude_values():
return list(seen_ids)
# Ranking signals (play_count / lastfm_playcount) are added by a
# migration, but probe for them so radio still works on a DB that
# predates it — the ranker treats missing columns as score 0, so
# we simply omit them from the SELECT when absent rather than
# crashing on "no such column".
cursor.execute("PRAGMA table_info(tracks)")
_track_cols = {row[1] for row in cursor.fetchall()}
_rank_cols = "".join(
f"t.{c}, " for c in ("play_count", "lastfm_playcount")
if c in _track_cols
)
_track_select = """
_track_select = f"""
SELECT t.id, t.title, t.track_number, t.duration,
t.file_path, t.bitrate,
t.album_id, t.artist_id,
{_rank_cols}
al.title AS album,
COALESCE(al.thumb_url, ar.thumb_url) AS image_url,
ar.name AS artist
@ -12824,98 +12891,72 @@ class MusicDatabase:
# Only return tracks that have actual files on disk
_file_filter = "t.file_path IS NOT NULL AND t.file_path != ''"
def _collect(rows, cap=None):
"""Append rows to collected. Stop at cap or limit."""
target = min(limit, (len(collected) + cap)) if cap else limit
for row in rows:
r = dict(row)
rid = str(r['id'])
if rid not in seen_ids:
seen_ids.add(rid)
collected.append(r)
if len(collected) >= target:
return True
return len(collected) >= limit
def _parse_tags(raw_val):
"""Parse a JSON array or comma-separated string into a list."""
if not raw_val:
return []
try:
parsed = json.loads(raw_val)
return parsed if isinstance(parsed, list) else [str(parsed)]
except (json.JSONDecodeError, ValueError):
return [t.strip() for t in raw_val.split(',') if t.strip()]
# --- 1. Same artist, different albums (capped at 30% of limit) ---
same_artist_cap = max(5, limit * 3 // 10)
artist_cap = same_artist_cap(limit)
cursor.execute(f"""
{_track_select}
WHERE {_file_filter} AND ar.name = ? AND t.album_id != ? AND t.id NOT IN ({_exclude_placeholders()})
WHERE {_file_filter} AND ar.name = ? AND t.album_id != ? AND t.id NOT IN ({collector.exclude_placeholders()})
ORDER BY RANDOM()
LIMIT ?
""", [artist_name, seed['album_id']] + _exclude_values() + [same_artist_cap])
_collect(cursor.fetchall(), cap=same_artist_cap)
""", [artist_name, seed['album_id']] + collector.exclude_values() + [_pool(artist_cap)])
collector.collect(cursor.fetchall(), cap=artist_cap, rank=True)
if len(collected) >= limit:
return {'success': True, 'tracks': collected}
if collector.filled:
return {'success': True, 'tracks': collector.tracks}
# --- 2. Same genre (album genres + artist genres, other artists) ---
genre_list = _parse_tags(seed.get('album_genres'))
artist_genre_list = _parse_tags(seed.get('artist_genres'))
all_genres = list(dict.fromkeys(genre_list + artist_genre_list)) # dedupe, preserve order
if all_genres:
genre_conditions = ' OR '.join(
['al.genres LIKE ?' for _ in all_genres] +
['ar.genres LIKE ?' for _ in all_genres]
)
genre_params = [f'%{g}%' for g in all_genres] * 2
all_genres = merge_tags(
parse_tags(seed.get('album_genres')),
parse_tags(seed.get('artist_genres')),
)
genre_conditions, genre_params = build_like_conditions(
all_genres, ('al.genres', 'ar.genres')
)
if genre_conditions:
cursor.execute(f"""
{_track_select}
WHERE {_file_filter} AND ({genre_conditions})
AND ar.name != ?
AND t.id NOT IN ({_exclude_placeholders()})
AND t.id NOT IN ({collector.exclude_placeholders()})
ORDER BY RANDOM()
LIMIT ?
""", genre_params + [artist_name] + _exclude_values() + [limit - len(collected)])
if _collect(cursor.fetchall()):
return {'success': True, 'tracks': collected}
""", genre_params + [artist_name] + collector.exclude_values() + [_pool(collector.remaining())])
if collector.collect(cursor.fetchall(), rank=True):
return {'success': True, 'tracks': collector.tracks}
# --- 3. Same mood / style (album + artist level) ---
for field_name in ('mood', 'style'):
album_tags = _parse_tags(seed.get(f'album_{field_name}'))
artist_tags = _parse_tags(seed.get(f'artist_{field_name}'))
all_tags = list(dict.fromkeys(album_tags + artist_tags))
if all_tags:
tag_conditions = ' OR '.join(
[f'al.{field_name} LIKE ?' for _ in all_tags] +
[f'ar.{field_name} LIKE ?' for _ in all_tags]
)
tag_params = [f'%{t}%' for t in all_tags] * 2
all_tags = merge_tags(
parse_tags(seed.get(f'album_{field_name}')),
parse_tags(seed.get(f'artist_{field_name}')),
)
tag_conditions, tag_params = build_like_conditions(
all_tags, (f'al.{field_name}', f'ar.{field_name}')
)
if tag_conditions:
cursor.execute(f"""
{_track_select}
WHERE {_file_filter} AND ({tag_conditions})
AND ar.name != ?
AND t.id NOT IN ({_exclude_placeholders()})
AND t.id NOT IN ({collector.exclude_placeholders()})
ORDER BY RANDOM()
LIMIT ?
""", tag_params + [artist_name] + _exclude_values() + [limit - len(collected)])
if _collect(cursor.fetchall()):
return {'success': True, 'tracks': collected}
""", tag_params + [artist_name] + collector.exclude_values() + [_pool(collector.remaining())])
if collector.collect(cursor.fetchall(), rank=True):
return {'success': True, 'tracks': collector.tracks}
# --- 4. Random library tracks ---
if len(collected) < limit:
# --- 4. Random library tracks (ranked: popular-but-unheard
# beats pure noise even in the last-resort tier) ---
if not collector.filled:
cursor.execute(f"""
{_track_select}
WHERE {_file_filter} AND t.id NOT IN ({_exclude_placeholders()})
WHERE {_file_filter} AND t.id NOT IN ({collector.exclude_placeholders()})
ORDER BY RANDOM()
LIMIT ?
""", _exclude_values() + [limit - len(collected)])
_collect(cursor.fetchall())
""", collector.exclude_values() + [_pool(collector.remaining())])
collector.collect(cursor.fetchall(), rank=True)
return {'success': True, 'tracks': collected}
return {'success': True, 'tracks': collector.tracks}
except Exception as e:
logger.error(f"Error getting radio tracks for track {track_id}: {e}")

View file

@ -81,15 +81,15 @@ chown soulsync:soulsync /app/config/settings.py 2>/dev/null || true
# Pre-mid-2026 the chown line had `|| true` but mkdir didn't — combined
# with `set -e`, a permission-denied mkdir crashed the container into a
# restart loop. Both lines are now best-effort.
mkdir -p /app/config /app/data /app/logs /app/downloads /app/Transfer /app/Staging /app/Stream 2>/dev/null || true
chown soulsync:soulsync /app/config /app/data /app/logs /app/downloads /app/Transfer /app/Staging /app/Stream 2>/dev/null || true
mkdir -p /app/config /app/data /app/logs /app/downloads /app/Transfer /app/Staging /app/Stream /app/storage /app/MusicVideos /app/scripts 2>/dev/null || true
chown soulsync:soulsync /app/config /app/data /app/logs /app/downloads /app/Transfer /app/Staging /app/Stream /app/storage /app/MusicVideos /app/scripts 2>/dev/null || true
# Writability audit — surface a loud warning if any bind-mounted dir
# isn't writable by the soulsync user. The restart-loop fix above makes
# the container start regardless, but a non-writable Staging / downloads
# / Transfer will fail silently inside the app (auto-import quarantine,
# download writes). Better to log now than to debug missing files later.
for dir in /app/config /app/data /app/logs /app/downloads /app/Transfer /app/Staging /app/Stream /app/MusicVideos /app/scripts; do
for dir in /app/config /app/data /app/logs /app/downloads /app/Transfer /app/Staging /app/Stream /app/storage /app/MusicVideos /app/scripts; do
if [ -d "$dir" ] && ! gosu soulsync test -w "$dir" 2>/dev/null; then
echo "⚠️ WARNING: $dir is not writable by soulsync (uid $(id -u soulsync))."
echo " Host bind-mount perms likely mismatch the PUID/PGID env vars."

43
revamp_plan.md Normal file
View file

@ -0,0 +1,43 @@
# Stream / Player / Radio Revamp — Plan
Goal: bring the audio stream + media-player + radio system to Spotify/Apple-level polish and feature set. Target stack: **plain JS** (`webui/static/media-player.js`), not the React migration. Intended architecture direction: **multi-listener** (final call deferred to Phase 3; Phases 02 stay compatible either way).
Rule for every phase: kettui standard — importable/testable logic, seam-level + differential tests, break nothing, ship one reviewable phase at a time.
---
## Phase 0 — Make it provable (foundation, no user-visible change)
- [x] **0a. Extract radio selection logic into testable `core/radio/`.** DONE (commit cbc001e2). `core/radio/selection.py` owns parse_tags/merge_tags/same_artist_cap/build_like_conditions/RadioCollector; DB method delegates. 29 tests, refactor-equivalence proven (behavioral tests pass against old AND new).
- [ ] **0b. Centralize frontend player state.** ~10 scattered `np*` globals in `media-player.js` → one `PlayerState` object. Seam for every later frontend phase. No behavior change.
## Phase 1 — Polish / feel (frontend)
- [x] Persistent queue across refresh (localStorage) — commit a8985b31
- [x] Drag-to-reorder queue; duration + art per queue item — ffbe669c + 3461d923
- [x] Seek tooltip (hover timestamp) — 112ecbb2
- [x] Crossfade via dual-`<audio>` swap (library tracks, experimental) — ccfb3fb0 + 592b68c1
- [x] Full Media Session API (lockscreen / hardware transport keys + position) — 866f2e4a
- [x] Fuller keyboard bindings (N/P/M/space/seek/vol) — 65f49cce
- [ ] Keyboard shortcut OVERLAY/cheatsheet (bindings done; no discoverability UI yet)
- [x] Click-art visualizer, sleep timer, up-next, click-to-seek lyrics, vibrant
art-color glow, crafted entrance, mini-player shuffle/repeat parity,
'Play next' + queue buttons, 'Playing from' context, web-player play logging
- [ ] (Optional) Sleep timer 'end of track' mode; waveform seek bar (flourish)
## Phase 2 — Smart radio (backend algorithm)
- [x] **Weighted ranking** DONE. Each tier now fetches a random POOL (4x, floored) and `core/radio/selection.rank_candidates` orders it by `score_candidate`: play_count + lastfm_playcount (log-damped), recently-played penalty, stable per-id jitter for run variety. Defensive column-probe → still works on a DB predating the play_count/lastfm migration. 43 radio tests; ranking math is deterministic-unit-proven; DB wiring shown via decoy-pool test (probabilistic by nature — documented).
- [ ] **Future (optional deepening):** wire `_recently_played` from `listening_history` (column + scorer support already exist; not yet populated in the query), genre-adjacency graph (currently exact-genre LIKE only).
## Phase 3 — Architecture (deepest, riskiest — multi-listener)
- [x] **3a. Stream-state store extracted + wired (foundation).** DONE. `core/streaming/state.py`: `StreamSession` (dict-compatible, own RLock) + `StreamStateStore` (named-session registry, lazy create, race-safe). `web_server.py` now binds `stream_state` to the store's DEFAULT session — behavior identical to the old single global (proven by call-site-compat + real-session worker tests). 33 streaming tests. This is the provable foundation multi-listener needs.
- [x] **3b. Per-listener session id.** DONE (commit f6174589). _stream_session_id() from the Flask cookie; all 5 stream routes route to the caller's session + lock; per-session background tasks (stream_tasks[sid]); per-session Stream/<sid> staging; executor 1→4 workers. Single-user behavior unchanged. EXPERIMENTAL — route-level two-client no-collision needs Boulder's live multi-client verification (can't boot Flask + 2 cookies in tests). Isolation invariant covered by test_stream_state_store.py.
- [ ] Server-side persistent queue (resume across devices/refresh).
---
## Order of execution
0a (radio extraction) → 2 (smart radio) first: highest *visible* upgrade, backend-only, cleanest to prove, zero playback risk. Then 0b → 1 (polish). Then 3 (architecture) last.

View file

@ -4,6 +4,29 @@ Creates a minimal Flask+SocketIO app that replicates the relevant
endpoints and event handlers without importing the full web_server.py
(which would try to initialize Spotify, Soulseek, Plex, etc.)."""
# ── TEST-DATABASE ISOLATION — MUST run before any other import ─────────────
# Force every MusicDatabase()/get_database() call in the suite onto a throwaway
# temp database so a test can NEVER open or write the real
# database/music_library.db. MusicDatabase resolves its default path from
# os.environ['DATABASE_PATH'] (see database/music_database.py); setting it here,
# at conftest import (before any test module loads), redirects ALL default-path
# DB access to /tmp.
#
# Why this is non-negotiable: tests exercise modules (e.g. album_mbid_cache)
# that call get_database() with no path → the real DB. Running those writers
# against the live DB over a WSL-mounted Windows drive corrupted a user's
# library. This guarantees it can't recur — tests get their own disposable DB.
import os as _os
import tempfile as _tempfile
import atexit as _atexit
import shutil as _shutil
if not _os.environ.get('SOULSYNC_TEST_DB_READY'):
_TEST_DB_DIR = _tempfile.mkdtemp(prefix='soulsync-testdb-')
_os.environ['DATABASE_PATH'] = _os.path.join(_TEST_DB_DIR, 'test_music_library.db')
_os.environ['SOULSYNC_TEST_DB_READY'] = '1'
_atexit.register(lambda: _shutil.rmtree(_TEST_DB_DIR, ignore_errors=True))
import copy
import pytest
import threading

View file

@ -0,0 +1,51 @@
"""Regression: the database-only sync matcher must accept candidate_pool.
sync_service calls _find_track_in_media_server(track, candidate_pool=...). When
no media server is connected, discovery/sync patches in a database-only matcher.
That override dropped the candidate_pool kwarg, so every Spotify sync failed with
"unexpected keyword argument 'candidate_pool'". These pin the contract.
"""
from __future__ import annotations
import asyncio
import inspect
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import core.discovery.sync as sync_mod
def test_matcher_signature_accepts_candidate_pool():
sig = inspect.signature(sync_mod._database_only_find_track)
assert 'candidate_pool' in sig.parameters
def _run(track, **kw):
fake_db = MagicMock()
fake_db.read_sync_match_cache.return_value = None
fake_db.check_track_exists.return_value = (None, 0.0)
fake_cm = MagicMock()
fake_cm.get_active_media_server.return_value = "plex"
with patch("database.music_database.MusicDatabase", return_value=fake_db), \
patch("config.settings.config_manager", fake_cm):
return asyncio.run(sync_mod._database_only_find_track(track, **kw))
def test_called_with_candidate_pool_no_match():
track = SimpleNamespace(name="Song", artists=["Artist"], id="sp1")
# The exact call sync_service makes — must not raise TypeError.
assert _run(track, candidate_pool={}) == (None, 0.0)
def test_returns_match_when_db_has_it():
track = SimpleNamespace(name="HUMBLE.", artists=["Kendrick Lamar"], id="sp2")
fake_db = MagicMock()
fake_db.read_sync_match_cache.return_value = None
fake_db.check_track_exists.return_value = (SimpleNamespace(id="t1", title="HUMBLE."), 0.95)
fake_cm = MagicMock()
fake_cm.get_active_media_server.return_value = "plex"
with patch("database.music_database.MusicDatabase", return_value=fake_db), \
patch("config.settings.config_manager", fake_cm):
match, conf = asyncio.run(sync_mod._database_only_find_track(track, candidate_pool={}))
assert conf == 0.95 and match.id == "t1"

View file

@ -223,7 +223,10 @@ def test_private_torrent_album_staging_miss_skips_per_track_search():
assert ('done', ('b1', 't1', False), {}) in rec.calls
def test_private_soulseek_album_staging_miss_skips_per_track_search():
def test_private_usenet_album_staging_miss_skips_per_track_search():
# Usenet keeps the short-circuit (the #743 change is Soulseek-only):
# per-track NZB search re-adds the same release, so the staged release
# stays authoritative.
_seed_task(track_info={
'id': 'sp-1', 'name': 'Song', 'artists': ['Artist'],
'album': 'Album', 'duration_ms': 180000,
@ -231,9 +234,9 @@ def test_private_soulseek_album_staging_miss_skips_per_track_search():
download_batches['b1'] = {
'album_bundle_private_staging': True,
'album_bundle_state': 'staged',
'album_bundle_source': 'soulseek',
'album_bundle_source': 'usenet',
}
client = _FakeClient(results=['should-not-search'], mode='soulseek')
client = _FakeClient(results=['should-not-search'], mode='usenet')
rec = _Recorder()
deps, _ = _build_deps(
soulseek=client,
@ -246,11 +249,43 @@ def test_private_soulseek_album_staging_miss_skips_per_track_search():
assert client.search_calls == []
assert download_tasks['t1']['status'] == 'not_found'
assert 'staged soulseek album release' in download_tasks['t1']['error_message']
assert 'staged usenet album release' in download_tasks['t1']['error_message']
assert ('done', ('b1', 't1', False), {}) in rec.calls
def test_private_hybrid_first_soulseek_album_staging_miss_skips_per_track_search():
def test_private_soulseek_album_staging_miss_falls_through_to_per_track_search():
# #743: a track the album needs but that wasn't in the staged Soulseek
# folder must NOT be short-circuited to not_found — it falls through to the
# normal per-track Soulseek search (unlike torrent/usenet). Even with
# album_bundle_partial unset (folder fully downloaded, just incomplete),
# Soulseek now always falls through.
_seed_task(track_info={
'id': 'sp-1', 'name': 'Song', 'artists': ['Artist'],
'album': 'Album', 'duration_ms': 180000,
})
download_batches['b1'] = {
'album_bundle_private_staging': True,
'album_bundle_state': 'staged',
'album_bundle_source': 'soulseek',
}
client = _FakeClient(results=[], mode='soulseek')
deps, _ = _build_deps(
soulseek=client,
matching=_FakeMatchEngine(queries=['Artist Song']),
try_staging_match=lambda *a, **kw: False,
)
tw.download_track_worker('t1', 'b1', deps)
assert client.search_calls # per-track search actually ran
assert download_tasks['t1']['status'] == 'not_found' # nothing found, but via search
assert 'staged soulseek album release' not in download_tasks['t1']['error_message']
def test_private_hybrid_first_soulseek_album_staging_miss_falls_through_to_per_track_search():
# Same as above but Soulseek is FIRST in a hybrid chain. The miss must fall
# through to per-track search, which (in hybrid) can then reach later
# sources — exactly the cross-source fallback #743 asks for.
_seed_task(track_info={
'id': 'sp-1', 'name': 'Song', 'artists': ['Artist'],
'album': 'Album', 'duration_ms': 180000,
@ -261,23 +296,21 @@ def test_private_hybrid_first_soulseek_album_staging_miss_skips_per_track_search
'album_bundle_source': 'soulseek',
}
client = _FakeClient(
results=['should-not-search'],
results=[],
mode='hybrid',
subclients={'hybrid_order': ['soulseek', 'hifi']},
)
rec = _Recorder()
deps, _ = _build_deps(
soulseek=client,
matching=_FakeMatchEngine(queries=['Artist Song']),
try_staging_match=lambda *a, **kw: False,
on_download_completed=rec('done'),
)
tw.download_track_worker('t1', 'b1', deps)
assert client.search_calls == []
assert client.search_calls # per-track search ran (not short-circuited)
assert download_tasks['t1']['status'] == 'not_found'
assert 'staged soulseek album release' in download_tasks['t1']['error_message']
assert 'staged soulseek album release' not in download_tasks['t1']['error_message']
def test_partial_private_hybrid_first_soulseek_album_staging_miss_allows_per_track_search():

View file

@ -0,0 +1,89 @@
"""Tests for the track-detail assembly (core/downloads/track_detail.py)."""
from __future__ import annotations
from core.downloads.track_detail import build_track_detail, classify_status_kind
# ── status classification ──────────────────────────────────────────────────
def test_classify_completed():
assert classify_status_kind('completed') == 'completed'
def test_classify_quarantined_from_integrity_message():
assert classify_status_kind('failed', 'File integrity check failed: Duration mismatch') == 'quarantined'
def test_classify_plain_failure():
assert classify_status_kind('failed', 'No sources found') == 'failed'
def test_classify_not_found():
assert classify_status_kind('not_found') == 'not_found'
def test_classify_in_progress():
assert classify_status_kind('downloading') == 'in_progress'
# ── merge: task only ────────────────────────────────────────────────────────
def test_build_from_task_only():
task = {
'task_id': 't1',
'status': 'completed',
'track_info': {'name': 'HUMBLE.', 'artists': [{'name': 'Kendrick Lamar'}], 'album': {'name': 'DAMN.'}},
'username': 'soulseek',
'filename': '/music/HUMBLE.flac',
}
d = build_track_detail(task)
assert d['status_kind'] == 'completed'
assert d['title'] == 'HUMBLE.'
assert d['artist'] == 'Kendrick Lamar'
assert d['album'] == 'DAMN.'
assert d['file_path'] == '/music/HUMBLE.flac'
assert d['source'] == 'soulseek'
def test_quarantined_task_carries_entry_id_and_reason():
task = {
'task_id': 't2',
'status': 'failed',
'error_message': 'File integrity check failed: Duration mismatch',
'quarantine_entry_id': '20260531_120000_song',
'track_info': {'name': 'Clean', 'artists': ['Taylor Swift']},
}
d = build_track_detail(task)
assert d['status_kind'] == 'quarantined'
assert d['quarantine_entry_id'] == '20260531_120000_song'
assert 'integrity' in d['reason'].lower()
assert d['artist'] == 'Taylor Swift' # string-form artist handled
# ── merge: history enriches ─────────────────────────────────────────────────
def test_history_enriches_provenance():
task = {'task_id': 't3', 'status': 'completed', 'track_info': {'name': 'N95', 'artists': [{'name': 'Kendrick Lamar'}]}}
history = {
'title': 'N95', 'artist_name': 'Kendrick Lamar', 'album_name': 'Mr. Morale',
'quality': 'FLAC 16bit', 'file_path': '/lib/N95.flac', 'acoustid_result': 'error',
'download_source': 'Soulseek', 'thumb_url': 'http://x/cover.jpg',
'source_track_title': 'N95', 'source_artist': 'Kendrick Lamar',
}
d = build_track_detail(task, history)
assert d['file_path'] == '/lib/N95.flac'
assert d['quality'] == 'FLAC 16bit'
assert d['acoustid_result'] == 'error'
assert d['source'] == 'Soulseek'
assert d['thumb_url'] == 'http://x/cover.jpg'
assert d['downloaded'] == {'title': 'N95', 'artist': 'Kendrick Lamar', 'album': 'Mr. Morale'}
assert d['expected'] == {'title': 'N95', 'artist': 'Kendrick Lamar'}
def test_history_fills_missing_title_from_task():
task = {'task_id': 't4', 'status': 'completed', 'track_info': {}} # task has no track name
history = {'title': 'Recovered Title', 'artist_name': 'Some Artist'}
d = build_track_detail(task, history)
assert d['title'] == 'Recovered Title'
assert d['artist'] == 'Some Artist'

View file

@ -124,6 +124,52 @@ def test_accepts_valid_wav_with_no_expected_duration(tmp_path: Path) -> None:
assert result.checks["length_check"] == "skipped"
# ---------------------------------------------------------------------------
# Zero-length-but-valid parse (#756): streamed / fragmented FLAC
# ---------------------------------------------------------------------------
def test_accepts_zero_length_when_parse_is_otherwise_valid(tmp_path: Path, monkeypatch) -> None:
"""#756: HiFi assembles FLAC from HLS segments and demuxes with
`ffmpeg -c copy`, leaving STREAMINFO total_samples=0. Mutagen then
reports length 0 even though every audio frame is present and the file
plays fine. Such a file large, identifiable format, valid info block
must be ACCEPTED (unknown length), not quarantined as 'zero-length'."""
import mutagen
f = tmp_path / "streamed.flac"
f.write_bytes(b"\x00" * (50 * 1024)) # clears size gate; bytes irrelevant (mutagen mocked)
# Valid parse, valid info block, but length 0 — the streamed-FLAC signature.
fake_audio = SimpleNamespace(info=SimpleNamespace(length=0))
monkeypatch.setattr(mutagen, "File", lambda *a, **k: fake_audio)
# Even with an expected duration present, it must accept (can't compare to
# an unknown length) rather than reject.
result = file_integrity.check_audio_integrity(str(f), expected_duration_ms=200_000)
assert result.ok is True
assert result.checks["mutagen_parse"] == "zero_length_unknown"
assert result.checks["length_check"] == "skipped_unknown_length"
def test_zero_length_still_rejects_when_too_small(tmp_path: Path, monkeypatch) -> None:
"""A genuinely empty/stub file with length 0 must still fail — the size
gate fires before parse, so the relaxation can't let real stubs through."""
import mutagen
f = tmp_path / "stub.flac"
f.write_bytes(b"\x00" * 200) # below the 10KB size gate
fake_audio = SimpleNamespace(info=SimpleNamespace(length=0))
monkeypatch.setattr(mutagen, "File", lambda *a, **k: fake_audio)
result = file_integrity.check_audio_integrity(str(f))
assert result.ok is False
assert "too small" in result.reason.lower()
# ---------------------------------------------------------------------------
# Duration agreement check
# ---------------------------------------------------------------------------

View file

@ -221,3 +221,80 @@ def test_post_process_matched_download_forwards_separate_metadata_runtime(tmp_pa
)
assert seen["runtime"] is metadata_runtime
# ---------------------------------------------------------------------------
# Quarantine entry-id propagation through the verification wrapper
# (the wrapper pops task_id out of context, so _mark_task_quarantined can't
# write to the task directly — it stashes on context and the wrapper applies it)
# ---------------------------------------------------------------------------
def test_mark_task_quarantined_stashes_entry_id_when_task_id_absent():
ctx = {} # wrapper popped task_id before the inner pipeline ran
import_pipeline._mark_task_quarantined(ctx, "/q/20260514_120000_song.flac.quarantined")
assert ctx["_quarantine_entry_id"] == "20260514_120000_song"
def test_mark_task_quarantined_sets_on_task_and_stashes_when_present():
original = dict(runtime_state.download_tasks)
try:
runtime_state.download_tasks.clear()
runtime_state.download_tasks["t1"] = {"status": "running"}
ctx = {"task_id": "t1"}
import_pipeline._mark_task_quarantined(ctx, "/q/20260514_120000_song.flac.quarantined")
assert runtime_state.download_tasks["t1"]["quarantine_entry_id"] == "20260514_120000_song"
assert ctx["_quarantine_entry_id"] == "20260514_120000_song"
finally:
runtime_state.download_tasks.clear()
runtime_state.download_tasks.update(original)
def test_mark_task_quarantined_noop_without_path():
ctx = {"task_id": "t1"}
import_pipeline._mark_task_quarantined(ctx, None)
assert "_quarantine_entry_id" not in ctx
def test_verification_wrapper_applies_quarantine_entry_id_on_integrity_failure(monkeypatch):
# End-to-end of the fix: the inner pipeline (mocked) quarantines on
# integrity failure and — because the wrapper popped task_id — stashes the
# entry id on context. The wrapper must apply it to the real task so the UI
# can manage the quarantined file.
task_id, batch_id, context_key = "qtask-1", "qbatch-1", "qctx-1"
context = {"track_info": {}, "task_id": task_id, "batch_id": batch_id}
def _fake_inner(ck, ctx, fp, runtime, metadata_runtime=None):
ctx["_integrity_failure_msg"] = "Duration mismatch: file is 231.0s, expected 271.0s"
ctx["_quarantine_entry_id"] = "20260514_120000_song"
monkeypatch.setattr(import_pipeline, "post_process_matched_download", _fake_inner)
original = dict(runtime_state.download_tasks)
original_ctx = dict(runtime_state.matched_downloads_context)
try:
runtime_state.download_tasks.clear()
runtime_state.download_tasks[task_id] = {"track_info": {}, "status": "running"}
runtime_state.matched_downloads_context.clear()
runtime_state.matched_downloads_context[context_key] = context
completion = []
runtime = types.SimpleNamespace(
automation_engine=None,
on_download_completed=lambda b, t, success: completion.append((b, t, success)),
web_scan_manager=None,
repair_worker=None,
)
import_pipeline.post_process_matched_download_with_verification(
context_key, context, "/tmp/source.flac", task_id, batch_id, runtime,
)
t = runtime_state.download_tasks[task_id]
assert t["status"] == "failed"
assert t["error_message"] == "File integrity check failed: Duration mismatch: file is 231.0s, expected 271.0s"
assert t["quarantine_entry_id"] == "20260514_120000_song" # the fix
assert completion == [(batch_id, task_id, False)]
finally:
runtime_state.download_tasks.clear()
runtime_state.download_tasks.update(original)
runtime_state.matched_downloads_context.clear()
runtime_state.matched_downloads_context.update(original_ctx)

View file

@ -5,6 +5,7 @@ from core.imports.quarantine import (
approve_quarantine_entry,
delete_quarantine_entry,
entry_id_from_quarantined_filename,
get_quarantine_entry_stream_info,
get_quarantined_source_keys,
list_quarantine_entries,
recover_to_staging,
@ -125,6 +126,41 @@ def test_list_handles_orphan_quarantined_file_without_sidecar(tmp_path):
assert entries[0]["has_full_context"] is False
# ──────────────────────────────────────────────────────────────────────
# get_quarantine_entry_stream_info — in-app "Listen" support
# ──────────────────────────────────────────────────────────────────────
def test_stream_info_resolves_path_and_extension_from_sidecar(tmp_path):
qfile, _ = _write_entry(tmp_path, "20260514_120000", "song.flac", with_context=True)
entry_id = entry_id_from_quarantined_filename(qfile.name)
info = get_quarantine_entry_stream_info(str(tmp_path), entry_id)
assert info is not None
file_path, ext = info
assert file_path == str(qfile)
assert ext == ".flac" # real audio ext, NOT ".quarantined"
def test_stream_info_recovers_extension_without_sidecar(tmp_path):
# Orphan .quarantined with no sidecar — extension comes from the filename
# convention so playback still gets a correct Content-Type.
qfile = tmp_path / "20260514_120000_orphan.mp3.quarantined"
qfile.write_bytes(b"X" * 100)
entry_id = entry_id_from_quarantined_filename(qfile.name)
info = get_quarantine_entry_stream_info(str(tmp_path), entry_id)
assert info is not None
file_path, ext = info
assert file_path == str(qfile)
assert ext == ".mp3"
def test_stream_info_returns_none_for_missing_entry(tmp_path):
assert get_quarantine_entry_stream_info(str(tmp_path), "does_not_exist") is None
def test_list_skips_orphan_sidecars_without_file(tmp_path):
sidecar = tmp_path / "20260514_120000_only.json"
sidecar.write_text(json.dumps({"original_filename": "only.flac", "quarantine_reason": "x"}))
@ -398,3 +434,23 @@ def test_source_keys_dedup_repeated_sources(tmp_path):
keys = get_quarantined_source_keys(str(tmp_path))
assert keys == {("peer", "dupe.flac")}
# ──────────────────────────────────────────────────────────────────────
# _move_with_retry — resilient move (Windows file-lock case)
# ──────────────────────────────────────────────────────────────────────
def test_move_with_retry_succeeds(tmp_path):
from core.imports.quarantine import _move_with_retry
src = tmp_path / "a.flac"; src.write_bytes(b"x" * 10)
dst = tmp_path / "out" / "a.flac"
(tmp_path / "out").mkdir()
assert _move_with_retry(str(src), str(dst)) is True
assert dst.exists() and not src.exists()
def test_move_with_retry_returns_false_on_missing_source(tmp_path):
from core.imports.quarantine import _move_with_retry
# attempts=1 keeps the test fast (no retry sleeps)
assert _move_with_retry(str(tmp_path / "nope.flac"), str(tmp_path / "dst.flac"),
attempts=1, delay=0) is False

View file

@ -140,7 +140,7 @@ class TestIssue442Regression:
verifier, fake_service = stubbed_verifier
# AcoustID returns the recording with kanji artist
verifier.acoustid_client.fingerprint_and_lookup.return_value = {
verifier.acoustid_client.lookup_with_status.return_value = {
'best_score': 0.95,
'recordings': [
{'title': 'YAMANAIAME', 'artist': '澤野弘之', 'mbid': 'rec-x'},
@ -165,7 +165,7 @@ class TestIssue442Regression:
"""Reporter's case 2 — Sergey Lazarev / Сергей Лазарев."""
verifier, fake_service = stubbed_verifier
verifier.acoustid_client.fingerprint_and_lookup.return_value = {
verifier.acoustid_client.lookup_with_status.return_value = {
'best_score': 0.95,
'recordings': [
{'title': 'On the Other Side', 'artist': 'Sergey Lazarev', 'mbid': 'rec-y'},
@ -196,7 +196,7 @@ class TestBackwardCompat:
verifier, fake_service = stubbed_verifier
# Wrong artist entirely — Latin script both sides, sim ~0
verifier.acoustid_client.fingerprint_and_lookup.return_value = {
verifier.acoustid_client.lookup_with_status.return_value = {
'best_score': 0.95,
'recordings': [
{'title': 'Some Track', 'artist': 'Khalil Turk & Friends'},
@ -214,7 +214,7 @@ class TestBackwardCompat:
"""Exact title + artist match → PASS regardless of aliases."""
verifier, fake_service = stubbed_verifier
verifier.acoustid_client.fingerprint_and_lookup.return_value = {
verifier.acoustid_client.lookup_with_status.return_value = {
'best_score': 0.95,
'recordings': [
{'title': 'Dirty White Boy', 'artist': 'Foreigner'},
@ -233,7 +233,7 @@ class TestBackwardCompat:
verifier, fake_service = stubbed_verifier
fake_service.lookup_artist_aliases.side_effect = Exception("MB down")
verifier.acoustid_client.fingerprint_and_lookup.return_value = {
verifier.acoustid_client.lookup_with_status.return_value = {
'best_score': 0.95,
'recordings': [
{'title': 'Dirty White Boy', 'artist': 'Foreigner'},
@ -261,7 +261,7 @@ class TestAliasLookupCalledOncePerVerify:
fire 60+ MB lookups (cached or not, that's wasteful)."""
verifier, fake_service = stubbed_verifier
verifier.acoustid_client.fingerprint_and_lookup.return_value = {
verifier.acoustid_client.lookup_with_status.return_value = {
'best_score': 0.95,
'recordings': [
{'title': 'X', 'artist': '澤野弘之'},
@ -295,7 +295,7 @@ class TestLazyAliasResolution:
this PR."""
verifier, fake_service = stubbed_verifier
verifier.acoustid_client.fingerprint_and_lookup.return_value = {
verifier.acoustid_client.lookup_with_status.return_value = {
'best_score': 0.95,
'recordings': [
{'title': 'Dirty White Boy', 'artist': 'Foreigner'},
@ -317,7 +317,7 @@ class TestLazyAliasResolution:
as expected."""
verifier, fake_service = stubbed_verifier
verifier.acoustid_client.fingerprint_and_lookup.return_value = {
verifier.acoustid_client.lookup_with_status.return_value = {
'best_score': 0.95,
'recordings': [
{'title': 'YAMANAIAME', 'artist': '澤野弘之'},
@ -343,7 +343,7 @@ class TestLazyAliasResolution:
# Force a code path that hits multiple sites: title matches
# several recordings but the best-match's artist sim is below
# threshold (forces secondary scan path).
verifier.acoustid_client.fingerprint_and_lookup.return_value = {
verifier.acoustid_client.lookup_with_status.return_value = {
'best_score': 0.95,
'recordings': [
{'title': 'X', 'artist': 'Different Latin Artist'}, # 0 alias hit

View file

@ -0,0 +1,260 @@
"""Seam tests for the per-source album-art lookups + availability.
Real clients are stubbed (monkeypatched at the lazy-import sites), so these
exercise the field-extraction, caching, guarding, and availability gating
without any network.
"""
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
from core.metadata import art_lookup
# ---------------------------------------------------------------------------
# Availability — "not everybody has every source"
# ---------------------------------------------------------------------------
def test_free_sources_always_available():
for s in ("caa", "deezer", "itunes", "audiodb"):
assert art_lookup.is_art_source_available(s) is True
def test_unknown_or_unsupported_source_unavailable():
assert art_lookup.is_art_source_available("tidal") is False # deferred
assert art_lookup.is_art_source_available("genius") is False
assert art_lookup.is_art_source_available("") is False
def test_spotify_availability_follows_connection(monkeypatch):
import core.metadata.registry as registry
monkeypatch.setattr(registry, "get_client_for_source", lambda s: object())
assert art_lookup.is_art_source_available("spotify") is True
monkeypatch.setattr(registry, "get_client_for_source", lambda s: None)
assert art_lookup.is_art_source_available("spotify") is False
def test_spotify_availability_swallows_errors(monkeypatch):
import core.metadata.registry as registry
def _boom(_s):
raise RuntimeError("registry down")
monkeypatch.setattr(registry, "get_client_for_source", _boom)
assert art_lookup.is_art_source_available("spotify") is False
def test_available_sources_lists_free_plus_connected_spotify(monkeypatch):
import core.metadata.registry as registry
monkeypatch.setattr(registry, "get_client_for_source", lambda s: object())
avail = art_lookup.available_art_sources()
assert avail == ["caa", "deezer", "itunes", "spotify", "audiodb"]
monkeypatch.setattr(registry, "get_client_for_source", lambda s: None)
assert "spotify" not in art_lookup.available_art_sources()
# ---------------------------------------------------------------------------
# Per-source extraction
# ---------------------------------------------------------------------------
def test_caa_art_builds_url_from_release_mbid():
url = art_lookup._caa_art("A", "B", {"musicbrainz_release_id": "abc-123"})
assert url == "https://coverartarchive.org/release/abc-123/front-1200"
def test_caa_art_none_without_mbid():
assert art_lookup._caa_art("A", "B", {}) is None
def test_deezer_art_extracts_and_prefers_largest(monkeypatch):
import core.metadata.registry as registry
client = MagicMock()
client.search_album.return_value = {"title": "B", "artist": {"name": "A"},
"cover_big": "http://x/big.jpg",
"cover_xl": "http://x/xl.jpg"}
monkeypatch.setattr(registry, "get_deezer_client", lambda *a, **k: client)
# _upgrade_deezer_cover_url returns non-Deezer URLs unchanged.
assert art_lookup._deezer_art("A", "B", {}) == "http://x/xl.jpg"
def test_deezer_art_none_when_no_cover_fields(monkeypatch):
import core.metadata.registry as registry
client = MagicMock()
# Matches the album but carries no cover_* keys.
client.search_album.return_value = {"title": "B", "artist": {"name": "A"}, "id": 1}
monkeypatch.setattr(registry, "get_deezer_client", lambda *a, **k: client)
assert art_lookup._deezer_art("A", "B", {}) is None
def test_deezer_art_rejects_wrong_album(monkeypatch):
import core.metadata.registry as registry
client = MagicMock()
client.search_album.return_value = {"title": "A Totally Different Record",
"artist": {"name": "A"},
"cover_xl": "http://x/wrong.jpg"}
monkeypatch.setattr(registry, "get_deezer_client", lambda *a, **k: client)
# Wrong album -> no art (falls back to today's cover), never wrong art.
assert art_lookup._deezer_art("A", "B", {}) is None
def test_deezer_art_rejects_wrong_artist(monkeypatch):
import core.metadata.registry as registry
client = MagicMock()
client.search_album.return_value = {"title": "21", "artist": {"name": "Someone Else"},
"cover_xl": "http://x/wrong.jpg"}
monkeypatch.setattr(registry, "get_deezer_client", lambda *a, **k: client)
# Album matches but the artist doesn't -> reject (don't embed wrong art).
assert art_lookup._deezer_art("Adele", "21", {}) is None
def test_itunes_art_returns_first_matching_album_image(monkeypatch):
import core.metadata.registry as registry
client = MagicMock()
client.search_albums.return_value = [
SimpleNamespace(name="B", artists=["A"], image_url=None), # match but no art -> skip
SimpleNamespace(name="Wrong", artists=["A"], image_url="http://it/wrong.jpg"), # art but wrong album -> skip
SimpleNamespace(name="B (Deluxe)", artists=["A"], image_url="http://it/600.jpg"), # match + art
]
monkeypatch.setattr(registry, "get_itunes_client", lambda *a, **k: client)
assert art_lookup._itunes_art("A", "B", {}) == "http://it/600.jpg"
def test_itunes_art_upgrades_to_max_resolution(monkeypatch):
import core.metadata.registry as registry
client = MagicMock()
client.search_albums.return_value = [
SimpleNamespace(name="GNX", artists=["Kendrick Lamar"],
image_url="https://is1.mzstatic.com/image/source/600x600bb.jpg")]
monkeypatch.setattr(registry, "get_itunes_client", lambda *a, **k: client)
# The 600x600 default is bumped to the max so iTunes contributes big art.
assert art_lookup._itunes_art("Kendrick Lamar", "GNX", {}) == \
"https://is1.mzstatic.com/image/source/3000x3000bb.jpg"
def test_audiodb_art_extracts_thumb(monkeypatch):
import core.audiodb_client as adb
fake = MagicMock()
fake.search_album.return_value = {"strAlbum": "B", "strArtist": "A",
"strAlbumThumb": "http://adb/cover.jpg"}
monkeypatch.setattr(adb, "AudioDBClient", lambda *a, **k: fake)
assert art_lookup._audiodb_art("A", "B", {}) == "http://adb/cover.jpg"
def test_spotify_art_uses_connected_client(monkeypatch):
import core.metadata.registry as registry
client = MagicMock()
client.search_albums.return_value = [
SimpleNamespace(name="B", artists=["A"], image_url="http://sp/640.jpg")]
monkeypatch.setattr(registry, "get_client_for_source", lambda s: client)
assert art_lookup._spotify_art("A", "B", {}) == "http://sp/640.jpg"
# --- album-match validation (the wrong-art guard) ---
def test_album_matches_exact_and_suffix_variants():
assert art_lookup._album_matches("Taylor Swift", "1989", "Taylor Swift", "1989")
assert art_lookup._album_matches("Taylor Swift", "1989", "Taylor Swift", "1989 (Deluxe)")
assert art_lookup._album_matches("Pink Floyd", "The Dark Side of the Moon",
"Pink Floyd", "Dark Side of the Moon - Remastered")
def test_album_matches_multi_artist_and_feat():
assert art_lookup._album_matches("Drake", "Scorpion", "Drake & Future", "Scorpion")
assert art_lookup._album_matches("Tyler, The Creator", "IGOR", "Tyler The Creator", "IGOR")
def test_album_matches_rejects_wrong_album_or_artist():
assert not art_lookup._album_matches("Adele", "21", "Adele", "Completely Different")
# Generic album title, different artist -> the artist gate rejects it.
assert not art_lookup._album_matches("Coldplay", "Greatest Hits", "Other Band", "Greatest Hits")
assert not art_lookup._album_matches("Adele", "21", "Adele", "")
assert not art_lookup._album_matches("Adele", "", "Adele", "21")
def test_album_matches_unknown_requested_artist_allows_album_match():
# cover.jpg path may lack artist context -> album match alone suffices.
assert art_lookup._album_matches("", "1989", "Taylor Swift", "1989")
# ---------------------------------------------------------------------------
# build_art_lookup — caching + guarding
# ---------------------------------------------------------------------------
def test_lookup_is_cached_per_source(monkeypatch):
import core.metadata.registry as registry
client = MagicMock()
client.search_album.return_value = {"title": "B", "artist": {"name": "A"},
"cover_xl": "http://x/xl.jpg"}
monkeypatch.setattr(registry, "get_deezer_client", lambda *a, **k: client)
lookup = art_lookup.build_art_lookup("A", "B", {})
first = lookup("deezer")
second = lookup("deezer")
assert first == second == "http://x/xl.jpg"
# Cached: the underlying client was only hit once across both calls.
assert client.search_album.call_count == 1
def test_lookup_guards_source_exceptions(monkeypatch):
import core.metadata.registry as registry
def _boom(*a, **k):
raise RuntimeError("network down")
monkeypatch.setattr(registry, "get_deezer_client", _boom)
lookup = art_lookup.build_art_lookup("A", "B", {})
assert lookup("deezer") is None # swallowed, not raised
def test_lookup_unknown_source_returns_none():
lookup = art_lookup.build_art_lookup("A", "B", {})
assert lookup("tidal") is None
assert lookup("bogus") is None
# ---------------------------------------------------------------------------
# select_preferred_art_url — the gate the artwork pipeline calls
# ---------------------------------------------------------------------------
def test_selector_feature_off_returns_none():
# The critical non-breaking case: no configured order -> no-op, caller
# keeps today's art.
assert art_lookup.select_preferred_art_url("A", "B", {}, None) is None
assert art_lookup.select_preferred_art_url("A", "B", {}, []) is None
assert art_lookup.select_preferred_art_url("A", "B", {}, "deezer") is None
def test_selector_resolves_first_available_source(monkeypatch):
import core.metadata.registry as registry
client = MagicMock()
client.search_album.return_value = {"title": "B", "artist": {"name": "A"},
"cover_xl": "http://x/xl.jpg"}
monkeypatch.setattr(registry, "get_deezer_client", lambda *a, **k: client)
# Order lists an unsupported source first (filtered out), then deezer.
url = art_lookup.select_preferred_art_url("A", "B", {}, ["tidal", "deezer"])
assert url == "http://x/xl.jpg"
def test_selector_none_when_order_has_no_available_sources(monkeypatch):
import core.metadata.registry as registry
monkeypatch.setattr(registry, "get_client_for_source", lambda s: None) # spotify off
# 'tidal' unsupported, 'spotify' unavailable -> empty effective order.
assert art_lookup.select_preferred_art_url("A", "B", {}, ["tidal", "spotify"]) is None
def test_selector_none_when_nothing_resolves(monkeypatch):
import core.metadata.registry as registry
client = MagicMock()
client.search_album.return_value = None # deezer has no match
monkeypatch.setattr(registry, "get_deezer_client", lambda *a, **k: client)
assert art_lookup.select_preferred_art_url("A", "B", {}, ["deezer"]) is None
def test_selector_caa_uses_release_mbid(monkeypatch):
url = art_lookup.select_preferred_art_url(
"A", "B", {"musicbrainz_release_id": "mbid-9"}, ["caa"])
assert url == "https://coverartarchive.org/release/mbid-9/front-1200"

View file

@ -0,0 +1,54 @@
"""Tests for the minimum-resolution guard on preferred cover art.
A source's art is only accepted when its shortest side meets the threshold, so
a low-res cover (e.g. a small Cover Art Archive upload) is skipped and the
resolver falls through to the next source instead of winning on priority alone.
Reproduces the two real cases that motivated it: Taylor Swift 599x531 and
Kendrick GNX 600x600 both rejected at the default 1000px floor.
"""
from __future__ import annotations
from unittest.mock import patch
from core.metadata import artwork
def _run(min_px, fetch_return, dims):
with patch.object(artwork, "_fetch_art_bytes", return_value=fetch_return), \
patch.object(artwork, "get_image_dimensions", return_value=dims):
validate, cache = artwork._min_size_art_validator(min_px)
return validate("deezer", "http://x/cover.jpg"), cache
def test_rejects_small_square_art():
ok, cache = _run(1000, (b"img", "image/jpeg"), (600, 600)) # GNX case
assert ok is False
# Bytes are cached even on reject (so a later accepted source reuses fetches).
assert cache["http://x/cover.jpg"] == (b"img", "image/jpeg")
def test_rejects_small_non_square_using_shortest_side():
ok, _ = _run(1000, (b"img", "image/jpeg"), (599, 531)) # Taylor case
assert ok is False
def test_accepts_big_art():
ok, cache = _run(1000, (b"img", "image/jpeg"), (1900, 1900))
assert ok is True
assert cache["http://x/cover.jpg"] == (b"img", "image/jpeg")
def test_accepts_unmeasurable_art_to_avoid_over_rejecting():
ok, _ = _run(1000, (b"img", "image/jpeg"), None)
assert ok is True
def test_rejects_when_fetch_returns_no_bytes():
ok, _ = _run(1000, (None, None), (4000, 4000))
assert ok is False
def test_zero_threshold_disables_the_gate():
ok, _ = _run(0, (b"img", "image/jpeg"), (10, 10))
assert ok is True

View file

@ -0,0 +1,114 @@
"""Unit tests for the pure cover-art source selection logic.
Pins the ordering + fallback + back-compat contract that the artwork
integration relies on. No network, config, or DB the per-source lookups are
injected, so these tests are fast and deterministic.
"""
from __future__ import annotations
from core.metadata.art_sources import (
ART_CAPABLE_SOURCES,
effective_art_order,
resolve_cover_art,
)
# ---------------------------------------------------------------------------
# effective_art_order — config resolution + legacy back-compat
# ---------------------------------------------------------------------------
def test_configured_order_wins_and_is_normalized():
assert effective_art_order(["Deezer", " CAA ", "iTunes"]) == ["deezer", "caa", "itunes"]
def test_unknown_sources_filtered_out():
# 'genius'/'lastfm' aren't art-capable; 'bogus' is unknown.
assert effective_art_order(["genius", "deezer", "bogus", "lastfm"]) == ["deezer"]
def test_duplicates_collapsed_keeping_first_position():
assert effective_art_order(["deezer", "caa", "deezer", "CAA"]) == ["deezer", "caa"]
def test_empty_order_with_prefer_caa_is_legacy_caa_first():
# Back-compat: an un-migrated install with prefer_caa_art on behaves as
# 'CAA first, then the download's own art' — exactly today's logic.
assert effective_art_order([], prefer_caa_art=True) == ["caa"]
assert effective_art_order(None, prefer_caa_art=True) == ["caa"]
def test_empty_order_without_prefer_caa_is_default_only():
# The critical non-breaking case: no list + no prefer_caa => empty order,
# so the caller uses the download's own art (today's default).
assert effective_art_order([], prefer_caa_art=False) == []
assert effective_art_order(None) == []
assert effective_art_order("not-a-list") == []
def test_all_invalid_entries_fall_back_to_legacy():
assert effective_art_order(["genius", "lastfm"], prefer_caa_art=True) == ["caa"]
assert effective_art_order(["genius", "lastfm"], prefer_caa_art=False) == []
def test_art_capable_sources_excludes_lyrics_only_sources():
assert "caa" in ART_CAPABLE_SOURCES
assert "deezer" in ART_CAPABLE_SOURCES
assert "genius" not in ART_CAPABLE_SOURCES
assert "lastfm" not in ART_CAPABLE_SOURCES
# ---------------------------------------------------------------------------
# resolve_cover_art — ordered walk + fallback + robustness
# ---------------------------------------------------------------------------
def test_first_source_with_art_wins():
art = {"caa": "http://caa/x.jpg", "deezer": "http://dz/y.jpg"}
url, src = resolve_cover_art(["deezer", "caa"], art.get)
assert (url, src) == ("http://dz/y.jpg", "deezer")
def test_falls_through_to_next_source_when_missing():
art = {"deezer": None, "caa": "http://caa/x.jpg"}
url, src = resolve_cover_art(["deezer", "caa"], art.get)
assert (url, src) == ("http://caa/x.jpg", "caa")
def test_returns_none_when_nothing_resolves():
url, src = resolve_cover_art(["deezer", "caa"], lambda _s: None)
assert (url, src) == (None, None)
def test_empty_order_returns_none_so_caller_uses_default():
url, src = resolve_cover_art([], lambda _s: "http://should/not/be/called.jpg")
assert (url, src) == (None, None)
def test_validate_rejection_skips_to_next_source():
art = {"deezer": "http://dz/tiny.jpg", "caa": "http://caa/big.jpg"}
# Pretend deezer's image fails validation (e.g. too small / placeholder).
def _validate(source, url):
return source != "deezer"
url, src = resolve_cover_art(["deezer", "caa"], art.get, validate=_validate)
assert (url, src) == ("http://caa/big.jpg", "caa")
def test_lookup_exception_is_treated_as_miss_not_fatal():
def _lookup(source):
if source == "deezer":
raise RuntimeError("network down")
return "http://caa/x.jpg"
url, src = resolve_cover_art(["deezer", "caa"], _lookup)
assert (url, src) == ("http://caa/x.jpg", "caa")
def test_validate_exception_is_treated_as_miss():
def _validate(source, url):
if source == "deezer":
raise ValueError("bad image header")
return True
art = {"deezer": "http://dz/x.jpg", "caa": "http://caa/x.jpg"}
url, src = resolve_cover_art(["deezer", "caa"], art.get, validate=_validate)
assert (url, src) == ("http://caa/x.jpg", "caa")

View file

@ -0,0 +1,181 @@
"""Tests for the metadata-cache hard capacity cap (LRU eviction).
TTL-only eviction had no upper bound, so heavy in-window caching let
metadata_cache_entities reach ~1.8M rows / 7.6 GB. evict_over_capacity adds an
LRU row ceiling. We test the pure decision function directly and the SQL
behavior against a real temp DB (proves it drops the LEAST-recently-accessed
rows, not arbitrary ones).
"""
from __future__ import annotations
import sqlite3
import sys
import types
import pytest
# Minimal stubs so importing core.metadata.cache doesn't drag in spotipy/config.
if "spotipy" not in sys.modules:
spotipy = types.ModuleType("spotipy")
spotipy.Spotify = object
oauth2 = types.ModuleType("spotipy.oauth2")
oauth2.SpotifyOAuth = object
oauth2.SpotifyClientCredentials = object
spotipy.oauth2 = oauth2
sys.modules["spotipy"] = spotipy
sys.modules["spotipy.oauth2"] = oauth2
if "config.settings" not in sys.modules:
config_pkg = types.ModuleType("config")
settings_mod = types.ModuleType("config.settings")
class _DummyCM:
def get(self, key, default=None):
return default
def get_active_media_server(self):
return "plex"
settings_mod.config_manager = _DummyCM()
config_pkg.settings = settings_mod
sys.modules["config"] = config_pkg
sys.modules["config.settings"] = settings_mod
from core.metadata.cache import ( # noqa: E402
MetadataCache,
entities_to_evict_for_capacity,
)
# ── pure decision function ─────────────────────────────────────────────────
def test_evict_count_over_cap():
assert entities_to_evict_for_capacity(1000, 250) == 750
def test_evict_count_at_or_under_cap_is_zero():
assert entities_to_evict_for_capacity(250, 250) == 0
assert entities_to_evict_for_capacity(10, 250) == 0
def test_cap_zero_or_negative_means_no_eviction():
assert entities_to_evict_for_capacity(10_000, 0) == 0
assert entities_to_evict_for_capacity(10_000, -1) == 0
def test_never_negative():
assert entities_to_evict_for_capacity(0, 100) == 0
# ── evict_over_capacity SQL behavior (real temp DB) ────────────────────────
class _NonClosingConn:
def __init__(self, real):
self._real = real
def cursor(self):
return self._real.cursor()
def commit(self):
return self._real.commit()
def close(self):
pass
def __enter__(self):
return self
def __exit__(self, *a):
pass
class _TempCache(MetadataCache):
"""MetadataCache whose _get_db returns a shim over a shared in-memory DB
holding just the entities table enough to exercise evict_over_capacity."""
def __init__(self):
self._conn = sqlite3.connect(":memory:")
self._conn.row_factory = sqlite3.Row
self._conn.execute("""
CREATE TABLE metadata_cache_entities (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source TEXT, entity_type TEXT, entity_id TEXT,
name TEXT, raw_json TEXT,
last_accessed_at TIMESTAMP,
ttl_days INTEGER DEFAULT 30
)
""")
self._conn.commit()
def _get_db(self):
outer = self
class _DB:
def _get_connection(self_inner):
return _NonClosingConn(outer._conn)
return _DB()
# NOTE: we deliberately do NOT override _run_maintenance_write — the test
# exercises the REAL method (retry + connection handling) so we're testing
# production code, not a stub. _get_db is the only injected seam.
def _add_rows(cache, specs):
"""specs: list of (entity_id, last_accessed_at). Inserts rows."""
cur = cache._conn.cursor()
for eid, ts in specs:
cur.execute(
"INSERT INTO metadata_cache_entities (source, entity_type, entity_id, name, raw_json, last_accessed_at) "
"VALUES ('spotify','artist',?,?, '{}', ?)",
(eid, eid, ts),
)
cache._conn.commit()
def _ids(cache):
return [r["entity_id"] for r in cache._conn.execute(
"SELECT entity_id FROM metadata_cache_entities ORDER BY entity_id"
).fetchall()]
def test_evict_over_capacity_drops_least_recently_accessed():
cache = _TempCache()
# 5 rows, distinct access times. Cap at 3 -> evict the 2 oldest-accessed.
_add_rows(cache, [
("a", "2026-05-01T00:00:00"), # oldest -> evicted
("b", "2026-05-02T00:00:00"), # oldest -> evicted
("c", "2026-05-03T00:00:00"),
("d", "2026-05-04T00:00:00"),
("e", "2026-05-05T00:00:00"), # newest -> kept
])
evicted = cache.evict_over_capacity(max_rows=3)
assert evicted == 2
assert _ids(cache) == ["c", "d", "e"] # a, b gone (LRU)
def test_evict_over_capacity_noop_under_cap():
cache = _TempCache()
_add_rows(cache, [("a", "2026-05-01T00:00:00"), ("b", "2026-05-02T00:00:00")])
assert cache.evict_over_capacity(max_rows=10) == 0
assert _ids(cache) == ["a", "b"]
def test_evict_over_capacity_disabled_with_zero_cap():
cache = _TempCache()
_add_rows(cache, [(str(i), f"2026-05-0{i}T00:00:00") for i in range(1, 6)])
assert cache.evict_over_capacity(max_rows=0) == 0
assert len(_ids(cache)) == 5
def test_null_access_times_evicted_first():
"""Rows never accessed since insert (NULL last_accessed_at) are the
coldest they should go before any touched row."""
cache = _TempCache()
_add_rows(cache, [
("never1", None),
("never2", None),
("touched", "2026-05-01T00:00:00"),
])
evicted = cache.evict_over_capacity(max_rows=1)
assert evicted == 2
assert _ids(cache) == ["touched"]

View file

@ -0,0 +1,122 @@
"""Query-construction tests for core/musicbrainz_client.py.
Regression guard for #754: the user-facing "Search for Match" / Fix popup
runs non-strict (strict=False) MusicBrainz searches. The old non-strict path
built a bare "track artist" blob with NO field scoping, so the artist was just
a free fuzzy term covers/karaoke whose TITLES contained the artist name
outranked the real recording (e.g. "Sweet Child O Mine" / "Guns N Roses"
returned only covers, never the Guns N' Roses original).
The fix keeps the track/album side loose (diacritic + bracket recall) but
field-scopes the artist (artist:(...)) so it actually constrains. These tests
pin the query STRING the client sends no network by capturing the params
passed to session.get.
"""
from __future__ import annotations
from unittest.mock import MagicMock
import pytest
from core.musicbrainz_client import MusicBrainzClient, _escape_lucene
@pytest.fixture
def client():
c = MusicBrainzClient("SoulSync", "2")
# Replace the HTTP session with a mock returning an empty result set, so
# we can inspect the query string without touching the network.
resp = MagicMock()
resp.raise_for_status = MagicMock()
resp.json = MagicMock(return_value={"recordings": [], "releases": []})
c.session = MagicMock()
c.session.get = MagicMock(return_value=resp)
return c
def _query_of(client) -> str:
"""The 'query' param of the last session.get call."""
_, kwargs = client.session.get.call_args
return kwargs["params"]["query"]
# ── recording: non-strict must field-scope the artist ──────────────────────
def test_recording_nonstrict_scopes_artist(client):
client.search_recording("Sweet Child O Mine", artist_name="Guns N Roses", strict=False)
q = _query_of(client)
assert "artist:(Guns N Roses)" in q # artist is a CONSTRAINT, not a loose term
assert q.startswith("Sweet Child O Mine") # track side stays loose (no phrase quotes)
assert '"' not in q # no phrase-quoting that kills bracket/diacritic recall
def test_recording_nonstrict_without_artist_is_bare_track(client):
client.search_recording("Hyperballad", strict=False)
assert _query_of(client) == "Hyperballad" # no artist → no AND clause
def test_recording_nonstrict_whitespace_artist_is_bare_track(client):
# A whitespace-only artist must not produce a malformed artist:( ) group.
client.search_recording("Hyperballad", artist_name=" ", strict=False)
assert _query_of(client) == "Hyperballad"
def test_recording_nonstrict_strips_artist_padding(client):
client.search_recording("Money", artist_name=" Pink Floyd ", strict=False)
assert _query_of(client) == "Money AND artist:(Pink Floyd)"
def test_recording_strict_unchanged(client):
client.search_recording("Say You Will", artist_name="Foreigner", strict=True)
q = _query_of(client)
assert q == 'recording:"Say You Will" AND artist:"Foreigner"' # strict path untouched
def test_recording_nonstrict_escapes_lucene_specials_in_artist(client):
# Artist names with parens/?/! must NOT break the artist:(...) group.
# Without escaping, "Sunn O)))" closes the group early and returns
# unrelated artists; "Anthony Green (Saosin)" returns nothing.
client.search_recording("Hunting Season", artist_name="Sunn O)))", strict=False)
q = _query_of(client)
assert "artist:(Sunn O\\)\\)\\))" in q # every ) escaped, group stays balanced
# The clause is well-formed: opening "artist:(" is closed by exactly one
# unescaped ")", so paren depth returns to zero.
depth = 0
after = q.split("artist:(", 1)[1]
i = 0
while i < len(after):
ch = after[i]
if ch == "\\":
i += 2
continue
if ch == "(":
depth += 1
elif ch == ")":
if depth == 0:
break # this is the closing paren of artist:(
depth -= 1
i += 1
assert depth == 0
def test_escape_lucene_helper():
assert _escape_lucene("Sunn O)))") == "Sunn O\\)\\)\\)"
assert _escape_lucene("Therapy?") == "Therapy\\?"
assert _escape_lucene("AC/DC") == "AC\\/DC"
assert _escape_lucene("Foreigner") == "Foreigner" # plain text untouched
# ── release: same fix, same guarantees ─────────────────────────────────────
def test_release_nonstrict_scopes_artist(client):
client.search_release("Nevermind", artist_name="Nirvana", strict=False)
q = _query_of(client)
assert "artist:(Nirvana)" in q
assert q.startswith("Nevermind")
assert '"' not in q
def test_release_strict_unchanged(client):
client.search_release("Nevermind", artist_name="Nirvana", strict=True)
assert _query_of(client) == 'release:"Nevermind" AND artist:"Nirvana"'

View file

View file

@ -0,0 +1,75 @@
"""Tests for core.playback.play_log.build_play_event."""
from __future__ import annotations
from core.playback.play_log import WEB_PLAYER_SOURCE, build_play_event
TS = "2026-05-30T12:00:00Z"
def test_library_track_full_event():
ev = build_play_event(
{"id": 4321, "title": "DtMF", "artist": "Bad Bunny", "album": "DeBÍ"},
TS, duration_ms=237000,
)
assert ev == {
"track_id": "4321",
"title": "DtMF",
"artist": "Bad Bunny",
"album": "DeBÍ",
"played_at": TS,
"duration_ms": 237000,
"server_source": WEB_PLAYER_SOURCE,
"db_track_id": 4321,
}
def test_int_string_id_is_db_track_id():
ev = build_play_event({"id": "99", "title": "X"}, TS)
assert ev["db_track_id"] == 99
assert ev["track_id"] == "99"
def test_composite_id_not_used_as_db_track_id():
# Streamed/search results can carry a composite id like "user||file" —
# must NOT become db_track_id (would corrupt the int FK join).
ev = build_play_event({"id": "peer||song.flac", "title": "Streamed"}, TS)
assert ev["db_track_id"] is None
assert ev["track_id"] == "peer||song.flac"
def test_missing_title_returns_none():
assert build_play_event({"id": 1, "artist": "A"}, TS) is None
assert build_play_event({"title": " "}, TS) is None
def test_non_dict_returns_none():
assert build_play_event(None, TS) is None
assert build_play_event("nope", TS) is None
def test_missing_fields_default_to_empty():
ev = build_play_event({"title": "Solo"}, TS)
assert ev["artist"] == ""
assert ev["album"] == ""
assert ev["duration_ms"] == 0
assert ev["db_track_id"] is None
assert ev["track_id"] is None
def test_bad_duration_is_zero():
ev = build_play_event({"id": 1, "title": "T"}, TS, duration_ms="not-a-number")
assert ev["duration_ms"] == 0
def test_bool_id_not_treated_as_int():
# True is an int in Python — must not slip through as a track id.
ev = build_play_event({"id": True, "title": "T"}, TS)
assert ev["db_track_id"] is None
def test_caller_supplies_timestamp_pure():
# The module never reads the clock — same input → same output.
a = build_play_event({"id": 1, "title": "T"}, TS)
b = build_play_event({"id": 1, "title": "T"}, TS)
assert a == b

0
tests/radio/__init__.py Normal file
View file

View file

@ -0,0 +1,293 @@
"""End-to-end behavioral pin for MusicDatabase.get_radio_tracks.
Phase 0a extracted the radio SELECTION logic into core.radio.selection but the
DB method still owns the SQL. These tests drive the REAL get_radio_tracks
against an in-memory sqlite to prove the refactor preserved behavior the
4-tier fallback (same-artist cap genre mood/style random), dedup, and
exclude handling all still work through the extracted helpers.
Reuses the in-memory MusicDatabase harness pattern from
tests/test_reorganize_db_methods.py.
"""
import sqlite3
import sys
import types
import pytest
# ── stubs (same shape used elsewhere in the suite) ────────────────────────
if "spotipy" not in sys.modules:
spotipy = types.ModuleType("spotipy")
spotipy.Spotify = object
oauth2 = types.ModuleType("spotipy.oauth2")
oauth2.SpotifyOAuth = object
oauth2.SpotifyClientCredentials = object
spotipy.oauth2 = oauth2
sys.modules["spotipy"] = spotipy
sys.modules["spotipy.oauth2"] = oauth2
if "config.settings" not in sys.modules:
config_pkg = types.ModuleType("config")
settings_mod = types.ModuleType("config.settings")
class _DummyConfigManager:
def get(self, key, default=None):
return default
def get_active_media_server(self):
return "primary"
settings_mod.config_manager = _DummyConfigManager()
config_pkg.settings = settings_mod
sys.modules["config"] = config_pkg
sys.modules["config.settings"] = settings_mod
from database.music_database import MusicDatabase # noqa: E402
class _InMemoryDB(MusicDatabase):
def __init__(self):
self._conn = sqlite3.connect(":memory:")
self._conn.row_factory = sqlite3.Row
def _get_connection(self):
return _NonClosingConn(self._conn)
class _NonClosingConn:
def __init__(self, real):
self._real = real
def cursor(self):
return self._real.cursor()
def commit(self):
return self._real.commit()
def close(self):
pass
def __enter__(self):
return self
def __exit__(self, *args):
pass
def _schema(db):
cur = db._conn.cursor()
cur.execute("""
CREATE TABLE artists (
id TEXT PRIMARY KEY, name TEXT,
genres TEXT, mood TEXT, style TEXT, thumb_url TEXT
)
""")
cur.execute("""
CREATE TABLE albums (
id TEXT PRIMARY KEY, artist_id TEXT, title TEXT,
genres TEXT, mood TEXT, style TEXT, thumb_url TEXT
)
""")
cur.execute("""
CREATE TABLE tracks (
id TEXT PRIMARY KEY, album_id TEXT, artist_id TEXT,
title TEXT, track_number INTEGER, duration INTEGER,
file_path TEXT, bitrate INTEGER,
play_count INTEGER DEFAULT 0, lastfm_playcount INTEGER
)
""")
db._conn.commit()
def _schema_no_rank_cols(db):
"""Schema WITHOUT play_count / lastfm_playcount — proves radio still works
on a DB that predates the smart-ranking migration (defensive column probe)."""
cur = db._conn.cursor()
cur.execute("CREATE TABLE artists (id TEXT PRIMARY KEY, name TEXT, genres TEXT, mood TEXT, style TEXT, thumb_url TEXT)")
cur.execute("CREATE TABLE albums (id TEXT PRIMARY KEY, artist_id TEXT, title TEXT, genres TEXT, mood TEXT, style TEXT, thumb_url TEXT)")
cur.execute("""
CREATE TABLE tracks (
id TEXT PRIMARY KEY, album_id TEXT, artist_id TEXT,
title TEXT, track_number INTEGER, duration INTEGER,
file_path TEXT, bitrate INTEGER
)
""")
db._conn.commit()
def _add_artist(db, aid, name, genres="", mood="", style=""):
db._conn.execute(
"INSERT INTO artists (id, name, genres, mood, style, thumb_url) VALUES (?,?,?,?,?,?)",
(aid, name, genres, mood, style, ""),
)
def _add_album(db, alid, aid, title, genres="", mood="", style=""):
db._conn.execute(
"INSERT INTO albums (id, artist_id, title, genres, mood, style, thumb_url) VALUES (?,?,?,?,?,?,?)",
(alid, aid, title, genres, mood, style, ""),
)
def _add_track(db, tid, alid, aid, title, file_path="/m/x.flac", play_count=0):
db._conn.execute(
"INSERT INTO tracks (id, album_id, artist_id, title, track_number, duration, file_path, bitrate, play_count) "
"VALUES (?,?,?,?,?,?,?,?,?)",
(tid, alid, aid, title, 1, 200, file_path, 1000, play_count),
)
@pytest.fixture
def db():
d = _InMemoryDB()
_schema(d)
return d
@pytest.fixture
def db_no_rank():
d = _InMemoryDB()
_schema_no_rank_cols(d)
return d
def test_missing_seed_track_returns_failure(db):
res = db.get_radio_tracks("nope", limit=10)
assert res["success"] is False
def test_tier1_same_artist_other_albums(db):
_add_artist(db, "ar1", "Artist One")
_add_album(db, "al1", "ar1", "Album A")
_add_album(db, "al2", "ar1", "Album B")
_add_track(db, "seed", "al1", "ar1", "Seed")
_add_track(db, "t2", "al2", "ar1", "Other Album Track")
db._conn.commit()
res = db.get_radio_tracks("seed", limit=10)
assert res["success"] is True
ids = [t["id"] for t in res["tracks"]]
assert "t2" in ids
assert "seed" not in ids # seed always excluded
def test_excludes_caller_supplied_ids(db):
_add_artist(db, "ar1", "Artist One")
_add_album(db, "al1", "ar1", "Album A")
_add_album(db, "al2", "ar1", "Album B")
_add_track(db, "seed", "al1", "ar1", "Seed")
_add_track(db, "t2", "al2", "ar1", "T2")
_add_track(db, "t3", "al2", "ar1", "T3")
db._conn.commit()
res = db.get_radio_tracks("seed", limit=10, exclude_ids=["t2"])
ids = [t["id"] for t in res["tracks"]]
assert "t2" not in ids
assert "t3" in ids
def test_tier2_genre_match_other_artists(db):
# No same-artist alternatives; falls to genre tier.
_add_artist(db, "ar1", "Seed Artist", genres='["shoegaze"]')
_add_artist(db, "ar2", "Other Artist", genres='["shoegaze"]')
_add_album(db, "al1", "ar1", "Seed Album", genres='["shoegaze"]')
_add_album(db, "al2", "ar2", "Other Album", genres='["shoegaze"]')
_add_track(db, "seed", "al1", "ar1", "Seed")
_add_track(db, "g1", "al2", "ar2", "Genre Match")
db._conn.commit()
res = db.get_radio_tracks("seed", limit=10)
ids = [t["id"] for t in res["tracks"]]
assert "g1" in ids
def test_tier4_random_fallback_fills_when_no_metadata_match(db):
# Seed has no genre/mood/style and no same-artist alts → random tier.
_add_artist(db, "ar1", "Seed Artist")
_add_artist(db, "ar2", "Unrelated")
_add_album(db, "al1", "ar1", "Seed Album")
_add_album(db, "al2", "ar2", "Unrelated Album")
_add_track(db, "seed", "al1", "ar1", "Seed")
_add_track(db, "r1", "al2", "ar2", "Random One")
db._conn.commit()
res = db.get_radio_tracks("seed", limit=10)
ids = [t["id"] for t in res["tracks"]]
assert "r1" in ids # filled from random tier
def test_only_returns_tracks_with_files(db):
_add_artist(db, "ar1", "Artist One")
_add_album(db, "al1", "ar1", "Album A")
_add_album(db, "al2", "ar1", "Album B")
_add_track(db, "seed", "al1", "ar1", "Seed")
_add_track(db, "nofile", "al2", "ar1", "No File", file_path="")
db._conn.commit()
res = db.get_radio_tracks("seed", limit=10)
ids = [t["id"] for t in res["tracks"]]
assert "nofile" not in ids # file_path filter still enforced
def test_no_duplicate_ids_across_tiers(db):
# A track that qualifies for both same-artist AND genre must appear once.
_add_artist(db, "ar1", "Artist One", genres='["pop"]')
_add_album(db, "al1", "ar1", "Album A", genres='["pop"]')
_add_album(db, "al2", "ar1", "Album B", genres='["pop"]')
_add_track(db, "seed", "al1", "ar1", "Seed")
_add_track(db, "dup", "al2", "ar1", "Could Match Twice")
db._conn.commit()
res = db.get_radio_tracks("seed", limit=10)
ids = [t["id"] for t in res["tracks"]]
assert ids.count("dup") == 1
def test_smart_ranking_prefers_more_played_in_same_tier(db):
"""Phase 2: within a tier, the ranker surfaces the heavily-played track
first out of the fetched pool.
Robustness note: this proves the ranking is WIRED IN end-to-end. The pool
factor (4x, floored) means with these few candidates the whole set is
fetched, so ranking is deterministic here. The deterministic guarantee of
the ranking *math* lives in TestRankCandidates / TestScoreCandidate (unit
level) those can't pass against pre-Phase-2 code at all. We seed many
unplayed decoys so a pre-Phase-2 ``ORDER BY RANDOM()`` would only return
'hit' first by a ~1-in-N fluke, making the wiring claim meaningful."""
_add_artist(db, "ar1", "Artist One")
_add_album(db, "al1", "ar1", "Seed Album")
_add_album(db, "al2", "ar1", "Other Album")
_add_track(db, "seed", "al1", "ar1", "Seed")
for i in range(15):
_add_track(db, f"rare{i}", "al2", "ar1", f"Rarely Played {i}", play_count=0)
_add_track(db, "hit", "al2", "ar1", "Big Hit", play_count=5000)
db._conn.commit()
res = db.get_radio_tracks("seed", limit=5)
assert res["success"] is True
ids = [t["id"] for t in res["tracks"]]
# The heavily-played track is ranked first out of the same-artist pool.
assert ids[0] == "hit"
def test_works_without_ranking_columns(db_no_rank):
"""Defensive: a DB predating the play_count/lastfm migration must still
return radio tracks (column probe omits the missing fields)."""
_add_artist(db_no_rank, "ar1", "Artist One")
_add_album(db_no_rank, "al1", "ar1", "Album A")
_add_album(db_no_rank, "al2", "ar1", "Album B")
# _add_track inserts play_count, so insert directly without it here.
db_no_rank._conn.execute(
"INSERT INTO tracks (id, album_id, artist_id, title, track_number, duration, file_path, bitrate) "
"VALUES (?,?,?,?,?,?,?,?)", ("seed", "al1", "ar1", "Seed", 1, 200, "/m/s.flac", 1000))
db_no_rank._conn.execute(
"INSERT INTO tracks (id, album_id, artist_id, title, track_number, duration, file_path, bitrate) "
"VALUES (?,?,?,?,?,?,?,?)", ("t2", "al2", "ar1", "Other", 1, 200, "/m/t2.flac", 1000))
db_no_rank._conn.commit()
res = db_no_rank.get_radio_tracks("seed", limit=10)
assert res["success"] is True
assert "t2" in [t["id"] for t in res["tracks"]]

View file

@ -0,0 +1,220 @@
"""Tests for the extracted radio-selection logic (Phase 0a of the player revamp).
These pin the behavior that used to be inline + untestable inside
``database.music_database.get_radio_tracks``. They lock current behavior so
Phase 2 (smart ranking) can evolve it against a green baseline.
"""
from __future__ import annotations
from core.radio.selection import (
RadioCollector,
build_like_conditions,
merge_tags,
parse_tags,
rank_candidates,
same_artist_cap,
score_candidate,
)
class TestParseTags:
def test_json_array(self):
assert parse_tags('["rock", "indie"]') == ["rock", "indie"]
def test_comma_separated_legacy(self):
assert parse_tags("rock, indie, folk") == ["rock", "indie", "folk"]
def test_comma_separated_strips_whitespace_and_blanks(self):
assert parse_tags("rock, , indie ,") == ["rock", "indie"]
def test_empty_and_none(self):
assert parse_tags("") == []
assert parse_tags(None) == []
def test_non_list_json_scalar_wrapped(self):
# A bare JSON scalar (e.g. a quoted string) becomes a single-item list.
assert parse_tags('"rock"') == ["rock"]
def test_garbage_falls_back_to_split(self):
assert parse_tags("not json at all") == ["not json at all"]
class TestSameArtistCap:
def test_thirty_percent(self):
assert same_artist_cap(50) == 15 # 50 * 3 // 10
assert same_artist_cap(20) == 6
def test_floored_at_five(self):
assert same_artist_cap(10) == 5 # 3, floored to 5
assert same_artist_cap(1) == 5
class TestMergeTags:
def test_dedupes_preserving_order(self):
assert merge_tags(["rock", "indie"], ["indie", "folk"]) == ["rock", "indie", "folk"]
def test_empty_groups(self):
assert merge_tags([], []) == []
class TestBuildLikeConditions:
def test_single_tag_two_columns(self):
sql, params = build_like_conditions(["rock"], ("al.genres", "ar.genres"))
assert sql == "al.genres LIKE ? OR ar.genres LIKE ?"
assert params == ["%rock%", "%rock%"]
def test_grouping_matches_original_order(self):
# Original emitted all album-col LIKEs, then all artist-col LIKEs;
# params were [%t%...] * 2. Reproduce that ordering exactly.
sql, params = build_like_conditions(["rock", "indie"], ("al.genres", "ar.genres"))
assert sql == (
"al.genres LIKE ? OR al.genres LIKE ? OR "
"ar.genres LIKE ? OR ar.genres LIKE ?"
)
assert params == ["%rock%", "%indie%", "%rock%", "%indie%"]
def test_no_tags_returns_empty(self):
assert build_like_conditions([], ("al.genres",)) == ("", [])
def test_no_columns_returns_empty(self):
assert build_like_conditions(["rock"], ()) == ("", [])
class TestRadioCollector:
def _rows(self, *ids):
return [{"id": i, "title": f"t{i}"} for i in ids]
def test_collects_and_dedupes(self):
c = RadioCollector(limit=10)
c.collect(self._rows(1, 2, 2, 3)) # dup 2 ignored
assert [t["id"] for t in c.tracks] == [1, 2, 3]
def test_excludes_seed_and_caller_ids(self):
c = RadioCollector(limit=10, exclude_ids=["1", "2"])
c.collect(self._rows(1, 2, 3, 4))
assert [t["id"] for t in c.tracks] == [3, 4]
def test_exclude_ids_coerced_to_str(self):
# Caller may pass ints; seen-set stores strings.
c = RadioCollector(limit=10, exclude_ids=[1])
c.collect(self._rows(1, 2))
assert [t["id"] for t in c.tracks] == [2]
def test_cap_bounds_a_single_tier(self):
c = RadioCollector(limit=10)
c.collect(self._rows(1, 2, 3, 4, 5), cap=2) # only 2 from this tier
assert [t["id"] for t in c.tracks] == [1, 2]
assert not c.filled
assert c.remaining() == 8
def test_filled_at_limit(self):
c = RadioCollector(limit=3)
ret = c.collect(self._rows(1, 2, 3, 4))
assert ret is True
assert c.filled
assert len(c.tracks) == 3
assert c.remaining() == 0
def test_capped_collect_returns_true_at_cap_target(self):
# Faithful to the original _collect: it returns True once the
# cap-bounded target is hit, even below the overall limit. The DB
# method IGNORES tier 1's capped return and checks .filled instead, so
# this never causes early exit — but the contract must match exactly.
c = RadioCollector(limit=5)
assert c.collect(self._rows(1, 2), cap=2) is True # hit cap target (2)
assert not c.filled # but not at limit (5)
def test_uncapped_collect_returns_true_only_at_limit(self):
c = RadioCollector(limit=5)
assert c.collect(self._rows(1, 2)) is False # below limit
assert c.collect(self._rows(3, 4, 5)) is True # now at limit
def test_exclude_placeholders_and_values_track_seen_set(self):
c = RadioCollector(limit=10, exclude_ids=["a", "b"])
assert c.exclude_placeholders() == "?,?"
assert set(c.exclude_values()) == {"a", "b"}
# After collecting, already-collected IDs join the NOT-IN set so the
# next tier's SQL won't re-pull them.
c.collect(self._rows("c"))
assert c.exclude_placeholders() == "?,?,?"
assert set(c.exclude_values()) == {"a", "b", "c"}
def test_ranked_collect_prefers_high_play_count(self):
# Pool given in worst-first order; rank=True should reorder so the
# most-played track is collected first.
c = RadioCollector(limit=2)
pool = [
{"id": 1, "play_count": 0},
{"id": 2, "play_count": 500},
{"id": 3, "play_count": 50},
]
c.collect(pool, rank=True)
assert [t["id"] for t in c.tracks] == [2, 3] # 500 then 50, 0 dropped at limit
# ── Phase 2: smart ranking ─────────────────────────────────────────────────
class TestScoreCandidate:
def test_missing_signals_score_is_pure_jitter(self):
# No play data → score is just the stable jitter, in [0, 1).
s = score_candidate({"id": "x"})
assert 0.0 <= s < 1.0
def test_higher_play_count_scores_higher(self):
low = score_candidate({"id": "same", "play_count": 1})
high = score_candidate({"id": "same", "play_count": 1000})
assert high > low # same id → same jitter, so play_count decides
def test_lastfm_contributes(self):
base = score_candidate({"id": "same"})
with_lastfm = score_candidate({"id": "same", "lastfm_playcount": 100000})
assert with_lastfm > base
def test_recently_played_is_penalized(self):
normal = score_candidate({"id": "same", "play_count": 10})
recent = score_candidate({"id": "same", "play_count": 10, "_recently_played": True})
assert recent < normal
def test_invalid_counts_treated_as_zero(self):
# Garbage values must not crash; they score as 0 (jitter only).
s = score_candidate({"id": "x", "play_count": None, "lastfm_playcount": "n/a"})
assert 0.0 <= s < 1.0
def test_jitter_is_stable_per_id(self):
a = score_candidate({"id": "track-42"})
b = score_candidate({"id": "track-42"})
assert a == b # deterministic — reproducible runs/tests
def test_jitter_differs_between_ids(self):
a = score_candidate({"id": "track-1"})
b = score_candidate({"id": "track-2"})
assert a != b
class TestRankCandidates:
def test_orders_best_first(self):
rows = [
{"id": 1, "play_count": 0},
{"id": 2, "play_count": 1000},
{"id": 3, "play_count": 100},
]
ranked = rank_candidates(rows)
assert [r["id"] for r in ranked] == [2, 3, 1]
def test_does_not_mutate_input(self):
rows = [{"id": 1, "play_count": 0}, {"id": 2, "play_count": 9}]
original = list(rows)
rank_candidates(rows)
assert rows == original
def test_empty(self):
assert rank_candidates([]) == []
def test_popularity_beats_jitter_at_scale(self):
# A heavily-played track must always outrank an unplayed one regardless
# of jitter (jitter is bounded to [0,1), play_count is log-scaled * 1.0).
pool = [{"id": f"unplayed-{i}", "play_count": 0} for i in range(20)]
pool.append({"id": "hit", "play_count": 5000})
ranked = rank_candidates(pool)
assert ranked[0]["id"] == "hit"

View file

@ -0,0 +1,124 @@
// Tests for the inline-onclick string escaping in `webui/static/stats-automations.js`.
//
// Run via:
// node --test tests/static/test_stats_automations_esc.mjs
//
// The pytest wrapper at `tests/test_stats_automations_esc_js.py` shells out to
// `node --test` so this fails the suite if the escaping regresses.
//
// Regression context — the "Road trip-The Rolfe's" delete bug:
// A mirrored playlist whose name contains an apostrophe rendered
// onclick="...deleteMirroredPlaylist(2, 'Road trip-The Rolfe&#39;s')"
// The browser HTML-decodes &#39; back to ' BEFORE the JS parser runs, producing
// deleteMirroredPlaylist(2, 'Road trip-The Rolfe's') // unterminated string
// so the whole handler threw a SyntaxError and never executed. Two visible
// symptoms: (1) event.stopPropagation() never ran, so clicking the ✕ bubbled to
// the card and opened the track preview instead; (2) the preview's "Delete
// Mirror" button silently did nothing (no DELETE request). `_escJs` fixes it by
// backslash-escaping the JS metacharacters first; `_escAttr` (HTML-only) cannot.
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, resolve } from 'node:path';
const __dirname = dirname(fileURLToPath(import.meta.url));
const SRC = readFileSync(
resolve(__dirname, '..', '..', 'webui', 'static', 'stats-automations.js'),
'utf8',
);
// Pull a top-level `function NAME(...) { ... }` out of the real source by
// brace-matching and return it as a live function, so the tests exercise the
// shipped implementation rather than a copy.
function extractFn(name) {
const at = SRC.indexOf(`function ${name}(`);
assert.notEqual(at, -1, `function ${name} must exist in stats-automations.js`);
const open = SRC.indexOf('{', at);
let depth = 0;
for (let i = open; i < SRC.length; i++) {
if (SRC[i] === '{') depth++;
else if (SRC[i] === '}' && --depth === 0) {
// eslint-disable-next-line no-eval
return eval(`(${SRC.slice(at, i + 1)})`); // named function expression -> returns the fn
}
}
throw new Error(`could not brace-match function ${name}`);
}
const _escJs = extractFn('_escJs');
const _escAttr = extractFn('_escAttr');
// Reproduce what a browser does to onclick="fn('<value>')" : the HTML parser
// resolves entities, THEN the JS engine parses the resulting source. &amp; is
// resolved last so already-decoded entities aren't double-processed.
function htmlAttrDecode(s) {
return s
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&amp;/g, '&');
}
// Build the exact onclick shape the cards build, decode it like the browser
// would, compile + run it, and capture the argument the handler received.
function invokeOnclick(escFn, name) {
const onclick = `cb(2, '${escFn(name)}')`;
const decoded = htmlAttrDecode(onclick);
let received;
const cb = (_id, n) => { received = n; };
// eslint-disable-next-line no-new-func
new Function('cb', decoded)(cb); // compile + execute the handler like the DOM does
return received;
}
describe('_escJs — onclick string args survive HTML+JS decoding', () => {
test("apostrophe name (the Rolfe's delete bug) round-trips intact", () => {
const name = "Road trip-The Rolfe's";
assert.equal(invokeOnclick(_escJs, name), name);
});
test('quotes, backslashes, ampersands, angle brackets all round-trip', () => {
for (const name of [
"Guns N' Roses",
'He said "hi"',
'back\\slash',
'Tom & Jerry',
'<script>alert(1)</script>',
"O'Brien \\ \"x\" & <y>",
"it's a \"test\" & more",
]) {
assert.equal(
invokeOnclick(_escJs, name), name,
`round-trip failed for ${JSON.stringify(name)}`,
);
}
});
test('plain names are not over-escaped (identical to input)', () => {
assert.equal(_escJs('Classic Rock'), 'Classic Rock');
assert.equal(_escJs('Discover Weekly'), 'Discover Weekly');
});
test('empty / falsy input yields empty string', () => {
assert.equal(_escJs(''), '');
assert.equal(_escJs(null), '');
assert.equal(_escJs(undefined), '');
});
});
describe('regression: _escAttr is unsafe for the onclick JS-string context', () => {
test('apostrophe name compiles to a SyntaxError under _escAttr (the original bug)', () => {
const decoded = htmlAttrDecode(`cb(2, '${_escAttr("Road trip-The Rolfe's")}')`);
// eslint-disable-next-line no-new-func
assert.throws(() => new Function('cb', decoded), SyntaxError);
});
test('_escJs compiles cleanly for that same name', () => {
const decoded = htmlAttrDecode(`cb(2, '${_escJs("Road trip-The Rolfe's")}')`);
// eslint-disable-next-line no-new-func
assert.doesNotThrow(() => new Function('cb', decoded));
});
});

View file

@ -178,3 +178,25 @@ def test_succeeded_state_with_partial_bytes_keeps_polling(tmp_path):
# Should NOT have gone to 'ready' because bytes were incomplete
assert deps._state['status'] != 'ready'
# ---------------------------------------------------------------------------
# Real StreamSession compatibility (player-revamp Phase 3 wiring)
# ---------------------------------------------------------------------------
def test_worker_drives_a_real_stream_session(tmp_path):
"""web_server.py now binds stream_state to a StreamStateStore session
(not a bare dict). Prove the prepare worker drives that real object
correctly end-to-end through the deps proxy the actual production type."""
from core.streaming.state import StreamStateStore
session = StreamStateStore().get() # the real production object
sk = _FakeSoulseek(download_id=None) # early error exit is enough to mutate state
deps = _build_deps(soulseek=sk, project_root=str(tmp_path), state=session)
sp.prepare_stream_task({'username': 'u', 'filename': 'song.flac', 'size': 1}, deps)
# Worker mutated the SAME session via update()/[k]= — proves dict-compat.
assert session['status'] == 'error'
assert 'Failed to initiate' in session['error_message']
assert session['track_info'] == {'username': 'u', 'filename': 'song.flac', 'size': 1}

View file

@ -0,0 +1,126 @@
"""Proves the StreamSession is a drop-in for the old stream_state dict.
web_server.py was swapped from a module-global ``dict`` + ``threading.Lock``
to ``StreamStateStore().get()`` (the default session) + that session's lock.
This exercises every access pattern the real call sites use, against the same
object web_server now binds, so the swap is verified without booting Flask.
The patterns mirrored here come verbatim from web_server.py:
- /api/library/play: with lock: state.update({... "is_library": True})
- /api/stream/start: with lock: state.update({"status": "loading", ...})
- /api/stream/status: with lock: read state["status"], state["progress"], ...
- /stream/audio: with lock: if state["status"] != "ready" or not state["file_path"]
- /api/stream/stop: with lock: state.get("is_library", False); state.update({... reset})
- prepare.py: state.update(...) and state["status"] = "queued"
"""
from __future__ import annotations
from core.streaming.state import StreamStateStore
def _server_like_state():
"""Reproduce exactly what web_server.py now binds."""
store = StreamStateStore()
state = store.get() # DEFAULT_SESSION
lock = state.lock
return state, lock
def test_library_play_pattern():
state, lock = _server_like_state()
with lock:
state.update({
"status": "ready",
"progress": 100,
"track_info": {"title": "T", "artist": "A", "album": "Al"},
"file_path": "/Stream/x.flac",
"is_library": True,
})
assert state["status"] == "ready"
assert state["file_path"] == "/Stream/x.flac"
assert state.get("is_library") is True
def test_stream_start_pattern():
state, lock = _server_like_state()
with lock:
state.update({
"status": "loading",
"progress": 0,
"track_info": {"title": "Song"},
"file_path": None,
"error_message": None,
})
assert state["status"] == "loading"
def test_stream_status_read_pattern():
state, lock = _server_like_state()
state.update({"status": "queued", "progress": 42,
"track_info": {"title": "Q"}, "error_message": None})
with lock:
payload = {
"status": state["status"],
"progress": state["progress"],
"track_info": state["track_info"],
"error_message": state["error_message"],
}
assert payload == {"status": "queued", "progress": 42,
"track_info": {"title": "Q"}, "error_message": None}
def test_stream_audio_guard_pattern():
state, lock = _server_like_state()
# Not ready → guard trips (would 404 in the route).
with lock:
not_ready = state["status"] != "ready" or not state["file_path"]
assert not_ready is True
state.update({"status": "ready", "file_path": "/Stream/y.flac"})
with lock:
not_ready = state["status"] != "ready" or not state["file_path"]
path = state["file_path"]
assert not_ready is False
assert path == "/Stream/y.flac"
def test_stream_stop_pattern():
state, lock = _server_like_state()
state.update({"status": "ready", "file_path": "/x", "is_library": True})
with lock:
is_library = state.get("is_library", False)
assert is_library is True
with lock:
state.update({
"status": "stopped", "progress": 0, "track_info": None,
"file_path": None, "error_message": None, "is_library": False,
})
assert state["status"] == "stopped"
assert state.get("is_library") is False
def test_prepare_worker_inplace_mutation_pattern():
state, _ = _server_like_state()
# prepare.py mutates in place via both update() and [k]=
state.update({"status": "loading", "progress": 0})
state["status"] = "queued"
state["progress"] = 10
state["status"] = "loading"
state["progress"] = 55
assert state["status"] == "loading"
assert state["progress"] == 55
def test_set_stream_state_replace_keeps_same_session_object():
"""web_server._set_stream_state now routes a reassignment through
replace() so the store's default session stays the live object. Verify a
'reassign' is reflected in the SAME session the store hands out."""
store = StreamStateStore()
state = store.get()
# simulate _set_stream_state(value): state.replace(dict(value))
state.replace({"status": "error", "error_message": "boom"})
# The store still hands back the same object, now carrying the new values.
assert store.get() is state
assert store.get()["status"] == "error"
assert store.get()["error_message"] == "boom"

View file

@ -0,0 +1,155 @@
"""Tests for the stream-state store (player revamp Phase 3 foundation).
Pins the dict-compatible behavior the web server relies on, and the
multi-session registry semantics that per-listener playback will build on.
"""
from __future__ import annotations
import threading
from core.streaming.state import (
DEFAULT_SESSION,
StreamSession,
StreamStateStore,
)
class TestStreamSession:
def test_fresh_baseline(self):
s = StreamSession()
assert s["status"] == "stopped"
assert s["progress"] == 0
assert s["track_info"] is None
assert s["file_path"] is None
assert s["error_message"] is None
def test_initial_overrides(self):
s = StreamSession({"status": "ready", "file_path": "/x.flac"})
assert s["status"] == "ready"
assert s["file_path"] == "/x.flac"
# untouched keys keep baseline
assert s["progress"] == 0
def test_dict_compatible_get_with_default(self):
s = StreamSession()
# The old code did stream_state.get("is_library", False) — a key not in
# the baseline. Must return the default, not raise.
assert s.get("is_library", False) is False
def test_setitem_and_getitem(self):
s = StreamSession()
s["status"] = "loading"
assert s["status"] == "loading"
def test_update(self):
s = StreamSession()
s.update({"status": "ready", "progress": 100, "is_library": True})
assert s["status"] == "ready"
assert s["progress"] == 100
assert s.get("is_library") is True
def test_contains(self):
s = StreamSession()
assert "status" in s
assert "is_library" not in s
def test_snapshot_is_a_copy(self):
s = StreamSession()
snap = s.snapshot()
snap["status"] = "mutated"
assert s["status"] == "stopped" # live state untouched
def test_reset_returns_to_baseline(self):
s = StreamSession()
s.update({"status": "ready", "progress": 100, "is_library": True})
s.reset()
assert s["status"] == "stopped"
assert s["progress"] == 0
assert s.get("is_library", False) is False # extra keys gone too
def test_replace_swaps_backing_dict(self):
s = StreamSession()
s.replace({"status": "error", "error_message": "boom"})
assert s["status"] == "error"
assert s["error_message"] == "boom"
def test_each_session_has_its_own_lock(self):
a, b = StreamSession(), StreamSession()
assert a.lock is not b.lock
def test_lock_is_reentrant(self):
# RLock — a call site that re-enters under its own lock won't deadlock.
s = StreamSession()
with s.lock:
with s.lock:
s["status"] = "ready"
assert s["status"] == "ready"
class TestStreamStateStore:
def test_default_session_is_stable(self):
store = StreamStateStore()
a = store.get()
b = store.get(DEFAULT_SESSION)
assert a is b # same object — reproduces the single-global behavior
def test_distinct_sessions_are_isolated(self):
store = StreamStateStore()
alice = store.get("alice")
bob = store.get("bob")
assert alice is not bob
alice["status"] = "ready"
assert bob["status"] == "stopped" # no cross-clobber — the whole point
def test_lazy_creation(self):
store = StreamStateStore()
assert not store.has("new")
store.get("new")
assert store.has("new")
def test_drop_removes_session(self):
store = StreamStateStore()
store.get("temp")
assert store.drop("temp") is True
assert not store.has("temp")
assert store.drop("temp") is False # already gone
def test_default_session_cannot_be_dropped(self):
store = StreamStateStore()
store.get() # materialize default
assert store.drop(DEFAULT_SESSION) is False
assert store.has(DEFAULT_SESSION)
def test_session_ids_lists_created(self):
store = StreamStateStore()
store.get("a")
store.get("b")
assert set(store.session_ids()) == {"a", "b"}
def test_active_ids_excludes_stopped(self):
store = StreamStateStore()
store.get("idle") # stays stopped
store.get("playing")["status"] = "ready"
store.get("loading")["status"] = "loading"
assert set(store.active_ids()) == {"playing", "loading"}
def test_concurrent_get_same_session_returns_one_object(self):
# The lazy-create must be race-safe: many threads getting the same new
# id must all see the SAME session (no lost writes from a torn create).
store = StreamStateStore()
seen = []
barrier = threading.Barrier(8)
def worker():
barrier.wait()
seen.append(store.get("contended"))
threads = [threading.Thread(target=worker) for _ in range(8)]
for t in threads:
t.start()
for t in threads:
t.join()
assert len(seen) == 8
assert all(s is seen[0] for s in seen) # exactly one object shared

View file

@ -0,0 +1,180 @@
"""AcoustID error-vs-no-match reporting.
Regression for the masking bug: an invalid API key (and other lookup errors)
used to collapse into the same `None` as a genuine no-match, so the UI showed a
benign "Skipped" and the "Test API key" button reported a dead key as valid.
These tests pin the distinction end to end:
- lookup_with_status separates ok / no_match / error / no_backend / unavailable
- fingerprint_and_lookup (legacy) stays dict-or-None
- verify_audio_file -> ERROR for a real error, SKIP for a genuine no-match
- test_api_key reports an invalid key (API error code 4) as invalid
"""
from __future__ import annotations
import sys
import types
from unittest.mock import MagicMock, patch
import pytest
import core.acoustid_client as acc
from core.acoustid_client import AcoustIDClient
from core.acoustid_verification import AcoustIDVerification, VerificationResult
# ── lookup_with_status: structured status distinction ──────────────────────
def _client_with_fake_acoustid(monkeypatch, *, match=None, raises=None):
"""An AcoustIDClient wired to a fake `acoustid` module so we can drive
match() without network or chromaprint."""
fake = types.ModuleType("acoustid")
class WebServiceError(Exception):
pass
class NoBackendError(Exception):
pass
class FingerprintGenerationError(Exception):
pass
fake.WebServiceError = WebServiceError
fake.NoBackendError = NoBackendError
fake.FingerprintGenerationError = FingerprintGenerationError
def _match(api_key, audio_file, parse=True):
if raises is not None:
raise raises
return match or []
fake.match = _match
monkeypatch.setitem(sys.modules, "acoustid", fake)
monkeypatch.setattr(acc, "ACOUSTID_AVAILABLE", True)
c = AcoustIDClient()
c._api_key = "testkey123" # bypass config
return c, fake
def test_lookup_status_ok(tmp_path, monkeypatch):
f = tmp_path / "a.bin"; f.write_bytes(b"not audio") # mutagen -> None, channel check skipped
c, _ = _client_with_fake_acoustid(monkeypatch, match=[(0.95, "mbid-1", "Title", "Artist")])
res = c.lookup_with_status(str(f))
assert res["status"] == "ok"
assert res["recordings"] and res["recordings"][0]["mbid"] == "mbid-1"
def test_lookup_status_no_match(tmp_path, monkeypatch):
f = tmp_path / "a.bin"; f.write_bytes(b"not audio")
c, _ = _client_with_fake_acoustid(monkeypatch, match=[])
res = c.lookup_with_status(str(f))
assert res["status"] == "no_match"
assert res["recordings"] == []
def test_lookup_status_error_on_webservice(tmp_path, monkeypatch):
f = tmp_path / "a.bin"; f.write_bytes(b"not audio")
c, fake = _client_with_fake_acoustid(monkeypatch)
# invalid key surfaces (old pyacoustid) as the bare "status: error"
monkeypatch.setattr(c, "_api_key", "testkey123")
def _raise(*a, **k):
raise fake.WebServiceError("status: error")
fake.match = _raise
res = c.lookup_with_status(str(f))
assert res["status"] == "error"
assert res["invalid_key"] is True
def test_lookup_status_no_backend(tmp_path, monkeypatch):
f = tmp_path / "a.bin"; f.write_bytes(b"not audio")
c, fake = _client_with_fake_acoustid(monkeypatch)
def _raise(*a, **k):
raise fake.NoBackendError()
fake.match = _raise
assert c.lookup_with_status(str(f))["status"] == "no_backend"
def test_lookup_status_unavailable_without_key(tmp_path, monkeypatch):
f = tmp_path / "a.bin"; f.write_bytes(b"x")
monkeypatch.setattr(acc, "ACOUSTID_AVAILABLE", True)
c = AcoustIDClient()
c._api_key = "" # no key
assert c.lookup_with_status(str(f))["status"] == "unavailable"
# ── fingerprint_and_lookup keeps its dict-or-None contract ─────────────────
def test_legacy_wrapper_returns_dict_on_match(tmp_path, monkeypatch):
f = tmp_path / "a.bin"; f.write_bytes(b"not audio")
c, _ = _client_with_fake_acoustid(monkeypatch, match=[(0.9, "mbid-1", "T", "A")])
out = c.fingerprint_and_lookup(str(f))
assert out is not None and out["recordings"][0]["mbid"] == "mbid-1"
def test_legacy_wrapper_returns_none_on_error(tmp_path, monkeypatch):
f = tmp_path / "a.bin"; f.write_bytes(b"not audio")
c, fake = _client_with_fake_acoustid(monkeypatch)
def _raise(*a, **k):
raise fake.WebServiceError("status: error")
fake.match = _raise
assert c.fingerprint_and_lookup(str(f)) is None
# ── verify_audio_file: ERROR vs SKIP ───────────────────────────────────────
def _verifier_with_lookup(result):
v = AcoustIDVerification()
client = MagicMock()
client.is_available.return_value = (True, "ready")
client.lookup_with_status.return_value = result
v.acoustid_client = client
return v
def test_verify_reports_error_for_api_error():
v = _verifier_with_lookup({"status": "error", "recordings": [], "error": "AcoustID API error: invalid"})
result, msg = v.verify_audio_file("/x.flac", "Song", "Artist")
assert result == VerificationResult.ERROR
assert "error" in msg.lower() or "invalid" in msg.lower()
def test_verify_reports_skip_for_no_match():
v = _verifier_with_lookup({"status": "no_match", "recordings": [], "error": "Track not found in AcoustID database"})
result, msg = v.verify_audio_file("/x.flac", "Song", "Artist")
assert result == VerificationResult.SKIP
assert "no match" in msg.lower() or "not found" in msg.lower()
# ── test_api_key: invalid key reported as invalid ──────────────────────────
def _api_response(payload):
r = MagicMock()
r.json.return_value = payload
return r
def test_test_api_key_invalid_when_code_4(monkeypatch):
c = AcoustIDClient()
c._api_key = "badkey"
with patch("requests.get",
return_value=_api_response({"status": "error", "error": {"code": 4, "message": "invalid API key"}})):
ok, msg = c.test_api_key()
assert ok is False
assert "invalid" in msg.lower()
def test_test_api_key_valid_when_accepted(monkeypatch):
c = AcoustIDClient()
c._api_key = "goodkey"
# A non-key error (e.g. bad dummy fingerprint) means the key was accepted.
with patch("requests.get",
return_value=_api_response({"status": "error", "error": {"code": 3, "message": "invalid fingerprint"}})):
ok, msg = c.test_api_key()
assert ok is True
assert "valid" in msg.lower()

View file

@ -52,7 +52,7 @@ def verifier(monkeypatch):
def is_available(self):
return True, 'available'
def fingerprint_and_lookup(self, path):
def lookup_with_status(self, path):
# Each test injects its own desired return value via
# monkeypatch on this method; default is empty.
return None
@ -62,8 +62,8 @@ def verifier(monkeypatch):
def _stub_lookup(verifier, *, recordings, best_score):
"""Make `fingerprint_and_lookup` return a fabricated AcoustID result."""
verifier.acoustid_client.fingerprint_and_lookup = lambda path: {
"""Make `lookup_with_status` return a fabricated AcoustID result."""
verifier.acoustid_client.lookup_with_status = lambda path: {
'recordings': recordings,
'best_score': best_score,
'recording_mbids': [r.get('id') for r in recordings if r.get('id')],

View file

@ -38,7 +38,7 @@ def verifier():
def is_available(self):
return True, "available"
def fingerprint_and_lookup(self, path):
def lookup_with_status(self, path):
return None # tests inject via _stub_lookup below
v.acoustid_client = _StubClient()
@ -46,7 +46,7 @@ def verifier():
def _stub_lookup(verifier, *, recordings, best_score=0.95):
verifier.acoustid_client.fingerprint_and_lookup = lambda path: {
verifier.acoustid_client.lookup_with_status = lambda path: {
"recordings": recordings,
"best_score": best_score,
"recording_mbids": [r.get("id") for r in recordings if r.get("id")],

View file

@ -27,6 +27,7 @@ from core.download_plugins.album_bundle import (
DEFAULT_POLL_TIMEOUT_SECONDS,
atomic_copy_to_staging,
copy_audio_files_atomically,
album_title_relevance,
get_completed_no_path_window_seconds,
get_poll_interval,
get_poll_timeout,
@ -117,6 +118,110 @@ def test_picker_rejects_oversized_box_sets() -> None:
assert pick_best_album_release([sane, box], _flac_quality_guess) is sane
# ---------------------------------------------------------------------------
# #730 — album-title relevance gate
# ---------------------------------------------------------------------------
def test_relevance_exact_match_is_full():
assert album_title_relevance("David Bowie - Heroes (2017 Remaster) [FLAC]", "Heroes") == 1.0
def test_relevance_word_boundary_not_substring():
# "Heroes" must NOT be satisfied by "Superheroes" (the substring trap).
assert album_title_relevance("Various - Superheroes Soundtrack", "Heroes") == 0.0
def test_relevance_wrong_album_scores_zero():
# The reporter's exact case: a "Heroes" request must not match a different
# Bowie album that shares no title words.
assert album_title_relevance("David Bowie - Scary Monsters and Super Creeps", "Heroes") == 0.0
def test_relevance_accent_folding():
# Björk folds to bjork, not "bj rk" — so an accented request still matches.
assert album_title_relevance("Bjork - Homogenic [FLAC]", "Björk Homogenic") == 1.0
def test_relevance_partial_word_coverage():
# 1 of 2 album words present -> 0.5 (below the 0.6 floor).
assert album_title_relevance("Artist - Dark Side [FLAC]", "Dark Moon") == 0.5
def test_relevance_no_album_name_is_neutral():
# Can't gate on nothing — preserves old behavior for callers w/o a title.
assert album_title_relevance("anything at all", "") == 1.0
def test_relevance_ignores_edition_suffix_on_album_name():
"""The RIGHT torrent must not be rejected just because the stored album
name carries an edition/remaster/format suffix the title lacks. (Caught in
review the naive 'all album words' version wrongly rejected these.)"""
floor = 0.6
assert album_title_relevance("Tame Impala - Currents [FLAC]", "Currents (Deluxe)") >= floor
assert album_title_relevance("David Bowie - Heroes [FLAC]", "Heroes (2017 Remaster)") >= floor
assert album_title_relevance("Daft Punk - Discovery [FLAC]", "Discovery (Remastered Edition)") >= floor
def test_relevance_full_phrase_bonus():
"""Full core phrase present intact -> high confidence (>=0.9) even when a
long multi-word album name would otherwise drag token-coverage down.
(Idea grafted from contributor PR #731.)"""
# All core words present AND contiguous -> already 1.0; the bonus matters
# most when extra album words aren't all matched. Construct that case:
# album core = [in, rainbows, the, basement] but title has the phrase
# "in rainbows" intact while missing 'basement'.
# album core = [in, rainbows, from, basement] ('the' is noise); title has
# 'in','rainbows' but not 'from'/'basement', and the full core phrase is
# NOT intact -> pure coverage 2/4 = 0.5, no bonus.
score = album_title_relevance(
"Radiohead - In Rainbows [FLAC]", "In Rainbows from the Basement")
assert score == 0.5
# Full core phrase intact in the title -> bonus floors at 0.9+ (here it's
# already 1.0 since both core words match).
score2 = album_title_relevance(
"Radiohead - In Rainbows Disc 2 [FLAC]", "In Rainbows")
assert score2 >= 0.9
# The bonus is word-boundary anchored: a phrase that's only a SUBSTRING of
# a title word must NOT get it.
assert album_title_relevance("Various - Superheroes", "Heroes") == 0.0
def test_relevance_album_named_only_with_noise_or_number():
# If the album name is JUST a noise/number word, don't strip it to nothing
# and match everything — keep the literal word.
assert album_title_relevance("Taylor Swift - 1989 [FLAC]", "1989") == 1.0
assert album_title_relevance("Taylor Swift - Red [FLAC]", "1989") == 0.0
assert album_title_relevance("Various - Deluxe [FLAC]", "Deluxe") == 1.0
def test_picker_refuses_wrong_album_falls_back():
"""The #730 scenario: a hugely-popular WRONG album must NOT be picked over
a less-popular RIGHT one and if nothing matches, return None so the
caller falls back to per-track."""
wrong_popular = _Release(title="David Bowie - Scary Monsters [FLAC]",
size=400_000_000, seeders=16000)
right_quiet = _Release(title='David Bowie - "Heroes" 2017 Remaster [FLAC]',
size=400_000_000, seeders=10)
picked = pick_best_album_release(
[wrong_popular, right_quiet], _flac_quality_guess, album_name="Heroes")
assert picked is right_quiet # relevance beats raw popularity
def test_picker_returns_none_when_nothing_matches_album():
# Only the wrong album is available -> refuse (None) -> per-track fallback.
wrong = _Release(title="David Bowie - Scary Monsters [FLAC]",
size=400_000_000, seeders=16000)
assert pick_best_album_release([wrong], _flac_quality_guess, album_name="Heroes") is None
def test_picker_without_album_name_unchanged():
# No album_name passed -> no gating -> old popularity behavior intact.
a = _Release(title="Whatever [FLAC]", size=400_000_000, seeders=5)
b = _Release(title="Other [FLAC]", size=400_000_000, seeders=999)
assert pick_best_album_release([a, b], _flac_quality_guess) is b
# ---------------------------------------------------------------------------
# quality_score
# ---------------------------------------------------------------------------

View file

@ -275,6 +275,26 @@ def test_dispatch_plugin_exception_treated_as_failure() -> None:
assert 'network down' in state.failed_with
def test_dispatch_staging_oserror_falls_back_to_per_track():
"""A filesystem/staging failure (e.g. #760's PermissionError creating the
staging dir) means the album downloaded but couldn't be staged locally —
fall back to the per-track flow rather than hard-failing the whole batch."""
state = _FakeState()
plugin = MagicMock()
plugin.download_album_to_staging.side_effect = PermissionError(
"[Errno 13] Permission denied: 'storage/album_bundle_staging'")
result = try_dispatch(
batch_id='b1', is_album=True,
album_context={'name': 'Carnival'}, artist_context={'name': 'Some Artist'},
config_get=_config({'download_source.mode': 'soulseek'}),
plugin_resolver=lambda _name: plugin, state=state,
)
assert result is False # fell back; master continues per-track
assert state.failed_with == '' # NOT hard-failed
assert state.fields['phase'] == 'analysis'
assert state.fields['album_bundle_state'] == 'fallback'
def test_dispatch_strips_whitespace_from_names() -> None:
"""Trailing whitespace in batch context shouldn't fail the
eligibility predicate AND should be cleaned before passing to

View file

@ -0,0 +1,86 @@
"""Tests for Amazon enrichment outage detection + back-off (core/amazon_outage.py).
Pins the contract behind issue #759: when the public T2Tunes proxy is down
(503 "Amazon Music API is not initialized", 5xx, or unreachable) the worker must
recognize a *source outage* not treat every album as a per-item error and
grind/flood the whole library.
"""
from __future__ import annotations
from core.amazon_client import AmazonClientError
from core.amazon_outage import is_source_outage, next_poll_delay_seconds
# --- is_source_outage: the reported case + the surfaces it can arrive in -----
def test_503_not_initialized_is_outage_via_status_code():
exc = AmazonClientError("HTTP 503 for .../search — body: '...not initialized...'",
status_code=503)
assert is_source_outage(exc) is True
def test_503_is_outage_even_without_status_code_attr():
# Message-only (e.g. re-raised/wrapped) — parsed from the "HTTP 503" prefix.
assert is_source_outage(Exception("HTTP 503 for https://t2tunes.site/...")) is True
def test_not_initialized_phrase_is_outage_without_code():
assert is_source_outage(Exception("Amazon Music API is not initialized")) is True
def test_gateway_5xx_are_outages():
for code in (500, 502, 504):
assert is_source_outage(AmazonClientError("x", status_code=code)) is True
def test_connection_failure_is_outage():
assert is_source_outage(AmazonClientError(
"Request failed for https://t2tunes.site/...: Connection refused")) is True
assert is_source_outage(Exception("Max retries exceeded ... Connection timed out")) is True
def test_non_json_error_page_is_outage():
assert is_source_outage(AmazonClientError(
"Response not JSON for ...: '<html>503 Service Unavailable</html>'")) is True
# --- NOT outages: real per-item misses / client errors -----------------------
def test_404_is_not_outage():
assert is_source_outage(AmazonClientError("HTTP 404 for ...", status_code=404)) is False
def test_transient_400_failed_to_search_is_not_outage():
# A per-query Amazon-side hiccup, not the whole source being down.
assert is_source_outage(AmazonClientError(
"HTTP 400 for ... — body: 'Failed to search'", status_code=400)) is False
def test_generic_error_is_not_outage():
assert is_source_outage(ValueError("something unrelated")) is False
# --- next_poll_delay_seconds: normal cadence vs escalating back-off ----------
def test_healthy_uses_normal_cadence():
assert next_poll_delay_seconds(0) == 2
assert next_poll_delay_seconds(-1) == 2
def test_backoff_escalates_then_caps():
assert next_poll_delay_seconds(1) == 30
assert next_poll_delay_seconds(2) == 60
assert next_poll_delay_seconds(3) == 120
assert next_poll_delay_seconds(4) == 240
# Escalation is capped at 30 minutes so it never grows unbounded.
assert next_poll_delay_seconds(50) == 1800
def test_backoff_is_monotonic_nondecreasing():
delays = [next_poll_delay_seconds(s) for s in range(1, 20)]
assert delays == sorted(delays)
assert max(delays) == 1800

View file

@ -0,0 +1,32 @@
"""Guard: the test suite must NEVER resolve the real database/music_library.db.
Tests exercise modules that call get_database() with no path. If that resolves
to the live DB, test writes can corrupt a real library (this happened, over a
WSL-mounted Windows drive). conftest.py sets DATABASE_PATH to a temp file before
anything imports; these tests prove it sticks for every default-path access.
"""
from __future__ import annotations
import os
from pathlib import Path
_REAL = os.path.join('database', 'music_library.db')
def test_database_path_env_is_isolated():
p = os.environ.get('DATABASE_PATH', '')
assert 'soulsync-testdb-' in p, f"DATABASE_PATH not isolated: {p!r}"
def test_musicdatabase_default_path_never_real():
from database.music_database import MusicDatabase
resolved = str(Path(MusicDatabase().database_path).resolve())
assert 'soulsync-testdb-' in resolved, resolved
assert not resolved.replace('\\', '/').endswith('database/music_library.db'), resolved
def test_get_database_path_never_real():
from database.music_database import get_database
resolved = str(Path(get_database().database_path).resolve())
assert 'soulsync-testdb-' in resolved, resolved

View file

@ -0,0 +1,95 @@
"""Tests for duplicate-keeper selection (core/library/duplicate_keep.py).
The headline contract: lossless format wins over lossy regardless of the
recorded bitrate the bug a user hit was a FLAC (no bitrate in the DB) being
deleted in favor of a 282 kbps MP3 because the old ranking compared bitrate
first.
"""
from __future__ import annotations
from core.library.duplicate_keep import (
duplicate_keep_sort_key,
format_rank_for_path,
pick_duplicate_to_keep,
)
def _t(path, bitrate=None, duration=None, track_number=None, tid=1):
return {"id": tid, "file_path": path, "bitrate": bitrate,
"duration": duration, "track_number": track_number}
# --- the reported regression --------------------------------------------------
def test_flac_with_missing_bitrate_beats_282kbps_mp3():
# Havok "Prepare For Attack": FLAC has no bitrate recorded, MP3 is 282 kbps.
mp3 = _t("/music/Havok/01 - Prepare For Attack.mp3", bitrate=282, duration=236, tid=1)
flac = _t("/music/Havok/01 - Prepare for Attack.flac", bitrate=None, duration=236, tid=2)
keep = pick_duplicate_to_keep([mp3, flac])
assert keep["id"] == 2 # the FLAC
def test_flac_beats_mp3_regardless_of_order():
mp3 = _t("/x/a.mp3", bitrate=320, tid=1)
flac = _t("/x/a.flac", bitrate=0, tid=2)
assert pick_duplicate_to_keep([mp3, flac])["id"] == 2
assert pick_duplicate_to_keep([flac, mp3])["id"] == 2
# --- format ranking -----------------------------------------------------------
def test_format_rank_lossless_outranks_lossy():
assert format_rank_for_path("a.flac") > format_rank_for_path("a.mp3")
assert format_rank_for_path("a.wav") > format_rank_for_path("a.aac")
assert format_rank_for_path("a.m4a") > format_rank_for_path("a.mp3")
def test_format_rank_unknown_and_missing():
assert format_rank_for_path("a.xyz") == 1
assert format_rank_for_path("noext") == 1
assert format_rank_for_path(None) == 1
assert format_rank_for_path("") == 1
def test_format_rank_case_insensitive():
assert format_rank_for_path("A.FLAC") == format_rank_for_path("a.flac")
# --- tie-breakers within the same format -------------------------------------
def test_same_format_higher_bitrate_wins():
lo = _t("/x/a.mp3", bitrate=192, tid=1)
hi = _t("/x/b.mp3", bitrate=320, tid=2)
assert pick_duplicate_to_keep([lo, hi])["id"] == 2
def test_same_format_same_bitrate_longer_duration_wins():
short = _t("/x/a.flac", bitrate=900, duration=200, tid=1)
long = _t("/x/b.flac", bitrate=900, duration=240, tid=2)
assert pick_duplicate_to_keep([short, long])["id"] == 2
def test_track_number_is_final_tiebreak():
a = _t("/x/a.flac", bitrate=900, duration=240, track_number=1, tid=1)
b = _t("/x/b.flac", bitrate=900, duration=240, track_number=7, tid=2)
assert pick_duplicate_to_keep([a, b])["id"] == 2
# --- shape / edge cases -------------------------------------------------------
def test_sort_key_tuple_order_is_format_first():
key = duplicate_keep_sort_key(_t("/x/a.flac", bitrate=100, duration=5, track_number=3))
assert key == (10, 100, 5, 3)
def test_missing_numeric_fields_default_to_zero():
assert duplicate_keep_sort_key(_t("/x/a.mp3")) == (5, 0, 0, 0)
def test_empty_group_returns_none():
assert pick_duplicate_to_keep([]) is None

View file

@ -4,12 +4,16 @@ from core.image_cache import ImageCache
class FakeResponse:
def __init__(self, body: bytes, *, status_code: int = 200, content_type: str = "image/jpeg"):
def __init__(self, body: bytes, *, status_code: int = 200, content_type: str = "image/jpeg",
declared_length: int | None = None):
self.body = body
self.status_code = status_code
# declared_length lets a test simulate a truncated download: the server
# promises N bytes (Content-Length) but the body delivers fewer.
length = len(body) if declared_length is None else declared_length
self.headers = {
"Content-Type": content_type,
"Content-Length": str(len(body)),
"Content-Length": str(length),
}
self.closed = False
@ -51,6 +55,73 @@ def test_get_url_fetches_once_then_serves_cached_file(tmp_path):
assert len(calls) == 1
def test_truncated_download_is_rejected_not_cached(tmp_path):
"""#750: a short/dropped download (body shorter than the declared
Content-Length) must NOT be committed as a good cache entry otherwise the
half-decoded cover (top strip, rest grey) is served forever. It should raise
and leave nothing cached, so the next request retries fresh."""
calls = []
def fetcher(url, **kwargs):
calls.append(url)
# Server promises 5000 bytes but only delivers 800 (connection dropped).
return FakeResponse(b"x" * 800, declared_length=5000)
cache = ImageCache(tmp_path, fetcher=fetcher)
url = "https://images.example.test/big-cover.jpg"
raised = False
try:
cache.get_url(url)
except Exception as exc:
raised = True
assert "Truncated" in str(exc) or "truncated" in str(exc)
assert raised, "Expected a truncated download to raise"
# Nothing partial left on disk for this key.
import glob
key = ImageCache.key_for_url(url)
leftover = glob.glob(str(tmp_path / "**" / f"{key}*"), recursive=True)
leftover = [p for p in leftover if not p.endswith(".sqlite3")]
assert leftover == [], f"truncated file should not be cached, found: {leftover}"
# A subsequent SUCCESSFUL fetch works (not poisoned by the failed attempt).
cache2 = ImageCache(
tmp_path,
fetcher=lambda u, **k: FakeResponse(b"complete-jpeg-bytes"),
)
result = cache2.get_url(url)
assert result.path.read_bytes() == b"complete-jpeg-bytes"
def test_complete_download_with_content_length_succeeds(tmp_path):
"""Positive control: a full download whose body matches Content-Length is
cached normally (the truncation guard doesn't false-positive)."""
cache = ImageCache(
tmp_path,
fetcher=lambda u, **k: FakeResponse(b"a-real-cover"), # declared==actual
)
result = cache.get_url("https://images.example.test/ok-cover.jpg")
assert result.path.read_bytes() == b"a-real-cover"
assert result.status == "miss"
def test_no_content_length_still_caches(tmp_path):
"""Some CDNs omit Content-Length (chunked transfer). With no declared size
we can't detect truncation, so we must NOT reject — cache as before."""
class NoLengthResponse(FakeResponse):
def __init__(self, body):
super().__init__(body)
del self.headers["Content-Length"]
cache = ImageCache(
tmp_path,
fetcher=lambda u, **k: NoLengthResponse(b"chunked-cover-bytes"),
)
result = cache.get_url("https://images.example.test/chunked.jpg")
assert result.path.read_bytes() == b"chunked-cover-bytes"
def test_get_url_rejects_non_image_responses(tmp_path):
cache = ImageCache(
tmp_path,

View file

@ -16,6 +16,56 @@ def db(tmp_path):
return MusicDatabase(str(tmp_path / "music.db"))
# ---------------------------------------------------------------------------
# normalize_library_track_id — regression guard for issue #754
# ("Invalid library track id" on Jellyfin/Navidrome/Subsonic servers)
# ---------------------------------------------------------------------------
def test_normalize_passes_numeric_plex_id():
# Plex ratingKeys are numeric strings — must survive unchanged (not int-ified).
assert mlm.normalize_library_track_id("12345") == "12345"
assert mlm.normalize_library_track_id(12345) == "12345"
def test_normalize_passes_guid_id():
# The #754 bug: Jellyfin/Navidrome ids are non-numeric. int() rejected them.
assert mlm.normalize_library_track_id("a1b2c3d4-e5f6") == "a1b2c3d4-e5f6"
assert mlm.normalize_library_track_id("Do I Wanna Know_opus") == "Do I Wanna Know_opus"
def test_normalize_trims_whitespace():
assert mlm.normalize_library_track_id(" guid-123 ") == "guid-123"
def test_normalize_rejects_empty_and_none():
assert mlm.normalize_library_track_id(None) is None
assert mlm.normalize_library_track_id("") is None
assert mlm.normalize_library_track_id(" ") is None
def test_guid_library_track_id_round_trips(db):
"""End-to-end regression for #754: a non-numeric library id must save,
read back identically, and enrich never get coerced or rejected."""
guid = "a1b2c3d4e5f6-jellyfin"
norm = mlm.normalize_library_track_id(guid)
assert norm == guid
ok = db.save_manual_library_match(1, "spotify", "src-track-1", norm,
source_title="Do I Wanna Know?",
source_artist="Arctic Monkeys")
assert ok is True
row = db.get_manual_library_match(1, "spotify", "src-track-1")
assert row is not None
assert row["library_track_id"] == guid # stored as-is, not mangled to int
# Enrichment must resolve the GUID against tracks.id (TEXT) without error.
with patch.object(db, "api_get_tracks_by_ids", return_value=[
{"title": "Do I Wanna Know?", "artist_name": "Arctic Monkeys",
"album_title": "AM", "file_path": "/m/x.opus", "bitrate": 196}]) as mock_get:
enriched = mlm._enrich_match(row, db)
mock_get.assert_called_once_with([guid]) # passes the string id straight through
assert enriched["library_title"] == "Do I Wanna Know?"
# ---------------------------------------------------------------------------
# DB-layer tests
# ---------------------------------------------------------------------------

View file

@ -0,0 +1,54 @@
"""Regression: metadata cache tables must self-heal when the migration marker
is stale.
After a DB corruption-recovery the `metadata` table (with the
'metadata_cache_v1' marker) can survive while the large
metadata_cache_entities/searches tables do not. A marker-only guard then
permanently skips re-creating them, so the cache silently stops working and the
browser shows nothing. _add_metadata_cache_tables must re-create the tables when
the marker is present but the tables are gone.
"""
from __future__ import annotations
from database.music_database import MusicDatabase
def _tables(cur):
return {r[0] for r in cur.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'metadata_cache_%'"
).fetchall()}
def test_recreates_cache_tables_when_marker_stale(tmp_path):
db = MusicDatabase(str(tmp_path / "m.db"))
conn = db._get_connection()
cur = conn.cursor()
# Fresh DB: tables + marker present.
assert 'metadata_cache_entities' in _tables(cur)
assert cur.execute("SELECT value FROM metadata WHERE key='metadata_cache_v1'").fetchone()
# Simulate corruption-recovery: marker survives, cache tables don't.
cur.execute("DROP TABLE metadata_cache_entities")
cur.execute("DROP TABLE metadata_cache_searches")
conn.commit()
assert 'metadata_cache_entities' not in _tables(cur)
assert cur.execute("SELECT value FROM metadata WHERE key='metadata_cache_v1'").fetchone() # stale marker
# Re-run the migration — must self-heal despite the stale marker.
db._add_metadata_cache_tables(cur)
conn.commit()
assert 'metadata_cache_entities' in _tables(cur)
assert 'metadata_cache_searches' in _tables(cur)
def test_skips_when_marker_and_tables_both_present(tmp_path):
# Idempotent: a healthy DB shouldn't error on re-run.
db = MusicDatabase(str(tmp_path / "m2.db"))
conn = db._get_connection()
cur = conn.cursor()
before = _tables(cur)
db._add_metadata_cache_tables(cur) # no-op fast path
conn.commit()
assert _tables(cur) == before

View file

@ -0,0 +1,72 @@
"""Run the JS escaping tests for `webui/static/stats-automations.js` under the
regular pytest sweep.
The actual contract tests live in `tests/static/test_stats_automations_esc.mjs`
and run via Node.js's stable built-in test runner (`node --test`). This shim
shells out to that runner and asserts a clean exit so the JS tests fail the
suite if the inline-onclick escaping (`_escJs`) regresses e.g. a playlist or
automation named with an apostrophe silently breaking its action buttons.
Skipped when:
- `node` isn't on PATH (e.g. Python-only dev container).
- Node version < 22 (the assert-flavor used by the test is 22+).
Run directly:
node --test tests/static/test_stats_automations_esc.mjs
"""
from __future__ import annotations
import shutil
import subprocess
from pathlib import Path
import pytest
_REPO_ROOT = Path(__file__).resolve().parents[1]
_TEST_FILE = _REPO_ROOT / "tests" / "static" / "test_stats_automations_esc.mjs"
def _node_available() -> bool:
if not shutil.which("node"):
return False
try:
result = subprocess.run(
["node", "--version"],
capture_output=True, text=True, timeout=10,
)
except (subprocess.SubprocessError, FileNotFoundError):
return False
if result.returncode != 0:
return False
raw = (result.stdout or "").strip().lstrip("v")
try:
major = int(raw.split(".")[0])
except (ValueError, IndexError):
return False
return major >= 22
def test_stats_automations_esc_js():
"""Pin the inline-onclick escaping contract via `node --test`."""
if not _node_available():
pytest.skip("Node.js >= 22 required to run the JS escaping tests")
if not _TEST_FILE.exists():
pytest.skip(f"JS test file missing: {_TEST_FILE}")
result = subprocess.run(
["node", "--test", str(_TEST_FILE)],
capture_output=True, text=True,
cwd=str(_REPO_ROOT),
timeout=60,
)
if result.returncode != 0:
pytest.fail(
"JS stats-automations escaping tests failed:\n\n"
f"--- stdout ---\n{result.stdout}\n"
f"--- stderr ---\n{result.stderr}",
pytrace=False,
)

View file

@ -589,9 +589,45 @@ def test_torrent_album_to_staging_ignores_candidates_without_download_url(tmp_pa
assert outcome['success'] is False
assert 'No torrent results' in outcome['error']
# Regression (Cezar): "no results" must be fallback-eligible so a
# torrent-first hybrid returns to the per-track flow (next source)
# instead of the dispatch marking the batch failed and freezing at
# "Torrent searching for release 0%".
assert outcome.get('fallback') is True
fake_adapter.add_torrent.assert_not_called()
def test_torrent_album_to_staging_no_results_flags_fallback(tmp_path: Path) -> None:
"""Empty Prowlarr search → fallback-eligible failure, not terminal."""
plugin = TorrentDownloadPlugin()
fake_adapter = MagicMock()
fake_adapter.is_configured.return_value = True
with patch.object(plugin, 'is_configured', return_value=True), \
patch.object(plugin._prowlarr, 'search', new=AsyncMock(return_value=[])), \
patch('core.download_plugins.torrent.get_active_torrent_adapter', return_value=fake_adapter):
outcome = plugin.download_album_to_staging('GNX', 'Kendrick Lamar', str(tmp_path))
assert outcome['success'] is False
assert 'No torrent results' in outcome['error']
assert outcome.get('fallback') is True
fake_adapter.add_torrent.assert_not_called()
def test_usenet_album_to_staging_no_results_flags_fallback(tmp_path: Path) -> None:
"""Same contract for usenet: an empty search must fall back to the
per-track flow rather than hard-failing the album batch."""
plugin = UsenetDownloadPlugin()
fake_adapter = MagicMock()
fake_adapter.is_configured.return_value = True
with patch.object(plugin, 'is_configured', return_value=True), \
patch.object(plugin._prowlarr, 'search', new=AsyncMock(return_value=[])), \
patch('core.download_plugins.usenet.get_active_usenet_adapter', return_value=fake_adapter):
outcome = plugin.download_album_to_staging('GNX', 'Kendrick Lamar', str(tmp_path))
assert outcome['success'] is False
assert 'No usenet results' in outcome['error']
assert outcome.get('fallback') is True
fake_adapter.add_nzb.assert_not_called()
def test_registry_includes_torrent_and_usenet() -> None:
"""The registry decides what shows up in the orchestrator's
iteration helpers. If we forget to register a new plugin the

View file

@ -40,7 +40,7 @@ logger = setup_logging(_log_level, _log_path)
# App version — single source of truth for backup metadata, system-info, update check, etc.
# Semver: MAJOR.MINOR.PATCH. Bump at each dev→main release.
_SOULSYNC_BASE_VERSION = "2.6.4"
_SOULSYNC_BASE_VERSION = "2.6.5"
def _build_version_string():
"""Append short commit hash to version when available (e.g. 2.35+abc1234)."""
@ -744,16 +744,51 @@ logger.info("Core service initialization complete.")
# modules and handlers were still referencing names that never got created.
# Global Streaming State Management
stream_state = {
"status": "stopped", # States: stopped, loading, queued, ready, error
"progress": 0,
"track_info": None,
"file_path": None, # Path to the audio file in the Stream folder
"error_message": None,
}
stream_lock = threading.Lock() # Prevent race conditions
stream_background_task = None
stream_executor = ThreadPoolExecutor(max_workers=1) # Only one stream at a time
# Stream playback state. Lifted into core.streaming.state.StreamStateStore —
# a per-session registry that's unit-tested and is the foundation for
# multi-listener playback (player-revamp Phase 3). Today we use only the
# DEFAULT session, so behavior is identical to the old single global: the
# whole server still shares one "currently playing". The store just makes the
# eventual per-listener split a wiring change instead of a rewrite.
#
# ``stream_state`` is the default session — dict-compatible (s["k"], s.get,
# s.update) so the ~20 existing call sites work unchanged. ``stream_lock`` is
# that session's own lock, so ``with stream_lock:`` guards exactly what it did.
from core.streaming.state import StreamStateStore as _StreamStateStore
from core.streaming.state import DEFAULT_SESSION as _DEFAULT_STREAM_SESSION
stream_state_store = _StreamStateStore()
stream_state = stream_state_store.get() # DEFAULT_SESSION (back-compat alias)
stream_lock = stream_state.lock
# Phase 3b — per-listener playback: each browser/device gets its own stream
# session so two listeners no longer collide on one global. Background tasks are
# tracked per session id. max_workers bumped so concurrent listeners don't queue
# behind each other. Single-user behavior is unchanged (one cookie → one session).
stream_background_task = None # legacy alias (default session)
stream_tasks = {} # session_id -> Future
stream_executor = ThreadPoolExecutor(max_workers=4, thread_name_prefix="StreamPrep")
def _stream_session_id():
"""Stable per-browser stream session id, from the Flask session cookie.
Falls back to the DEFAULT session when there's no request context or no
cookie yet (e.g. the 1s socket broadcast thread) so single-user behavior
is identical to before. Each distinct browser/device gets its own id and
therefore its own independent playback + Stream/<id> staging dir.
"""
try:
sid = session.get('stream_sid')
if not sid:
sid = uuid.uuid4().hex[:16]
session['stream_sid'] = sid
return sid
except Exception:
return _DEFAULT_STREAM_SESSION
def _current_stream_state():
"""The StreamSession for the calling browser (dict-compatible)."""
return stream_state_store.get(_stream_session_id())
# Global OAuth State Management
# Store PKCE values for Tidal OAuth flow
@ -1776,20 +1811,36 @@ _EDITION_BARE_RE = _re.compile(
from core.streaming import prepare as _streaming_prepare
def _build_prepare_stream_deps():
"""Build the PrepareStreamDeps bundle from web_server.py globals on each call."""
def _build_prepare_stream_deps(sess, sid):
"""Build the PrepareStreamDeps bundle for a specific stream session.
``sess`` is the StreamSession (dict-compatible) for this listener; ``sid`` is
its id, used to give each session its own ``Stream/<sid>`` staging subdir so
concurrent listeners don't clear each other's files.
"""
def _get_stream_state():
return stream_state
return sess
def _set_stream_state(value):
global stream_state
stream_state = value
# prepare.py only mutates in place (.update / [k]=) so this is
# effectively dead — but if anything reassigns, route it through the
# session's replace() so the store keeps the live object.
if value is sess:
return
sess.replace(dict(value))
base_root = os.path.dirname(os.path.abspath(__file__))
# prepare.py stages into <project_root>/Stream. Default session keeps the
# historical flat Stream/; a named session stages under Stream/<sid>/Stream so
# concurrent listeners never clear each other's files. (The served file_path
# is absolute, so staging location only affects isolation/cleanup.)
project_root = base_root if sid == _DEFAULT_STREAM_SESSION else os.path.join(base_root, 'Stream', sid)
return _streaming_prepare.PrepareStreamDeps(
config_manager=config_manager,
download_orchestrator=download_orchestrator,
stream_lock=stream_lock,
project_root=os.path.dirname(os.path.abspath(__file__)),
stream_lock=sess.lock,
project_root=project_root,
docker_resolve_path=docker_resolve_path,
find_streaming_download_in_all_downloads=_find_streaming_download_in_all_downloads,
find_downloaded_file=_find_downloaded_file,
@ -1800,8 +1851,8 @@ def _build_prepare_stream_deps():
)
def _prepare_stream_task(track_data):
return _streaming_prepare.prepare_stream_task(track_data, _build_prepare_stream_deps())
def _prepare_stream_task(track_data, sess, sid):
return _streaming_prepare.prepare_stream_task(track_data, _build_prepare_stream_deps(sess, sid))
def _find_streaming_download_in_all_downloads(all_downloads, track_data):
@ -2945,6 +2996,28 @@ def handle_settings():
return jsonify({"error": str(e)}), 500
@app.route('/api/metadata/art-sources', methods=['GET'])
def get_art_sources():
"""Cover-art sources the current user can actually use, in default order.
Free sources (CAA/Deezer/iTunes/AudioDB) are always available; account
sources (Spotify) only when connected. The settings UI uses this to offer
only sources the user is set up with ("not everybody has every source").
"""
try:
from core.metadata.art_lookup import available_art_sources
labels = {
'caa': 'Cover Art Archive', 'deezer': 'Deezer', 'itunes': 'iTunes',
'spotify': 'Spotify', 'audiodb': 'TheAudioDB',
}
available = [{'id': s, 'name': labels.get(s, s.title())}
for s in available_art_sources()]
return jsonify({'available': available})
except Exception as e:
logger.error(f"Error listing art sources: {e}")
return jsonify({'available': [], 'error': str(e)}), 500
@app.route('/api/dev-mode', methods=['GET', 'POST'])
def handle_dev_mode():
global dev_mode_enabled
@ -6661,6 +6734,48 @@ def _list_available_download_sources() -> tuple:
return download_mode, sources
def _norm_track_key(s: str) -> str:
"""Loose normalization for matching a task to its library_history row."""
import re
return re.sub(r'[^a-z0-9]+', '', (s or '').lower())
@app.route('/api/downloads/task/<task_id>/detail', methods=['GET'])
def get_task_detail(task_id):
"""Full per-track detail for the track-detail modal: live task state merged
with the durable library_history provenance (location, quality, AcoustID
verdict, source, expected-vs-downloaded). Thin glue over build_track_detail."""
try:
from core.downloads.track_detail import build_track_detail
with tasks_lock:
t = download_tasks.get(task_id)
task = dict(t) if isinstance(t, dict) else None
if task is None:
return jsonify({"success": False, "error": "Task not found"}), 404
task['task_id'] = task_id
# Enrich from the most recent download-history row matching this track.
history = None
try:
ti = task.get('track_info') if isinstance(task.get('track_info'), dict) else {}
want_title = _norm_track_key(ti.get('name', ''))
if want_title:
db = get_database()
entries, _ = db.get_library_history(event_type='download', page=1, limit=100)
for e in entries:
if _norm_track_key(e.get('title', '')) == want_title:
history = e
break
except Exception as hist_err:
logger.debug(f"track-detail history lookup failed: {hist_err}")
detail = build_track_detail(task, history)
return jsonify({"success": True, "detail": detail})
except Exception as e:
logger.error(f"get_task_detail error: {e}")
return jsonify({"success": False, "error": str(e)}), 500
@app.route('/api/downloads/task/<task_id>/candidates', methods=['GET'])
def get_task_candidates(task_id):
"""Returns the cached search candidates for a download task so the UI can show what was found."""
@ -7066,20 +7181,58 @@ def approve_quarantine_item(entry_id):
# for this one restored pass so multi-reason failures do not loop.
context['_skip_quarantine_check'] = 'all'
context['_approved_quarantine_trigger'] = trigger
# Re-dispatch through the same pipeline. Run async so the HTTP
# request returns quickly — UI polls /list to see the entry vanish.
# If the caller (download-modal chooser) passed the originating task, run
# the re-import through the verification WRAPPER with that task_id so the
# task is marked completed on success — otherwise the modal row stays
# stuck on "Quarantined" even though the file imported. The sidecar
# 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
# original inner-pipeline path.
_req = request.get_json(silent=True) or {}
_task_id = (_req.get('task_id') or '').strip() or None
_batch_id = None
if _task_id:
with tasks_lock:
_t = download_tasks.get(_task_id)
if isinstance(_t, dict):
_batch_id = _t.get('batch_id')
context['task_id'] = _task_id
if _batch_id:
context['batch_id'] = _batch_id
context_key = f"approve_{entry_id}_{int(time.time())}"
threading.Thread(
target=lambda: _post_process_matched_download(context_key, context, restored_path),
daemon=True,
).start()
logger.info(f"[Quarantine] Approved {entry_id} (original_trigger={trigger}, bypass=all) → re-running pipeline")
if _task_id:
_reprocess = lambda: _post_process_matched_download_with_verification(
context_key, context, restored_path, _task_id, _batch_id,
)
else:
_reprocess = lambda: _post_process_matched_download(context_key, context, restored_path)
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")
return jsonify({"success": True, "trigger_bypassed": "all", "original_trigger": trigger})
except Exception as e:
logger.error(f"[Quarantine] Error approving {entry_id}: {e}")
return jsonify({"success": False, "error": str(e)}), 500
@app.route('/api/quarantine/<entry_id>/stream', methods=['GET'])
def stream_quarantine_item(entry_id):
"""Stream a quarantined audio file in-app (range-supported) so the user can
listen before deciding to approve, search again, or delete it. The file
lives in the quarantine dir with a `.quarantined` suffix, so the real audio
extension (and thus Content-Type) is recovered from the sidecar."""
try:
from core.imports.quarantine import get_quarantine_entry_stream_info
info = get_quarantine_entry_stream_info(_get_quarantine_dir(), entry_id)
if info is None:
return jsonify({"error": "Quarantined file not found"}), 404
file_path, extension = info
mimetype = _AUDIO_MIME_TYPES.get(extension, 'audio/mpeg')
return _serve_audio_file_with_range(file_path, mimetype_override=mimetype)
except Exception as e:
logger.error(f"[Quarantine] Error streaming {entry_id}: {e}")
return jsonify({"error": str(e)}), 500
@app.route('/api/quarantine/<entry_id>/recover', methods=['POST'])
def recover_quarantine_item(entry_id):
"""Fallback for legacy thin sidecars: move file into Staging so the user
@ -10577,9 +10730,10 @@ def library_play_track():
logger.info(f"Library play request: {os.path.basename(file_path)}")
# Set stream state to ready with the library file path directly
with stream_lock:
stream_state.update({
# Set THIS listener's stream state to ready with the library file path.
sess = _current_stream_state()
with sess.lock:
sess.update({
"status": "ready",
"progress": 100,
"track_info": {
@ -10598,6 +10752,30 @@ def library_play_track():
logger.error(f"Error playing library track: {e}")
return jsonify({"success": False, "error": str(e)}), 500
@app.route('/api/library/log-play', methods=['POST'])
def library_log_play():
"""Record a SoulSync web-player play into listening_history + bump the
track's play_count/last_played (feeds 'recently played' and smart radio).
Fire-and-forget from the frontend ~10s into a track. Best-effort: a logging
failure never affects playback, so we always return 200-ish.
"""
try:
from core.playback.play_log import build_play_event
data = request.get_json(silent=True) or {}
track = data.get('track') or data
played_at = datetime.now().isoformat()
duration_ms = data.get('duration_ms', 0)
event = build_play_event(track, played_at, duration_ms)
if not event:
return jsonify({"success": False, "skipped": True}), 200
get_database().record_web_player_play(event)
return jsonify({"success": True})
except Exception as e:
logger.debug(f"log-play failed (non-fatal): {e}")
return jsonify({"success": False, "error": str(e)}), 200
_enrichment_locks = {svc: threading.Lock() for svc in ('audiodb', 'deezer', 'musicbrainz', 'spotify', 'itunes', 'lastfm', 'genius', 'tidal', 'qobuz', 'discogs')}
@app.route('/api/library/enrich', methods=['POST'])
@ -11734,23 +11912,25 @@ def library_radio():
@app.route('/api/stream/start', methods=['POST'])
def stream_start():
"""Start streaming a track in the background"""
"""Start streaming a track in the background (per-listener session)."""
global stream_background_task
data = request.get_json()
if not data:
return jsonify({"success": False, "error": "No track data provided"}), 400
logger.info(f"Web UI Stream request for: {data.get('filename')}")
try:
# Stop any existing streaming task
if stream_background_task and not stream_background_task.done():
stream_background_task.cancel()
# Reset stream state
with stream_lock:
stream_state.update({
sid = _stream_session_id()
sess = stream_state_store.get(sid)
# Stop only THIS listener's existing task — others keep playing.
prev = stream_tasks.get(sid)
if prev and not prev.done():
prev.cancel()
with sess.lock:
sess.update({
"status": "stopped",
"progress": 0,
"track_info": None,
@ -11758,27 +11938,30 @@ def stream_start():
"error_message": None,
"is_library": False
})
# Start new background streaming task
stream_background_task = stream_executor.submit(_prepare_stream_task, data)
# Start new background streaming task for this session.
fut = stream_executor.submit(_prepare_stream_task, data, sess, sid)
stream_tasks[sid] = fut
if sid == _DEFAULT_STREAM_SESSION:
stream_background_task = fut # keep legacy alias in sync
return jsonify({"success": True, "message": "Streaming started"})
except Exception as e:
logger.error(f"Error starting stream: {e}")
return jsonify({"success": False, "error": str(e)}), 500
@app.route('/api/stream/status')
def stream_status():
"""Get current streaming status and progress"""
"""Get current streaming status and progress for THIS listener."""
try:
with stream_lock:
# Return copy of current stream state
sess = _current_stream_state()
with sess.lock:
return jsonify({
"status": stream_state["status"],
"progress": stream_state["progress"],
"track_info": stream_state["track_info"],
"error_message": stream_state["error_message"]
"status": sess["status"],
"progress": sess["progress"],
"track_info": sess["track_info"],
"error_message": sess["error_message"]
})
except Exception as e:
logger.error(f"Error getting stream status: {e}")
@ -11789,123 +11972,138 @@ def stream_status():
"error_message": str(e)
}), 500
_AUDIO_MIME_TYPES = {
'.mp3': 'audio/mpeg', '.flac': 'audio/flac', '.ogg': 'audio/ogg',
'.aac': 'audio/aac', '.m4a': 'audio/mp4', '.wav': 'audio/wav',
'.opus': 'audio/ogg', '.webm': 'audio/webm', '.wma': 'audio/x-ms-wma',
}
def _serve_audio_file_with_range(file_path, mimetype_override=None):
"""Serve an on-disk audio file with HTTP range support (HTML5 seeking).
Shared by /stream/audio (current track) and /stream/library-audio (the
crossfade pre-loader, which plays the NEXT track on a second <audio>).
``mimetype_override`` lets callers supply the Content-Type explicitly when
the on-disk extension isn't the audio extension — e.g. the quarantine
streamer, whose files end in ``.quarantined`` (real type recovered from the
sidecar). When None, the type is inferred from the file extension as before.
"""
if not os.path.exists(file_path):
return jsonify({"error": "Audio file not found"}), 404
file_ext = os.path.splitext(file_path)[1].lower()
mimetype = mimetype_override or _AUDIO_MIME_TYPES.get(file_ext, 'audio/mpeg')
file_size = os.path.getsize(file_path)
range_header = request.headers.get('Range', None)
if range_header:
byte_start = 0
byte_end = file_size - 1
try:
range_match = re.match(r'bytes=(\d*)-(\d*)', range_header)
if range_match:
start_str, end_str = range_match.groups()
if start_str:
byte_start = int(start_str)
if end_str:
byte_end = int(end_str)
else:
byte_end = file_size - 1
except (ValueError, AttributeError):
pass
byte_start = max(0, byte_start)
byte_end = min(file_size - 1, byte_end)
content_length = byte_end - byte_start + 1
def generate():
with open(file_path, 'rb') as f:
f.seek(byte_start)
remaining = content_length
while remaining:
chunk_size = min(8192, remaining)
chunk = f.read(chunk_size)
if not chunk:
break
remaining -= len(chunk)
yield chunk
response = Response(generate(), status=206, mimetype=mimetype, direct_passthrough=True)
response.headers.add('Content-Range', f'bytes {byte_start}-{byte_end}/{file_size}')
response.headers.add('Accept-Ranges', 'bytes')
response.headers.add('Content-Length', str(content_length))
response.headers.add('Cache-Control', 'no-cache')
return response
else:
response = send_file(file_path, as_attachment=False, mimetype=mimetype)
response.headers.add('Accept-Ranges', 'bytes')
response.headers.add('Content-Length', str(file_size))
response.headers['Cache-Control'] = 'no-cache'
return response
@app.route('/stream/audio')
def stream_audio():
"""Serve the audio file from the Stream folder with range request support"""
"""Serve THIS listener's current audio file with range request support."""
try:
with stream_lock:
if stream_state["status"] != "ready" or not stream_state["file_path"]:
sess = _current_stream_state()
with sess.lock:
if sess["status"] != "ready" or not sess["file_path"]:
return jsonify({"error": "No audio file ready for streaming"}), 404
file_path = stream_state["file_path"]
if not os.path.exists(file_path):
return jsonify({"error": "Audio file not found"}), 404
file_path = sess["file_path"]
logger.info(f"Serving audio file: {os.path.basename(file_path)}")
# Determine MIME type based on file extension
file_ext = os.path.splitext(file_path)[1].lower()
mime_types = {
'.mp3': 'audio/mpeg',
'.flac': 'audio/flac',
'.ogg': 'audio/ogg',
'.aac': 'audio/aac',
'.m4a': 'audio/mp4',
'.wav': 'audio/wav',
'.opus': 'audio/ogg',
'.webm': 'audio/webm',
'.wma': 'audio/x-ms-wma'
}
mimetype = mime_types.get(file_ext, 'audio/mpeg')
# Get file size
file_size = os.path.getsize(file_path)
# Handle range requests (important for HTML5 audio seeking)
range_header = request.headers.get('Range', None)
if range_header:
byte_start = 0
byte_end = file_size - 1
# Parse range header (format: "bytes=start-end")
try:
range_match = re.match(r'bytes=(\d*)-(\d*)', range_header)
if range_match:
start_str, end_str = range_match.groups()
if start_str:
byte_start = int(start_str)
if end_str:
byte_end = int(end_str)
else:
# If no end specified, serve from start to end of file
byte_end = file_size - 1
except (ValueError, AttributeError):
# Invalid range header, serve full file
pass
# Ensure valid range
byte_start = max(0, byte_start)
byte_end = min(file_size - 1, byte_end)
content_length = byte_end - byte_start + 1
# Create response with partial content
def generate():
with open(file_path, 'rb') as f:
f.seek(byte_start)
remaining = content_length
while remaining:
chunk_size = min(8192, remaining) # 8KB chunks
chunk = f.read(chunk_size)
if not chunk:
break
remaining -= len(chunk)
yield chunk
response = Response(generate(),
status=206, # Partial Content
mimetype=mimetype,
direct_passthrough=True)
# Set range headers
response.headers.add('Content-Range', f'bytes {byte_start}-{byte_end}/{file_size}')
response.headers.add('Accept-Ranges', 'bytes')
response.headers.add('Content-Length', str(content_length))
response.headers.add('Cache-Control', 'no-cache')
return response
else:
# No range request, serve entire file
response = send_file(file_path, as_attachment=False, mimetype=mimetype)
response.headers.add('Accept-Ranges', 'bytes')
response.headers.add('Content-Length', str(file_size))
# Override the default static-cache max-age — streaming media
# bypasses caching (range requests, mid-track seeks).
response.headers['Cache-Control'] = 'no-cache'
return response
return _serve_audio_file_with_range(file_path)
except Exception as e:
logger.error(f"Error serving audio file: {e}")
return jsonify({"error": str(e)}), 500
@app.route('/stream/library-audio')
def stream_library_audio():
"""Serve an arbitrary LIBRARY audio file by path, with range support.
Powers crossfade: the player preloads the NEXT queued track on a second
<audio> element and fades between them. Security: the path is resolved
through ``_resolve_library_file_path`` the same validator /api/library/play
uses which only resolves files within the configured transfer/download/
media-library directories, so this can't be used to read arbitrary disk.
"""
try:
raw_path = request.args.get('path', '')
if not raw_path:
return jsonify({"error": "path is required"}), 400
resolved = _resolve_library_file_path(raw_path)
if not resolved or not os.path.exists(resolved):
return jsonify({"error": _get_file_not_found_error(raw_path)}), 404
return _serve_audio_file_with_range(resolved)
except Exception as e:
logger.error(f"Error serving library audio file: {e}")
return jsonify({"error": str(e)}), 500
@app.route('/api/stream/stop', methods=['POST'])
def stream_stop():
"""Stop streaming and clean up"""
global stream_background_task
"""Stop THIS listener's stream and clean up its staging dir."""
try:
# Cancel background task
if stream_background_task and not stream_background_task.done():
stream_background_task.cancel()
sid = _stream_session_id()
sess = stream_state_store.get(sid)
# Only clear Stream folder if NOT playing a library file
with stream_lock:
is_library = stream_state.get("is_library", False)
# Cancel only this session's background task.
fut = stream_tasks.get(sid)
if fut and not fut.done():
fut.cancel()
# Only clear the (per-session) Stream folder if NOT a library file.
with sess.lock:
is_library = sess.get("is_library", False)
if not is_library:
project_root = os.path.dirname(os.path.abspath(__file__))
stream_folder = os.path.join(project_root, 'Stream')
base_root = os.path.dirname(os.path.abspath(__file__))
stream_folder = (os.path.join(base_root, 'Stream')
if sid == _DEFAULT_STREAM_SESSION
else os.path.join(base_root, 'Stream', sid, 'Stream'))
if os.path.exists(stream_folder):
for filename in os.listdir(stream_folder):
@ -11916,9 +12114,9 @@ def stream_stop():
else:
logger.info("Library playback stopped - skipping file deletion")
# Reset stream state
with stream_lock:
stream_state.update({
# Reset this session's stream state
with sess.lock:
sess.update({
"status": "stopped",
"progress": 0,
"track_info": None,
@ -18517,13 +18715,9 @@ def mlm_save():
data = request.get_json(force=True) or {}
source = data.get('source', '').strip()
source_track_id = data.get('source_track_id', '').strip()
library_track_id = data.get('library_track_id')
library_track_id = mlm.normalize_library_track_id(data.get('library_track_id'))
if not source or not source_track_id or not library_track_id:
return jsonify({"success": False, "error": "source, source_track_id, library_track_id required"}), 400
try:
library_track_id = int(library_track_id)
except (TypeError, ValueError):
return jsonify({"success": False, "error": "Invalid library track id"}), 400
db = get_database()
profile_id = get_current_profile_id()
# Validate library track exists before saving
@ -32705,9 +32899,13 @@ try:
amazon_db = MusicDatabase()
amazon_worker = AmazonWorker(database=amazon_db)
amazon_worker.start()
if config_manager.get('amazon_enrichment_paused', False):
# Opt-in by default: Amazon enrichment depends on an external public proxy
# (T2Tunes) that can be down, so it stays paused unless the user has
# explicitly enabled it (amazon_enrichment_paused=False). This stops an
# instance outage from grinding/log-flooding installs that never opted in.
if config_manager.get('amazon_enrichment_paused', True):
amazon_worker.pause()
logger.info("Amazon enrichment worker initialized (paused — restored from config)")
logger.info("Amazon enrichment worker initialized (paused — enable it in Settings)")
else:
logger.info("Amazon enrichment worker initialized and started")
except Exception as e:

View file

@ -2365,6 +2365,9 @@
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg>
</button>
</div>
<div class="adl-batch-summary" id="adl-batch-summary" style="display:none">
<!-- Live aggregate (batches · downloading · speed · ETA) rendered by JS -->
</div>
<div class="adl-batch-active" id="adl-batch-active">
<!-- Active batch cards rendered by JS -->
</div>
@ -5467,7 +5470,12 @@
<input type="checkbox" id="prefer-caa-art">
Use MusicBrainz Cover Art Archive for album art
</label>
<small>Higher resolution but quality may vary. When off, uses Spotify/iTunes/Deezer art (consistent 640x640).</small>
<small>Higher resolution but quality may vary. When off, uses Spotify/iTunes/Deezer art (consistent 640x640). Ignored when a preferred album art source list is set below — add Cover Art Archive to that list instead.</small>
</div>
<div class="form-group">
<label class="checkbox-label" style="cursor:default;">Preferred album art sources</label>
<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>
<div class="form-group">
<label class="checkbox-label">
@ -7007,6 +7015,8 @@
<!-- Hidden HTML5 Audio Player for Streaming -->
<audio id="audio-player" style="display: none;"></audio>
<!-- Secondary audio element used only for crossfade preloading of the next track -->
<audio id="audio-player-xfade" style="display: none;"></audio>
<!-- Expanded Now Playing Modal -->
<div class="np-modal-overlay hidden" id="np-modal-overlay">
@ -7017,15 +7027,24 @@
<div class="np-body">
<!-- Left: album art + track info -->
<div class="np-left">
<div class="np-album-art-container">
<div class="np-album-art-container" id="np-album-art-container" title="Click for visualizer">
<img class="np-album-art hidden" id="np-album-art" src="" alt="Album Art">
<div class="np-album-art-placeholder" id="np-album-art-placeholder">
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3"/><line x1="12" y1="2" x2="12" y2="5"/><line x1="12" y1="19" x2="12" y2="22"/>
</svg>
</div>
<!-- Big music-synced visualizer takeover (Plexamp-style); click art to toggle -->
<div class="np-art-viz" id="np-art-viz" aria-hidden="true"></div>
<div class="np-art-hint" id="np-art-hint" title="Visualizer">
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><rect x="3" y="10" width="3" height="11" rx="1"/><rect x="10.5" y="4" width="3" height="17" rx="1"/><rect x="18" y="13" width="3" height="8" rx="1"/></svg>
</div>
</div>
<div class="np-track-info">
<div class="np-play-context hidden" id="np-play-context">
<span class="np-play-context-label">Playing from</span>
<span class="np-play-context-name" id="np-play-context-name"></span>
</div>
<div class="np-track-title" id="np-track-title">No track</div>
<div class="np-artist-name" id="np-artist-name">Unknown Artist</div>
<div class="np-album-name" id="np-album-name">Unknown Album</div>
@ -7041,12 +7060,23 @@
</div>
<!-- Right: controls -->
<div class="np-right">
<div class="np-util-row">
<button class="np-util-btn" id="np-sleep-btn" title="Sleep timer">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
<span id="np-sleep-label">Sleep</span>
</button>
<button class="np-util-btn" id="np-crossfade-btn" title="Crossfade between tracks">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M7 8l-4 4 4 4"/><path d="M17 8l4 4-4 4"/><line x1="3" y1="12" x2="21" y2="12"/></svg>
<span>Crossfade</span>
</button>
</div>
<div class="np-progress-section">
<div class="np-progress-bar-container">
<div class="np-progress-track">
<div class="np-progress-fill" id="np-progress-fill"></div>
</div>
<input type="range" class="np-progress-bar" id="np-progress-bar" min="0" max="100" value="0" step="0.1">
<div class="np-seek-tip" id="np-seek-tip">0:00</div>
</div>
<div class="np-time-display">
<span class="np-current-time" id="np-current-time">0:00</span>
@ -7085,6 +7115,15 @@
<input type="range" class="np-volume-slider" id="np-volume-slider" min="0" max="100" value="70">
</div>
</div>
<!-- Up-next peek (hidden when queue has no next track) -->
<div class="np-upnext hidden" id="np-upnext">
<span class="np-upnext-label">Next</span>
<img class="np-upnext-art" id="np-upnext-art" alt="">
<div class="np-upnext-info">
<div class="np-upnext-title" id="np-upnext-title"></div>
<div class="np-upnext-artist" id="np-upnext-artist"></div>
</div>
</div>
<div class="np-stop-row">
<button class="np-btn np-btn-stop" id="np-stop-btn" title="Stop playback">
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><rect x="4" y="4" width="16" height="16" rx="2"/></svg>
@ -7981,6 +8020,7 @@
<script src="{{ url_for('static', filename='search.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='sync-spotify.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='downloads.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='track-detail.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='wishlist-tools.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='sync-services.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='sync-listenbrainz.js', v=static_v) }}"></script>
@ -8014,6 +8054,8 @@
</div>
<div class="gsearch-results" id="gsearch-results"></div>
{% include 'track-detail-modal.html' %}
<!-- Floating Mini Media Player — fixed bottom-right, above bell/help -->
<div class="media-player idle" id="media-player">
<!-- Thin interactive progress bar spanning full width at top -->
@ -8040,6 +8082,9 @@
<div class="artist-name" id="artist-name">Unknown Artist</div>
</div>
<div class="mini-player-controls">
<button class="mini-toggle-btn" id="mini-shuffle-btn" title="Shuffle">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="13" height="13"><polyline points="16 3 21 3 21 8"/><line x1="4" y1="20" x2="21" y2="3"/><polyline points="21 16 21 21 16 21"/><line x1="15" y1="15" x2="21" y2="21"/><line x1="4" y1="4" x2="9" y2="9"/></svg>
</button>
<button class="mini-nav-btn" id="mini-prev-btn" title="Previous" disabled>
<svg viewBox="0 0 24 24" fill="currentColor" width="14" height="14"><path d="M6 6h2v12H6zm3.5 6l8.5 6V6z"/></svg>
</button>
@ -8050,6 +8095,10 @@
<button class="mini-nav-btn" id="mini-next-btn" title="Next" disabled>
<svg viewBox="0 0 24 24" fill="currentColor" width="14" height="14"><path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z"/></svg>
</button>
<button class="mini-toggle-btn" id="mini-repeat-btn" title="Repeat">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="13" height="13"><polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/><polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/></svg>
<span class="mini-repeat-one" id="mini-repeat-one-badge" style="display:none">1</span>
</button>
<button class="stop-button" id="stop-button" disabled>
<svg viewBox="0 0 24 24" fill="currentColor" width="10" height="10"><rect x="6" y="6" width="12" height="12" rx="1.5"/></svg>
</button>

View file

@ -2983,7 +2983,16 @@ function _ensureCandidatesClickListener(statusEl) {
e.stopPropagation();
_hideErrorTooltip();
const taskId = this.dataset.taskId;
if (taskId) showCandidatesModal(taskId);
if (!taskId) return;
// Decide at click-time from dataset set each render: completed and
// quarantined rows open the rich track-detail modal (it carries the
// play/listen + Accept/Search actions); plain failed/not-found go
// straight to the search modal.
if (this.dataset.detailOpen && typeof openTrackDetail === 'function') {
openTrackDetail(taskId);
} else {
showCandidatesModal(taskId);
}
});
}
@ -3344,6 +3353,7 @@ async function downloadCandidate(taskId, candidate, trackName) {
async function approveQuarantineFromDownloadRow(button) {
const entryId = button?.dataset?.entryId || '';
const taskId = button?.dataset?.taskId || '';
if (!entryId) {
showToast('Open Quarantine to approve this file.', 'warning');
return;
@ -3361,7 +3371,11 @@ async function approveQuarantineFromDownloadRow(button) {
button.disabled = true;
button.textContent = 'Approving...';
try {
const response = await fetch(`/api/quarantine/${encodeURIComponent(entryId)}/approve`, { method: 'POST' });
const response = await fetch(`/api/quarantine/${encodeURIComponent(entryId)}/approve`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ task_id: taskId }),
});
const data = await response.json();
if (data.success) {
showToast('Approved quarantined file. Re-running post-processing.', 'success');
@ -3377,6 +3391,10 @@ async function approveQuarantineFromDownloadRow(button) {
}
}
// Quarantine actions (Listen / Accept & Import / Search) now live in the
// track-detail modal (static/track-detail.js), which a quarantined row opens
// via _ensureCandidatesClickListener + dataset.detailOpen.
function closeCandidatesModal() {
const overlay = document.getElementById('candidates-modal-overlay');
if (overlay) {
@ -3641,6 +3659,15 @@ function processModalStatusUpdate(playlistId, data) {
statusEl.classList.remove('has-error-tooltip');
statusEl.removeAttribute('title');
statusEl.removeAttribute('data-error-msg');
// Clear clickable/quarantine state each render; the failure
// branch below re-adds it when still applicable. Without this a
// task that flips failed/quarantined -> completed (e.g. after
// Accept & Import) keeps a stale chooser on the cell.
statusEl.classList.remove('has-candidates');
delete statusEl.dataset.quarantineEntryId;
delete statusEl.dataset.quarantineReason;
delete statusEl.dataset.quarantineTrack;
delete statusEl.dataset.detailOpen;
statusEl.textContent = statusText;
if ((task.status === 'failed' || task.status === 'cancelled' || task.status === 'not_found') && task.error_message) {
@ -3648,13 +3675,23 @@ function processModalStatusUpdate(playlistId, data) {
statusEl.dataset.errorMsg = task.error_message;
_ensureErrorTooltipListeners(statusEl);
}
// Make not_found / failed / cancelled cells clickable to open
// the candidates modal. Always bind — even when no auto-search
// candidates were cached — because the modal carries the manual
// search bar, which is the user's recourse for empty results.
if (task.status === 'not_found' || task.status === 'failed' || task.status === 'cancelled') {
// Completed rows are clickable into the rich track-detail modal
// (play, location, AcoustID verdict, provenance).
if (task.status === 'completed') {
statusEl.classList.add('has-candidates');
statusEl.dataset.taskId = task.task_id;
statusEl.dataset.detailOpen = '1';
_ensureCandidatesClickListener(statusEl);
} else if (task.status === 'not_found' || task.status === 'failed' || task.status === 'cancelled') {
// Clickable to recover: quarantined -> track-detail modal
// (Listen / Accept / Search); plain failed/not-found ->
// straight to the search modal. detailOpen is set/cleared
// each render so a row that changes kind stays correct.
statusEl.classList.add('has-candidates');
statusEl.dataset.taskId = task.task_id;
if (isQuarantinedTask && task.quarantine_entry_id) {
statusEl.dataset.detailOpen = '1';
}
_ensureCandidatesClickListener(statusEl);
}
console.debug(`✅ [Status Update] Updated track ${task.track_index} to: ${statusText}${isV2Task ? ' (V2)' : ''}`);
@ -3674,7 +3711,7 @@ function processModalStatusUpdate(playlistId, data) {
}
} else if (actionsEl && task.status === 'failed' && isQuarantinedTask && task.quarantine_entry_id) {
const entryId = escapeHtml(task.quarantine_entry_id);
actionsEl.innerHTML = `<button class="approve-quarantine-inline-btn" data-entry-id="${entryId}" title="Approve quarantined file">Approve</button>`;
actionsEl.innerHTML = `<button class="approve-quarantine-inline-btn" data-entry-id="${entryId}" data-task-id="${escapeHtml(task.task_id)}" title="Approve quarantined file">Approve</button>`;
const approveBtn = actionsEl.querySelector('.approve-quarantine-inline-btn');
if (approveBtn) {
approveBtn.addEventListener('click', () => approveQuarantineFromDownloadRow(approveBtn));
@ -5658,7 +5695,10 @@ let _gsController = null;
if (clearBtn) clearBtn.style.display = q.length > 0 ? '' : 'none';
if (_gsState.debounceTimer) clearTimeout(_gsState.debounceTimer);
if (q.length < 2) { _gsHideResults(); return; }
_gsState.debounceTimer = setTimeout(() => _gsController.submitQuery(q), 300);
// 600ms (was 300) — coalesce a name being typed into one search
// instead of one external-API search per letter (#751). Enter still
// fires immediately via the keydown handler.
_gsState.debounceTimer = setTimeout(() => _gsController.submitQuery(q), 600);
});
if (clearBtn) {

View file

@ -2859,12 +2859,21 @@ function _renderFindingDetail(f) {
case 'duplicate_tracks':
if (!d.tracks || !d.tracks.length) return _gridRows([['Count', d.count || '?']]);
// Determine best copy (same logic as backend: highest bitrate, then duration, then track number)
// Determine best copy — same logic as the backend
// (core/library/duplicate_keep.py): lossless format first, so a FLAC
// beats an MP3 even when the FLAC's bitrate is missing, then bitrate,
// duration, track number.
const _dupFmtRank = (fp) => {
const r = { flac: 10, wav: 9, aiff: 9, aif: 9, ape: 8, m4a: 7, ogg: 6, opus: 6, mp3: 5, aac: 5, wma: 3 };
return r[String(fp || '').split('.').pop().toLowerCase()] || 1;
};
const _dupKey = (t) => [_dupFmtRank(t.file_path), t.bitrate || 0, t.duration || 0, t.track_number || 0];
const bestDup = d.tracks.reduce((best, t) => {
const bBr = best.bitrate || 0, tBr = t.bitrate || 0;
const bDur = best.duration || 0, tDur = t.duration || 0;
const bTn = best.track_number || 0, tTn = t.track_number || 0;
return (tBr > bBr || (tBr === bBr && tDur > bDur) || (tBr === bBr && tDur === bDur && tTn > bTn)) ? t : best;
const bk = _dupKey(best), tk = _dupKey(t);
for (let i = 0; i < bk.length; i++) {
if (tk[i] !== bk[i]) return tk[i] > bk[i] ? t : best;
}
return best;
}, d.tracks[0]);
const findingId = f.id;
return media + `<div class="repair-detail-sublist">${d.tracks.map((t, i) => {

View file

@ -4085,6 +4085,13 @@ function _buildTrackRow(track, album, admin) {
const queueTd = document.createElement('td');
queueTd.className = 'col-queue';
if (!track._missingExpected && track.file_path) {
const playNextBtn = document.createElement('button');
playNextBtn.className = 'enhanced-playnext-btn';
// Play-next glyph (queue-with-arrow feel)
playNextBtn.innerHTML = '&#8677;'; // ⇥
playNextBtn.title = 'Play next';
queueTd.appendChild(playNextBtn);
const queueBtn = document.createElement('button');
queueBtn.className = 'enhanced-queue-btn';
queueBtn.innerHTML = '&#43;';
@ -4270,8 +4277,10 @@ function _attachTableDelegation(table, album) {
}
}
// Queue button
if (target.closest('.enhanced-queue-btn')) {
// Queue / Play-next buttons (share the same track payload)
const isQueueBtn = target.closest('.enhanced-queue-btn');
const isPlayNextBtn = target.closest('.enhanced-playnext-btn');
if (isQueueBtn || isPlayNextBtn) {
e.stopPropagation();
if (track.file_path) {
const artistName = artistDetailPageState.enhancedData ? artistDetailPageState.enhancedData.artist.name : '';
@ -4279,7 +4288,7 @@ function _attachTableDelegation(table, album) {
if (!albumArt && artistDetailPageState.enhancedData) {
albumArt = artistDetailPageState.enhancedData.artist?.thumb_url;
}
addToQueue({
const payload = {
title: track.title || 'Unknown Track',
artist: artistName || 'Unknown Artist',
album: album.title || 'Unknown Album',
@ -4292,7 +4301,9 @@ function _attachTableDelegation(table, album) {
album_id: album.id,
bitrate: track.bitrate,
sample_rate: track.sample_rate
});
};
if (isPlayNextBtn && typeof playNext === 'function') playNext(payload);
else addToQueue(payload);
}
return;
}

View file

@ -16,14 +16,21 @@ function initializeMediaPlayer() {
if (audioPlayer) {
// Set up audio event listeners
audioPlayer.addEventListener('timeupdate', updateAudioProgress);
audioPlayer.addEventListener('timeupdate', npCrossfadeTick);
audioPlayer.addEventListener('timeupdate', npThrottledPositionState);
audioPlayer.addEventListener('timeupdate', npMaybeLogPlay);
audioPlayer.addEventListener('ended', onAudioEnded);
audioPlayer.addEventListener('error', onAudioError);
audioPlayer.addEventListener('loadstart', onAudioLoadStart);
audioPlayer.addEventListener('canplay', onAudioCanPlay);
// Set initial volume
audioPlayer.volume = 0.7; // 70%
if (volumeSlider) volumeSlider.value = 70;
// Set initial volume — restore the saved level (Spotify-style), else 70%.
const _savedVol = npLoadSavedVolume();
const _initialVol = _savedVol === null ? 70 : _savedVol;
audioPlayer.volume = _initialVol / 100;
if (volumeSlider) volumeSlider.value = _initialVol;
// Sync the modal slider/fill too once DOM is ready.
syncVolumeUI(_initialVol);
}
// Track title click handled by initExpandedPlayer's media-player click handler
@ -54,6 +61,15 @@ function initializeMediaPlayer() {
const miniNextBtn = document.getElementById('mini-next-btn');
if (miniPrevBtn) miniPrevBtn.addEventListener('click', (e) => { e.stopPropagation(); playPreviousInQueue(); });
if (miniNextBtn) miniNextBtn.addEventListener('click', (e) => { e.stopPropagation(); playNextInQueue(); });
// Mini shuffle / repeat — share the modal handlers (which now sync both UIs)
const miniShuffleBtn = document.getElementById('mini-shuffle-btn');
const miniRepeatBtn = document.getElementById('mini-repeat-btn');
if (miniShuffleBtn) miniShuffleBtn.addEventListener('click', (e) => { e.stopPropagation(); handleNpShuffle(); });
if (miniRepeatBtn) miniRepeatBtn.addEventListener('click', (e) => { e.stopPropagation(); handleNpRepeat(); });
// Restore a previously-saved queue (does not auto-play)
npRestoreQueue();
}
function toggleMediaPlayerExpansion() {
@ -107,6 +123,7 @@ function _stripSourceIdPrefix(value) {
function setTrackInfo(track) {
currentTrack = track;
npPlayLogged = false; // new track — allow one play-log once it's heard a bit
const trackTitleElement = document.getElementById('track-title');
const trackTitle = _stripSourceIdPrefix(track.title) || 'Unknown Track';
@ -161,6 +178,8 @@ function setTrackInfo(track) {
updateNpTrackInfo();
updateMediaSessionMetadata();
updateMediaSessionPlaybackState();
// Reset the lock-screen scrubber when duration becomes known for the new track.
if (audioPlayer) audioPlayer.addEventListener('loadedmetadata', updateMediaSessionPositionState, { once: true });
// Kick off lyrics fetch for the new track. The panel stays
// collapsed by default — fetching in the background means the
@ -205,6 +224,7 @@ function clearTrack() {
// Clear track state
currentTrack = null;
isPlaying = false;
npSetPlayContext(''); // hide "Playing from" when nothing's playing
const trackTitleElement = document.getElementById('track-title');
trackTitleElement.innerHTML = '<span class="title-text">No track</span>';
@ -296,6 +316,8 @@ async function handlePlayPause() {
}
async function handleStop() {
// Tear down any in-flight crossfade so its second audio doesn't keep playing.
npCancelCrossfade();
// Use new streaming system stop function
await stopStream();
clearTrack();
@ -304,6 +326,7 @@ async function handleStop() {
function handleVolumeChange(event) {
const volume = event.target.value;
updateVolumeSliderAppearance();
npPersistVolume(volume);
// Update HTML5 audio player volume
if (audioPlayer) {
@ -1034,8 +1057,21 @@ function _npLyricsRenderSynced(lines) {
}
content.innerHTML = lines.map((line, idx) => {
const safe = (line.text || '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])) || '&nbsp;';
return `<div class="np-lyrics-line" data-idx="${idx}">${safe}</div>`;
return `<div class="np-lyrics-line" data-idx="${idx}" title="Jump to this line">${safe}</div>`;
}).join('');
// Click a line → seek playback to its timestamp (synced lyrics only).
content.querySelectorAll('.np-lyrics-line').forEach(el => {
el.addEventListener('click', () => {
const idx = Number(el.dataset.idx);
const line = _npLyricsState.lines[idx];
if (!line || !audioPlayer || !isFinite(line.time)) return;
try {
audioPlayer.currentTime = line.time;
if (audioPlayer.paused) audioPlayer.play().catch(() => {});
} catch (_) {}
});
});
}
function _npLyricsRenderPlain(text) {
@ -1186,6 +1222,11 @@ function onAudioEnded() {
currentTimeElement.textContent = '0:00';
}
// If a crossfade is mid-flight it OWNS the advance to the next track —
// bail so we don't double-advance (crossfade's npFinishCrossfade →
// playQueueItem already handles it).
if (npXfadeActive) return;
// Repeat-one is handled by audioPlayer.loop (set in handleNpRepeat)
// Auto-advance to next track if queue has a next item (guard against race conditions)
if (npQueue.length > 0 && !npLoadingQueueItem) {
@ -1382,6 +1423,9 @@ let npAnalyser = null;
let npMediaSource = null;
let npVizAnimFrame = null;
let npVizInitialized = false;
let npCrossfadeOn = false;
let npSleepMinutes = 0; // 0 = off
let npSleepTimerId = null;
function npQueueHasNext() {
if (npQueue.length === 0) return false;
@ -1425,6 +1469,13 @@ function npSetRadioMode(enabled, options = {}) {
if (toast) {
showToast(npRadioMode ? 'Radio mode on - similar tracks will auto-queue' : 'Radio mode off', 'success');
}
// Context label: only set the generic "Radio" if a more specific one (e.g.
// "<Artist> Radio") wasn't already set by the caller.
if (npRadioMode) {
if (!npPlayContext || !/radio/i.test(npPlayContext)) npSetPlayContext('Radio');
} else if (/radio/i.test(npPlayContext)) {
npSetPlayContext('');
}
if (npRadioMode && fetchIfNeeded && currentTrack && currentTrack.id && !npLoadingQueueItem && !npQueueHasNext()) {
npEnsureCurrentTrackInQueue();
npFetchRadioTracks();
@ -1451,6 +1502,32 @@ function initExpandedPlayer() {
// Control handlers
playBtn.addEventListener('click', () => { togglePlayback(); });
stopBtn.addEventListener('click', async () => { await handleStop(); closeNowPlayingModal(); });
// Click album art → toggle the music-synced visualizer takeover
const artContainer = document.getElementById('np-album-art-container');
if (artContainer) {
artContainer.addEventListener('click', () => {
const on = artContainer.classList.toggle('viz-on');
if (on) { npBuildArtViz(); npInitVisualizer(); npStartVisualizerLoop(); }
});
}
// Sleep timer — cycles off → 15 → 30 → 60 min → off
const sleepBtn = document.getElementById('np-sleep-btn');
if (sleepBtn) sleepBtn.addEventListener('click', npCycleSleepTimer);
// Crossfade toggle (real dual-audio crossfade for library tracks)
const xfadeBtn = document.getElementById('np-crossfade-btn');
if (xfadeBtn) {
try { npCrossfadeOn = localStorage.getItem('soulsync-crossfade') === '1'; } catch (e) {}
xfadeBtn.classList.toggle('active', npCrossfadeOn);
xfadeBtn.addEventListener('click', () => {
npCrossfadeOn = !npCrossfadeOn;
xfadeBtn.classList.toggle('active', npCrossfadeOn);
try { localStorage.setItem('soulsync-crossfade', npCrossfadeOn ? '1' : '0'); } catch (e) {}
});
}
shuffleBtn.addEventListener('click', handleNpShuffle);
repeatBtn.addEventListener('click', handleNpRepeat);
muteBtn.addEventListener('click', handleNpMuteToggle);
@ -1460,6 +1537,23 @@ function initExpandedPlayer() {
npProgressBar.addEventListener('mousedown', () => { npProgressBar.dataset.seeking = 'true'; });
npProgressBar.addEventListener('mouseup', () => { delete npProgressBar.dataset.seeking; });
// Seek hover tooltip — shows the timestamp the cursor is over.
const npSeekTip = document.getElementById('np-seek-tip');
if (npSeekTip) {
npProgressBar.addEventListener('mousemove', (e) => {
if (!audioPlayer || !isFinite(audioPlayer.duration) || audioPlayer.duration <= 0) {
npSeekTip.classList.remove('visible');
return;
}
const rect = npProgressBar.getBoundingClientRect();
const frac = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
npSeekTip.textContent = formatTime(frac * audioPlayer.duration);
npSeekTip.style.left = (frac * 100) + '%';
npSeekTip.classList.add('visible');
});
npProgressBar.addEventListener('mouseleave', () => npSeekTip.classList.remove('visible'));
}
// Progress bar (touch)
npProgressBar.addEventListener('touchstart', () => { npProgressBar.dataset.seeking = 'true'; }, { passive: true });
npProgressBar.addEventListener('touchmove', (e) => {
@ -1588,6 +1682,10 @@ function syncExpandedPlayerUI() {
const viz = document.getElementById('np-visualizer');
if (viz) viz.classList.toggle('playing', isPlaying);
// Album-art scale-on-play (Phase A restyle — CSS keys off .np-modal.playing)
const npModalEl = document.querySelector('.np-modal');
if (npModalEl) npModalEl.classList.toggle('playing', isPlaying);
// Queue
renderNpQueue();
updateNpPrevNextButtons();
@ -1706,24 +1804,46 @@ function npExtractAmbientColor(imgEl) {
try {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
canvas.width = 50;
canvas.height = 50;
ctx.drawImage(imgEl, 0, 0, 50, 50);
const data = ctx.getImageData(0, 0, 50, 50).data;
let rSum = 0, gSum = 0, bSum = 0, count = 0;
canvas.width = 64;
canvas.height = 64;
ctx.drawImage(imgEl, 0, 0, 64, 64);
const data = ctx.getImageData(0, 0, 64, 64).data;
// Dominant VIBRANT color, not a flat average (averaging muddies to
// grey-brown). Bin colors into a coarse 4-bit-per-channel histogram,
// weight each bin by saturation² × pixel-count so a punchy accent in
// the cover wins over a large dull background. Apple-Music-style.
const bins = new Map();
for (let i = 0; i < data.length; i += 16) { // sample every 4th pixel
const r = data[i], g = data[i + 1], b = data[i + 2];
const r = data[i], g = data[i + 1], b = data[i + 2], a = data[i + 3];
if (a < 128) continue;
const max = Math.max(r, g, b), min = Math.min(r, g, b);
const brightness = (r + g + b) / 3;
if (brightness > 20 && brightness < 230) {
rSum += r; gSum += g; bSum += b; count++;
}
if (brightness < 24 || brightness > 240) continue; // skip near-black/white
const sat = max === 0 ? 0 : (max - min) / max; // 0..1
const key = ((r >> 4) << 8) | ((g >> 4) << 4) | (b >> 4);
const weight = (0.15 + sat * sat) ; // floor so greys still count a little
const bin = bins.get(key);
if (bin) { bin.r += r; bin.g += g; bin.b += b; bin.n++; bin.w += weight; }
else bins.set(key, { r, g, b, n: 1, w: weight });
}
if (count > 0) {
let best = null, bestScore = -1;
for (const bin of bins.values()) {
const score = bin.w; // saturation-weighted population
if (score > bestScore) { bestScore = score; best = bin; }
}
if (best) {
let r = Math.round(best.r / best.n);
let g = Math.round(best.g / best.n);
let b = Math.round(best.b / best.n);
// Nudge toward vivid: lift saturation/brightness a touch so the
// glow reads as a color, not a wash.
[r, g, b] = npPunchUpColor(r, g, b);
const modal = document.querySelector('.np-modal');
if (modal) {
modal.style.setProperty('--np-ambient-r', Math.round(rSum / count));
modal.style.setProperty('--np-ambient-g', Math.round(gSum / count));
modal.style.setProperty('--np-ambient-b', Math.round(bSum / count));
modal.style.setProperty('--np-ambient-r', r);
modal.style.setProperty('--np-ambient-g', g);
modal.style.setProperty('--np-ambient-b', b);
}
}
} catch (e) {
@ -1731,6 +1851,119 @@ function npExtractAmbientColor(imgEl) {
}
}
// Lift a color toward vividness for the ambient glow (boost saturation,
// floor brightness) without fully desaturating dark/pastel covers.
function npPunchUpColor(r, g, b) {
const max = Math.max(r, g, b), min = Math.min(r, g, b);
if (max === min) return [r, g, b]; // grey — leave it
// Pull each channel away from the mid to boost perceived saturation ~1.3x.
const mid = (max + min) / 2;
const boost = 1.3;
let nr = Math.round(mid + (r - mid) * boost);
let ng = Math.round(mid + (g - mid) * boost);
let nb = Math.round(mid + (b - mid) * boost);
// Floor overall brightness so very dark covers still glow.
const bright = (nr + ng + nb) / 3;
if (bright < 70) { const lift = 70 / Math.max(bright, 1); nr *= lift; ng *= lift; nb *= lift; }
const clamp = v => Math.max(0, Math.min(255, Math.round(v)));
return [clamp(nr), clamp(ng), clamp(nb)];
}
// ── Crossfade engine (library tracks only) ──
// EXPERIMENTAL. Real crossfade needs two tracks playing at once; /stream/audio
// only serves the ONE current track (single global stream_state), so we use a
// dedicated /stream/library-audio endpoint + a second <audio> to play the NEXT
// library track and ramp volumes. Streamed (non-library) tracks can't crossfade
// and fall back to the normal hard cut.
const NP_CROSSFADE_SECONDS = 6;
let npXfadeAudio = null;
let npXfadeActive = false;
let npXfadeTimer = null;
let npXfadeMainVol = null; // main-player volume to restore if a crossfade is aborted
// Abort an in-flight crossfade (manual skip / stop during the fade). Restores
// the main player's volume and tears down the second audio element. Safe to
// call when no crossfade is active (no-op).
function npCancelCrossfade() {
if (npXfadeTimer) { clearInterval(npXfadeTimer); npXfadeTimer = null; }
if (npXfadeAudio) { try { npXfadeAudio.pause(); } catch (_) {} npXfadeAudio.src = ''; npXfadeAudio.volume = 0; npXfadeAudio = null; }
if (npXfadeActive && audioPlayer && npXfadeMainVol !== null) {
audioPlayer.volume = npXfadeMainVol; // undo any partial fade-down
}
npXfadeActive = false;
npXfadeMainVol = null;
}
function npCrossfadeTick() {
if (!npCrossfadeOn || npXfadeActive || npRepeatMode === 'one') return;
if (!audioPlayer || !audioPlayer.duration || !isFinite(audioPlayer.duration)) return;
const remaining = audioPlayer.duration - audioPlayer.currentTime;
if (remaining > NP_CROSSFADE_SECONDS || remaining <= 0.2) return;
// Determine the next track (respects shuffle/repeat-all the same way
// playNextInQueue does, but we only crossfade plain sequential next).
const nextIdx = npQueueIndex + 1;
const next = npQueue[nextIdx];
if (!next || !next.is_library || !next.file_path) return; // only library→library
npStartCrossfade(nextIdx, next);
}
function npStartCrossfade(nextIdx, next) {
npXfadeActive = true;
const xa = document.getElementById('audio-player-xfade');
if (!xa) { npXfadeActive = false; return; }
npXfadeAudio = xa;
const targetVol = audioPlayer.volume; // fade the new track up to current level
npXfadeMainVol = targetVol; // remember to restore on abort
xa.src = `/stream/library-audio?path=${encodeURIComponent(next.file_path)}&t=${Date.now()}`;
xa.volume = 0;
xa.play().then(() => {
const fadeMs = NP_CROSSFADE_SECONDS * 1000;
const step = 60; // ms between volume steps
const steps = Math.max(1, Math.floor(fadeMs / step));
let n = 0;
const startOutVol = audioPlayer.volume;
npXfadeTimer = setInterval(() => {
// A manual skip/stop may have cancelled us mid-fade.
if (!npXfadeActive) { clearInterval(npXfadeTimer); npXfadeTimer = null; return; }
n++;
const t = Math.min(1, n / steps);
audioPlayer.volume = Math.max(0, startOutVol * (1 - t));
xa.volume = Math.min(targetVol, targetVol * t);
if (t >= 1) {
clearInterval(npXfadeTimer);
npXfadeTimer = null;
npFinishCrossfade(nextIdx, targetVol);
}
}, step);
}).catch(() => {
// Couldn't preload (e.g. endpoint/file issue) — abort gracefully, let
// the normal 'ended' hard-cut advance handle it.
npXfadeActive = false;
npXfadeAudio = null;
npXfadeMainVol = null;
});
}
function npFinishCrossfade(nextIdx, restoreVol) {
// The crossfade audio has fully faded in; promote the queue index and let
// the normal play path take over so all the usual state (track info, art,
// visualizer, server stream_state) is set for the now-current track.
const xa = npXfadeAudio;
if (xa) { try { xa.pause(); } catch (_) {} xa.src = ''; xa.volume = 0; }
npXfadeAudio = null;
npXfadeActive = false;
npXfadeMainVol = null;
if (npXfadeTimer) { clearInterval(npXfadeTimer); npXfadeTimer = null; }
if (audioPlayer) audioPlayer.volume = restoreVol;
// playQueueItem re-points stream_state + reloads audioPlayer for the next
// track; there's a brief silent reload, but the perceived crossfade already
// happened. Honest trade-off of the single-stream-state design.
playQueueItem(nextIdx);
}
function npResetAmbientGlow() {
const modal = document.querySelector('.np-modal');
if (modal) {
@ -1791,6 +2024,7 @@ function handleNpProgressBarChange(event) {
try {
audioPlayer.currentTime = newTime;
updateMediaSessionPositionState();
// Sync sidebar progress
const sidebarBar = document.getElementById('progress-bar');
@ -1815,6 +2049,7 @@ function handleNpProgressBarChange(event) {
function handleNpVolumeChange(event) {
const volume = parseInt(event.target.value);
if (audioPlayer) audioPlayer.volume = volume / 100;
npPersistVolume(volume);
// Sync sidebar volume slider
const sidebarVol = document.getElementById('volume-slider');
@ -1862,15 +2097,32 @@ function updateNpMuteIcon() {
if (muteBtn) muteBtn.classList.toggle('muted', npMuted);
}
// Reflect shuffle/repeat state on BOTH the modal and mini-player buttons.
function syncShuffleRepeatUI() {
const npShuffle = document.getElementById('np-shuffle-btn');
const miniShuffle = document.getElementById('mini-shuffle-btn');
if (npShuffle) npShuffle.classList.toggle('active', npShuffleOn);
if (miniShuffle) miniShuffle.classList.toggle('active', npShuffleOn);
const repeatOn = npRepeatMode !== 'off';
const repeatOne = npRepeatMode === 'one';
const npRepeat = document.getElementById('np-repeat-btn');
const miniRepeat = document.getElementById('mini-repeat-btn');
if (npRepeat) npRepeat.classList.toggle('active', repeatOn);
if (miniRepeat) miniRepeat.classList.toggle('active', repeatOn);
const npBadge = document.getElementById('np-repeat-one-badge');
const miniBadge = document.getElementById('mini-repeat-one-badge');
if (npBadge) npBadge.classList.toggle('hidden', !repeatOne);
if (miniBadge) miniBadge.style.display = repeatOne ? '' : 'none';
}
function handleNpShuffle() {
npShuffleOn = !npShuffleOn;
const btn = document.getElementById('np-shuffle-btn');
if (btn) btn.classList.toggle('active', npShuffleOn);
syncShuffleRepeatUI();
updateNpPrevNextButtons();
}
function handleNpRepeat() {
const badge = document.getElementById('np-repeat-one-badge');
if (npRepeatMode === 'off') {
npRepeatMode = 'all';
if (audioPlayer) audioPlayer.loop = false;
@ -1881,9 +2133,7 @@ function handleNpRepeat() {
npRepeatMode = 'off';
if (audioPlayer) audioPlayer.loop = false;
}
const btn = document.getElementById('np-repeat-btn');
if (btn) btn.classList.toggle('active', npRepeatMode !== 'off');
if (badge) badge.classList.toggle('hidden', npRepeatMode !== 'one');
syncShuffleRepeatUI();
updateNpPrevNextButtons();
}
@ -1891,6 +2141,21 @@ function handleNpRepeat() {
// QUEUE MANAGEMENT
// ===============================
// "Playing from" context shown above the track title (Spotify-style).
let npPlayContext = '';
function npSetPlayContext(text) {
npPlayContext = text || '';
const box = document.getElementById('np-play-context');
const nameEl = document.getElementById('np-play-context-name');
if (!box || !nameEl) return;
if (npPlayContext) {
nameEl.textContent = npPlayContext;
box.classList.remove('hidden');
} else {
box.classList.add('hidden');
}
}
function addToQueue(track) {
npQueue.push(track);
showToast('Added to queue', 'success');
@ -1902,6 +2167,19 @@ function addToQueue(track) {
}
}
// Insert a track to play right after the current one (Spotify "Play next").
function playNext(track) {
if (npQueue.length === 0 || npQueueIndex < 0) {
// Nothing queued / playing — same as add-to-queue (which auto-plays).
addToQueue(track);
return;
}
npQueue.splice(npQueueIndex + 1, 0, track);
showToast('Playing next', 'success');
renderNpQueue();
updateNpPrevNextButtons();
}
function removeFromQueue(index) {
if (index < 0 || index >= npQueue.length) return;
const wasCurrentTrack = (index === npQueueIndex);
@ -1978,6 +2256,10 @@ function playPreviousInQueue() {
async function playQueueItem(index) {
if (index < 0 || index >= npQueue.length) return;
if (npLoadingQueueItem) return; // Prevent race condition from double-advance
// Manual skip / row-click during a crossfade: tear down the stray fade so it
// can't fire npFinishCrossfade on top of this change. No-op for the
// legitimate handoff (npFinishCrossfade already cleared the flag first).
npCancelCrossfade();
npLoadingQueueItem = true;
npQueueIndex = index;
const track = npQueue[index];
@ -2074,6 +2356,26 @@ function renderNpQueue() {
item.className = 'np-queue-item' + (i === npQueueIndex ? ' active' : '');
item.onclick = () => playQueueItem(i);
// Drag-to-reorder
item.draggable = true;
item.dataset.qindex = i;
item.addEventListener('dragstart', npQueueDragStart);
item.addEventListener('dragover', npQueueDragOver);
item.addEventListener('drop', npQueueDrop);
item.addEventListener('dragend', npQueueDragEnd);
// Album thumbnail
const art = document.createElement('img');
art.className = 'np-queue-item-art';
art.alt = '';
if (track.image_url) {
art.src = track.image_url;
art.onerror = () => { art.style.visibility = 'hidden'; };
} else {
art.style.visibility = 'hidden';
}
item.appendChild(art);
const info = document.createElement('div');
info.className = 'np-queue-item-info';
@ -2089,6 +2391,19 @@ function renderNpQueue() {
info.appendChild(artist);
item.appendChild(info);
// Active row → equalizer animation; others → duration
if (i === npQueueIndex) {
const eq = document.createElement('div');
eq.className = 'np-queue-item-eq';
eq.innerHTML = '<i></i><i></i><i></i>';
item.appendChild(eq);
} else if (track.duration) {
const dur = document.createElement('span');
dur.className = 'np-queue-item-duration';
dur.textContent = formatTime(track.duration);
item.appendChild(dur);
}
const removeBtn = document.createElement('button');
removeBtn.className = 'np-queue-item-remove';
removeBtn.innerHTML = '&#10005;';
@ -2101,6 +2416,130 @@ function renderNpQueue() {
listEl.appendChild(item);
});
npUpdateUpNext();
npPersistQueue();
}
// ── Queue persistence across page reloads (localStorage) ──
const NP_QUEUE_STORAGE_KEY = 'soulsync-np-queue';
function npPersistQueue() {
try {
if (!npQueue.length) { localStorage.removeItem(NP_QUEUE_STORAGE_KEY); return; }
localStorage.setItem(NP_QUEUE_STORAGE_KEY, JSON.stringify({
queue: npQueue,
index: npQueueIndex,
savedAt: Date.now(),
}));
} catch (e) { /* quota / disabled storage — non-fatal */ }
}
// Restore the saved queue into the panel WITHOUT auto-playing (the user
// reloaded; resume playback is their choice via clicking a row).
function npRestoreQueue() {
try {
const raw = localStorage.getItem(NP_QUEUE_STORAGE_KEY);
if (!raw) return;
const data = JSON.parse(raw);
if (data && Array.isArray(data.queue) && data.queue.length) {
npQueue = data.queue;
// Don't claim a track is "playing" on a fresh load — nothing is.
npQueueIndex = -1;
renderNpQueue();
updateNpPrevNextButtons();
}
} catch (e) { /* corrupt entry — ignore */ }
}
// ── Queue drag-to-reorder ──
let npDragFromIndex = null;
function npQueueDragStart(e) {
npDragFromIndex = Number(e.currentTarget.dataset.qindex);
e.currentTarget.classList.add('dragging');
e.dataTransfer.effectAllowed = 'move';
// Firefox requires data to be set for drag to fire.
try { e.dataTransfer.setData('text/plain', String(npDragFromIndex)); } catch (_) {}
}
function npQueueDragOver(e) {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
const row = e.currentTarget;
document.querySelectorAll('.np-queue-item.drag-over').forEach(r => r.classList.remove('drag-over'));
row.classList.add('drag-over');
}
function npQueueDrop(e) {
e.preventDefault();
e.stopPropagation();
const to = Number(e.currentTarget.dataset.qindex);
npReorderQueue(npDragFromIndex, to);
}
function npQueueDragEnd() {
document.querySelectorAll('.np-queue-item').forEach(r => r.classList.remove('dragging', 'drag-over'));
npDragFromIndex = null;
}
// Move a queue item, keeping npQueueIndex pointed at the SAME playing track.
function npReorderQueue(from, to) {
if (from === null || from === to || from < 0 || to < 0) return;
if (from >= npQueue.length || to >= npQueue.length) return;
const [moved] = npQueue.splice(from, 1);
npQueue.splice(to, 0, moved);
// Recompute which index now holds the currently-playing track.
if (npQueueIndex === from) {
npQueueIndex = to;
} else if (from < npQueueIndex && to >= npQueueIndex) {
npQueueIndex -= 1;
} else if (from > npQueueIndex && to <= npQueueIndex) {
npQueueIndex += 1;
}
renderNpQueue();
updateNpPrevNextButtons();
}
// Up-next peek: show the track that plays after the current one.
function npUpdateUpNext() {
const box = document.getElementById('np-upnext');
if (!box) return;
const next = npQueue[npQueueIndex + 1];
if (!next) { box.classList.add('hidden'); return; }
box.classList.remove('hidden');
const art = document.getElementById('np-upnext-art');
const title = document.getElementById('np-upnext-title');
const artist = document.getElementById('np-upnext-artist');
if (title) title.textContent = next.title || 'Unknown Track';
if (artist) artist.textContent = next.artist || 'Unknown Artist';
if (art) {
if (next.image_url) { art.src = next.image_url; art.style.visibility = ''; art.onerror = () => { art.style.visibility = 'hidden'; }; }
else { art.style.visibility = 'hidden'; }
}
}
// Sleep timer: cycle off → 15 → 30 → 60 → off; stops playback when it fires.
function npCycleSleepTimer() {
const steps = [0, 15, 30, 60];
npSleepMinutes = steps[(steps.indexOf(npSleepMinutes) + 1) % steps.length];
const btn = document.getElementById('np-sleep-btn');
const label = document.getElementById('np-sleep-label');
if (npSleepTimerId) { clearTimeout(npSleepTimerId); npSleepTimerId = null; }
if (npSleepMinutes > 0) {
if (label) label.textContent = `Sleep ${npSleepMinutes}m`;
if (btn) btn.classList.add('active');
npSleepTimerId = setTimeout(() => {
handleStop();
npSleepMinutes = 0;
if (label) label.textContent = 'Sleep';
if (btn) btn.classList.remove('active');
}, npSleepMinutes * 60 * 1000);
} else {
if (label) label.textContent = 'Sleep';
if (btn) btn.classList.remove('active');
}
}
function updateNpPrevNextButtons() {
@ -2163,7 +2602,18 @@ function handlePlayerKeyboardShortcuts(event) {
break;
case 'm':
case 'M':
if (npModalOpen) handleNpMuteToggle();
event.preventDefault();
handleNpMuteToggle(); // works whether or not the modal is open
break;
case 'n':
case 'N':
event.preventDefault();
if (npQueue.length > 0) playNextInQueue();
break;
case 'p':
case 'P':
event.preventDefault();
playPreviousInQueue();
break;
case 'Escape':
if (npModalOpen) closeNowPlayingModal();
@ -2185,6 +2635,21 @@ function syncVolumeUI(volumePercent) {
}
if (npVol) npVol.value = volumePercent;
if (npFill) npFill.style.width = volumePercent + '%';
npPersistVolume(volumePercent);
}
// Remember volume across reloads (Spotify-style). Stored 0..100.
const NP_VOLUME_STORAGE_KEY = 'soulsync-volume';
function npPersistVolume(percent) {
try { localStorage.setItem(NP_VOLUME_STORAGE_KEY, String(Math.round(percent))); } catch (e) {}
}
function npLoadSavedVolume() {
try {
const raw = localStorage.getItem(NP_VOLUME_STORAGE_KEY);
if (raw === null) return null;
const v = parseInt(raw, 10);
return (isFinite(v) && v >= 0 && v <= 100) ? v : null;
} catch (e) { return null; }
}
function getNpAlbumArtUrl() {
@ -2220,6 +2685,19 @@ function npInitVisualizer() {
}
}
// Number of bars in the big album-art visualizer takeover.
const NP_ART_VIZ_BAR_COUNT = 28;
function npBuildArtViz() {
const container = document.getElementById('np-art-viz');
if (!container || container.children.length > 0) return;
for (let i = 0; i < NP_ART_VIZ_BAR_COUNT; i++) {
const bar = document.createElement('div');
bar.className = 'np-art-viz-bar';
container.appendChild(bar);
}
}
function npStartVisualizerLoop() {
if (npVizAnimFrame) return; // Already running
if (!npAnalyser) return; // No analyser — CSS fallback handles it
@ -2229,7 +2707,6 @@ function npStartVisualizerLoop() {
}
const bars = document.querySelectorAll('.np-viz-bar');
if (bars.length === 0) return;
const bufferLength = npAnalyser.frequencyBinCount;
const dataArray = new Uint8Array(bufferLength);
@ -2237,14 +2714,25 @@ function npStartVisualizerLoop() {
npVizAnimFrame = requestAnimationFrame(draw);
npAnalyser.getByteFrequencyData(dataArray);
// Map 7 bars to frequency bins (skip bin 0 which is DC offset)
const binCount = Math.min(bufferLength - 1, 7);
// Map 7 transport bars to frequency bins (skip bin 0 = DC offset)
for (let i = 0; i < bars.length; i++) {
const binIndex = Math.min(i + 1, bufferLength - 1);
const value = dataArray[binIndex] / 255; // 0..1
const scale = Math.max(0.08, value); // minimum visible height
bars[i].style.transform = `scaleY(${scale})`;
}
// Big album-art visualizer (when toggled on) — same real analyser,
// spread across more bars for a fuller spectrum.
const artBars = document.querySelectorAll('.np-art-viz-bar');
if (artBars.length) {
const span = bufferLength - 1;
for (let i = 0; i < artBars.length; i++) {
const binIndex = 1 + Math.floor((i / artBars.length) * span);
const value = dataArray[Math.min(binIndex, bufferLength - 1)] / 255;
artBars[i].style.height = Math.max(6, value * 100) + '%';
}
}
}
draw();
}
@ -2652,6 +3140,65 @@ function initMediaSession() {
navigator.mediaSession.setActionHandler('nexttrack', () => {
if (npQueue.length > 0) playNextInQueue();
});
// Scrub from the lock screen / notification scrubber.
try {
navigator.mediaSession.setActionHandler('seekto', (details) => {
if (!audioPlayer || !isFinite(audioPlayer.duration)) return;
if (details.fastSeek && 'fastSeek' in audioPlayer) {
audioPlayer.fastSeek(details.seekTime);
} else if (typeof details.seekTime === 'number') {
audioPlayer.currentTime = details.seekTime;
}
updateMediaSessionPositionState();
});
} catch (e) { /* some browsers don't support seekto — handlers above still work */ }
}
// Log a SoulSync play once a track has been heard ~10s (the standard "counts
// as a play" threshold) — feeds 'recently played' + smart-radio recency.
let npPlayLogged = false;
function npMaybeLogPlay() {
if (npPlayLogged || !currentTrack || !audioPlayer) return;
if (!isFinite(audioPlayer.currentTime) || audioPlayer.currentTime < 10) return;
npPlayLogged = true; // set first so a slow request can't double-fire
try {
fetch('/api/library/log-play', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
track: {
id: currentTrack.id,
title: currentTrack.title,
artist: currentTrack.artist,
album: currentTrack.album,
},
duration_ms: Math.round((audioPlayer.duration || 0) * 1000),
}),
}).catch(() => {}); // fire-and-forget; logging must never affect playback
} catch (e) { /* non-fatal */ }
}
// timeupdate fires ~4x/s; only push position to the OS ~1x/s.
let _npPosStateLast = 0;
function npThrottledPositionState() {
const now = (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now();
if (now - _npPosStateLast < 950) return;
_npPosStateLast = now;
updateMediaSessionPositionState();
}
// Feeds the lock-screen scrubber its progress (elapsed / duration / rate).
// Without this the OS shows a dead, position-less media control.
function updateMediaSessionPositionState() {
if (!('mediaSession' in navigator) || !('setPositionState' in navigator.mediaSession)) return;
if (!audioPlayer || !isFinite(audioPlayer.duration) || audioPlayer.duration <= 0) return;
try {
navigator.mediaSession.setPositionState({
duration: audioPlayer.duration,
playbackRate: audioPlayer.playbackRate || 1,
position: Math.min(audioPlayer.currentTime, audioPlayer.duration),
});
} catch (e) { /* invalid state (e.g. mid-load) — skip this tick */ }
}
function updateMediaSessionMetadata() {

View file

@ -2193,6 +2193,85 @@ function _getBatchColor(batchId) {
return _batchColorMap[batchId];
}
// Per-batch progress samples for a client-side ETA (no backend timing needed
// for Phase A). batch_id -> [{t: ms, done: int}], capped to the recent window.
const _adlRateSamples = {};
const _ADL_RATE_WINDOW = 8;
function _adlSampleRate(batchId, done) {
const arr = _adlRateSamples[batchId] || (_adlRateSamples[batchId] = []);
const now = Date.now();
const last = arr[arr.length - 1];
if (!last || last.done !== done) arr.push({ t: now, done });
while (arr.length > _ADL_RATE_WINDOW) arr.shift();
// tracks/sec over the sampled window
if (arr.length < 2) return 0;
const first = arr[0];
const dt = (arr[arr.length - 1].t - first.t) / 1000;
const dd = arr[arr.length - 1].done - first.done;
return dt > 0 && dd > 0 ? dd / dt : 0;
}
function _adlFmtDuration(sec) {
if (!sec || sec < 0 || !isFinite(sec)) return '';
if (sec < 60) return `${Math.round(sec)}s`;
if (sec < 3600) return `${Math.round(sec / 60)}m`;
return `${Math.floor(sec / 3600)}h ${Math.round((sec % 3600) / 60)}m`;
}
// ETA string for a batch's stat line. Album bundles use the downloader's own
// speed/size; track batches use the client-side completion rate.
function _adlBatchEta(batch) {
if (batch.phase === 'album_downloading') {
const ab = batch.album_bundle || {};
const bits = [];
if (ab.speed) bits.push(ab.speed);
if (ab.downloaded && ab.size) bits.push(`${ab.downloaded} / ${ab.size}`);
return bits.join(' · ');
}
if (batch.phase !== 'downloading') return '';
const total = batch.total || 0;
const done = (batch.completed || 0) + (batch.failed || 0);
const remaining = total - done;
if (remaining <= 0) return '';
const rate = _adlSampleRate(batch.batch_id, done); // tracks/sec
if (rate <= 0) return '';
return `~${_adlFmtDuration(remaining / rate)} left`;
}
// Glanceable aggregate strip atop the panel: batches · downloading · queued ·
// speed · ETA. Hidden when nothing is active.
function _adlRenderBatchSummary(activeBatches) {
const el = document.getElementById('adl-batch-summary');
if (!el) return;
if (!activeBatches.length) { el.style.display = 'none'; return; }
let downloading = 0, queued = 0, remaining = 0, rate = 0, bundleSpeed = '';
for (const b of activeBatches) {
downloading += (b.active || 0);
queued += (b.queued || 0);
if (b.phase === 'downloading') {
const done = (b.completed || 0) + (b.failed || 0);
remaining += Math.max(0, (b.total || 0) - done);
rate += _adlSampleRate(b.batch_id, done);
}
if (b.phase === 'album_downloading' && b.album_bundle && b.album_bundle.speed && !bundleSpeed) {
bundleSpeed = b.album_bundle.speed;
}
}
const parts = [`${activeBatches.length} batch${activeBatches.length === 1 ? '' : 'es'}`];
if (downloading) parts.push(`${downloading} downloading`);
if (queued) parts.push(`${queued} queued`);
if (bundleSpeed) parts.push(_adlEsc(bundleSpeed));
const etaStr = (rate > 0 && remaining > 0) ? `~${_adlFmtDuration(remaining / rate)} left` : '';
el.style.display = '';
el.innerHTML =
`<span class="adl-batch-summary-main">${parts.join(' · ')}</span>` +
(etaStr ? `<span class="adl-batch-summary-eta">${etaStr}</span>` : '');
}
function loadActiveDownloadsPage() {
_adlFetch();
_adlFetchBatchHistory();
@ -2553,17 +2632,27 @@ function _adlRenderBatchPanel() {
return elapsed < _BATCH_FADE_SECONDS;
});
const activeBatches = visibleBatches.filter(b => b.phase !== 'complete' && b.phase !== 'cancelled' && b.phase !== 'error');
// Update header with count
if (headerTitle) {
const activeCount = visibleBatches.filter(b => b.phase !== 'complete' && b.phase !== 'cancelled' && b.phase !== 'error').length;
headerTitle.textContent = activeCount > 0 ? `Batches (${activeCount})` : 'Batches';
headerTitle.textContent = activeBatches.length > 0 ? `Batches (${activeBatches.length})` : 'Batches';
}
_adlRenderBatchSummary(activeBatches);
if (visibleBatches.length === 0) {
container.innerHTML = `<div class="adl-batch-empty">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.2" style="opacity:0.25;margin-bottom:6px"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
<div>No active batches</div>
<div style="font-size:0.7rem;margin-top:2px;opacity:0.5">Start a download from Search, Sync, or Wishlist</div>
<div class="adl-batch-empty-icon">
<svg width="34" height="34" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.3"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
</div>
<div class="adl-batch-empty-title">Nothing downloading</div>
<div class="adl-batch-empty-sub">Batches show up here as they run.</div>
<div class="adl-batch-empty-links">
<a href="#" onclick="event.preventDefault(); navigateToPage('search')">Search</a>
<a href="#" onclick="event.preventDefault(); navigateToPage('sync')">Sync</a>
<a href="#" onclick="event.preventDefault(); navigateToPage('wishlist')">Wishlist</a>
</div>
</div>`;
return;
}
@ -2649,34 +2738,37 @@ function _adlRenderBatchPanel() {
let tracksHtml = '';
if (isExpanded) {
if (batchTracks.length > 0) {
tracksHtml = batchTracks.map(t => {
tracksHtml = batchTracks.map((t, i) => {
const cls = _adlStatusClass(t.status);
const progress = t.progress || 0;
const idx = (t.track_index != null ? t.track_index + 1 : i + 1);
// Status indicator with detail
let statusHtml = '';
// Right-aligned state: % / spinner / \u2713 / \u2717 / \u00B7 \u2014 color via row class.
let stateHtml = '';
if (t.status === 'downloading' && progress > 0) {
statusHtml = `<span class="adl-batch-track-status active">${Math.round(progress)}%</span>`;
stateHtml = `<span class="adl-batch-track-state">${Math.round(progress)}%</span>`;
} else if (t.status === 'searching') {
statusHtml = `<span class="adl-batch-track-status active"><span class="adl-spinner" style="width:8px;height:8px"></span></span>`;
stateHtml = `<span class="adl-batch-track-state"><span class="adl-spinner" style="width:9px;height:9px"></span></span>`;
} else if (t.status === 'post_processing') {
statusHtml = `<span class="adl-batch-track-status active" title="Processing">proc</span>`;
stateHtml = `<span class="adl-batch-track-state" title="Processing">proc</span>`;
} else if (cls === 'completed') {
statusHtml = `<span class="adl-batch-track-status completed">\u2713</span>`;
stateHtml = `<span class="adl-batch-track-state">\u2713</span>`;
} else if (cls === 'failed') {
statusHtml = `<span class="adl-batch-track-status failed">\u2717</span>`;
stateHtml = `<span class="adl-batch-track-state" title="${_adlEsc(t.error || 'Failed')}">\u2717</span>`;
} else {
statusHtml = `<span class="adl-batch-track-status queued">\u00B7</span>`;
stateHtml = `<span class="adl-batch-track-state">\u00B7</span>`;
}
// Mini progress bar for downloading tracks
const miniBar = t.status === 'downloading' && progress > 0
const isDownloading = (t.status === 'downloading' && progress > 0);
const miniBar = isDownloading
? `<div class="adl-batch-track-progress"><div class="adl-batch-track-progress-fill" style="width:${progress}%"></div></div>`
: '';
const sub = t.artist ? `<span class="adl-batch-track-sub">${_adlEsc(t.artist)}</span>` : '';
return `<div class="adl-batch-track-row">
<span class="adl-batch-track-title">${_adlEsc(t.title || 'Unknown')}</span>
${statusHtml}
return `<div class="adl-batch-track-row ${cls}${isDownloading ? ' downloading' : ''}">
<span class="adl-batch-track-idx">${idx}</span>
<span class="adl-batch-track-text"><span class="adl-batch-track-title">${_adlEsc(t.title || 'Unknown')}</span>${sub}</span>
${stateHtml}
${miniBar}
</div>`;
}).join('');
@ -2687,19 +2779,50 @@ function _adlRenderBatchPanel() {
}
}
const cardClasses = ['adl-batch-card'];
const cardClasses = ['adl-batch-card', `phase-${batch.phase}`];
if (isExpanded) cardClasses.push('expanded');
if (isActive) cardClasses.push('active-glow');
if (isFiltered) cardClasses.push('filtered');
const playlistId = _adlEsc(batch.playlist_id || '');
// Segmented progress: done (green) / failed (red) / active (accent) of
// total; the remaining width is the dim bar background (queued).
let segDone = 0, segFail = 0, segActive = 0;
if (batch.phase === 'album_downloading') {
segActive = bundleProgress; // one release downloading
} else {
segDone = Math.max(0, Math.min(100, (batch.completed / total) * 100));
segFail = Math.max(0, Math.min(100 - segDone, (batch.failed / total) * 100));
segActive = Math.max(0, Math.min(100 - segDone - segFail, ((batch.active || 0) / total) * 100));
}
// "Now downloading" — the live track on active batches.
const nowTrack = isActive
? (batchTracks.find(t => t.status === 'downloading') || batchTracks.find(t => t.status === 'searching'))
: null;
const nowHtml = (nowTrack && nowTrack.title)
? `<div class="adl-batch-card-now"><span class="adl-batch-now-icon">↓</span> ${_adlEsc(nowTrack.title)}</div>`
: '';
// Stat chips + ETA line.
const chips = [];
if (batch.completed) chips.push(`<span class="adl-chip adl-chip-done">✓ ${batch.completed}</span>`);
if (batch.failed) chips.push(`<span class="adl-chip adl-chip-fail">✗ ${batch.failed}</span>`);
if (batch.active) chips.push(`<span class="adl-chip adl-chip-active">↓ ${batch.active}</span>`);
if (batch.queued) chips.push(`<span class="adl-chip adl-chip-queued">${batch.queued} queued</span>`);
const etaText = _adlBatchEta(batch);
const statLine = (chips.length || etaText)
? `<div class="adl-batch-statline"><div class="adl-batch-chips">${chips.join('')}</div>${etaText ? `<span class="adl-batch-eta">${_adlEsc(etaText)}</span>` : ''}</div>`
: '';
html += `<div class="${cardClasses.join(' ')}" style="${colorStyle}${fadeStyle}" data-batch-id="${batch.batch_id}" onclick="_adlToggleBatch('${batch.batch_id}')">
<div class="adl-batch-card-top">
${thumbHtml}
<div class="adl-batch-card-info">
<div class="adl-batch-card-name adl-batch-card-link" onclick="event.stopPropagation(); _adlOpenBatchModal('${batch.batch_id}', '${playlistId}', '${_adlEsc(batch.batch_name || 'Download')}')" title="Open download modal">${_adlEsc(batch.batch_name || 'Download')}</div>
<div class="adl-batch-card-meta">${phaseIcon}${phaseText}</div>
${nowHtml}
</div>
${sourceBadge}
<div class="adl-batch-card-actions">
@ -2709,11 +2832,17 @@ function _adlRenderBatchPanel() {
${!isTerminal ? `<button class="adl-batch-card-cancel" onclick="event.stopPropagation(); _adlCancelBatch('${batch.batch_id}')" title="Cancel batch">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>` : ''}
<span class="adl-batch-card-chevron" aria-hidden="true">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg>
</span>
</div>
</div>
<div class="adl-batch-progress">
<div class="adl-batch-progress-fill${hasFailed ? ' has-failed' : ''}" style="width:${pct}%"></div>
<div class="adl-batch-segbar">
<div class="adl-batch-seg seg-done" style="width:${segDone}%"></div>
<div class="adl-batch-seg seg-fail" style="width:${segFail}%"></div>
<div class="adl-batch-seg seg-active${isActive ? ' shimmer' : ''}" style="width:${segActive}%"></div>
</div>
${statLine}
<div class="adl-batch-tracks">${tracksHtml}</div>
</div>`;
}

View file

@ -243,10 +243,13 @@ function initializeSearchModeToggle() {
return;
}
// Debounce search
// Debounce search. 600ms (was 300) so a name being typed coalesces
// into ONE search after the user pauses, instead of firing a new
// external-API search per letter (#751). Enter still triggers an
// immediate search via the keypress handler below.
debounceTimer = setTimeout(() => {
searchController.submitQuery(query);
}, 300);
}, 600);
});
enhancedInput.addEventListener('keypress', (e) => {

View file

@ -718,6 +718,116 @@ function getHybridOrder() {
return _hybridSourceOrder.filter(s => _hybridSourceEnabled[s] !== false);
}
// ---- Preferred album-art sources (reuses the hybrid-source-list styling) ----
const ART_SOURCES = [
{ id: 'caa', name: 'Cover Art Archive', icon: 'https://upload.wikimedia.org/wikipedia/commons/thumb/9/9e/MusicBrainz_Logo_%282016%29.svg/500px-MusicBrainz_Logo_%282016%29.svg.png', emoji: '🎨' },
{ id: 'deezer', name: 'Deezer', icon: 'https://cdn.brandfetch.io/idEUKgCNtu/theme/dark/symbol.svg?c=1bxid64Mup7aczewSAYMX&t=1758260798610', emoji: '🎧' },
{ id: 'itunes', name: 'iTunes', icon: 'https://upload.wikimedia.org/wikipedia/commons/thumb/d/df/ITunes_logo.svg/960px-ITunes_logo.svg.png', emoji: '🍎' },
{ id: 'spotify', name: 'Spotify', icon: 'https://storage.googleapis.com/pr-newsroom-wp/1/2023/05/Spotify_Primary_Logo_RGB_Green.png', emoji: '🟢' },
{ id: 'audiodb', name: 'TheAudioDB', icon: null, emoji: '💿' },
];
let _artSourceEnabled = {}; // id -> bool
let _artVisualOrder = []; // available source ids, in display order
let _artAvailable = []; // ids the user is connected to
function buildArtSourceList() {
const container = document.getElementById('art-source-list');
if (!container) return;
container.innerHTML = '';
if (!_artVisualOrder.length) {
container.innerHTML = '<div style="padding:10px;color:var(--text-secondary,#888);font-size:13px;">No connected art sources available.</div>';
return;
}
const enabledOrder = getArtOrder();
_artVisualOrder.forEach((srcId) => {
const src = ART_SOURCES.find(s => s.id === srcId);
if (!src) return;
const enabled = _artSourceEnabled[srcId] === true;
const priorityNum = enabled ? enabledOrder.indexOf(srcId) + 1 : '';
const item = document.createElement('div');
item.className = `hybrid-source-item${enabled ? '' : ' disabled'}`;
item.dataset.sourceId = srcId;
item.innerHTML = `
<span class="hybrid-source-arrows">
<button class="hybrid-arrow-btn" onclick="moveArtSource('${srcId}', -1)" title="Move up"></button>
<button class="hybrid-arrow-btn" onclick="moveArtSource('${srcId}', 1)" title="Move down"></button>
</span>
${src.icon
? `<img class="hybrid-source-icon" src="${src.icon}" alt="${src.name}" onerror="this.outerHTML='<span class=\\'hybrid-source-icon emoji-icon\\'>${src.emoji}</span>'">`
: `<span class="hybrid-source-icon emoji-icon">${src.emoji}</span>`
}
<span class="hybrid-source-name">${src.name}</span>
<span class="hybrid-source-priority">${priorityNum}</span>
<label class="hybrid-source-toggle">
<input type="checkbox" ${enabled ? 'checked' : ''} onchange="toggleArtSource('${srcId}', this.checked)">
<span class="toggle-track"></span>
</label>
`;
container.appendChild(item);
});
}
function moveArtSource(srcId, direction) {
const idx = _artVisualOrder.indexOf(srcId);
if (idx < 0) return;
const newIdx = idx + direction;
if (newIdx < 0 || newIdx >= _artVisualOrder.length) return;
[_artVisualOrder[idx], _artVisualOrder[newIdx]] = [_artVisualOrder[newIdx], _artVisualOrder[idx]];
buildArtSourceList();
if (typeof debouncedAutoSaveSettings === 'function') debouncedAutoSaveSettings();
}
function toggleArtSource(srcId, enabled) {
_artSourceEnabled[srcId] = enabled;
buildArtSourceList();
if (typeof debouncedAutoSaveSettings === 'function') debouncedAutoSaveSettings();
}
// Saved value: enabled sources in their displayed order.
function getArtOrder() {
return _artVisualOrder.filter(id => _artSourceEnabled[id] === true);
}
async function loadArtSourceOrder(settings) {
const valid = new Set(ART_SOURCES.map(s => s.id));
const saved = (settings && settings.metadata_enhancement
&& Array.isArray(settings.metadata_enhancement.album_art_order))
? settings.metadata_enhancement.album_art_order : [];
// Populate the saved order SYNCHRONOUSLY, filtered to known art sources
// (NOT by availability). This guarantees a save that fires before the
// availability fetch resolves — or while a saved source is temporarily
// disconnected — can never wipe the user's saved order. The backend skips
// any unavailable source at resolution time, and it re-activates on
// reconnect, so keeping it in the list is safe and preserves intent.
_artSourceEnabled = {};
_artVisualOrder = [];
saved.forEach(id => {
if (valid.has(id) && !_artVisualOrder.includes(id)) {
_artVisualOrder.push(id);
_artSourceEnabled[id] = true;
}
});
buildArtSourceList();
// Then fetch which sources are actually connected and append any that
// aren't already listed (shown disabled, ready to enable).
try {
const resp = await fetch('/api/metadata/art-sources');
const data = await resp.json();
_artAvailable = (data.available || []).map(s => (s && s.id) ? s.id : s);
} catch (e) {
_artAvailable = [];
}
_artAvailable.forEach(id => {
if (valid.has(id) && !_artVisualOrder.includes(id)) {
_artVisualOrder.push(id);
_artSourceEnabled[id] = false;
}
});
buildArtSourceList();
}
function loadHybridSourceOrder(settings) {
const order = settings.download_source?.hybrid_order;
const sourceStatus = settings._source_status || {};
@ -943,6 +1053,7 @@ async function loadSettingsData() {
document.getElementById('stream-source').value = settings.download_source?.stream_source || 'youtube';
document.getElementById('max-concurrent-downloads').value = settings.download_source?.max_concurrent || '3';
loadHybridSourceOrder(settings);
loadArtSourceOrder(settings);
document.getElementById('tidal-download-quality').value = settings.tidal_download?.quality || 'lossless';
document.getElementById('tidal-allow-fallback').checked = settings.tidal_download?.allow_fallback !== false;
document.getElementById('qobuz-quality').value = settings.qobuz?.quality || 'lossless';
@ -2778,6 +2889,7 @@ async function saveSettings(quiet = false) {
embed_album_art: document.getElementById('embed-album-art').checked,
cover_art_download: document.getElementById('cover-art-download').checked,
prefer_caa_art: document.getElementById('prefer-caa-art').checked,
album_art_order: getArtOrder(),
lrclib_enabled: document.getElementById('lrclib-enabled').checked,
tags: {
quality_tag: _getTagConfig('metadata_enhancement.tags.quality_tag'),

View file

@ -518,10 +518,10 @@ function renderMirroredCard(p, container) {
${phaseHtml}
</div>
</div>
${disc > 0 ? `<button class="mirrored-card-clear" onclick="event.stopPropagation(); clearMirroredDiscovery(${p.id}, '${_escAttr(p.name)}')" title="Clear discovery data">↺</button>` : ''}
<button class="mirrored-card-pipeline" onclick="event.stopPropagation(); runMirroredPlaylistPipeline(${p.id}, '${_escAttr(p.name)}')" title="Refresh, discover, sync, and queue missing tracks">Auto-Sync</button>
<button class="mirrored-card-link" onclick="event.stopPropagation(); editMirroredSourceRef(${p.id}, '${_escAttr(p.name)}', '${_escAttr(p.source)}', '${_escAttr(sourceRef)}')" title="Edit original playlist link">🔗</button>
<button class="mirrored-card-delete" onclick="event.stopPropagation(); deleteMirroredPlaylist(${p.id}, '${_escAttr(p.name)}')" title="Delete mirror"></button>
${disc > 0 ? `<button class="mirrored-card-clear" onclick="event.stopPropagation(); clearMirroredDiscovery(${p.id}, '${_escJs(p.name)}')" title="Clear discovery data">↺</button>` : ''}
<button class="mirrored-card-pipeline" onclick="event.stopPropagation(); runMirroredPlaylistPipeline(${p.id}, '${_escJs(p.name)}')" title="Refresh, discover, sync, and queue missing tracks">Auto-Sync</button>
<button class="mirrored-card-link" onclick="event.stopPropagation(); editMirroredSourceRef(${p.id}, '${_escJs(p.name)}', '${_escJs(p.source)}', '${_escJs(sourceRef)}')" title="Edit original playlist link">🔗</button>
<button class="mirrored-card-delete" onclick="event.stopPropagation(); deleteMirroredPlaylist(${p.id}, '${_escJs(p.name)}')" title="Delete mirror"></button>
`;
card.addEventListener('click', () => {
const st = youtubePlaylistStates[hash];
@ -829,52 +829,74 @@ async function openMirroredPlaylistModal(playlistId) {
const tracks = data.tracks || [];
const source = data.source || 'unknown';
const sourceRef = getMirroredSourceRef(data);
const sourceIcons = { spotify: '🎵', tidal: '🌊', youtube: '▶', beatport: '🎛' };
const sourceIcons = { spotify: '🎵', spotify_public: '🎵', tidal: '🌊', youtube: '▶', beatport: '🎛', deezer: '🎧', qobuz: '♫' };
const sourceLabels = { spotify: 'Spotify', spotify_public: 'Spotify', tidal: 'Tidal', youtube: 'YouTube', beatport: 'Beatport', deezer: 'Deezer', qobuz: 'Qobuz' };
const sourceIcon = sourceIcons[source] || '📋';
const srcLabel = sourceLabels[source] || source;
// Hero artwork: playlist cover → first track with art → gradient fallback.
const heroArt = data.image_url || (tracks.find(t => t.image_url) || {}).image_url || '';
// Total runtime for the meta line.
const totalMs = tracks.reduce((sum, t) => sum + (t.duration_ms || 0), 0);
const totalMin = Math.round(totalMs / 60000);
const totalLabel = totalMin >= 60 ? `${Math.floor(totalMin / 60)} hr ${totalMin % 60} min` : `${totalMin} min`;
const trackRows = tracks.map(t => {
const dur = t.duration_ms ? `${Math.floor(t.duration_ms / 60000)}:${String(Math.floor((t.duration_ms % 60000) / 1000)).padStart(2, '0')}` : '';
return `<div class="mirrored-track-row">
<span class="track-pos">${t.position}</span>
<span class="track-title">${_esc(t.track_name)}</span>
<span class="track-artist">${_esc(t.artist_name)}</span>
<span class="track-album">${_esc(t.album_name)}</span>
<span class="track-duration">${dur}</span>
const art = t.image_url
? `<img class="mm-row-art" src="${_escAttr(t.image_url)}" loading="lazy" onerror="this.onerror=null;this.classList.add('mm-art-empty');this.removeAttribute('src');">`
: `<span class="mm-row-art mm-art-empty"></span>`;
return `<div class="mm-row">
<span class="mm-row-pos">${t.position}</span>
${art}
<div class="mm-row-main">
<span class="mm-row-title">${_esc(t.track_name)}</span>
<span class="mm-row-artist">${_esc(t.artist_name)}</span>
</div>
<span class="mm-row-album">${_esc(t.album_name || '')}</span>
<span class="mm-row-dur">${dur}</span>
</div>`;
}).join('');
const heroCover = heroArt
? `<div class="mm-cover" style="background-image:url('${_escAttr(heroArt)}')"></div>`
: `<div class="mm-cover mm-cover-empty ${_escAttr(source)}">${sourceIcon}</div>`;
overlay.innerHTML = `
<div class="mirrored-modal">
<div class="mirrored-modal-header">
<div class="mirrored-modal-hero">
<div class="mirrored-modal-hero-icon ${_escAttr(source)}">${sourceIcon}</div>
<div class="mirrored-modal-hero-info">
<h2 class="mirrored-modal-hero-title">${_esc(data.name)}</h2>
<div class="mirrored-modal-hero-subtitle">
<span class="mirrored-modal-hero-badge">${_esc(source)}</span>
<span>${tracks.length} tracks</span>
<span>&middot;</span>
<span>Mirrored ${timeAgo(data.updated_at || data.mirrored_at)}</span>
<div class="mm-hero">
${heroArt ? `<div class="mm-hero-bg" style="background-image:url('${_escAttr(heroArt)}')"></div>` : ''}
<div class="mm-hero-content">
${heroCover}
<div class="mm-hero-info">
<span class="mm-eyebrow">Mirrored Playlist</span>
<h2 class="mm-title">${_esc(data.name)}</h2>
<div class="mm-meta">
<span class="mm-source-pill ${_escAttr(source)}">${_esc(srcLabel)}</span>
${data.owner ? `<span class="mm-meta-item">${_esc(data.owner)}</span><span class="mm-dot">&middot;</span>` : ''}
<span class="mm-meta-item">${tracks.length} tracks</span>
${totalMs ? `<span class="mm-dot">&middot;</span><span class="mm-meta-item">${totalLabel}</span>` : ''}
<span class="mm-dot">&middot;</span>
<span class="mm-meta-item">Mirrored ${timeAgo(data.updated_at || data.mirrored_at)}</span>
</div>
</div>
</div>
<span class="mirrored-modal-close" onclick="closeMirroredModal()">&times;</span>
<button class="mm-close" onclick="closeMirroredModal()" aria-label="Close">&times;</button>
</div>
<div class="mirrored-modal-tracks">
<div class="mirrored-track-header">
<span>#</span><span>Track</span><span>Artist</span><span>Album</span><span style="text-align:right">Time</span>
<div class="mm-list">
<div class="mm-list-head">
<span>#</span><span></span><span>Title</span><span>Album</span><span class="mm-col-dur">Time</span>
</div>
${trackRows}
${trackRows || '<div class="mm-empty">No tracks in this mirror yet.</div>'}
</div>
<div class="mirrored-modal-footer">
<div class="mirrored-modal-footer-left">
<button class="mirrored-btn-delete" onclick="closeMirroredModal(); deleteMirroredPlaylist(${playlistId}, '${_escAttr(data.name)}')">Delete Mirror</button>
</div>
<div class="mirrored-modal-footer-right" style="display:flex;gap:10px;">
<button class="mirrored-btn-close" onclick="editMirroredSourceRef(${playlistId}, '${_escAttr(data.name)}', '${_escAttr(source)}', '${_escAttr(sourceRef)}')">Edit Source</button>
<button class="mirrored-btn-pipeline" onclick="runMirroredPlaylistPipeline(${playlistId}, '${_escAttr(data.name)}')">Auto-Sync</button>
<button class="mirrored-btn-close" onclick="closeMirroredModal()">Close</button>
<button class="mirrored-btn-discover" onclick="discoverMirroredPlaylist(${playlistId})">Discover</button>
<div class="mm-actions">
<button class="mm-btn mm-btn-danger" onclick="closeMirroredModal(); deleteMirroredPlaylist(${playlistId}, '${_escJs(data.name)}')">Delete Mirror</button>
<div class="mm-actions-right">
<button class="mm-btn mm-btn-ghost" onclick="editMirroredSourceRef(${playlistId}, '${_escJs(data.name)}', '${_escJs(source)}', '${_escJs(sourceRef)}')">Edit Source</button>
<button class="mm-btn mm-btn-secondary" onclick="runMirroredPlaylistPipeline(${playlistId}, '${_escJs(data.name)}')">Auto-Sync</button>
<button class="mm-btn mm-btn-ghost" onclick="closeMirroredModal()">Close</button>
<button class="mm-btn mm-btn-primary" onclick="discoverMirroredPlaylist(${playlistId})">Discover</button>
</div>
</div>
</div>
@ -1176,7 +1198,7 @@ function renderPoolList() {
<span class="pool-track-playlist-badge">${_esc(t.playlist_name)}</span>
</div>
</div>
<button class="playlist-modal-btn playlist-modal-btn-primary pool-fix-btn" onclick="openPoolFixModal(${t.id}, '${_escAttr(t.track_name)}', '${_escAttr(t.artist_name)}')">Fix Match</button>
<button class="playlist-modal-btn playlist-modal-btn-primary pool-fix-btn" onclick="openPoolFixModal(${t.id}, '${_escJs(t.track_name)}', '${_escJs(t.artist_name)}')">Fix Match</button>
</div>
`).join('');
} else {
@ -1218,7 +1240,7 @@ function renderPoolList() {
</div>
<span class="pool-confidence-badge ${confClass}">${conf}%</span>
<span class="pool-use-count">${e.use_count}&times;</span>
<button class="pool-rematch-btn" onclick="rematchPoolCacheEntry(${e.id}, '${_escAttr(e.original_title)}', '${_escAttr(e.original_artist)}')" title="Rematch this track">Rematch</button>
<button class="pool-rematch-btn" onclick="rematchPoolCacheEntry(${e.id}, '${_escJs(e.original_title)}', '${_escJs(e.original_artist)}')" title="Rematch this track">Rematch</button>
<button class="pool-remove-btn" onclick="removePoolCacheEntry(${e.id})" title="Remove cached match">&times;</button>
</div>
`;
@ -2141,13 +2163,13 @@ function _buildAutomationSection(id, label, automations, useGrid, options = {})
const allEnabled = enabledCount === automations.length;
actionsHtml = `
<div class="section-actions" onclick="event.stopPropagation();">
<button class="section-action-btn" title="${allEnabled ? 'Disable all' : 'Enable all'}" onclick="_bulkToggleGroup('${_escAttr(groupName)}', ${allEnabled})">
<button class="section-action-btn" title="${allEnabled ? 'Disable all' : 'Enable all'}" onclick="_bulkToggleGroup('${_escJs(groupName)}', ${allEnabled})">
${allEnabled ? '⏸' : '▶'}
</button>
<button class="section-action-btn" title="Rename group" onclick="_startRenameGroup('${_escAttr(groupName)}', this)">
<button class="section-action-btn" title="Rename group" onclick="_startRenameGroup('${_escJs(groupName)}', this)">
</button>
<button class="section-action-btn section-action-danger" title="Delete group" onclick="_deleteGroup('${_escAttr(groupName)}')">
<button class="section-action-btn section-action-danger" title="Delete group" onclick="_deleteGroup('${_escJs(groupName)}')">
🗑
</button>
</div>
@ -3108,7 +3130,7 @@ function _showGroupDropdown(event, autoId, currentGroup) {
}
allGroups.forEach(g => {
const isActive = g === currentGroup;
html += `<div class="auto-group-option${isActive ? ' active' : ''}" onclick="_assignGroup(${autoId}, '${_escAttr(g)}')">${_esc(g)}</div>`;
html += `<div class="auto-group-option${isActive ? ' active' : ''}" onclick="_assignGroup(${autoId}, '${_escJs(g)}')">${_esc(g)}</div>`;
});
if (allGroups.size) html += '<div class="auto-group-divider"></div>';
html += `<input class="auto-group-input" placeholder="New group name..." onkeydown="if(event.key==='Enter'){_assignGroup(${autoId}, this.value.trim()); event.preventDefault();}">`;
@ -3204,15 +3226,15 @@ function renderAutomationCard(a) {
const _timerTriggers = ['schedule', 'daily_time', 'weekly_time'];
if (a.next_run && a.enabled && _timerTriggers.includes(a.trigger_type)) metaParts.push('<span class="auto-next-run" data-next="' + _escAttr(a.next_run) + '">Next: ' + _autoTimeUntil(a.next_run) + '</span>');
if (!_timerTriggers.includes(a.trigger_type) && a.enabled) metaParts.push('Listening');
if (a.run_count) metaParts.push('<span class="auto-runs-link" onclick="event.stopPropagation(); showAutomationHistory(' + a.id + ', \'' + _escAttr(a.name) + '\', \'' + _escAttr(a.action_type || '') + '\')" title="View run history">Runs: ' + a.run_count + '</span>');
if (a.run_count) metaParts.push('<span class="auto-runs-link" onclick="event.stopPropagation(); showAutomationHistory(' + a.id + ', \'' + _escJs(a.name) + '\', \'' + _escJs(a.action_type || '') + '\')" title="View run history">Runs: ' + a.run_count + '</span>');
if (a.last_error) metaParts.push('Error: ' + _esc(a.last_error));
const dupeBtn = a.is_system ? '' :
`<button class="automation-dupe-btn" title="Duplicate" onclick="event.stopPropagation(); duplicateAutomation(${a.id})">&#128203;</button>`;
const groupBtn = a.is_system ? '' :
`<button class="automation-group-btn${a.group_name ? ' grouped' : ''}" data-group="${_escAttr(a.group_name || '')}" title="${a.group_name ? 'Group: ' + _escAttr(a.group_name) : 'Assign group'}" onclick="event.stopPropagation(); _showGroupDropdown(event, ${a.id}, ${a.group_name ? "'" + _escAttr(a.group_name) + "'" : 'null'})">&#128193;</button>`;
`<button class="automation-group-btn${a.group_name ? ' grouped' : ''}" data-group="${_escAttr(a.group_name || '')}" title="${a.group_name ? 'Group: ' + _escAttr(a.group_name) : 'Assign group'}" onclick="event.stopPropagation(); _showGroupDropdown(event, ${a.id}, ${a.group_name ? "'" + _escJs(a.group_name) + "'" : 'null'})">&#128193;</button>`;
const deleteBtn = a.is_system ? '' :
`<button class="automation-delete-btn" title="Delete" onclick="event.stopPropagation(); deleteAutomation(${a.id}, '${_escAttr(a.name)}')">&#128465;</button>`;
`<button class="automation-delete-btn" title="Delete" onclick="event.stopPropagation(); deleteAutomation(${a.id}, '${_escJs(a.name)}')">&#128465;</button>`;
card.innerHTML = `
<div class="automation-status ${a.enabled ? 'enabled' : 'disabled'}"></div>
@ -4660,6 +4682,29 @@ function _escAttr(str) {
return String(str).replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/'/g, '&#39;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
// Escape a string for use INSIDE a single-quoted JS string literal that itself
// sits inside a double-quoted HTML attribute, e.g. onclick="fn('<value>')".
// Such a value is decoded twice: first the HTML attribute parser resolves
// entities, then the JS parser reads the string. _escAttr only HTML-escapes, so
// an apostrophe becomes &#39; which the attribute parser turns back into a bare
// ' — terminating the JS string and throwing a SyntaxError that silently kills
// the whole handler (this is the "Road trip-The Rolfe's" delete-button bug).
// Fix: backslash-escape the JS metacharacters (\ and ') FIRST so they survive
// HTML decoding intact, then HTML-escape the characters that would otherwise
// break the surrounding attribute. Order matters — & is escaped before the
// later replacements insert their own entities.
function _escJs(str) {
if (!str) return '';
return String(str)
.replace(/\\/g, '\\\\')
.replace(/'/g, "\\'")
.replace(/\r?\n/g, ' ')
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
// ===== ENHANCE QUALITY MODAL =====
let _enhanceQualityData = null;
@ -4740,9 +4785,12 @@ async function playArtistRadio() {
audioPlayer.pause();
}
// Play the track first, then enable radio mode after a short delay
// so currentTrack is set and the radio queue fill triggers
playLibraryTrack({
// Play the track and WAIT for it to resolve (playLibraryTrack is async
// and fetches the canonical DB row before setting currentTrack) — so
// currentTrack.id is reliably set before we fetch the radio queue. A
// fixed setTimeout raced this and often fired before the id existed,
// leaving the queue empty until the song ended.
await playLibraryTrack({
id: random.track.id,
title: random.track.title,
file_path: random.track.file_path,
@ -4751,12 +4799,17 @@ async function playArtistRadio() {
album_id: random.album.id,
}, random.album.title || '', artistName);
// Enable radio mode after track starts loading
setTimeout(() => {
// Enable radio mode + immediately seed the queue with similar tracks —
// same path the modal's Radio button uses (fetchIfNeeded also adds the
// current track to the queue first).
if (typeof npSetPlayContext === 'function') {
npSetPlayContext(`${artistName || 'Artist'} Radio`);
}
if (typeof npSetRadioMode === 'function') {
npSetRadioMode(true, { toast: false, fetchIfNeeded: true });
} else {
npRadioMode = true;
const radioBtn = document.querySelector('.np-radio-btn');
if (radioBtn) radioBtn.classList.add('active');
}, 1000);
}
showToast(`Playing ${artistName} radio — similar tracks will auto-queue`, 'success');
} catch (e) {

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,233 @@
/* Track Detail modal opens from any track row in the download modal and
* shows a rich, status-aware view: cover, title/artist/album, play/listen,
* source, quality, AcoustID verdict, file location, expected-vs-downloaded,
* and the right actions for the row's state (Accept/Search for quarantined,
* Search for failed). Backed by /api/downloads/task/<id>/detail.
*
* Globals it leans on (defined in downloads.js, loaded first): showToast,
* showConfirmDialog, showCandidatesModal, escapeHtml.
*/
const _TD_STATUS = {
completed: { label: 'Completed', cls: 'td-badge-ok' },
quarantined: { label: 'Quarantined', cls: 'td-badge-warn' },
failed: { label: 'Failed', cls: 'td-badge-bad' },
not_found: { label: 'Not Found', cls: 'td-badge-muted' },
in_progress: { label: 'In Progress', cls: 'td-badge-info' },
};
const _TD_ACOUSTID = {
pass: { label: 'Verified', cls: 'td-aid-ok' },
fail: { label: 'Failed', cls: 'td-aid-bad' },
error: { label: 'Error', cls: 'td-aid-bad' },
skip: { label: 'No match', cls: 'td-aid-muted' },
disabled: { label: 'Off', cls: 'td-aid-muted' },
};
function _tdEsc(s) {
if (typeof escapeHtml === 'function') return escapeHtml(s == null ? '' : s);
return String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function _tdSetText(id, value, fallback = '—') {
const el = document.getElementById(id);
if (el) el.textContent = (value && String(value).trim()) ? value : fallback;
}
// Release the preview <audio> so the OS file handle is freed before any move
// (Windows locks an open file — same reason the quarantine chooser does this).
function _tdReleaseAudio() {
const a = document.getElementById('td-audio');
if (!a) return Promise.resolve();
try { a.pause(); a.removeAttribute('src'); a.load(); } catch (e) { /* ignore */ }
return new Promise((r) => setTimeout(r, 400));
}
function closeTrackDetail() {
const o = document.getElementById('track-detail-overlay');
if (!o) return;
const a = document.getElementById('td-audio');
if (a) { try { a.pause(); a.removeAttribute('src'); a.load(); } catch (e) { /* ignore */ } }
o.classList.remove('visible');
o.setAttribute('aria-hidden', 'true');
}
async function openTrackDetail(taskId) {
if (!taskId) return;
const overlay = document.getElementById('track-detail-overlay');
if (!overlay) { console.warn('track-detail modal not present'); return; }
let detail;
try {
const resp = await fetch(`/api/downloads/task/${encodeURIComponent(taskId)}/detail`);
const data = await resp.json();
if (!data.success) { showToast(data.error || 'Could not load track detail', 'error'); return; }
detail = data.detail;
} catch (err) {
showToast(`Could not load track detail: ${err.message}`, 'error');
return;
}
_tdRender(detail, taskId);
overlay.classList.add('visible');
overlay.setAttribute('aria-hidden', 'false');
}
function _tdRender(d, taskId) {
const kind = d.status_kind || 'in_progress';
// Header
_tdSetText('td-title', d.title, 'Unknown Track');
_tdSetText('td-artist', d.artist, '');
_tdSetText('td-album', d.album, '');
const thumb = document.getElementById('td-thumb');
const thumbPh = document.getElementById('td-thumb-ph');
if (thumb && thumbPh) {
if (d.thumb_url && /^https?:\/\//.test(d.thumb_url)) {
thumb.src = d.thumb_url; thumb.hidden = false; thumbPh.hidden = true;
thumb.onerror = () => { thumb.hidden = true; thumbPh.hidden = false; };
} else {
thumb.hidden = true; thumbPh.hidden = false;
}
}
const badge = document.getElementById('td-status-badge');
if (badge) {
const s = _TD_STATUS[kind] || _TD_STATUS.in_progress;
badge.textContent = s.label;
badge.className = `td-status-badge ${s.cls}`;
}
// Info grid
_tdSetText('td-f-source', d.source);
_tdSetText('td-f-quality', d.quality);
_tdSetText('td-f-location', d.file_path);
const aidEl = document.getElementById('td-f-acoustid');
if (aidEl) {
const a = _TD_ACOUSTID[d.acoustid_result];
aidEl.textContent = a ? a.label : '—';
aidEl.className = `td-value ${a ? a.cls : ''}`;
}
// Expected vs downloaded (only when we have provenance)
const prov = document.getElementById('td-provenance');
const exp = (d.expected && (d.expected.title || d.expected.artist));
const dl = (d.downloaded && (d.downloaded.title || d.downloaded.artist));
if (prov) {
if (exp || dl) {
prov.hidden = false;
_tdSetText('td-exp', exp ? `${d.expected.title}${d.expected.artist ? ' — ' + d.expected.artist : ''}` : '', '—');
_tdSetText('td-dl', dl ? `${d.downloaded.title}${d.downloaded.artist ? ' — ' + d.downloaded.artist : ''}` : '', '—');
} else {
prov.hidden = true;
}
}
// Reason banner (quarantined / failed)
const reason = document.getElementById('td-reason');
if (reason) {
if ((kind === 'quarantined' || kind === 'failed') && d.reason) {
reason.hidden = false;
reason.innerHTML = `<strong>${kind === 'quarantined' ? 'Why it was quarantined' : 'Why it failed'}:</strong><br>${_tdEsc(d.reason)}`;
} else {
reason.hidden = true;
}
}
// Audio: completed -> library stream; quarantined -> quarantine stream.
const audio = document.getElementById('td-audio');
if (audio) {
let src = '';
if (kind === 'completed' && d.file_path) {
src = `/stream/library-audio?path=${encodeURIComponent(d.file_path)}`;
} else if (kind === 'quarantined' && d.quarantine_entry_id) {
src = `/api/quarantine/${encodeURIComponent(d.quarantine_entry_id)}/stream`;
}
if (src) { audio.src = src; audio.hidden = false; } else { audio.removeAttribute('src'); audio.hidden = true; }
}
_tdRenderActions(d, taskId, kind);
}
function _tdRenderActions(d, taskId, kind) {
const el = document.getElementById('td-actions');
if (!el) return;
el.innerHTML = '';
const add = (label, cls, onClick) => {
const b = document.createElement('button');
b.className = `td-action-btn ${cls}`;
b.textContent = label;
b.addEventListener('click', onClick);
el.appendChild(b);
return b;
};
if (kind === 'quarantined') {
add('✓ Accept & Import', 'td-action-primary', (e) => _tdAccept(e.currentTarget, d.quarantine_entry_id, taskId));
add('🔍 Search for a different result', 'td-action-secondary', () => { closeTrackDetail(); if (taskId) showCandidatesModal(taskId); });
} else if (kind === 'failed' || kind === 'not_found') {
add('🔍 Search for a different result', 'td-action-secondary', () => { closeTrackDetail(); if (taskId) showCandidatesModal(taskId); });
}
// completed / in_progress: no destructive actions — the player + info is it.
}
async function _tdAccept(button, entryId, taskId) {
if (!entryId) { showToast('Cannot accept — missing quarantine id.', 'error'); return; }
const confirmed = await showConfirmDialog({
title: 'Accept Quarantined File',
message: 'Import this file and skip the quarantine checks for this approved pass?',
confirmText: 'Accept & Import',
cancelText: 'Cancel',
});
if (!confirmed) return;
const original = button.textContent;
button.disabled = true;
button.textContent = 'Importing…';
await _tdReleaseAudio(); // free the file handle before the move (Windows lock)
try {
const resp = await fetch(`/api/quarantine/${encodeURIComponent(entryId)}/approve`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ task_id: taskId || '' }),
});
const data = await resp.json();
if (data.success) {
showToast('Accepted. Re-running post-processing.', 'success');
closeTrackDetail();
return;
}
const needsRecover = /thin sidecar|recover to staging|embedded context|missing file or sidecar/i.test(data.error || '');
if (needsRecover) {
button.textContent = 'Recovering…';
const rec = await fetch(`/api/quarantine/${encodeURIComponent(entryId)}/recover`, { method: 'POST' });
const recData = await rec.json();
if (recData.success) {
showToast('Older entry — moved to Staging. Finish it from the Import page.', 'success');
closeTrackDetail();
return;
}
showToast(`Recover failed: ${recData.error || 'Unknown error'}`, 'error');
} else {
showToast(`Accept failed: ${data.error || 'Unknown error'}`, 'error');
}
} catch (err) {
showToast(`Accept failed: ${err.message}`, 'error');
}
button.disabled = false;
button.textContent = original;
}
// Close on backdrop click + Escape.
document.addEventListener('DOMContentLoaded', () => {
const overlay = document.getElementById('track-detail-overlay');
if (overlay) {
overlay.addEventListener('mousedown', (e) => { if (e.target === overlay) closeTrackDetail(); });
}
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
const o = document.getElementById('track-detail-overlay');
if (o && o.classList.contains('visible')) closeTrackDetail();
}
});

View file

@ -0,0 +1,44 @@
{# Track Detail modal — click any track row in the download modal to inspect it.
Populated by static/track-detail.js (openTrackDetail). One shell that adapts
by status_kind: completed shows play + provenance; quarantined adds the
reason + Listen / Accept / Search; failed/not-found offer Search. #}
<div id="track-detail-overlay" class="td-overlay" aria-hidden="true">
<div class="td-modal" role="dialog" aria-modal="true" aria-labelledby="td-title">
<div class="td-header">
<div class="td-thumb-wrap">
<img id="td-thumb" class="td-thumb" alt="" hidden>
<div id="td-thumb-ph" class="td-thumb-ph">🎵</div>
</div>
<div class="td-headtext">
<h2 id="td-title" class="td-title">Track</h2>
<div id="td-artist" class="td-artist"></div>
<div id="td-album" class="td-album"></div>
</div>
<span id="td-status-badge" class="td-status-badge"></span>
<button type="button" class="td-close" aria-label="Close" onclick="closeTrackDetail()">&times;</button>
</div>
<div class="td-body">
<audio id="td-audio" class="td-audio" controls preload="none" hidden></audio>
<div id="td-reason" class="td-reason" hidden></div>
<div class="td-grid">
<div class="td-field"><span class="td-label">Source</span><span id="td-f-source" class="td-value"></span></div>
<div class="td-field"><span class="td-label">Quality</span><span id="td-f-quality" class="td-value"></span></div>
<div class="td-field"><span class="td-label">AcoustID</span><span id="td-f-acoustid" class="td-value"></span></div>
<div class="td-field td-field-wide">
<span class="td-label">Location</span>
<span id="td-f-location" class="td-value td-mono"></span>
</div>
</div>
<div id="td-provenance" class="td-provenance" hidden>
<div class="td-prov-row"><span class="td-prov-k">Expected</span><span id="td-exp" class="td-prov-v"></span></div>
<div class="td-prov-row"><span class="td-prov-k">Downloaded</span><span id="td-dl" class="td-prov-v"></span></div>
</div>
<div id="td-actions" class="td-actions"></div>
</div>
</div>
</div>