commit
117f52bb25
82 changed files with 7485 additions and 823 deletions
|
|
@ -605,7 +605,16 @@ class ConfigManager:
|
||||||
"metadata_enhancement": {
|
"metadata_enhancement": {
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
"embed_album_art": 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": {
|
"musicbrainz": {
|
||||||
"embed_tags": True
|
"embed_tags": True
|
||||||
|
|
|
||||||
|
|
@ -282,8 +282,9 @@ class AcoustIDClient:
|
||||||
|
|
||||||
def test_api_key(self) -> Tuple[bool, str]:
|
def test_api_key(self) -> Tuple[bool, str]:
|
||||||
"""
|
"""
|
||||||
Validate the API key by fingerprinting a real audio file and looking it up.
|
Validate the API key with a direct AcoustID lookup call. An invalid key
|
||||||
Falls back to a direct API call if no audio files are available.
|
is reported as invalid (error code 4); any other error means the key was
|
||||||
|
accepted.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Tuple of (success, message)
|
Tuple of (success, message)
|
||||||
|
|
@ -294,24 +295,12 @@ class AcoustIDClient:
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Try to find a real audio file to fingerprint for an end-to-end test
|
# Authoritative key check: a direct API lookup with a dummy
|
||||||
test_file = self._find_test_audio_file()
|
# fingerprint. AcoustID validates the client key first, so an
|
||||||
|
# invalid key returns error code 4 regardless of the fingerprint.
|
||||||
if test_file and CHROMAPRINT_AVAILABLE:
|
# (The previous real-file path trusted "no exception = valid", but
|
||||||
logger.info(f"Testing API key with real audio file: {test_file}")
|
# fingerprint_and_lookup swallows the invalid-key error and returns
|
||||||
try:
|
# None — so it reported broken keys as valid. #756-adjacent.)
|
||||||
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
|
|
||||||
url = 'https://api.acoustid.org/v2/lookup'
|
url = 'https://api.acoustid.org/v2/lookup'
|
||||||
params = {
|
params = {
|
||||||
'client': self.api_key,
|
'client': self.api_key,
|
||||||
|
|
@ -326,7 +315,6 @@ class AcoustIDClient:
|
||||||
if data.get('status') == 'error':
|
if data.get('status') == 'error':
|
||||||
error = data.get('error', {})
|
error = data.get('error', {})
|
||||||
error_code = error.get('code', 0)
|
error_code = error.get('code', 0)
|
||||||
error_msg = error.get('message', 'Unknown error')
|
|
||||||
|
|
||||||
# Error code 4 is specifically "invalid API key"
|
# Error code 4 is specifically "invalid API key"
|
||||||
if error_code == 4:
|
if error_code == 4:
|
||||||
|
|
@ -346,33 +334,33 @@ class AcoustIDClient:
|
||||||
logger.error(f"Error testing AcoustID API key: {e}")
|
logger.error(f"Error testing AcoustID API key: {e}")
|
||||||
return False, f"Error: {str(e)}"
|
return False, f"Error: {str(e)}"
|
||||||
|
|
||||||
def fingerprint_and_lookup(self, audio_file: str) -> Optional[Dict[str, Any]]:
|
def lookup_with_status(self, audio_file: str) -> Dict[str, Any]:
|
||||||
"""
|
"""Fingerprint + AcoustID lookup returning a STRUCTURED result.
|
||||||
Generate fingerprint and look up recording in AcoustID.
|
|
||||||
|
|
||||||
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:
|
Returns dict with:
|
||||||
audio_file: Path to the audio file
|
'status': 'ok' | 'no_match' | 'error' | 'no_backend'
|
||||||
|
| 'fingerprint_error' | 'unsupported' | 'unavailable'
|
||||||
Returns:
|
| 'not_found'
|
||||||
Dict with:
|
'recordings': list (meaningful only for 'ok')
|
||||||
'recordings': list of dicts with 'mbid', 'title', 'artist', 'score'
|
'best_score': float
|
||||||
'best_score': float (highest score across all results)
|
'recording_mbids': list
|
||||||
'recording_mbids': list of unique MBIDs (for backward compat)
|
'error': human-readable detail for any non-'ok' status
|
||||||
Or None on error.
|
'invalid_key': bool (True when the API specifically rejected the key)
|
||||||
"""
|
"""
|
||||||
if not ACOUSTID_AVAILABLE:
|
if not ACOUSTID_AVAILABLE:
|
||||||
logger.debug("Cannot lookup: pyacoustid not available")
|
return {'status': 'unavailable', 'recordings': [], 'error': 'pyacoustid library not installed'}
|
||||||
return None
|
|
||||||
|
|
||||||
if not self.api_key:
|
if not self.api_key:
|
||||||
logger.debug("Cannot lookup: no API key")
|
return {'status': 'unavailable', 'recordings': [], 'error': 'No AcoustID API key configured'}
|
||||||
return None
|
|
||||||
|
|
||||||
if not os.path.isfile(audio_file):
|
if not os.path.isfile(audio_file):
|
||||||
logger.warning(f"Cannot lookup: file not found: {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)
|
# Check channel count — chromaprint crashes (SIGABRT) on >2 channel files (e.g. 5.1 surround)
|
||||||
try:
|
try:
|
||||||
|
|
@ -382,7 +370,8 @@ class AcoustIDClient:
|
||||||
channels = getattr(mf.info, 'channels', 2)
|
channels = getattr(mf.info, 'channels', 2)
|
||||||
if channels and channels > 2:
|
if channels and channels > 2:
|
||||||
logger.warning(f"Skipping AcoustID: file has {channels} channels (surround audio): {audio_file}")
|
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:
|
except Exception as e:
|
||||||
logger.debug(f"Could not check channel count, proceeding anyway: {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"
|
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})")
|
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()...")
|
logger.debug("Running acoustid.match()...")
|
||||||
recordings = []
|
recordings = []
|
||||||
seen_mbids = set()
|
seen_mbids = set()
|
||||||
best_score = 0.0
|
best_score = 0.0
|
||||||
|
|
||||||
for result in acoustid.match(
|
for result in acoustid.match(self.api_key, audio_file, parse=True):
|
||||||
self.api_key,
|
|
||||||
audio_file,
|
|
||||||
parse=True
|
|
||||||
):
|
|
||||||
# match() with parse=True returns (score, recording_id, title, artist)
|
# match() with parse=True returns (score, recording_id, title, artist)
|
||||||
if not isinstance(result, tuple) or len(result) < 2:
|
if not isinstance(result, tuple) or len(result) < 2:
|
||||||
logger.warning(f"Unexpected result format: {result}")
|
logger.warning(f"Unexpected result format: {result}")
|
||||||
|
|
@ -420,45 +404,57 @@ class AcoustIDClient:
|
||||||
|
|
||||||
if recording_id and recording_id not in seen_mbids:
|
if recording_id and recording_id not in seen_mbids:
|
||||||
seen_mbids.add(recording_id)
|
seen_mbids.add(recording_id)
|
||||||
recordings.append({
|
recordings.append({'mbid': recording_id, 'title': title, 'artist': artist, 'score': score})
|
||||||
'mbid': recording_id,
|
|
||||||
'title': title,
|
|
||||||
'artist': artist,
|
|
||||||
'score': score,
|
|
||||||
})
|
|
||||||
logger.debug(f"Found match: {title} by {artist} (MBID: {recording_id}, score: {score})")
|
logger.debug(f"Found match: {title} by {artist} (MBID: {recording_id}, score: {score})")
|
||||||
|
|
||||||
if not recordings:
|
if not recordings:
|
||||||
logger.info(f"No AcoustID matches found for: {audio_file}")
|
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})")
|
logger.info(f"AcoustID found {len(recordings)} recording(s) (best score: {best_score:.2f})")
|
||||||
return {
|
return {'status': 'ok', 'recordings': recordings, 'best_score': best_score,
|
||||||
'recordings': recordings,
|
'recording_mbids': list(seen_mbids)}
|
||||||
'best_score': best_score,
|
|
||||||
'recording_mbids': list(seen_mbids),
|
|
||||||
}
|
|
||||||
|
|
||||||
except acoustid.NoBackendError:
|
except acoustid.NoBackendError:
|
||||||
logger.error("Chromaprint library not found and fpcalc not available")
|
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:
|
except acoustid.FingerprintGenerationError as e:
|
||||||
logger.warning(f"Failed to fingerprint {audio_file}: {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:
|
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 "???"
|
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}")
|
logger.warning(f"AcoustID API error (key: {api_key_preview}): {e}")
|
||||||
# Check for common errors
|
|
||||||
error_str = str(e).lower()
|
error_str = str(e).lower()
|
||||||
if 'invalid' in error_str or 'unknown' in error_str:
|
# Old pyacoustid reports an invalid key as the bare "status: error"
|
||||||
logger.error("API key appears to be invalid - check your AcoustID settings")
|
# (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:
|
elif 'rate' in error_str or 'limit' in error_str:
|
||||||
logger.warning("Rate limited by AcoustID - will retry later")
|
logger.warning("Rate limited by AcoustID — will retry later")
|
||||||
return None
|
return {'status': 'error', 'recordings': [], 'invalid_key': invalid,
|
||||||
|
'error': f'AcoustID API error: {e}'}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Unexpected error in AcoustID lookup: {e}", exc_info=True)
|
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):
|
def refresh_config(self):
|
||||||
"""Refresh cached config values (call after settings change)."""
|
"""Refresh cached config values (call after settings change)."""
|
||||||
|
|
|
||||||
|
|
@ -50,8 +50,9 @@ class VerificationResult(Enum):
|
||||||
"""Possible outcomes of audio verification."""
|
"""Possible outcomes of audio verification."""
|
||||||
PASS = "pass" # Title/artist match - file is correct
|
PASS = "pass" # Title/artist match - file is correct
|
||||||
FAIL = "fail" # Title/artist mismatch - wrong file downloaded
|
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
|
DISABLED = "disabled" # Verification not enabled
|
||||||
|
ERROR = "error" # Lookup errored (invalid key / rate limit / no backend) - continue, but flag it
|
||||||
|
|
||||||
|
|
||||||
def _normalize(text: str) -> str:
|
def _normalize(text: str) -> str:
|
||||||
|
|
@ -399,18 +400,33 @@ class AcoustIDVerification:
|
||||||
logger.debug(f"AcoustID verification skipped: {reason}")
|
logger.debug(f"AcoustID verification skipped: {reason}")
|
||||||
return VerificationResult.SKIP, 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}")
|
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:
|
if status in ('error', 'no_backend', 'fingerprint_error', 'unavailable'):
|
||||||
return VerificationResult.SKIP, "Track not found in AcoustID database"
|
# 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', [])
|
recordings = acoustid_result.get('recordings', [])
|
||||||
best_score = acoustid_result.get('best_score', 0)
|
best_score = acoustid_result.get('best_score', 0)
|
||||||
|
|
||||||
if not recordings:
|
if not recordings:
|
||||||
return VerificationResult.SKIP, "AcoustID returned no recordings"
|
return VerificationResult.SKIP, "No match in AcoustID database"
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"AcoustID returned {len(recordings)} recording(s) "
|
f"AcoustID returned {len(recordings)} recording(s) "
|
||||||
|
|
|
||||||
|
|
@ -70,7 +70,16 @@ _meta_cache_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
class AmazonClientError(RuntimeError):
|
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
|
continue
|
||||||
raise AmazonClientError(
|
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
|
) from exc
|
||||||
except requests.RequestException as exc:
|
except requests.RequestException as exc:
|
||||||
raise AmazonClientError(f"Request failed for {url}: {exc}") from exc
|
raise AmazonClientError(f"Request failed for {url}: {exc}") from exc
|
||||||
|
|
|
||||||
61
core/amazon_outage.py
Normal file
61
core/amazon_outage.py
Normal 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)
|
||||||
|
|
@ -9,6 +9,7 @@ from database.music_database import MusicDatabase
|
||||||
from core.amazon_client import AmazonClient
|
from core.amazon_client import AmazonClient
|
||||||
from core.worker_utils import interruptible_sleep, set_album_api_track_count
|
from core.worker_utils import interruptible_sleep, set_album_api_track_count
|
||||||
from core.enrichment.manual_match_honoring import honor_stored_match
|
from core.enrichment.manual_match_honoring import honor_stored_match
|
||||||
|
from core.amazon_outage import is_source_outage, next_poll_delay_seconds
|
||||||
|
|
||||||
logger = get_logger("amazon_worker")
|
logger = get_logger("amazon_worker")
|
||||||
|
|
||||||
|
|
@ -39,6 +40,11 @@ class AmazonWorker:
|
||||||
self.retry_days = 30
|
self.retry_days = 30
|
||||||
self.name_similarity_threshold = 0.80
|
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")
|
logger.info("Amazon background worker initialized")
|
||||||
|
|
||||||
def _ensure_amazon_schema(self, cursor) -> None:
|
def _ensure_amazon_schema(self, cursor) -> None:
|
||||||
|
|
@ -151,7 +157,9 @@ class AmazonWorker:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
self._process_item(item)
|
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:
|
except Exception as e:
|
||||||
logger.error(f"Error in worker loop: {e}")
|
logger.error(f"Error in worker loop: {e}")
|
||||||
|
|
@ -275,7 +283,32 @@ class AmazonWorker:
|
||||||
elif item_type == 'track':
|
elif item_type == 'track':
|
||||||
self._process_track(item_id, item_name, item.get('artist', ''), item)
|
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:
|
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}")
|
logger.error(f"Error processing {item['type']} #{item['id']}: {e}")
|
||||||
self.stats['errors'] += 1
|
self.stats['errors'] += 1
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,90 @@ class SyncDeps:
|
||||||
sync_lock: Any # threading.Lock
|
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'):
|
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."""
|
"""The actual sync function that runs in the background thread."""
|
||||||
sync_states = deps.sync_states
|
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():
|
if media_client is None or not media_client.is_connected():
|
||||||
logger.info("Media client not connected - patching sync service for database-only matching")
|
logger.info("Media client not connected - patching sync service for database-only matching")
|
||||||
|
|
||||||
# Store original method
|
# Patch the matcher to the module-level database-only implementation
|
||||||
original_find_track = sync_service._find_track_in_media_server
|
# (importable + unit-tested; accepts candidate_pool for parity).
|
||||||
|
sync_service._find_track_in_media_server = _database_only_find_track
|
||||||
# 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
|
|
||||||
logger.info("Patched sync service to use database-only matching")
|
logger.info("Patched sync service to use database-only matching")
|
||||||
|
|
||||||
sync_start_time = time.time()
|
sync_start_time = time.time()
|
||||||
|
|
|
||||||
|
|
@ -21,8 +21,10 @@ folder scan.
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import time
|
import time
|
||||||
|
import unicodedata
|
||||||
import uuid
|
import uuid
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Callable, Iterable, Optional
|
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")
|
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)
|
# Album-pick size floor / ceiling. Single-track torrents (~10 MB)
|
||||||
# are rejected when bigger candidates exist; anything past 3 GB is
|
# 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)
|
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.0–1.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.
|
"""Pick the single best torrent / NZB for an album-bundle download.
|
||||||
|
|
||||||
Heuristic, in priority order:
|
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
|
1. Reasonable album-ish size (40 MB – 3 GB) — drops single-track
|
||||||
releases that snuck in and quarantines suspicious giants.
|
releases that snuck in and quarantines suspicious giants.
|
||||||
2. Higher seeders > lower (dead torrents = dead downloads).
|
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:
|
if not candidates:
|
||||||
return None
|
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
|
sized = [c for c in candidates
|
||||||
if ALBUM_PICK_MIN_BYTES <= (c.size or 0) <= ALBUM_PICK_MAX_BYTES]
|
if ALBUM_PICK_MIN_BYTES <= (c.size or 0) <= ALBUM_PICK_MAX_BYTES]
|
||||||
pool = sized or list(candidates)
|
pool = sized or list(candidates)
|
||||||
|
|
|
||||||
|
|
@ -494,12 +494,25 @@ class TorrentDownloadPlugin(DownloadSourcePlugin):
|
||||||
candidates = [r for r in search_results
|
candidates = [r for r in search_results
|
||||||
if r.protocol == 'torrent' and (r.magnet_uri or r.download_url)]
|
if r.protocol == 'torrent' and (r.magnet_uri or r.download_url)]
|
||||||
if not candidates:
|
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['error'] = f'No torrent results found for "{query}"'
|
||||||
|
result['fallback'] = True
|
||||||
return result
|
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:
|
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
|
return result
|
||||||
|
|
||||||
download_url = picked.magnet_uri or picked.download_url
|
download_url = picked.magnet_uri or picked.download_url
|
||||||
|
|
|
||||||
|
|
@ -465,12 +465,22 @@ class UsenetDownloadPlugin(DownloadSourcePlugin):
|
||||||
candidates = [r for r in search_results
|
candidates = [r for r in search_results
|
||||||
if r.protocol == 'usenet' and r.download_url]
|
if r.protocol == 'usenet' and r.download_url]
|
||||||
if not candidates:
|
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['error'] = f'No usenet results found for "{query}"'
|
||||||
|
result['fallback'] = True
|
||||||
return result
|
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:
|
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
|
return result
|
||||||
|
|
||||||
logger.info("[Usenet album] Picked '%s' (size=%.1fMB grabs=%s indexer=%s)",
|
logger.info("[Usenet album] Picked '%s' (size=%.1fMB grabs=%s indexer=%s)",
|
||||||
|
|
|
||||||
|
|
@ -173,7 +173,22 @@ def try_dispatch(
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.exception("[Album Bundle] %s plugin raised: %s", mode, 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'):
|
if not outcome.get('success'):
|
||||||
err = outcome.get('error', 'Album bundle download failed')
|
err = outcome.get('error', 'Album bundle download failed')
|
||||||
|
|
|
||||||
|
|
@ -34,11 +34,22 @@ logger = logging.getLogger(__name__)
|
||||||
def _private_album_bundle_staging_miss_reason(batch_id: Optional[str], deps: Any) -> Optional[str]:
|
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.
|
"""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
|
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
|
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
|
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:
|
if not batch_id:
|
||||||
return None
|
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')
|
batch.get('album_bundle_private_staging')
|
||||||
and batch.get('album_bundle_state') == 'staged'
|
and batch.get('album_bundle_state') == 'staged'
|
||||||
and not batch.get('album_bundle_partial')
|
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))
|
and (mode == source or (mode == 'hybrid' and hybrid_first == source))
|
||||||
):
|
):
|
||||||
return f'Track was not found in the staged {source} album release'
|
return f'Track was not found in the staged {source} album release'
|
||||||
|
|
|
||||||
106
core/downloads/track_detail.py
Normal file
106
core/downloads/track_detail.py
Normal 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
|
||||||
|
|
@ -172,11 +172,14 @@ class ImageCache:
|
||||||
raise ImageCacheError(f"Upstream response is not an image: {mime_type}")
|
raise ImageCacheError(f"Upstream response is not an image: {mime_type}")
|
||||||
|
|
||||||
declared_size = response.headers.get("Content-Length")
|
declared_size = response.headers.get("Content-Length")
|
||||||
|
expected_bytes = None
|
||||||
try:
|
try:
|
||||||
if declared_size and int(declared_size) > self.max_download_bytes:
|
if declared_size:
|
||||||
raise ImageCacheError("Image exceeds configured size limit")
|
expected_bytes = int(declared_size)
|
||||||
|
if expected_bytes > self.max_download_bytes:
|
||||||
|
raise ImageCacheError("Image exceeds configured size limit")
|
||||||
except ValueError:
|
except ValueError:
|
||||||
pass
|
expected_bytes = None
|
||||||
|
|
||||||
ext = mimetypes.guess_extension(mime_type) or ".img"
|
ext = mimetypes.guess_extension(mime_type) or ".img"
|
||||||
if ext == ".jpe":
|
if ext == ".jpe":
|
||||||
|
|
@ -205,6 +208,22 @@ class ImageCache:
|
||||||
if total <= 0:
|
if total <= 0:
|
||||||
raise ImageCacheError("Image response was empty")
|
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)
|
os.replace(tmp_path, path)
|
||||||
expires_at = now + self.ttl_seconds
|
expires_at = now + self.ttl_seconds
|
||||||
with self._db_lock:
|
with self._db_lock:
|
||||||
|
|
|
||||||
|
|
@ -183,11 +183,29 @@ def check_audio_integrity(
|
||||||
checks["actual_length_s"] = actual_length_s
|
checks["actual_length_s"] = actual_length_s
|
||||||
|
|
||||||
if actual_length_s <= 0:
|
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(
|
return IntegrityResult(
|
||||||
ok=False,
|
ok=True,
|
||||||
reason="Mutagen reports zero-length audio — file has no playable "
|
checks={**checks, "mutagen_parse": "zero_length_unknown",
|
||||||
"audio data",
|
"length_check": "skipped_unknown_length"},
|
||||||
checks={**checks, "mutagen_parse": "zero_length"},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# --- Check 3: duration agreement (optional) ---
|
# --- Check 3: duration agreement (optional) ---
|
||||||
|
|
|
||||||
|
|
@ -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:
|
def _mark_task_quarantined(context: dict, quarantine_path: str | None) -> None:
|
||||||
if not quarantine_path:
|
if not quarantine_path:
|
||||||
return
|
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')
|
task_id = context.get('task_id')
|
||||||
if not task_id:
|
if not task_id:
|
||||||
return
|
return
|
||||||
with tasks_lock:
|
with tasks_lock:
|
||||||
if task_id in download_tasks:
|
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(
|
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:
|
if task_id in download_tasks:
|
||||||
download_tasks[task_id]['status'] = 'failed'
|
download_tasks[task_id]['status'] = 'failed'
|
||||||
download_tasks[task_id]['error_message'] = f"AcoustID verification failed: {failure_msg}"
|
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:
|
with matched_context_lock:
|
||||||
if context_key in matched_downloads_context:
|
if context_key in matched_downloads_context:
|
||||||
del matched_downloads_context[context_key]
|
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'] = (
|
download_tasks[task_id]['error_message'] = (
|
||||||
f"File integrity check failed: {failure_msg}"
|
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:
|
with matched_context_lock:
|
||||||
if context_key in matched_downloads_context:
|
if context_key in matched_downloads_context:
|
||||||
del matched_downloads_context[context_key]
|
del matched_downloads_context[context_key]
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ from __future__ import annotations
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict, List, Optional, Tuple
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
|
@ -289,6 +290,56 @@ def _restore_filename(quarantined_filename: str, sidecar_original: Optional[str]
|
||||||
return base
|
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(
|
def approve_quarantine_entry(
|
||||||
quarantine_dir: str,
|
quarantine_dir: str,
|
||||||
entry_id: str,
|
entry_id: str,
|
||||||
|
|
@ -334,10 +385,9 @@ def approve_quarantine_entry(
|
||||||
restored_path = os.path.join(restore_dir, original_name)
|
restored_path = os.path.join(restore_dir, original_name)
|
||||||
restored_path = _ensure_unique_path(restored_path)
|
restored_path = _ensure_unique_path(restored_path)
|
||||||
|
|
||||||
try:
|
if not _move_with_retry(file_path, restored_path):
|
||||||
shutil.move(file_path, restored_path)
|
logger.error("approve: failed to restore %s -> %s (file may still be in use)",
|
||||||
except OSError as exc:
|
file_path, restored_path)
|
||||||
logger.error("approve: failed to restore %s -> %s: %s", file_path, restored_path, exc)
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
@ -377,10 +427,8 @@ def recover_to_staging(
|
||||||
os.makedirs(staging_dir, exist_ok=True)
|
os.makedirs(staging_dir, exist_ok=True)
|
||||||
target = _ensure_unique_path(os.path.join(staging_dir, restored_name))
|
target = _ensure_unique_path(os.path.join(staging_dir, restored_name))
|
||||||
|
|
||||||
try:
|
if not _move_with_retry(file_path, target):
|
||||||
shutil.move(file_path, target)
|
logger.error("recover: failed to move %s -> %s (file may still be in use)", file_path, target)
|
||||||
except OSError as exc:
|
|
||||||
logger.error("recover: failed to move %s -> %s: %s", file_path, target, exc)
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
if sidecar_path and os.path.isfile(sidecar_path):
|
if sidecar_path and os.path.isfile(sidecar_path):
|
||||||
|
|
|
||||||
57
core/library/duplicate_keep.py
Normal file
57
core/library/duplicate_keep.py
Normal 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)
|
||||||
|
|
@ -14,12 +14,27 @@ from utils.logging_config import get_logger
|
||||||
logger = get_logger("library.manual_library_match")
|
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(
|
def save_match(
|
||||||
db,
|
db,
|
||||||
profile_id: int,
|
profile_id: int,
|
||||||
source: str,
|
source: str,
|
||||||
source_track_id: str,
|
source_track_id: str,
|
||||||
library_track_id: int,
|
library_track_id: str,
|
||||||
**meta,
|
**meta,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Save (insert or replace) a manual match."""
|
"""Save (insert or replace) a manual match."""
|
||||||
|
|
|
||||||
291
core/metadata/art_lookup.py
Normal file
291
core/metadata/art_lookup.py
Normal 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
|
||||||
106
core/metadata/art_sources.py
Normal file
106
core/metadata/art_sources.py
Normal 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
|
||||||
|
|
@ -8,7 +8,7 @@ import urllib.request
|
||||||
from ipaddress import ip_address
|
from ipaddress import ip_address
|
||||||
from urllib.parse import quote, urlparse
|
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 (
|
from core.metadata.common import (
|
||||||
get_config_manager,
|
get_config_manager,
|
||||||
get_image_dimensions,
|
get_image_dimensions,
|
||||||
|
|
@ -330,6 +330,35 @@ def _fetch_art_bytes(art_url: str):
|
||||||
return None, None
|
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):
|
def embed_album_art_metadata(audio_file, metadata: dict):
|
||||||
cfg = get_config_manager()
|
cfg = get_config_manager()
|
||||||
symbols = get_mutagen_symbols()
|
symbols = get_mutagen_symbols()
|
||||||
|
|
@ -340,8 +369,31 @@ def embed_album_art_metadata(audio_file, metadata: dict):
|
||||||
image_data = None
|
image_data = None
|
||||||
mime_type = 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")
|
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:
|
try:
|
||||||
# 1200px CDN thumbnail, not the flaky bare /front original.
|
# 1200px CDN thumbnail, not the flaky bare /front original.
|
||||||
caa_url = f"https://coverartarchive.org/release/{release_mbid}/front-1200"
|
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")
|
cover_path = os.path.join(target_dir, "cover.jpg")
|
||||||
album_info = album_info or {}
|
album_info = album_info or {}
|
||||||
release_mbid = album_info.get("musicbrainz_release_id")
|
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 os.path.exists(cover_path):
|
||||||
if release_mbid and prefer_caa:
|
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
|
is_upgrade = False
|
||||||
|
|
||||||
image_data = None
|
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:
|
try:
|
||||||
# 1200px CDN thumbnail, not the flaky bare /front original.
|
# 1200px CDN thumbnail, not the flaky bare /front original.
|
||||||
caa_url = f"https://coverartarchive.org/release/{release_mbid}/front-1200"
|
caa_url = f"https://coverartarchive.org/release/{release_mbid}/front-1200"
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,26 @@ from typing import Optional, Dict, List, Tuple
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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
|
# Singleton
|
||||||
_cache_instance = None
|
_cache_instance = None
|
||||||
_cache_lock = threading.Lock()
|
_cache_lock = threading.Lock()
|
||||||
|
|
@ -619,6 +639,43 @@ class MetadataCache:
|
||||||
|
|
||||||
return self._run_maintenance_write("Cache eviction", _operation)
|
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:
|
def clean_junk_entities(self) -> int:
|
||||||
"""Delete cached entities with empty/placeholder names."""
|
"""Delete cached entities with empty/placeholder names."""
|
||||||
def _operation(conn):
|
def _operation(conn):
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,19 @@ from utils.logging_config import get_logger
|
||||||
|
|
||||||
logger = get_logger("musicbrainz_client")
|
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
|
# Global rate limiting variables
|
||||||
_last_api_call_time = 0
|
_last_api_call_time = 0
|
||||||
_api_call_lock = threading.Lock()
|
_api_call_lock = threading.Lock()
|
||||||
|
|
@ -158,14 +171,14 @@ class MusicBrainzClient:
|
||||||
safe_artist = artist_name.replace('\\', '\\\\').replace('"', '\\"')
|
safe_artist = artist_name.replace('\\', '\\\\').replace('"', '\\"')
|
||||||
query += f' AND artist:"{safe_artist}"'
|
query += f' AND artist:"{safe_artist}"'
|
||||||
else:
|
else:
|
||||||
# Bare query — MB tokenizes against title + artist credit +
|
# Loose title terms (no phrase quotes → diacritic folding and
|
||||||
# alias + sortname indexes together with diacritic folding.
|
# alias/sortname recall, e.g. "Bjork" → "Björk"), but
|
||||||
# Recovers cases like "Bjork" → "Björk" that strict phrase
|
# FIELD-SCOPE the artist so it constrains rather than floating
|
||||||
# queries miss.
|
# as a free fuzzy term that lets unrelated releases whose titles
|
||||||
parts = [album_name]
|
# echo the artist name rank first (same root cause as #754).
|
||||||
if artist_name:
|
query = album_name
|
||||||
parts.append(artist_name)
|
if artist_name and artist_name.strip():
|
||||||
query = ' '.join(p for p in parts if p)
|
query += f' AND artist:({_escape_lucene(artist_name.strip())})'
|
||||||
|
|
||||||
params = {
|
params = {
|
||||||
'query': query,
|
'query': query,
|
||||||
|
|
@ -223,11 +236,17 @@ class MusicBrainzClient:
|
||||||
safe_artist = artist_name.replace('\\', '\\\\').replace('"', '\\"')
|
safe_artist = artist_name.replace('\\', '\\\\').replace('"', '\\"')
|
||||||
query += f' AND artist:"{safe_artist}"'
|
query += f' AND artist:"{safe_artist}"'
|
||||||
else:
|
else:
|
||||||
# Bare query — see search_release for rationale.
|
# Loose track terms (no phrase quotes → tolerant of bracketed
|
||||||
parts = [track_name]
|
# suffixes and diacritics, hitting MB's alias/sortname indexes),
|
||||||
if artist_name:
|
# but FIELD-SCOPE the artist so it actually constrains results.
|
||||||
parts.append(artist_name)
|
# A bare "track artist" blob let the artist float as a free
|
||||||
query = ' '.join(p for p in parts if p)
|
# 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 = {
|
params = {
|
||||||
'query': query,
|
'query': query,
|
||||||
|
|
|
||||||
1
core/playback/__init__.py
Normal file
1
core/playback/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
"""Playback helpers (web-player play logging)."""
|
||||||
65
core/playback/play_log.py
Normal file
65
core/playback/play_log.py
Normal 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
28
core/radio/__init__.py
Normal 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
222
core/radio/selection.py
Normal 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)
|
||||||
|
|
@ -89,4 +89,21 @@ class CacheEvictorJob(RepairJob):
|
||||||
logger.error("MB null cleanup failed: %s", e, exc_info=True)
|
logger.error("MB null cleanup failed: %s", e, exc_info=True)
|
||||||
result.errors += 1
|
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
|
return result
|
||||||
|
|
|
||||||
|
|
@ -1364,8 +1364,11 @@ class RepairWorker:
|
||||||
return {'success': False, 'error': f'Selected track ID {keep_id} not found in duplicates'}
|
return {'success': False, 'error': f'Selected track ID {keep_id} not found in duplicates'}
|
||||||
best_id = keep_id
|
best_id = keep_id
|
||||||
else:
|
else:
|
||||||
# Auto-pick: highest bitrate, then longest duration, then highest track number (correct > 01)
|
# Auto-pick the keeper: lossless format first (so a FLAC beats an
|
||||||
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))
|
# 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')
|
best_id = best.get('track_id') or best.get('id')
|
||||||
|
|
||||||
if not best_id:
|
if not best_id:
|
||||||
|
|
|
||||||
140
core/streaming/state.py
Normal file
140
core/streaming/state.py
Normal 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()))
|
||||||
|
|
@ -3654,6 +3654,36 @@ class MusicDatabase:
|
||||||
if conn:
|
if conn:
|
||||||
conn.close()
|
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):
|
def update_track_play_counts(self, counts):
|
||||||
"""Update play_count and last_played on the tracks table.
|
"""Update play_count and last_played on the tracks table.
|
||||||
|
|
||||||
|
|
@ -4237,9 +4267,17 @@ class MusicDatabase:
|
||||||
def _add_metadata_cache_tables(self, cursor):
|
def _add_metadata_cache_tables(self, cursor):
|
||||||
"""Create metadata_cache_entities and metadata_cache_searches tables for universal API response caching"""
|
"""Create metadata_cache_entities and metadata_cache_searches tables for universal API response caching"""
|
||||||
try:
|
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")
|
cursor.execute("SELECT value FROM metadata WHERE key = 'metadata_cache_v1' LIMIT 1")
|
||||||
if cursor.fetchone():
|
if cursor.fetchone() and tables_present:
|
||||||
return # Already migrated
|
return # Already migrated and tables present
|
||||||
|
|
||||||
logger.info("Creating metadata cache tables...")
|
logger.info("Creating metadata cache tables...")
|
||||||
|
|
||||||
|
|
@ -4410,7 +4448,7 @@ class MusicDatabase:
|
||||||
logger.error(f"Error creating manual_library_track_matches table: {e}")
|
logger.error(f"Error creating manual_library_track_matches table: {e}")
|
||||||
|
|
||||||
def save_manual_library_match(self, profile_id: int, source: str, source_track_id: str,
|
def save_manual_library_match(self, profile_id: int, source: str, source_track_id: str,
|
||||||
library_track_id: int, **meta) -> bool:
|
library_track_id: str, **meta) -> bool:
|
||||||
"""Insert or replace a manual match. meta keys: source_title, source_artist,
|
"""Insert or replace a manual match. meta keys: source_title, source_artist,
|
||||||
source_album, source_context_json, server_source."""
|
source_album, source_context_json, server_source."""
|
||||||
try:
|
try:
|
||||||
|
|
@ -12796,24 +12834,53 @@ class MusicDatabase:
|
||||||
seed = dict(seed)
|
seed = dict(seed)
|
||||||
artist_name = seed['artist_name']
|
artist_name = seed['artist_name']
|
||||||
|
|
||||||
# Build the set of IDs to exclude (seed + caller-supplied)
|
# Selection decisions (dedup, caps, tag parsing, condition
|
||||||
excluded = {str(track_id)}
|
# 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:
|
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] = []
|
# Phase 2 smart radio: each tier pulls a generous RANDOM pool,
|
||||||
seen_ids: set[str] = set(excluded)
|
# 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():
|
def _pool(n):
|
||||||
return ','.join('?' * len(seen_ids))
|
return max(n * _POOL_FACTOR, n + 10)
|
||||||
|
|
||||||
def _exclude_values():
|
# Ranking signals (play_count / lastfm_playcount) are added by a
|
||||||
return list(seen_ids)
|
# 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,
|
SELECT t.id, t.title, t.track_number, t.duration,
|
||||||
t.file_path, t.bitrate,
|
t.file_path, t.bitrate,
|
||||||
t.album_id, t.artist_id,
|
t.album_id, t.artist_id,
|
||||||
|
{_rank_cols}
|
||||||
al.title AS album,
|
al.title AS album,
|
||||||
COALESCE(al.thumb_url, ar.thumb_url) AS image_url,
|
COALESCE(al.thumb_url, ar.thumb_url) AS image_url,
|
||||||
ar.name AS artist
|
ar.name AS artist
|
||||||
|
|
@ -12824,98 +12891,72 @@ class MusicDatabase:
|
||||||
# Only return tracks that have actual files on disk
|
# Only return tracks that have actual files on disk
|
||||||
_file_filter = "t.file_path IS NOT NULL AND t.file_path != ''"
|
_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) ---
|
# --- 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"""
|
cursor.execute(f"""
|
||||||
{_track_select}
|
{_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()
|
ORDER BY RANDOM()
|
||||||
LIMIT ?
|
LIMIT ?
|
||||||
""", [artist_name, seed['album_id']] + _exclude_values() + [same_artist_cap])
|
""", [artist_name, seed['album_id']] + collector.exclude_values() + [_pool(artist_cap)])
|
||||||
_collect(cursor.fetchall(), cap=same_artist_cap)
|
collector.collect(cursor.fetchall(), cap=artist_cap, rank=True)
|
||||||
|
|
||||||
if len(collected) >= limit:
|
if collector.filled:
|
||||||
return {'success': True, 'tracks': collected}
|
return {'success': True, 'tracks': collector.tracks}
|
||||||
|
|
||||||
# --- 2. Same genre (album genres + artist genres, other artists) ---
|
# --- 2. Same genre (album genres + artist genres, other artists) ---
|
||||||
genre_list = _parse_tags(seed.get('album_genres'))
|
all_genres = merge_tags(
|
||||||
artist_genre_list = _parse_tags(seed.get('artist_genres'))
|
parse_tags(seed.get('album_genres')),
|
||||||
all_genres = list(dict.fromkeys(genre_list + artist_genre_list)) # dedupe, preserve order
|
parse_tags(seed.get('artist_genres')),
|
||||||
|
)
|
||||||
if all_genres:
|
genre_conditions, genre_params = build_like_conditions(
|
||||||
genre_conditions = ' OR '.join(
|
all_genres, ('al.genres', 'ar.genres')
|
||||||
['al.genres LIKE ?' for _ in all_genres] +
|
)
|
||||||
['ar.genres LIKE ?' for _ in all_genres]
|
if genre_conditions:
|
||||||
)
|
|
||||||
genre_params = [f'%{g}%' for g in all_genres] * 2
|
|
||||||
cursor.execute(f"""
|
cursor.execute(f"""
|
||||||
{_track_select}
|
{_track_select}
|
||||||
WHERE {_file_filter} AND ({genre_conditions})
|
WHERE {_file_filter} AND ({genre_conditions})
|
||||||
AND ar.name != ?
|
AND ar.name != ?
|
||||||
AND t.id NOT IN ({_exclude_placeholders()})
|
AND t.id NOT IN ({collector.exclude_placeholders()})
|
||||||
ORDER BY RANDOM()
|
ORDER BY RANDOM()
|
||||||
LIMIT ?
|
LIMIT ?
|
||||||
""", genre_params + [artist_name] + _exclude_values() + [limit - len(collected)])
|
""", genre_params + [artist_name] + collector.exclude_values() + [_pool(collector.remaining())])
|
||||||
if _collect(cursor.fetchall()):
|
if collector.collect(cursor.fetchall(), rank=True):
|
||||||
return {'success': True, 'tracks': collected}
|
return {'success': True, 'tracks': collector.tracks}
|
||||||
|
|
||||||
# --- 3. Same mood / style (album + artist level) ---
|
# --- 3. Same mood / style (album + artist level) ---
|
||||||
for field_name in ('mood', 'style'):
|
for field_name in ('mood', 'style'):
|
||||||
album_tags = _parse_tags(seed.get(f'album_{field_name}'))
|
all_tags = merge_tags(
|
||||||
artist_tags = _parse_tags(seed.get(f'artist_{field_name}'))
|
parse_tags(seed.get(f'album_{field_name}')),
|
||||||
all_tags = list(dict.fromkeys(album_tags + artist_tags))
|
parse_tags(seed.get(f'artist_{field_name}')),
|
||||||
|
)
|
||||||
if all_tags:
|
tag_conditions, tag_params = build_like_conditions(
|
||||||
tag_conditions = ' OR '.join(
|
all_tags, (f'al.{field_name}', f'ar.{field_name}')
|
||||||
[f'al.{field_name} LIKE ?' for _ in all_tags] +
|
)
|
||||||
[f'ar.{field_name} LIKE ?' for _ in all_tags]
|
if tag_conditions:
|
||||||
)
|
|
||||||
tag_params = [f'%{t}%' for t in all_tags] * 2
|
|
||||||
cursor.execute(f"""
|
cursor.execute(f"""
|
||||||
{_track_select}
|
{_track_select}
|
||||||
WHERE {_file_filter} AND ({tag_conditions})
|
WHERE {_file_filter} AND ({tag_conditions})
|
||||||
AND ar.name != ?
|
AND ar.name != ?
|
||||||
AND t.id NOT IN ({_exclude_placeholders()})
|
AND t.id NOT IN ({collector.exclude_placeholders()})
|
||||||
ORDER BY RANDOM()
|
ORDER BY RANDOM()
|
||||||
LIMIT ?
|
LIMIT ?
|
||||||
""", tag_params + [artist_name] + _exclude_values() + [limit - len(collected)])
|
""", tag_params + [artist_name] + collector.exclude_values() + [_pool(collector.remaining())])
|
||||||
if _collect(cursor.fetchall()):
|
if collector.collect(cursor.fetchall(), rank=True):
|
||||||
return {'success': True, 'tracks': collected}
|
return {'success': True, 'tracks': collector.tracks}
|
||||||
|
|
||||||
# --- 4. Random library tracks ---
|
# --- 4. Random library tracks (ranked: popular-but-unheard
|
||||||
if len(collected) < limit:
|
# beats pure noise even in the last-resort tier) ---
|
||||||
|
if not collector.filled:
|
||||||
cursor.execute(f"""
|
cursor.execute(f"""
|
||||||
{_track_select}
|
{_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()
|
ORDER BY RANDOM()
|
||||||
LIMIT ?
|
LIMIT ?
|
||||||
""", _exclude_values() + [limit - len(collected)])
|
""", collector.exclude_values() + [_pool(collector.remaining())])
|
||||||
_collect(cursor.fetchall())
|
collector.collect(cursor.fetchall(), rank=True)
|
||||||
|
|
||||||
return {'success': True, 'tracks': collected}
|
return {'success': True, 'tracks': collector.tracks}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error getting radio tracks for track {track_id}: {e}")
|
logger.error(f"Error getting radio tracks for track {track_id}: {e}")
|
||||||
|
|
|
||||||
|
|
@ -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
|
# 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
|
# with `set -e`, a permission-denied mkdir crashed the container into a
|
||||||
# restart loop. Both lines are now best-effort.
|
# 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
|
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 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
|
# Writability audit — surface a loud warning if any bind-mounted dir
|
||||||
# isn't writable by the soulsync user. The restart-loop fix above makes
|
# isn't writable by the soulsync user. The restart-loop fix above makes
|
||||||
# the container start regardless, but a non-writable Staging / downloads
|
# the container start regardless, but a non-writable Staging / downloads
|
||||||
# / Transfer will fail silently inside the app (auto-import quarantine,
|
# / Transfer will fail silently inside the app (auto-import quarantine,
|
||||||
# download writes). Better to log now than to debug missing files later.
|
# 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
|
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 "⚠️ WARNING: $dir is not writable by soulsync (uid $(id -u soulsync))."
|
||||||
echo " Host bind-mount perms likely mismatch the PUID/PGID env vars."
|
echo " Host bind-mount perms likely mismatch the PUID/PGID env vars."
|
||||||
|
|
|
||||||
43
revamp_plan.md
Normal file
43
revamp_plan.md
Normal 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 0–2 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.
|
||||||
|
|
@ -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
|
endpoints and event handlers without importing the full web_server.py
|
||||||
(which would try to initialize Spotify, Soulseek, Plex, etc.)."""
|
(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 copy
|
||||||
import pytest
|
import pytest
|
||||||
import threading
|
import threading
|
||||||
|
|
|
||||||
51
tests/discovery/test_sync_database_only_matcher.py
Normal file
51
tests/discovery/test_sync_database_only_matcher.py
Normal 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"
|
||||||
|
|
@ -223,7 +223,10 @@ def test_private_torrent_album_staging_miss_skips_per_track_search():
|
||||||
assert ('done', ('b1', 't1', False), {}) in rec.calls
|
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={
|
_seed_task(track_info={
|
||||||
'id': 'sp-1', 'name': 'Song', 'artists': ['Artist'],
|
'id': 'sp-1', 'name': 'Song', 'artists': ['Artist'],
|
||||||
'album': 'Album', 'duration_ms': 180000,
|
'album': 'Album', 'duration_ms': 180000,
|
||||||
|
|
@ -231,9 +234,9 @@ def test_private_soulseek_album_staging_miss_skips_per_track_search():
|
||||||
download_batches['b1'] = {
|
download_batches['b1'] = {
|
||||||
'album_bundle_private_staging': True,
|
'album_bundle_private_staging': True,
|
||||||
'album_bundle_state': 'staged',
|
'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()
|
rec = _Recorder()
|
||||||
deps, _ = _build_deps(
|
deps, _ = _build_deps(
|
||||||
soulseek=client,
|
soulseek=client,
|
||||||
|
|
@ -246,11 +249,43 @@ def test_private_soulseek_album_staging_miss_skips_per_track_search():
|
||||||
|
|
||||||
assert client.search_calls == []
|
assert client.search_calls == []
|
||||||
assert download_tasks['t1']['status'] == 'not_found'
|
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
|
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={
|
_seed_task(track_info={
|
||||||
'id': 'sp-1', 'name': 'Song', 'artists': ['Artist'],
|
'id': 'sp-1', 'name': 'Song', 'artists': ['Artist'],
|
||||||
'album': 'Album', 'duration_ms': 180000,
|
'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',
|
'album_bundle_source': 'soulseek',
|
||||||
}
|
}
|
||||||
client = _FakeClient(
|
client = _FakeClient(
|
||||||
results=['should-not-search'],
|
results=[],
|
||||||
mode='hybrid',
|
mode='hybrid',
|
||||||
subclients={'hybrid_order': ['soulseek', 'hifi']},
|
subclients={'hybrid_order': ['soulseek', 'hifi']},
|
||||||
)
|
)
|
||||||
rec = _Recorder()
|
|
||||||
deps, _ = _build_deps(
|
deps, _ = _build_deps(
|
||||||
soulseek=client,
|
soulseek=client,
|
||||||
matching=_FakeMatchEngine(queries=['Artist Song']),
|
matching=_FakeMatchEngine(queries=['Artist Song']),
|
||||||
try_staging_match=lambda *a, **kw: False,
|
try_staging_match=lambda *a, **kw: False,
|
||||||
on_download_completed=rec('done'),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
tw.download_track_worker('t1', 'b1', deps)
|
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 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():
|
def test_partial_private_hybrid_first_soulseek_album_staging_miss_allows_per_track_search():
|
||||||
|
|
|
||||||
89
tests/downloads/test_track_detail.py
Normal file
89
tests/downloads/test_track_detail.py
Normal 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'
|
||||||
|
|
@ -124,6 +124,52 @@ def test_accepts_valid_wav_with_no_expected_duration(tmp_path: Path) -> None:
|
||||||
assert result.checks["length_check"] == "skipped"
|
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
|
# Duration agreement check
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -221,3 +221,80 @@ def test_post_process_matched_download_forwards_separate_metadata_runtime(tmp_pa
|
||||||
)
|
)
|
||||||
|
|
||||||
assert seen["runtime"] is metadata_runtime
|
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)
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ from core.imports.quarantine import (
|
||||||
approve_quarantine_entry,
|
approve_quarantine_entry,
|
||||||
delete_quarantine_entry,
|
delete_quarantine_entry,
|
||||||
entry_id_from_quarantined_filename,
|
entry_id_from_quarantined_filename,
|
||||||
|
get_quarantine_entry_stream_info,
|
||||||
get_quarantined_source_keys,
|
get_quarantined_source_keys,
|
||||||
list_quarantine_entries,
|
list_quarantine_entries,
|
||||||
recover_to_staging,
|
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
|
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):
|
def test_list_skips_orphan_sidecars_without_file(tmp_path):
|
||||||
sidecar = tmp_path / "20260514_120000_only.json"
|
sidecar = tmp_path / "20260514_120000_only.json"
|
||||||
sidecar.write_text(json.dumps({"original_filename": "only.flac", "quarantine_reason": "x"}))
|
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))
|
keys = get_quarantined_source_keys(str(tmp_path))
|
||||||
|
|
||||||
assert keys == {("peer", "dupe.flac")}
|
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
|
||||||
|
|
|
||||||
|
|
@ -140,7 +140,7 @@ class TestIssue442Regression:
|
||||||
verifier, fake_service = stubbed_verifier
|
verifier, fake_service = stubbed_verifier
|
||||||
|
|
||||||
# AcoustID returns the recording with kanji artist
|
# 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,
|
'best_score': 0.95,
|
||||||
'recordings': [
|
'recordings': [
|
||||||
{'title': 'YAMANAIAME', 'artist': '澤野弘之', 'mbid': 'rec-x'},
|
{'title': 'YAMANAIAME', 'artist': '澤野弘之', 'mbid': 'rec-x'},
|
||||||
|
|
@ -165,7 +165,7 @@ class TestIssue442Regression:
|
||||||
"""Reporter's case 2 — Sergey Lazarev / Сергей Лазарев."""
|
"""Reporter's case 2 — Sergey Lazarev / Сергей Лазарев."""
|
||||||
verifier, fake_service = stubbed_verifier
|
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,
|
'best_score': 0.95,
|
||||||
'recordings': [
|
'recordings': [
|
||||||
{'title': 'On the Other Side', 'artist': 'Sergey Lazarev', 'mbid': 'rec-y'},
|
{'title': 'On the Other Side', 'artist': 'Sergey Lazarev', 'mbid': 'rec-y'},
|
||||||
|
|
@ -196,7 +196,7 @@ class TestBackwardCompat:
|
||||||
verifier, fake_service = stubbed_verifier
|
verifier, fake_service = stubbed_verifier
|
||||||
|
|
||||||
# Wrong artist entirely — Latin script both sides, sim ~0
|
# 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,
|
'best_score': 0.95,
|
||||||
'recordings': [
|
'recordings': [
|
||||||
{'title': 'Some Track', 'artist': 'Khalil Turk & Friends'},
|
{'title': 'Some Track', 'artist': 'Khalil Turk & Friends'},
|
||||||
|
|
@ -214,7 +214,7 @@ class TestBackwardCompat:
|
||||||
"""Exact title + artist match → PASS regardless of aliases."""
|
"""Exact title + artist match → PASS regardless of aliases."""
|
||||||
verifier, fake_service = stubbed_verifier
|
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,
|
'best_score': 0.95,
|
||||||
'recordings': [
|
'recordings': [
|
||||||
{'title': 'Dirty White Boy', 'artist': 'Foreigner'},
|
{'title': 'Dirty White Boy', 'artist': 'Foreigner'},
|
||||||
|
|
@ -233,7 +233,7 @@ class TestBackwardCompat:
|
||||||
verifier, fake_service = stubbed_verifier
|
verifier, fake_service = stubbed_verifier
|
||||||
fake_service.lookup_artist_aliases.side_effect = Exception("MB down")
|
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,
|
'best_score': 0.95,
|
||||||
'recordings': [
|
'recordings': [
|
||||||
{'title': 'Dirty White Boy', 'artist': 'Foreigner'},
|
{'title': 'Dirty White Boy', 'artist': 'Foreigner'},
|
||||||
|
|
@ -261,7 +261,7 @@ class TestAliasLookupCalledOncePerVerify:
|
||||||
fire 60+ MB lookups (cached or not, that's wasteful)."""
|
fire 60+ MB lookups (cached or not, that's wasteful)."""
|
||||||
verifier, fake_service = stubbed_verifier
|
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,
|
'best_score': 0.95,
|
||||||
'recordings': [
|
'recordings': [
|
||||||
{'title': 'X', 'artist': '澤野弘之'},
|
{'title': 'X', 'artist': '澤野弘之'},
|
||||||
|
|
@ -295,7 +295,7 @@ class TestLazyAliasResolution:
|
||||||
this PR."""
|
this PR."""
|
||||||
verifier, fake_service = stubbed_verifier
|
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,
|
'best_score': 0.95,
|
||||||
'recordings': [
|
'recordings': [
|
||||||
{'title': 'Dirty White Boy', 'artist': 'Foreigner'},
|
{'title': 'Dirty White Boy', 'artist': 'Foreigner'},
|
||||||
|
|
@ -317,7 +317,7 @@ class TestLazyAliasResolution:
|
||||||
as expected."""
|
as expected."""
|
||||||
verifier, fake_service = stubbed_verifier
|
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,
|
'best_score': 0.95,
|
||||||
'recordings': [
|
'recordings': [
|
||||||
{'title': 'YAMANAIAME', 'artist': '澤野弘之'},
|
{'title': 'YAMANAIAME', 'artist': '澤野弘之'},
|
||||||
|
|
@ -343,7 +343,7 @@ class TestLazyAliasResolution:
|
||||||
# Force a code path that hits multiple sites: title matches
|
# Force a code path that hits multiple sites: title matches
|
||||||
# several recordings but the best-match's artist sim is below
|
# several recordings but the best-match's artist sim is below
|
||||||
# threshold (forces secondary scan path).
|
# 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,
|
'best_score': 0.95,
|
||||||
'recordings': [
|
'recordings': [
|
||||||
{'title': 'X', 'artist': 'Different Latin Artist'}, # 0 alias hit
|
{'title': 'X', 'artist': 'Different Latin Artist'}, # 0 alias hit
|
||||||
|
|
|
||||||
260
tests/metadata/test_art_lookup.py
Normal file
260
tests/metadata/test_art_lookup.py
Normal 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"
|
||||||
54
tests/metadata/test_art_min_size.py
Normal file
54
tests/metadata/test_art_min_size.py
Normal 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
|
||||||
114
tests/metadata/test_art_sources.py
Normal file
114
tests/metadata/test_art_sources.py
Normal 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")
|
||||||
181
tests/metadata/test_cache_capacity_eviction.py
Normal file
181
tests/metadata/test_cache_capacity_eviction.py
Normal 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"]
|
||||||
122
tests/metadata/test_musicbrainz_client_query.py
Normal file
122
tests/metadata/test_musicbrainz_client_query.py
Normal 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"'
|
||||||
0
tests/playback/__init__.py
Normal file
0
tests/playback/__init__.py
Normal file
75
tests/playback/test_play_log.py
Normal file
75
tests/playback/test_play_log.py
Normal 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
0
tests/radio/__init__.py
Normal file
293
tests/radio/test_get_radio_tracks_db.py
Normal file
293
tests/radio/test_get_radio_tracks_db.py
Normal 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"]]
|
||||||
220
tests/radio/test_selection.py
Normal file
220
tests/radio/test_selection.py
Normal 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"
|
||||||
124
tests/static/test_stats_automations_esc.mjs
Normal file
124
tests/static/test_stats_automations_esc.mjs
Normal 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's')"
|
||||||
|
// The browser HTML-decodes ' 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. & is
|
||||||
|
// resolved last so already-decoded entities aren't double-processed.
|
||||||
|
function htmlAttrDecode(s) {
|
||||||
|
return s
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/&/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));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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
|
# Should NOT have gone to 'ready' because bytes were incomplete
|
||||||
assert deps._state['status'] != 'ready'
|
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}
|
||||||
|
|
|
||||||
126
tests/streaming/test_stream_state_callsite_compat.py
Normal file
126
tests/streaming/test_stream_state_callsite_compat.py
Normal 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"
|
||||||
155
tests/streaming/test_stream_state_store.py
Normal file
155
tests/streaming/test_stream_state_store.py
Normal 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
|
||||||
180
tests/test_acoustid_error_reporting.py
Normal file
180
tests/test_acoustid_error_reporting.py
Normal 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()
|
||||||
|
|
@ -52,7 +52,7 @@ def verifier(monkeypatch):
|
||||||
def is_available(self):
|
def is_available(self):
|
||||||
return True, 'available'
|
return True, 'available'
|
||||||
|
|
||||||
def fingerprint_and_lookup(self, path):
|
def lookup_with_status(self, path):
|
||||||
# Each test injects its own desired return value via
|
# Each test injects its own desired return value via
|
||||||
# monkeypatch on this method; default is empty.
|
# monkeypatch on this method; default is empty.
|
||||||
return None
|
return None
|
||||||
|
|
@ -62,8 +62,8 @@ def verifier(monkeypatch):
|
||||||
|
|
||||||
|
|
||||||
def _stub_lookup(verifier, *, recordings, best_score):
|
def _stub_lookup(verifier, *, recordings, best_score):
|
||||||
"""Make `fingerprint_and_lookup` return a fabricated AcoustID result."""
|
"""Make `lookup_with_status` return a fabricated AcoustID result."""
|
||||||
verifier.acoustid_client.fingerprint_and_lookup = lambda path: {
|
verifier.acoustid_client.lookup_with_status = lambda path: {
|
||||||
'recordings': recordings,
|
'recordings': recordings,
|
||||||
'best_score': best_score,
|
'best_score': best_score,
|
||||||
'recording_mbids': [r.get('id') for r in recordings if r.get('id')],
|
'recording_mbids': [r.get('id') for r in recordings if r.get('id')],
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,7 @@ def verifier():
|
||||||
def is_available(self):
|
def is_available(self):
|
||||||
return True, "available"
|
return True, "available"
|
||||||
|
|
||||||
def fingerprint_and_lookup(self, path):
|
def lookup_with_status(self, path):
|
||||||
return None # tests inject via _stub_lookup below
|
return None # tests inject via _stub_lookup below
|
||||||
|
|
||||||
v.acoustid_client = _StubClient()
|
v.acoustid_client = _StubClient()
|
||||||
|
|
@ -46,7 +46,7 @@ def verifier():
|
||||||
|
|
||||||
|
|
||||||
def _stub_lookup(verifier, *, recordings, best_score=0.95):
|
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,
|
"recordings": recordings,
|
||||||
"best_score": best_score,
|
"best_score": best_score,
|
||||||
"recording_mbids": [r.get("id") for r in recordings if r.get("id")],
|
"recording_mbids": [r.get("id") for r in recordings if r.get("id")],
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ from core.download_plugins.album_bundle import (
|
||||||
DEFAULT_POLL_TIMEOUT_SECONDS,
|
DEFAULT_POLL_TIMEOUT_SECONDS,
|
||||||
atomic_copy_to_staging,
|
atomic_copy_to_staging,
|
||||||
copy_audio_files_atomically,
|
copy_audio_files_atomically,
|
||||||
|
album_title_relevance,
|
||||||
get_completed_no_path_window_seconds,
|
get_completed_no_path_window_seconds,
|
||||||
get_poll_interval,
|
get_poll_interval,
|
||||||
get_poll_timeout,
|
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
|
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
|
# quality_score
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -275,6 +275,26 @@ def test_dispatch_plugin_exception_treated_as_failure() -> None:
|
||||||
assert 'network down' in state.failed_with
|
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:
|
def test_dispatch_strips_whitespace_from_names() -> None:
|
||||||
"""Trailing whitespace in batch context shouldn't fail the
|
"""Trailing whitespace in batch context shouldn't fail the
|
||||||
eligibility predicate AND should be cleaned before passing to
|
eligibility predicate AND should be cleaned before passing to
|
||||||
|
|
|
||||||
86
tests/test_amazon_outage.py
Normal file
86
tests/test_amazon_outage.py
Normal 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
|
||||||
32
tests/test_db_isolation_guard.py
Normal file
32
tests/test_db_isolation_guard.py
Normal 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
|
||||||
95
tests/test_duplicate_keep.py
Normal file
95
tests/test_duplicate_keep.py
Normal 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
|
||||||
|
|
@ -4,12 +4,16 @@ from core.image_cache import ImageCache
|
||||||
|
|
||||||
|
|
||||||
class FakeResponse:
|
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.body = body
|
||||||
self.status_code = status_code
|
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 = {
|
self.headers = {
|
||||||
"Content-Type": content_type,
|
"Content-Type": content_type,
|
||||||
"Content-Length": str(len(body)),
|
"Content-Length": str(length),
|
||||||
}
|
}
|
||||||
self.closed = False
|
self.closed = False
|
||||||
|
|
||||||
|
|
@ -51,6 +55,73 @@ def test_get_url_fetches_once_then_serves_cached_file(tmp_path):
|
||||||
assert len(calls) == 1
|
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):
|
def test_get_url_rejects_non_image_responses(tmp_path):
|
||||||
cache = ImageCache(
|
cache = ImageCache(
|
||||||
tmp_path,
|
tmp_path,
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,56 @@ def db(tmp_path):
|
||||||
return MusicDatabase(str(tmp_path / "music.db"))
|
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
|
# DB-layer tests
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
54
tests/test_metadata_cache_table_recreate.py
Normal file
54
tests/test_metadata_cache_table_recreate.py
Normal 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
|
||||||
72
tests/test_stats_automations_esc_js.py
Normal file
72
tests/test_stats_automations_esc_js.py
Normal 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,
|
||||||
|
)
|
||||||
|
|
@ -589,9 +589,45 @@ def test_torrent_album_to_staging_ignores_candidates_without_download_url(tmp_pa
|
||||||
|
|
||||||
assert outcome['success'] is False
|
assert outcome['success'] is False
|
||||||
assert 'No torrent results' in outcome['error']
|
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()
|
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:
|
def test_registry_includes_torrent_and_usenet() -> None:
|
||||||
"""The registry decides what shows up in the orchestrator's
|
"""The registry decides what shows up in the orchestrator's
|
||||||
iteration helpers. If we forget to register a new plugin the
|
iteration helpers. If we forget to register a new plugin the
|
||||||
|
|
|
||||||
514
web_server.py
514
web_server.py
|
|
@ -40,7 +40,7 @@ logger = setup_logging(_log_level, _log_path)
|
||||||
|
|
||||||
# App version — single source of truth for backup metadata, system-info, update check, etc.
|
# App version — single source of truth for backup metadata, system-info, update check, etc.
|
||||||
# Semver: MAJOR.MINOR.PATCH. Bump at each dev→main release.
|
# Semver: MAJOR.MINOR.PATCH. Bump at each dev→main release.
|
||||||
_SOULSYNC_BASE_VERSION = "2.6.4"
|
_SOULSYNC_BASE_VERSION = "2.6.5"
|
||||||
|
|
||||||
def _build_version_string():
|
def _build_version_string():
|
||||||
"""Append short commit hash to version when available (e.g. 2.35+abc1234)."""
|
"""Append short commit hash to version when available (e.g. 2.35+abc1234)."""
|
||||||
|
|
@ -744,16 +744,51 @@ logger.info("Core service initialization complete.")
|
||||||
# modules and handlers were still referencing names that never got created.
|
# modules and handlers were still referencing names that never got created.
|
||||||
|
|
||||||
# Global Streaming State Management
|
# Global Streaming State Management
|
||||||
stream_state = {
|
# Stream playback state. Lifted into core.streaming.state.StreamStateStore —
|
||||||
"status": "stopped", # States: stopped, loading, queued, ready, error
|
# a per-session registry that's unit-tested and is the foundation for
|
||||||
"progress": 0,
|
# multi-listener playback (player-revamp Phase 3). Today we use only the
|
||||||
"track_info": None,
|
# DEFAULT session, so behavior is identical to the old single global: the
|
||||||
"file_path": None, # Path to the audio file in the Stream folder
|
# whole server still shares one "currently playing". The store just makes the
|
||||||
"error_message": None,
|
# eventual per-listener split a wiring change instead of a rewrite.
|
||||||
}
|
#
|
||||||
stream_lock = threading.Lock() # Prevent race conditions
|
# ``stream_state`` is the default session — dict-compatible (s["k"], s.get,
|
||||||
stream_background_task = None
|
# s.update) so the ~20 existing call sites work unchanged. ``stream_lock`` is
|
||||||
stream_executor = ThreadPoolExecutor(max_workers=1) # Only one stream at a time
|
# 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
|
# Global OAuth State Management
|
||||||
# Store PKCE values for Tidal OAuth flow
|
# Store PKCE values for Tidal OAuth flow
|
||||||
|
|
@ -1776,20 +1811,36 @@ _EDITION_BARE_RE = _re.compile(
|
||||||
from core.streaming import prepare as _streaming_prepare
|
from core.streaming import prepare as _streaming_prepare
|
||||||
|
|
||||||
|
|
||||||
def _build_prepare_stream_deps():
|
def _build_prepare_stream_deps(sess, sid):
|
||||||
"""Build the PrepareStreamDeps bundle from web_server.py globals on each call."""
|
"""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():
|
def _get_stream_state():
|
||||||
return stream_state
|
return sess
|
||||||
|
|
||||||
def _set_stream_state(value):
|
def _set_stream_state(value):
|
||||||
global stream_state
|
# prepare.py only mutates in place (.update / [k]=) so this is
|
||||||
stream_state = value
|
# 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(
|
return _streaming_prepare.PrepareStreamDeps(
|
||||||
config_manager=config_manager,
|
config_manager=config_manager,
|
||||||
download_orchestrator=download_orchestrator,
|
download_orchestrator=download_orchestrator,
|
||||||
stream_lock=stream_lock,
|
stream_lock=sess.lock,
|
||||||
project_root=os.path.dirname(os.path.abspath(__file__)),
|
project_root=project_root,
|
||||||
docker_resolve_path=docker_resolve_path,
|
docker_resolve_path=docker_resolve_path,
|
||||||
find_streaming_download_in_all_downloads=_find_streaming_download_in_all_downloads,
|
find_streaming_download_in_all_downloads=_find_streaming_download_in_all_downloads,
|
||||||
find_downloaded_file=_find_downloaded_file,
|
find_downloaded_file=_find_downloaded_file,
|
||||||
|
|
@ -1800,8 +1851,8 @@ def _build_prepare_stream_deps():
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _prepare_stream_task(track_data):
|
def _prepare_stream_task(track_data, sess, sid):
|
||||||
return _streaming_prepare.prepare_stream_task(track_data, _build_prepare_stream_deps())
|
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):
|
def _find_streaming_download_in_all_downloads(all_downloads, track_data):
|
||||||
|
|
@ -2945,6 +2996,28 @@ def handle_settings():
|
||||||
return jsonify({"error": str(e)}), 500
|
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'])
|
@app.route('/api/dev-mode', methods=['GET', 'POST'])
|
||||||
def handle_dev_mode():
|
def handle_dev_mode():
|
||||||
global dev_mode_enabled
|
global dev_mode_enabled
|
||||||
|
|
@ -6661,6 +6734,48 @@ def _list_available_download_sources() -> tuple:
|
||||||
return download_mode, sources
|
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'])
|
@app.route('/api/downloads/task/<task_id>/candidates', methods=['GET'])
|
||||||
def get_task_candidates(task_id):
|
def get_task_candidates(task_id):
|
||||||
"""Returns the cached search candidates for a download task so the UI can show what was found."""
|
"""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.
|
# for this one restored pass so multi-reason failures do not loop.
|
||||||
context['_skip_quarantine_check'] = 'all'
|
context['_skip_quarantine_check'] = 'all'
|
||||||
context['_approved_quarantine_trigger'] = trigger
|
context['_approved_quarantine_trigger'] = trigger
|
||||||
# Re-dispatch through the same pipeline. Run async so the HTTP
|
# If the caller (download-modal chooser) passed the originating task, run
|
||||||
# request returns quickly — UI polls /list to see the entry vanish.
|
# 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())}"
|
context_key = f"approve_{entry_id}_{int(time.time())}"
|
||||||
threading.Thread(
|
if _task_id:
|
||||||
target=lambda: _post_process_matched_download(context_key, context, restored_path),
|
_reprocess = lambda: _post_process_matched_download_with_verification(
|
||||||
daemon=True,
|
context_key, context, restored_path, _task_id, _batch_id,
|
||||||
).start()
|
)
|
||||||
logger.info(f"[Quarantine] Approved {entry_id} (original_trigger={trigger}, bypass=all) → re-running pipeline")
|
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})
|
return jsonify({"success": True, "trigger_bypassed": "all", "original_trigger": trigger})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[Quarantine] Error approving {entry_id}: {e}")
|
logger.error(f"[Quarantine] Error approving {entry_id}: {e}")
|
||||||
return jsonify({"success": False, "error": str(e)}), 500
|
return jsonify({"success": False, "error": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@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'])
|
@app.route('/api/quarantine/<entry_id>/recover', methods=['POST'])
|
||||||
def recover_quarantine_item(entry_id):
|
def recover_quarantine_item(entry_id):
|
||||||
"""Fallback for legacy thin sidecars: move file into Staging so the user
|
"""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)}")
|
logger.info(f"Library play request: {os.path.basename(file_path)}")
|
||||||
|
|
||||||
# Set stream state to ready with the library file path directly
|
# Set THIS listener's stream state to ready with the library file path.
|
||||||
with stream_lock:
|
sess = _current_stream_state()
|
||||||
stream_state.update({
|
with sess.lock:
|
||||||
|
sess.update({
|
||||||
"status": "ready",
|
"status": "ready",
|
||||||
"progress": 100,
|
"progress": 100,
|
||||||
"track_info": {
|
"track_info": {
|
||||||
|
|
@ -10598,6 +10752,30 @@ def library_play_track():
|
||||||
logger.error(f"Error playing library track: {e}")
|
logger.error(f"Error playing library track: {e}")
|
||||||
return jsonify({"success": False, "error": str(e)}), 500
|
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')}
|
_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'])
|
@app.route('/api/library/enrich', methods=['POST'])
|
||||||
|
|
@ -11734,9 +11912,8 @@ def library_radio():
|
||||||
|
|
||||||
@app.route('/api/stream/start', methods=['POST'])
|
@app.route('/api/stream/start', methods=['POST'])
|
||||||
def stream_start():
|
def stream_start():
|
||||||
"""Start streaming a track in the background"""
|
"""Start streaming a track in the background (per-listener session)."""
|
||||||
global stream_background_task
|
global stream_background_task
|
||||||
|
|
||||||
data = request.get_json()
|
data = request.get_json()
|
||||||
if not data:
|
if not data:
|
||||||
return jsonify({"success": False, "error": "No track data provided"}), 400
|
return jsonify({"success": False, "error": "No track data provided"}), 400
|
||||||
|
|
@ -11744,13 +11921,16 @@ def stream_start():
|
||||||
logger.info(f"Web UI Stream request for: {data.get('filename')}")
|
logger.info(f"Web UI Stream request for: {data.get('filename')}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Stop any existing streaming task
|
sid = _stream_session_id()
|
||||||
if stream_background_task and not stream_background_task.done():
|
sess = stream_state_store.get(sid)
|
||||||
stream_background_task.cancel()
|
|
||||||
|
|
||||||
# Reset stream state
|
# Stop only THIS listener's existing task — others keep playing.
|
||||||
with stream_lock:
|
prev = stream_tasks.get(sid)
|
||||||
stream_state.update({
|
if prev and not prev.done():
|
||||||
|
prev.cancel()
|
||||||
|
|
||||||
|
with sess.lock:
|
||||||
|
sess.update({
|
||||||
"status": "stopped",
|
"status": "stopped",
|
||||||
"progress": 0,
|
"progress": 0,
|
||||||
"track_info": None,
|
"track_info": None,
|
||||||
|
|
@ -11759,8 +11939,11 @@ def stream_start():
|
||||||
"is_library": False
|
"is_library": False
|
||||||
})
|
})
|
||||||
|
|
||||||
# Start new background streaming task
|
# Start new background streaming task for this session.
|
||||||
stream_background_task = stream_executor.submit(_prepare_stream_task, data)
|
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"})
|
return jsonify({"success": True, "message": "Streaming started"})
|
||||||
|
|
||||||
|
|
@ -11770,15 +11953,15 @@ def stream_start():
|
||||||
|
|
||||||
@app.route('/api/stream/status')
|
@app.route('/api/stream/status')
|
||||||
def stream_status():
|
def stream_status():
|
||||||
"""Get current streaming status and progress"""
|
"""Get current streaming status and progress for THIS listener."""
|
||||||
try:
|
try:
|
||||||
with stream_lock:
|
sess = _current_stream_state()
|
||||||
# Return copy of current stream state
|
with sess.lock:
|
||||||
return jsonify({
|
return jsonify({
|
||||||
"status": stream_state["status"],
|
"status": sess["status"],
|
||||||
"progress": stream_state["progress"],
|
"progress": sess["progress"],
|
||||||
"track_info": stream_state["track_info"],
|
"track_info": sess["track_info"],
|
||||||
"error_message": stream_state["error_message"]
|
"error_message": sess["error_message"]
|
||||||
})
|
})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error getting stream status: {e}")
|
logger.error(f"Error getting stream status: {e}")
|
||||||
|
|
@ -11789,123 +11972,138 @@ def stream_status():
|
||||||
"error_message": str(e)
|
"error_message": str(e)
|
||||||
}), 500
|
}), 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')
|
@app.route('/stream/audio')
|
||||||
def 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:
|
try:
|
||||||
with stream_lock:
|
sess = _current_stream_state()
|
||||||
if stream_state["status"] != "ready" or not stream_state["file_path"]:
|
with sess.lock:
|
||||||
|
if sess["status"] != "ready" or not sess["file_path"]:
|
||||||
return jsonify({"error": "No audio file ready for streaming"}), 404
|
return jsonify({"error": "No audio file ready for streaming"}), 404
|
||||||
|
file_path = sess["file_path"]
|
||||||
file_path = stream_state["file_path"]
|
|
||||||
|
|
||||||
if not os.path.exists(file_path):
|
|
||||||
return jsonify({"error": "Audio file not found"}), 404
|
|
||||||
|
|
||||||
logger.info(f"Serving audio file: {os.path.basename(file_path)}")
|
logger.info(f"Serving audio file: {os.path.basename(file_path)}")
|
||||||
|
return _serve_audio_file_with_range(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
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error serving audio file: {e}")
|
logger.error(f"Error serving audio file: {e}")
|
||||||
return jsonify({"error": str(e)}), 500
|
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'])
|
@app.route('/api/stream/stop', methods=['POST'])
|
||||||
def stream_stop():
|
def stream_stop():
|
||||||
"""Stop streaming and clean up"""
|
"""Stop THIS listener's stream and clean up its staging dir."""
|
||||||
global stream_background_task
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Cancel background task
|
sid = _stream_session_id()
|
||||||
if stream_background_task and not stream_background_task.done():
|
sess = stream_state_store.get(sid)
|
||||||
stream_background_task.cancel()
|
|
||||||
|
|
||||||
# Only clear Stream folder if NOT playing a library file
|
# Cancel only this session's background task.
|
||||||
with stream_lock:
|
fut = stream_tasks.get(sid)
|
||||||
is_library = stream_state.get("is_library", False)
|
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:
|
if not is_library:
|
||||||
project_root = os.path.dirname(os.path.abspath(__file__))
|
base_root = os.path.dirname(os.path.abspath(__file__))
|
||||||
stream_folder = os.path.join(project_root, 'Stream')
|
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):
|
if os.path.exists(stream_folder):
|
||||||
for filename in os.listdir(stream_folder):
|
for filename in os.listdir(stream_folder):
|
||||||
|
|
@ -11916,9 +12114,9 @@ def stream_stop():
|
||||||
else:
|
else:
|
||||||
logger.info("Library playback stopped - skipping file deletion")
|
logger.info("Library playback stopped - skipping file deletion")
|
||||||
|
|
||||||
# Reset stream state
|
# Reset this session's stream state
|
||||||
with stream_lock:
|
with sess.lock:
|
||||||
stream_state.update({
|
sess.update({
|
||||||
"status": "stopped",
|
"status": "stopped",
|
||||||
"progress": 0,
|
"progress": 0,
|
||||||
"track_info": None,
|
"track_info": None,
|
||||||
|
|
@ -18517,13 +18715,9 @@ def mlm_save():
|
||||||
data = request.get_json(force=True) or {}
|
data = request.get_json(force=True) or {}
|
||||||
source = data.get('source', '').strip()
|
source = data.get('source', '').strip()
|
||||||
source_track_id = data.get('source_track_id', '').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:
|
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
|
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()
|
db = get_database()
|
||||||
profile_id = get_current_profile_id()
|
profile_id = get_current_profile_id()
|
||||||
# Validate library track exists before saving
|
# Validate library track exists before saving
|
||||||
|
|
@ -32705,9 +32899,13 @@ try:
|
||||||
amazon_db = MusicDatabase()
|
amazon_db = MusicDatabase()
|
||||||
amazon_worker = AmazonWorker(database=amazon_db)
|
amazon_worker = AmazonWorker(database=amazon_db)
|
||||||
amazon_worker.start()
|
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()
|
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:
|
else:
|
||||||
logger.info("Amazon enrichment worker initialized and started")
|
logger.info("Amazon enrichment worker initialized and started")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
|
||||||
|
|
@ -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>
|
<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>
|
</button>
|
||||||
</div>
|
</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">
|
<div class="adl-batch-active" id="adl-batch-active">
|
||||||
<!-- Active batch cards rendered by JS -->
|
<!-- Active batch cards rendered by JS -->
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -5467,7 +5470,12 @@
|
||||||
<input type="checkbox" id="prefer-caa-art">
|
<input type="checkbox" id="prefer-caa-art">
|
||||||
Use MusicBrainz Cover Art Archive for album art
|
Use MusicBrainz Cover Art Archive for album art
|
||||||
</label>
|
</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>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="checkbox-label">
|
<label class="checkbox-label">
|
||||||
|
|
@ -7007,6 +7015,8 @@
|
||||||
|
|
||||||
<!-- Hidden HTML5 Audio Player for Streaming -->
|
<!-- Hidden HTML5 Audio Player for Streaming -->
|
||||||
<audio id="audio-player" style="display: none;"></audio>
|
<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 -->
|
<!-- Expanded Now Playing Modal -->
|
||||||
<div class="np-modal-overlay hidden" id="np-modal-overlay">
|
<div class="np-modal-overlay hidden" id="np-modal-overlay">
|
||||||
|
|
@ -7017,15 +7027,24 @@
|
||||||
<div class="np-body">
|
<div class="np-body">
|
||||||
<!-- Left: album art + track info -->
|
<!-- Left: album art + track info -->
|
||||||
<div class="np-left">
|
<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">
|
<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">
|
<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">
|
<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"/>
|
<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>
|
</svg>
|
||||||
</div>
|
</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>
|
||||||
<div class="np-track-info">
|
<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-track-title" id="np-track-title">No track</div>
|
||||||
<div class="np-artist-name" id="np-artist-name">Unknown Artist</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>
|
<div class="np-album-name" id="np-album-name">Unknown Album</div>
|
||||||
|
|
@ -7041,12 +7060,23 @@
|
||||||
</div>
|
</div>
|
||||||
<!-- Right: controls -->
|
<!-- Right: controls -->
|
||||||
<div class="np-right">
|
<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-section">
|
||||||
<div class="np-progress-bar-container">
|
<div class="np-progress-bar-container">
|
||||||
<div class="np-progress-track">
|
<div class="np-progress-track">
|
||||||
<div class="np-progress-fill" id="np-progress-fill"></div>
|
<div class="np-progress-fill" id="np-progress-fill"></div>
|
||||||
</div>
|
</div>
|
||||||
<input type="range" class="np-progress-bar" id="np-progress-bar" min="0" max="100" value="0" step="0.1">
|
<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>
|
||||||
<div class="np-time-display">
|
<div class="np-time-display">
|
||||||
<span class="np-current-time" id="np-current-time">0:00</span>
|
<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">
|
<input type="range" class="np-volume-slider" id="np-volume-slider" min="0" max="100" value="70">
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="np-stop-row">
|
||||||
<button class="np-btn np-btn-stop" id="np-stop-btn" title="Stop playback">
|
<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>
|
<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='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='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='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='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-services.js', v=static_v) }}"></script>
|
||||||
<script src="{{ url_for('static', filename='sync-listenbrainz.js', v=static_v) }}"></script>
|
<script src="{{ url_for('static', filename='sync-listenbrainz.js', v=static_v) }}"></script>
|
||||||
|
|
@ -8014,6 +8054,8 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="gsearch-results" id="gsearch-results"></div>
|
<div class="gsearch-results" id="gsearch-results"></div>
|
||||||
|
|
||||||
|
{% include 'track-detail-modal.html' %}
|
||||||
|
|
||||||
<!-- Floating Mini Media Player — fixed bottom-right, above bell/help -->
|
<!-- Floating Mini Media Player — fixed bottom-right, above bell/help -->
|
||||||
<div class="media-player idle" id="media-player">
|
<div class="media-player idle" id="media-player">
|
||||||
<!-- Thin interactive progress bar spanning full width at top -->
|
<!-- Thin interactive progress bar spanning full width at top -->
|
||||||
|
|
@ -8040,6 +8082,9 @@
|
||||||
<div class="artist-name" id="artist-name">Unknown Artist</div>
|
<div class="artist-name" id="artist-name">Unknown Artist</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mini-player-controls">
|
<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>
|
<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>
|
<svg viewBox="0 0 24 24" fill="currentColor" width="14" height="14"><path d="M6 6h2v12H6zm3.5 6l8.5 6V6z"/></svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -8050,6 +8095,10 @@
|
||||||
<button class="mini-nav-btn" id="mini-next-btn" title="Next" disabled>
|
<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>
|
<svg viewBox="0 0 24 24" fill="currentColor" width="14" height="14"><path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z"/></svg>
|
||||||
</button>
|
</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>
|
<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>
|
<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>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -2983,7 +2983,16 @@ function _ensureCandidatesClickListener(statusEl) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
_hideErrorTooltip();
|
_hideErrorTooltip();
|
||||||
const taskId = this.dataset.taskId;
|
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) {
|
async function approveQuarantineFromDownloadRow(button) {
|
||||||
const entryId = button?.dataset?.entryId || '';
|
const entryId = button?.dataset?.entryId || '';
|
||||||
|
const taskId = button?.dataset?.taskId || '';
|
||||||
if (!entryId) {
|
if (!entryId) {
|
||||||
showToast('Open Quarantine to approve this file.', 'warning');
|
showToast('Open Quarantine to approve this file.', 'warning');
|
||||||
return;
|
return;
|
||||||
|
|
@ -3361,7 +3371,11 @@ async function approveQuarantineFromDownloadRow(button) {
|
||||||
button.disabled = true;
|
button.disabled = true;
|
||||||
button.textContent = 'Approving...';
|
button.textContent = 'Approving...';
|
||||||
try {
|
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();
|
const data = await response.json();
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
showToast('Approved quarantined file. Re-running post-processing.', '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() {
|
function closeCandidatesModal() {
|
||||||
const overlay = document.getElementById('candidates-modal-overlay');
|
const overlay = document.getElementById('candidates-modal-overlay');
|
||||||
if (overlay) {
|
if (overlay) {
|
||||||
|
|
@ -3641,6 +3659,15 @@ function processModalStatusUpdate(playlistId, data) {
|
||||||
statusEl.classList.remove('has-error-tooltip');
|
statusEl.classList.remove('has-error-tooltip');
|
||||||
statusEl.removeAttribute('title');
|
statusEl.removeAttribute('title');
|
||||||
statusEl.removeAttribute('data-error-msg');
|
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;
|
statusEl.textContent = statusText;
|
||||||
|
|
||||||
if ((task.status === 'failed' || task.status === 'cancelled' || task.status === 'not_found') && task.error_message) {
|
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;
|
statusEl.dataset.errorMsg = task.error_message;
|
||||||
_ensureErrorTooltipListeners(statusEl);
|
_ensureErrorTooltipListeners(statusEl);
|
||||||
}
|
}
|
||||||
// Make not_found / failed / cancelled cells clickable to open
|
// Completed rows are clickable into the rich track-detail modal
|
||||||
// the candidates modal. Always bind — even when no auto-search
|
// (play, location, AcoustID verdict, provenance).
|
||||||
// candidates were cached — because the modal carries the manual
|
if (task.status === 'completed') {
|
||||||
// search bar, which is the user's recourse for empty results.
|
|
||||||
if (task.status === 'not_found' || task.status === 'failed' || task.status === 'cancelled') {
|
|
||||||
statusEl.classList.add('has-candidates');
|
statusEl.classList.add('has-candidates');
|
||||||
statusEl.dataset.taskId = task.task_id;
|
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);
|
_ensureCandidatesClickListener(statusEl);
|
||||||
}
|
}
|
||||||
console.debug(`✅ [Status Update] Updated track ${task.track_index} to: ${statusText}${isV2Task ? ' (V2)' : ''}`);
|
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) {
|
} else if (actionsEl && task.status === 'failed' && isQuarantinedTask && task.quarantine_entry_id) {
|
||||||
const entryId = escapeHtml(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');
|
const approveBtn = actionsEl.querySelector('.approve-quarantine-inline-btn');
|
||||||
if (approveBtn) {
|
if (approveBtn) {
|
||||||
approveBtn.addEventListener('click', () => approveQuarantineFromDownloadRow(approveBtn));
|
approveBtn.addEventListener('click', () => approveQuarantineFromDownloadRow(approveBtn));
|
||||||
|
|
@ -5658,7 +5695,10 @@ let _gsController = null;
|
||||||
if (clearBtn) clearBtn.style.display = q.length > 0 ? '' : 'none';
|
if (clearBtn) clearBtn.style.display = q.length > 0 ? '' : 'none';
|
||||||
if (_gsState.debounceTimer) clearTimeout(_gsState.debounceTimer);
|
if (_gsState.debounceTimer) clearTimeout(_gsState.debounceTimer);
|
||||||
if (q.length < 2) { _gsHideResults(); return; }
|
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) {
|
if (clearBtn) {
|
||||||
|
|
|
||||||
|
|
@ -2859,12 +2859,21 @@ function _renderFindingDetail(f) {
|
||||||
|
|
||||||
case 'duplicate_tracks':
|
case 'duplicate_tracks':
|
||||||
if (!d.tracks || !d.tracks.length) return _gridRows([['Count', d.count || '?']]);
|
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 bestDup = d.tracks.reduce((best, t) => {
|
||||||
const bBr = best.bitrate || 0, tBr = t.bitrate || 0;
|
const bk = _dupKey(best), tk = _dupKey(t);
|
||||||
const bDur = best.duration || 0, tDur = t.duration || 0;
|
for (let i = 0; i < bk.length; i++) {
|
||||||
const bTn = best.track_number || 0, tTn = t.track_number || 0;
|
if (tk[i] !== bk[i]) return tk[i] > bk[i] ? t : best;
|
||||||
return (tBr > bBr || (tBr === bBr && tDur > bDur) || (tBr === bBr && tDur === bDur && tTn > bTn)) ? t : best;
|
}
|
||||||
|
return best;
|
||||||
}, d.tracks[0]);
|
}, d.tracks[0]);
|
||||||
const findingId = f.id;
|
const findingId = f.id;
|
||||||
return media + `<div class="repair-detail-sublist">${d.tracks.map((t, i) => {
|
return media + `<div class="repair-detail-sublist">${d.tracks.map((t, i) => {
|
||||||
|
|
|
||||||
|
|
@ -4085,6 +4085,13 @@ function _buildTrackRow(track, album, admin) {
|
||||||
const queueTd = document.createElement('td');
|
const queueTd = document.createElement('td');
|
||||||
queueTd.className = 'col-queue';
|
queueTd.className = 'col-queue';
|
||||||
if (!track._missingExpected && track.file_path) {
|
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 = '⇥'; // ⇥
|
||||||
|
playNextBtn.title = 'Play next';
|
||||||
|
queueTd.appendChild(playNextBtn);
|
||||||
|
|
||||||
const queueBtn = document.createElement('button');
|
const queueBtn = document.createElement('button');
|
||||||
queueBtn.className = 'enhanced-queue-btn';
|
queueBtn.className = 'enhanced-queue-btn';
|
||||||
queueBtn.innerHTML = '+';
|
queueBtn.innerHTML = '+';
|
||||||
|
|
@ -4270,8 +4277,10 @@ function _attachTableDelegation(table, album) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Queue button
|
// Queue / Play-next buttons (share the same track payload)
|
||||||
if (target.closest('.enhanced-queue-btn')) {
|
const isQueueBtn = target.closest('.enhanced-queue-btn');
|
||||||
|
const isPlayNextBtn = target.closest('.enhanced-playnext-btn');
|
||||||
|
if (isQueueBtn || isPlayNextBtn) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (track.file_path) {
|
if (track.file_path) {
|
||||||
const artistName = artistDetailPageState.enhancedData ? artistDetailPageState.enhancedData.artist.name : '';
|
const artistName = artistDetailPageState.enhancedData ? artistDetailPageState.enhancedData.artist.name : '';
|
||||||
|
|
@ -4279,7 +4288,7 @@ function _attachTableDelegation(table, album) {
|
||||||
if (!albumArt && artistDetailPageState.enhancedData) {
|
if (!albumArt && artistDetailPageState.enhancedData) {
|
||||||
albumArt = artistDetailPageState.enhancedData.artist?.thumb_url;
|
albumArt = artistDetailPageState.enhancedData.artist?.thumb_url;
|
||||||
}
|
}
|
||||||
addToQueue({
|
const payload = {
|
||||||
title: track.title || 'Unknown Track',
|
title: track.title || 'Unknown Track',
|
||||||
artist: artistName || 'Unknown Artist',
|
artist: artistName || 'Unknown Artist',
|
||||||
album: album.title || 'Unknown Album',
|
album: album.title || 'Unknown Album',
|
||||||
|
|
@ -4292,7 +4301,9 @@ function _attachTableDelegation(table, album) {
|
||||||
album_id: album.id,
|
album_id: album.id,
|
||||||
bitrate: track.bitrate,
|
bitrate: track.bitrate,
|
||||||
sample_rate: track.sample_rate
|
sample_rate: track.sample_rate
|
||||||
});
|
};
|
||||||
|
if (isPlayNextBtn && typeof playNext === 'function') playNext(payload);
|
||||||
|
else addToQueue(payload);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,14 +16,21 @@ function initializeMediaPlayer() {
|
||||||
if (audioPlayer) {
|
if (audioPlayer) {
|
||||||
// Set up audio event listeners
|
// Set up audio event listeners
|
||||||
audioPlayer.addEventListener('timeupdate', updateAudioProgress);
|
audioPlayer.addEventListener('timeupdate', updateAudioProgress);
|
||||||
|
audioPlayer.addEventListener('timeupdate', npCrossfadeTick);
|
||||||
|
audioPlayer.addEventListener('timeupdate', npThrottledPositionState);
|
||||||
|
audioPlayer.addEventListener('timeupdate', npMaybeLogPlay);
|
||||||
audioPlayer.addEventListener('ended', onAudioEnded);
|
audioPlayer.addEventListener('ended', onAudioEnded);
|
||||||
audioPlayer.addEventListener('error', onAudioError);
|
audioPlayer.addEventListener('error', onAudioError);
|
||||||
audioPlayer.addEventListener('loadstart', onAudioLoadStart);
|
audioPlayer.addEventListener('loadstart', onAudioLoadStart);
|
||||||
audioPlayer.addEventListener('canplay', onAudioCanPlay);
|
audioPlayer.addEventListener('canplay', onAudioCanPlay);
|
||||||
|
|
||||||
// Set initial volume
|
// Set initial volume — restore the saved level (Spotify-style), else 70%.
|
||||||
audioPlayer.volume = 0.7; // 70%
|
const _savedVol = npLoadSavedVolume();
|
||||||
if (volumeSlider) volumeSlider.value = 70;
|
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
|
// Track title click handled by initExpandedPlayer's media-player click handler
|
||||||
|
|
@ -54,6 +61,15 @@ function initializeMediaPlayer() {
|
||||||
const miniNextBtn = document.getElementById('mini-next-btn');
|
const miniNextBtn = document.getElementById('mini-next-btn');
|
||||||
if (miniPrevBtn) miniPrevBtn.addEventListener('click', (e) => { e.stopPropagation(); playPreviousInQueue(); });
|
if (miniPrevBtn) miniPrevBtn.addEventListener('click', (e) => { e.stopPropagation(); playPreviousInQueue(); });
|
||||||
if (miniNextBtn) miniNextBtn.addEventListener('click', (e) => { e.stopPropagation(); playNextInQueue(); });
|
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() {
|
function toggleMediaPlayerExpansion() {
|
||||||
|
|
@ -107,6 +123,7 @@ function _stripSourceIdPrefix(value) {
|
||||||
|
|
||||||
function setTrackInfo(track) {
|
function setTrackInfo(track) {
|
||||||
currentTrack = track;
|
currentTrack = track;
|
||||||
|
npPlayLogged = false; // new track — allow one play-log once it's heard a bit
|
||||||
|
|
||||||
const trackTitleElement = document.getElementById('track-title');
|
const trackTitleElement = document.getElementById('track-title');
|
||||||
const trackTitle = _stripSourceIdPrefix(track.title) || 'Unknown Track';
|
const trackTitle = _stripSourceIdPrefix(track.title) || 'Unknown Track';
|
||||||
|
|
@ -161,6 +178,8 @@ function setTrackInfo(track) {
|
||||||
updateNpTrackInfo();
|
updateNpTrackInfo();
|
||||||
updateMediaSessionMetadata();
|
updateMediaSessionMetadata();
|
||||||
updateMediaSessionPlaybackState();
|
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
|
// Kick off lyrics fetch for the new track. The panel stays
|
||||||
// collapsed by default — fetching in the background means the
|
// collapsed by default — fetching in the background means the
|
||||||
|
|
@ -205,6 +224,7 @@ function clearTrack() {
|
||||||
// Clear track state
|
// Clear track state
|
||||||
currentTrack = null;
|
currentTrack = null;
|
||||||
isPlaying = false;
|
isPlaying = false;
|
||||||
|
npSetPlayContext(''); // hide "Playing from" when nothing's playing
|
||||||
|
|
||||||
const trackTitleElement = document.getElementById('track-title');
|
const trackTitleElement = document.getElementById('track-title');
|
||||||
trackTitleElement.innerHTML = '<span class="title-text">No track</span>';
|
trackTitleElement.innerHTML = '<span class="title-text">No track</span>';
|
||||||
|
|
@ -296,6 +316,8 @@ async function handlePlayPause() {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleStop() {
|
async function handleStop() {
|
||||||
|
// Tear down any in-flight crossfade so its second audio doesn't keep playing.
|
||||||
|
npCancelCrossfade();
|
||||||
// Use new streaming system stop function
|
// Use new streaming system stop function
|
||||||
await stopStream();
|
await stopStream();
|
||||||
clearTrack();
|
clearTrack();
|
||||||
|
|
@ -304,6 +326,7 @@ async function handleStop() {
|
||||||
function handleVolumeChange(event) {
|
function handleVolumeChange(event) {
|
||||||
const volume = event.target.value;
|
const volume = event.target.value;
|
||||||
updateVolumeSliderAppearance();
|
updateVolumeSliderAppearance();
|
||||||
|
npPersistVolume(volume);
|
||||||
|
|
||||||
// Update HTML5 audio player volume
|
// Update HTML5 audio player volume
|
||||||
if (audioPlayer) {
|
if (audioPlayer) {
|
||||||
|
|
@ -1034,8 +1057,21 @@ function _npLyricsRenderSynced(lines) {
|
||||||
}
|
}
|
||||||
content.innerHTML = lines.map((line, idx) => {
|
content.innerHTML = lines.map((line, idx) => {
|
||||||
const safe = (line.text || '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])) || ' ';
|
const safe = (line.text || '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])) || ' ';
|
||||||
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('');
|
}).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) {
|
function _npLyricsRenderPlain(text) {
|
||||||
|
|
@ -1186,6 +1222,11 @@ function onAudioEnded() {
|
||||||
currentTimeElement.textContent = '0:00';
|
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)
|
// 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)
|
// Auto-advance to next track if queue has a next item (guard against race conditions)
|
||||||
if (npQueue.length > 0 && !npLoadingQueueItem) {
|
if (npQueue.length > 0 && !npLoadingQueueItem) {
|
||||||
|
|
@ -1382,6 +1423,9 @@ let npAnalyser = null;
|
||||||
let npMediaSource = null;
|
let npMediaSource = null;
|
||||||
let npVizAnimFrame = null;
|
let npVizAnimFrame = null;
|
||||||
let npVizInitialized = false;
|
let npVizInitialized = false;
|
||||||
|
let npCrossfadeOn = false;
|
||||||
|
let npSleepMinutes = 0; // 0 = off
|
||||||
|
let npSleepTimerId = null;
|
||||||
|
|
||||||
function npQueueHasNext() {
|
function npQueueHasNext() {
|
||||||
if (npQueue.length === 0) return false;
|
if (npQueue.length === 0) return false;
|
||||||
|
|
@ -1425,6 +1469,13 @@ function npSetRadioMode(enabled, options = {}) {
|
||||||
if (toast) {
|
if (toast) {
|
||||||
showToast(npRadioMode ? 'Radio mode on - similar tracks will auto-queue' : 'Radio mode off', 'success');
|
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()) {
|
if (npRadioMode && fetchIfNeeded && currentTrack && currentTrack.id && !npLoadingQueueItem && !npQueueHasNext()) {
|
||||||
npEnsureCurrentTrackInQueue();
|
npEnsureCurrentTrackInQueue();
|
||||||
npFetchRadioTracks();
|
npFetchRadioTracks();
|
||||||
|
|
@ -1451,6 +1502,32 @@ function initExpandedPlayer() {
|
||||||
// Control handlers
|
// Control handlers
|
||||||
playBtn.addEventListener('click', () => { togglePlayback(); });
|
playBtn.addEventListener('click', () => { togglePlayback(); });
|
||||||
stopBtn.addEventListener('click', async () => { await handleStop(); closeNowPlayingModal(); });
|
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);
|
shuffleBtn.addEventListener('click', handleNpShuffle);
|
||||||
repeatBtn.addEventListener('click', handleNpRepeat);
|
repeatBtn.addEventListener('click', handleNpRepeat);
|
||||||
muteBtn.addEventListener('click', handleNpMuteToggle);
|
muteBtn.addEventListener('click', handleNpMuteToggle);
|
||||||
|
|
@ -1460,6 +1537,23 @@ function initExpandedPlayer() {
|
||||||
npProgressBar.addEventListener('mousedown', () => { npProgressBar.dataset.seeking = 'true'; });
|
npProgressBar.addEventListener('mousedown', () => { npProgressBar.dataset.seeking = 'true'; });
|
||||||
npProgressBar.addEventListener('mouseup', () => { delete npProgressBar.dataset.seeking; });
|
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)
|
// Progress bar (touch)
|
||||||
npProgressBar.addEventListener('touchstart', () => { npProgressBar.dataset.seeking = 'true'; }, { passive: true });
|
npProgressBar.addEventListener('touchstart', () => { npProgressBar.dataset.seeking = 'true'; }, { passive: true });
|
||||||
npProgressBar.addEventListener('touchmove', (e) => {
|
npProgressBar.addEventListener('touchmove', (e) => {
|
||||||
|
|
@ -1588,6 +1682,10 @@ function syncExpandedPlayerUI() {
|
||||||
const viz = document.getElementById('np-visualizer');
|
const viz = document.getElementById('np-visualizer');
|
||||||
if (viz) viz.classList.toggle('playing', isPlaying);
|
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
|
// Queue
|
||||||
renderNpQueue();
|
renderNpQueue();
|
||||||
updateNpPrevNextButtons();
|
updateNpPrevNextButtons();
|
||||||
|
|
@ -1706,24 +1804,46 @@ function npExtractAmbientColor(imgEl) {
|
||||||
try {
|
try {
|
||||||
const canvas = document.createElement('canvas');
|
const canvas = document.createElement('canvas');
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d');
|
||||||
canvas.width = 50;
|
canvas.width = 64;
|
||||||
canvas.height = 50;
|
canvas.height = 64;
|
||||||
ctx.drawImage(imgEl, 0, 0, 50, 50);
|
ctx.drawImage(imgEl, 0, 0, 64, 64);
|
||||||
const data = ctx.getImageData(0, 0, 50, 50).data;
|
const data = ctx.getImageData(0, 0, 64, 64).data;
|
||||||
let rSum = 0, gSum = 0, bSum = 0, count = 0;
|
|
||||||
|
// 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
|
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;
|
const brightness = (r + g + b) / 3;
|
||||||
if (brightness > 20 && brightness < 230) {
|
if (brightness < 24 || brightness > 240) continue; // skip near-black/white
|
||||||
rSum += r; gSum += g; bSum += b; count++;
|
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');
|
const modal = document.querySelector('.np-modal');
|
||||||
if (modal) {
|
if (modal) {
|
||||||
modal.style.setProperty('--np-ambient-r', Math.round(rSum / count));
|
modal.style.setProperty('--np-ambient-r', r);
|
||||||
modal.style.setProperty('--np-ambient-g', Math.round(gSum / count));
|
modal.style.setProperty('--np-ambient-g', g);
|
||||||
modal.style.setProperty('--np-ambient-b', Math.round(bSum / count));
|
modal.style.setProperty('--np-ambient-b', b);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} 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() {
|
function npResetAmbientGlow() {
|
||||||
const modal = document.querySelector('.np-modal');
|
const modal = document.querySelector('.np-modal');
|
||||||
if (modal) {
|
if (modal) {
|
||||||
|
|
@ -1791,6 +2024,7 @@ function handleNpProgressBarChange(event) {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
audioPlayer.currentTime = newTime;
|
audioPlayer.currentTime = newTime;
|
||||||
|
updateMediaSessionPositionState();
|
||||||
|
|
||||||
// Sync sidebar progress
|
// Sync sidebar progress
|
||||||
const sidebarBar = document.getElementById('progress-bar');
|
const sidebarBar = document.getElementById('progress-bar');
|
||||||
|
|
@ -1815,6 +2049,7 @@ function handleNpProgressBarChange(event) {
|
||||||
function handleNpVolumeChange(event) {
|
function handleNpVolumeChange(event) {
|
||||||
const volume = parseInt(event.target.value);
|
const volume = parseInt(event.target.value);
|
||||||
if (audioPlayer) audioPlayer.volume = volume / 100;
|
if (audioPlayer) audioPlayer.volume = volume / 100;
|
||||||
|
npPersistVolume(volume);
|
||||||
|
|
||||||
// Sync sidebar volume slider
|
// Sync sidebar volume slider
|
||||||
const sidebarVol = document.getElementById('volume-slider');
|
const sidebarVol = document.getElementById('volume-slider');
|
||||||
|
|
@ -1862,15 +2097,32 @@ function updateNpMuteIcon() {
|
||||||
if (muteBtn) muteBtn.classList.toggle('muted', npMuted);
|
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() {
|
function handleNpShuffle() {
|
||||||
npShuffleOn = !npShuffleOn;
|
npShuffleOn = !npShuffleOn;
|
||||||
const btn = document.getElementById('np-shuffle-btn');
|
syncShuffleRepeatUI();
|
||||||
if (btn) btn.classList.toggle('active', npShuffleOn);
|
|
||||||
updateNpPrevNextButtons();
|
updateNpPrevNextButtons();
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleNpRepeat() {
|
function handleNpRepeat() {
|
||||||
const badge = document.getElementById('np-repeat-one-badge');
|
|
||||||
if (npRepeatMode === 'off') {
|
if (npRepeatMode === 'off') {
|
||||||
npRepeatMode = 'all';
|
npRepeatMode = 'all';
|
||||||
if (audioPlayer) audioPlayer.loop = false;
|
if (audioPlayer) audioPlayer.loop = false;
|
||||||
|
|
@ -1881,9 +2133,7 @@ function handleNpRepeat() {
|
||||||
npRepeatMode = 'off';
|
npRepeatMode = 'off';
|
||||||
if (audioPlayer) audioPlayer.loop = false;
|
if (audioPlayer) audioPlayer.loop = false;
|
||||||
}
|
}
|
||||||
const btn = document.getElementById('np-repeat-btn');
|
syncShuffleRepeatUI();
|
||||||
if (btn) btn.classList.toggle('active', npRepeatMode !== 'off');
|
|
||||||
if (badge) badge.classList.toggle('hidden', npRepeatMode !== 'one');
|
|
||||||
updateNpPrevNextButtons();
|
updateNpPrevNextButtons();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1891,6 +2141,21 @@ function handleNpRepeat() {
|
||||||
// QUEUE MANAGEMENT
|
// 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) {
|
function addToQueue(track) {
|
||||||
npQueue.push(track);
|
npQueue.push(track);
|
||||||
showToast('Added to queue', 'success');
|
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) {
|
function removeFromQueue(index) {
|
||||||
if (index < 0 || index >= npQueue.length) return;
|
if (index < 0 || index >= npQueue.length) return;
|
||||||
const wasCurrentTrack = (index === npQueueIndex);
|
const wasCurrentTrack = (index === npQueueIndex);
|
||||||
|
|
@ -1978,6 +2256,10 @@ function playPreviousInQueue() {
|
||||||
async function playQueueItem(index) {
|
async function playQueueItem(index) {
|
||||||
if (index < 0 || index >= npQueue.length) return;
|
if (index < 0 || index >= npQueue.length) return;
|
||||||
if (npLoadingQueueItem) return; // Prevent race condition from double-advance
|
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;
|
npLoadingQueueItem = true;
|
||||||
npQueueIndex = index;
|
npQueueIndex = index;
|
||||||
const track = npQueue[index];
|
const track = npQueue[index];
|
||||||
|
|
@ -2074,6 +2356,26 @@ function renderNpQueue() {
|
||||||
item.className = 'np-queue-item' + (i === npQueueIndex ? ' active' : '');
|
item.className = 'np-queue-item' + (i === npQueueIndex ? ' active' : '');
|
||||||
item.onclick = () => playQueueItem(i);
|
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');
|
const info = document.createElement('div');
|
||||||
info.className = 'np-queue-item-info';
|
info.className = 'np-queue-item-info';
|
||||||
|
|
||||||
|
|
@ -2089,6 +2391,19 @@ function renderNpQueue() {
|
||||||
info.appendChild(artist);
|
info.appendChild(artist);
|
||||||
item.appendChild(info);
|
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');
|
const removeBtn = document.createElement('button');
|
||||||
removeBtn.className = 'np-queue-item-remove';
|
removeBtn.className = 'np-queue-item-remove';
|
||||||
removeBtn.innerHTML = '✕';
|
removeBtn.innerHTML = '✕';
|
||||||
|
|
@ -2101,6 +2416,130 @@ function renderNpQueue() {
|
||||||
|
|
||||||
listEl.appendChild(item);
|
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() {
|
function updateNpPrevNextButtons() {
|
||||||
|
|
@ -2163,7 +2602,18 @@ function handlePlayerKeyboardShortcuts(event) {
|
||||||
break;
|
break;
|
||||||
case 'm':
|
case 'm':
|
||||||
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;
|
break;
|
||||||
case 'Escape':
|
case 'Escape':
|
||||||
if (npModalOpen) closeNowPlayingModal();
|
if (npModalOpen) closeNowPlayingModal();
|
||||||
|
|
@ -2185,6 +2635,21 @@ function syncVolumeUI(volumePercent) {
|
||||||
}
|
}
|
||||||
if (npVol) npVol.value = volumePercent;
|
if (npVol) npVol.value = volumePercent;
|
||||||
if (npFill) npFill.style.width = 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() {
|
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() {
|
function npStartVisualizerLoop() {
|
||||||
if (npVizAnimFrame) return; // Already running
|
if (npVizAnimFrame) return; // Already running
|
||||||
if (!npAnalyser) return; // No analyser — CSS fallback handles it
|
if (!npAnalyser) return; // No analyser — CSS fallback handles it
|
||||||
|
|
@ -2229,7 +2707,6 @@ function npStartVisualizerLoop() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const bars = document.querySelectorAll('.np-viz-bar');
|
const bars = document.querySelectorAll('.np-viz-bar');
|
||||||
if (bars.length === 0) return;
|
|
||||||
const bufferLength = npAnalyser.frequencyBinCount;
|
const bufferLength = npAnalyser.frequencyBinCount;
|
||||||
const dataArray = new Uint8Array(bufferLength);
|
const dataArray = new Uint8Array(bufferLength);
|
||||||
|
|
||||||
|
|
@ -2237,14 +2714,25 @@ function npStartVisualizerLoop() {
|
||||||
npVizAnimFrame = requestAnimationFrame(draw);
|
npVizAnimFrame = requestAnimationFrame(draw);
|
||||||
npAnalyser.getByteFrequencyData(dataArray);
|
npAnalyser.getByteFrequencyData(dataArray);
|
||||||
|
|
||||||
// Map 7 bars to frequency bins (skip bin 0 which is DC offset)
|
// Map 7 transport bars to frequency bins (skip bin 0 = DC offset)
|
||||||
const binCount = Math.min(bufferLength - 1, 7);
|
|
||||||
for (let i = 0; i < bars.length; i++) {
|
for (let i = 0; i < bars.length; i++) {
|
||||||
const binIndex = Math.min(i + 1, bufferLength - 1);
|
const binIndex = Math.min(i + 1, bufferLength - 1);
|
||||||
const value = dataArray[binIndex] / 255; // 0..1
|
const value = dataArray[binIndex] / 255; // 0..1
|
||||||
const scale = Math.max(0.08, value); // minimum visible height
|
const scale = Math.max(0.08, value); // minimum visible height
|
||||||
bars[i].style.transform = `scaleY(${scale})`;
|
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();
|
draw();
|
||||||
}
|
}
|
||||||
|
|
@ -2652,6 +3140,65 @@ function initMediaSession() {
|
||||||
navigator.mediaSession.setActionHandler('nexttrack', () => {
|
navigator.mediaSession.setActionHandler('nexttrack', () => {
|
||||||
if (npQueue.length > 0) playNextInQueue();
|
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() {
|
function updateMediaSessionMetadata() {
|
||||||
|
|
|
||||||
|
|
@ -2193,6 +2193,85 @@ function _getBatchColor(batchId) {
|
||||||
return _batchColorMap[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() {
|
function loadActiveDownloadsPage() {
|
||||||
_adlFetch();
|
_adlFetch();
|
||||||
_adlFetchBatchHistory();
|
_adlFetchBatchHistory();
|
||||||
|
|
@ -2553,17 +2632,27 @@ function _adlRenderBatchPanel() {
|
||||||
return elapsed < _BATCH_FADE_SECONDS;
|
return elapsed < _BATCH_FADE_SECONDS;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const activeBatches = visibleBatches.filter(b => b.phase !== 'complete' && b.phase !== 'cancelled' && b.phase !== 'error');
|
||||||
|
|
||||||
// Update header with count
|
// Update header with count
|
||||||
if (headerTitle) {
|
if (headerTitle) {
|
||||||
const activeCount = visibleBatches.filter(b => b.phase !== 'complete' && b.phase !== 'cancelled' && b.phase !== 'error').length;
|
headerTitle.textContent = activeBatches.length > 0 ? `Batches (${activeBatches.length})` : 'Batches';
|
||||||
headerTitle.textContent = activeCount > 0 ? `Batches (${activeCount})` : 'Batches';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_adlRenderBatchSummary(activeBatches);
|
||||||
|
|
||||||
if (visibleBatches.length === 0) {
|
if (visibleBatches.length === 0) {
|
||||||
container.innerHTML = `<div class="adl-batch-empty">
|
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 class="adl-batch-empty-icon">
|
||||||
<div>No active batches</div>
|
<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 style="font-size:0.7rem;margin-top:2px;opacity:0.5">Start a download from Search, Sync, or Wishlist</div>
|
</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>`;
|
</div>`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -2649,34 +2738,37 @@ function _adlRenderBatchPanel() {
|
||||||
let tracksHtml = '';
|
let tracksHtml = '';
|
||||||
if (isExpanded) {
|
if (isExpanded) {
|
||||||
if (batchTracks.length > 0) {
|
if (batchTracks.length > 0) {
|
||||||
tracksHtml = batchTracks.map(t => {
|
tracksHtml = batchTracks.map((t, i) => {
|
||||||
const cls = _adlStatusClass(t.status);
|
const cls = _adlStatusClass(t.status);
|
||||||
const progress = t.progress || 0;
|
const progress = t.progress || 0;
|
||||||
|
const idx = (t.track_index != null ? t.track_index + 1 : i + 1);
|
||||||
|
|
||||||
// Status indicator with detail
|
// Right-aligned state: % / spinner / \u2713 / \u2717 / \u00B7 \u2014 color via row class.
|
||||||
let statusHtml = '';
|
let stateHtml = '';
|
||||||
if (t.status === 'downloading' && progress > 0) {
|
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') {
|
} 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') {
|
} 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') {
|
} 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') {
|
} 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 {
|
} 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 isDownloading = (t.status === 'downloading' && progress > 0);
|
||||||
const miniBar = 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>`
|
? `<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">
|
return `<div class="adl-batch-track-row ${cls}${isDownloading ? ' downloading' : ''}">
|
||||||
<span class="adl-batch-track-title">${_adlEsc(t.title || 'Unknown')}</span>
|
<span class="adl-batch-track-idx">${idx}</span>
|
||||||
${statusHtml}
|
<span class="adl-batch-track-text"><span class="adl-batch-track-title">${_adlEsc(t.title || 'Unknown')}</span>${sub}</span>
|
||||||
|
${stateHtml}
|
||||||
${miniBar}
|
${miniBar}
|
||||||
</div>`;
|
</div>`;
|
||||||
}).join('');
|
}).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 (isExpanded) cardClasses.push('expanded');
|
||||||
if (isActive) cardClasses.push('active-glow');
|
if (isActive) cardClasses.push('active-glow');
|
||||||
if (isFiltered) cardClasses.push('filtered');
|
if (isFiltered) cardClasses.push('filtered');
|
||||||
|
|
||||||
const playlistId = _adlEsc(batch.playlist_id || '');
|
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}')">
|
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">
|
<div class="adl-batch-card-top">
|
||||||
${thumbHtml}
|
${thumbHtml}
|
||||||
<div class="adl-batch-card-info">
|
<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-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>
|
<div class="adl-batch-card-meta">${phaseIcon}${phaseText}</div>
|
||||||
|
${nowHtml}
|
||||||
</div>
|
</div>
|
||||||
${sourceBadge}
|
${sourceBadge}
|
||||||
<div class="adl-batch-card-actions">
|
<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">
|
${!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>
|
<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>` : ''}
|
</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>
|
</div>
|
||||||
<div class="adl-batch-progress">
|
<div class="adl-batch-segbar">
|
||||||
<div class="adl-batch-progress-fill${hasFailed ? ' has-failed' : ''}" style="width:${pct}%"></div>
|
<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>
|
</div>
|
||||||
|
${statLine}
|
||||||
<div class="adl-batch-tracks">${tracksHtml}</div>
|
<div class="adl-batch-tracks">${tracksHtml}</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -243,10 +243,13 @@ function initializeSearchModeToggle() {
|
||||||
return;
|
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(() => {
|
debounceTimer = setTimeout(() => {
|
||||||
searchController.submitQuery(query);
|
searchController.submitQuery(query);
|
||||||
}, 300);
|
}, 600);
|
||||||
});
|
});
|
||||||
|
|
||||||
enhancedInput.addEventListener('keypress', (e) => {
|
enhancedInput.addEventListener('keypress', (e) => {
|
||||||
|
|
|
||||||
|
|
@ -718,6 +718,116 @@ function getHybridOrder() {
|
||||||
return _hybridSourceOrder.filter(s => _hybridSourceEnabled[s] !== false);
|
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) {
|
function loadHybridSourceOrder(settings) {
|
||||||
const order = settings.download_source?.hybrid_order;
|
const order = settings.download_source?.hybrid_order;
|
||||||
const sourceStatus = settings._source_status || {};
|
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('stream-source').value = settings.download_source?.stream_source || 'youtube';
|
||||||
document.getElementById('max-concurrent-downloads').value = settings.download_source?.max_concurrent || '3';
|
document.getElementById('max-concurrent-downloads').value = settings.download_source?.max_concurrent || '3';
|
||||||
loadHybridSourceOrder(settings);
|
loadHybridSourceOrder(settings);
|
||||||
|
loadArtSourceOrder(settings);
|
||||||
document.getElementById('tidal-download-quality').value = settings.tidal_download?.quality || 'lossless';
|
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('tidal-allow-fallback').checked = settings.tidal_download?.allow_fallback !== false;
|
||||||
document.getElementById('qobuz-quality').value = settings.qobuz?.quality || 'lossless';
|
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,
|
embed_album_art: document.getElementById('embed-album-art').checked,
|
||||||
cover_art_download: document.getElementById('cover-art-download').checked,
|
cover_art_download: document.getElementById('cover-art-download').checked,
|
||||||
prefer_caa_art: document.getElementById('prefer-caa-art').checked,
|
prefer_caa_art: document.getElementById('prefer-caa-art').checked,
|
||||||
|
album_art_order: getArtOrder(),
|
||||||
lrclib_enabled: document.getElementById('lrclib-enabled').checked,
|
lrclib_enabled: document.getElementById('lrclib-enabled').checked,
|
||||||
tags: {
|
tags: {
|
||||||
quality_tag: _getTagConfig('metadata_enhancement.tags.quality_tag'),
|
quality_tag: _getTagConfig('metadata_enhancement.tags.quality_tag'),
|
||||||
|
|
|
||||||
|
|
@ -518,10 +518,10 @@ function renderMirroredCard(p, container) {
|
||||||
${phaseHtml}
|
${phaseHtml}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
${disc > 0 ? `<button class="mirrored-card-clear" onclick="event.stopPropagation(); clearMirroredDiscovery(${p.id}, '${_escAttr(p.name)}')" title="Clear discovery data">↺</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}, '${_escAttr(p.name)}')" title="Refresh, discover, sync, and queue missing tracks">Auto-Sync</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}, '${_escAttr(p.name)}', '${_escAttr(p.source)}', '${_escAttr(sourceRef)}')" title="Edit original playlist link">🔗</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}, '${_escAttr(p.name)}')" title="Delete mirror">✕</button>
|
<button class="mirrored-card-delete" onclick="event.stopPropagation(); deleteMirroredPlaylist(${p.id}, '${_escJs(p.name)}')" title="Delete mirror">✕</button>
|
||||||
`;
|
`;
|
||||||
card.addEventListener('click', () => {
|
card.addEventListener('click', () => {
|
||||||
const st = youtubePlaylistStates[hash];
|
const st = youtubePlaylistStates[hash];
|
||||||
|
|
@ -829,52 +829,74 @@ async function openMirroredPlaylistModal(playlistId) {
|
||||||
const tracks = data.tracks || [];
|
const tracks = data.tracks || [];
|
||||||
const source = data.source || 'unknown';
|
const source = data.source || 'unknown';
|
||||||
const sourceRef = getMirroredSourceRef(data);
|
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 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 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')}` : '';
|
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">
|
const art = t.image_url
|
||||||
<span class="track-pos">${t.position}</span>
|
? `<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="track-title">${_esc(t.track_name)}</span>
|
: `<span class="mm-row-art mm-art-empty"></span>`;
|
||||||
<span class="track-artist">${_esc(t.artist_name)}</span>
|
return `<div class="mm-row">
|
||||||
<span class="track-album">${_esc(t.album_name)}</span>
|
<span class="mm-row-pos">${t.position}</span>
|
||||||
<span class="track-duration">${dur}</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>`;
|
</div>`;
|
||||||
}).join('');
|
}).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 = `
|
overlay.innerHTML = `
|
||||||
<div class="mirrored-modal">
|
<div class="mirrored-modal">
|
||||||
<div class="mirrored-modal-header">
|
<div class="mm-hero">
|
||||||
<div class="mirrored-modal-hero">
|
${heroArt ? `<div class="mm-hero-bg" style="background-image:url('${_escAttr(heroArt)}')"></div>` : ''}
|
||||||
<div class="mirrored-modal-hero-icon ${_escAttr(source)}">${sourceIcon}</div>
|
<div class="mm-hero-content">
|
||||||
<div class="mirrored-modal-hero-info">
|
${heroCover}
|
||||||
<h2 class="mirrored-modal-hero-title">${_esc(data.name)}</h2>
|
<div class="mm-hero-info">
|
||||||
<div class="mirrored-modal-hero-subtitle">
|
<span class="mm-eyebrow">Mirrored Playlist</span>
|
||||||
<span class="mirrored-modal-hero-badge">${_esc(source)}</span>
|
<h2 class="mm-title">${_esc(data.name)}</h2>
|
||||||
<span>${tracks.length} tracks</span>
|
<div class="mm-meta">
|
||||||
<span>·</span>
|
<span class="mm-source-pill ${_escAttr(source)}">${_esc(srcLabel)}</span>
|
||||||
<span>Mirrored ${timeAgo(data.updated_at || data.mirrored_at)}</span>
|
${data.owner ? `<span class="mm-meta-item">${_esc(data.owner)}</span><span class="mm-dot">·</span>` : ''}
|
||||||
|
<span class="mm-meta-item">${tracks.length} tracks</span>
|
||||||
|
${totalMs ? `<span class="mm-dot">·</span><span class="mm-meta-item">${totalLabel}</span>` : ''}
|
||||||
|
<span class="mm-dot">·</span>
|
||||||
|
<span class="mm-meta-item">Mirrored ${timeAgo(data.updated_at || data.mirrored_at)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span class="mirrored-modal-close" onclick="closeMirroredModal()">×</span>
|
<button class="mm-close" onclick="closeMirroredModal()" aria-label="Close">×</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="mirrored-modal-tracks">
|
<div class="mm-list">
|
||||||
<div class="mirrored-track-header">
|
<div class="mm-list-head">
|
||||||
<span>#</span><span>Track</span><span>Artist</span><span>Album</span><span style="text-align:right">Time</span>
|
<span>#</span><span></span><span>Title</span><span>Album</span><span class="mm-col-dur">Time</span>
|
||||||
</div>
|
</div>
|
||||||
${trackRows}
|
${trackRows || '<div class="mm-empty">No tracks in this mirror yet.</div>'}
|
||||||
</div>
|
</div>
|
||||||
<div class="mirrored-modal-footer">
|
<div class="mm-actions">
|
||||||
<div class="mirrored-modal-footer-left">
|
<button class="mm-btn mm-btn-danger" onclick="closeMirroredModal(); deleteMirroredPlaylist(${playlistId}, '${_escJs(data.name)}')">Delete Mirror</button>
|
||||||
<button class="mirrored-btn-delete" onclick="closeMirroredModal(); deleteMirroredPlaylist(${playlistId}, '${_escAttr(data.name)}')">Delete Mirror</button>
|
<div class="mm-actions-right">
|
||||||
</div>
|
<button class="mm-btn mm-btn-ghost" onclick="editMirroredSourceRef(${playlistId}, '${_escJs(data.name)}', '${_escJs(source)}', '${_escJs(sourceRef)}')">Edit Source</button>
|
||||||
<div class="mirrored-modal-footer-right" style="display:flex;gap:10px;">
|
<button class="mm-btn mm-btn-secondary" onclick="runMirroredPlaylistPipeline(${playlistId}, '${_escJs(data.name)}')">Auto-Sync</button>
|
||||||
<button class="mirrored-btn-close" onclick="editMirroredSourceRef(${playlistId}, '${_escAttr(data.name)}', '${_escAttr(source)}', '${_escAttr(sourceRef)}')">Edit Source</button>
|
<button class="mm-btn mm-btn-ghost" onclick="closeMirroredModal()">Close</button>
|
||||||
<button class="mirrored-btn-pipeline" onclick="runMirroredPlaylistPipeline(${playlistId}, '${_escAttr(data.name)}')">Auto-Sync</button>
|
<button class="mm-btn mm-btn-primary" onclick="discoverMirroredPlaylist(${playlistId})">Discover</button>
|
||||||
<button class="mirrored-btn-close" onclick="closeMirroredModal()">Close</button>
|
|
||||||
<button class="mirrored-btn-discover" onclick="discoverMirroredPlaylist(${playlistId})">Discover</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1176,7 +1198,7 @@ function renderPoolList() {
|
||||||
<span class="pool-track-playlist-badge">${_esc(t.playlist_name)}</span>
|
<span class="pool-track-playlist-badge">${_esc(t.playlist_name)}</span>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
`).join('');
|
`).join('');
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -1218,7 +1240,7 @@ function renderPoolList() {
|
||||||
</div>
|
</div>
|
||||||
<span class="pool-confidence-badge ${confClass}">${conf}%</span>
|
<span class="pool-confidence-badge ${confClass}">${conf}%</span>
|
||||||
<span class="pool-use-count">${e.use_count}×</span>
|
<span class="pool-use-count">${e.use_count}×</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">×</button>
|
<button class="pool-remove-btn" onclick="removePoolCacheEntry(${e.id})" title="Remove cached match">×</button>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
@ -2141,13 +2163,13 @@ function _buildAutomationSection(id, label, automations, useGrid, options = {})
|
||||||
const allEnabled = enabledCount === automations.length;
|
const allEnabled = enabledCount === automations.length;
|
||||||
actionsHtml = `
|
actionsHtml = `
|
||||||
<div class="section-actions" onclick="event.stopPropagation();">
|
<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 ? '⏸' : '▶'}
|
${allEnabled ? '⏸' : '▶'}
|
||||||
</button>
|
</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>
|
||||||
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -3108,7 +3130,7 @@ function _showGroupDropdown(event, autoId, currentGroup) {
|
||||||
}
|
}
|
||||||
allGroups.forEach(g => {
|
allGroups.forEach(g => {
|
||||||
const isActive = g === currentGroup;
|
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>';
|
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();}">`;
|
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'];
|
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 (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 (!_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));
|
if (a.last_error) metaParts.push('Error: ' + _esc(a.last_error));
|
||||||
|
|
||||||
const dupeBtn = a.is_system ? '' :
|
const dupeBtn = a.is_system ? '' :
|
||||||
`<button class="automation-dupe-btn" title="Duplicate" onclick="event.stopPropagation(); duplicateAutomation(${a.id})">📋</button>`;
|
`<button class="automation-dupe-btn" title="Duplicate" onclick="event.stopPropagation(); duplicateAutomation(${a.id})">📋</button>`;
|
||||||
const groupBtn = a.is_system ? '' :
|
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'})">📁</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'})">📁</button>`;
|
||||||
const deleteBtn = a.is_system ? '' :
|
const deleteBtn = a.is_system ? '' :
|
||||||
`<button class="automation-delete-btn" title="Delete" onclick="event.stopPropagation(); deleteAutomation(${a.id}, '${_escAttr(a.name)}')">🗑</button>`;
|
`<button class="automation-delete-btn" title="Delete" onclick="event.stopPropagation(); deleteAutomation(${a.id}, '${_escJs(a.name)}')">🗑</button>`;
|
||||||
|
|
||||||
card.innerHTML = `
|
card.innerHTML = `
|
||||||
<div class="automation-status ${a.enabled ? 'enabled' : 'disabled'}"></div>
|
<div class="automation-status ${a.enabled ? 'enabled' : 'disabled'}"></div>
|
||||||
|
|
@ -4660,6 +4682,29 @@ function _escAttr(str) {
|
||||||
return String(str).replace(/&/g, '&').replace(/"/g, '"').replace(/'/g, ''').replace(/</g, '<').replace(/>/g, '>');
|
return String(str).replace(/&/g, '&').replace(/"/g, '"').replace(/'/g, ''').replace(/</g, '<').replace(/>/g, '>');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 ' 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, '&')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>');
|
||||||
|
}
|
||||||
|
|
||||||
// ===== ENHANCE QUALITY MODAL =====
|
// ===== ENHANCE QUALITY MODAL =====
|
||||||
|
|
||||||
let _enhanceQualityData = null;
|
let _enhanceQualityData = null;
|
||||||
|
|
@ -4740,9 +4785,12 @@ async function playArtistRadio() {
|
||||||
audioPlayer.pause();
|
audioPlayer.pause();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Play the track first, then enable radio mode after a short delay
|
// Play the track and WAIT for it to resolve (playLibraryTrack is async
|
||||||
// so currentTrack is set and the radio queue fill triggers
|
// and fetches the canonical DB row before setting currentTrack) — so
|
||||||
playLibraryTrack({
|
// 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,
|
id: random.track.id,
|
||||||
title: random.track.title,
|
title: random.track.title,
|
||||||
file_path: random.track.file_path,
|
file_path: random.track.file_path,
|
||||||
|
|
@ -4751,12 +4799,17 @@ async function playArtistRadio() {
|
||||||
album_id: random.album.id,
|
album_id: random.album.id,
|
||||||
}, random.album.title || '', artistName);
|
}, random.album.title || '', artistName);
|
||||||
|
|
||||||
// Enable radio mode after track starts loading
|
// Enable radio mode + immediately seed the queue with similar tracks —
|
||||||
setTimeout(() => {
|
// 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;
|
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');
|
showToast(`Playing ${artistName} radio — similar tracks will auto-queue`, 'success');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
233
webui/static/track-detail.js
Normal file
233
webui/static/track-detail.js
Normal 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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
44
webui/track-detail-modal.html
Normal file
44
webui/track-detail-modal.html
Normal 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()">×</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>
|
||||||
Loading…
Reference in a new issue