commit
117f52bb25
82 changed files with 7485 additions and 823 deletions
|
|
@ -605,7 +605,16 @@ class ConfigManager:
|
|||
"metadata_enhancement": {
|
||||
"enabled": True,
|
||||
"embed_album_art": True,
|
||||
"post_process_order": ["musicbrainz", "deezer", "audiodb", "tidal", "qobuz", "lastfm", "genius"]
|
||||
"post_process_order": ["musicbrainz", "deezer", "audiodb", "tidal", "qobuz", "lastfm", "genius"],
|
||||
# Ordered preferred cover-art sources (empty = use the
|
||||
# download's own art, i.e. today's behavior). Resolved + walked
|
||||
# with fallback by core/metadata/art_sources.py.
|
||||
"album_art_order": [],
|
||||
# Minimum cover-art resolution (shortest side, px). A preferred
|
||||
# source whose art is smaller is skipped so the next source is
|
||||
# tried — stops a low-res Cover Art Archive upload from winning.
|
||||
# 0 disables the size gate.
|
||||
"min_art_size": 1000
|
||||
},
|
||||
"musicbrainz": {
|
||||
"embed_tags": True
|
||||
|
|
|
|||
|
|
@ -282,8 +282,9 @@ class AcoustIDClient:
|
|||
|
||||
def test_api_key(self) -> Tuple[bool, str]:
|
||||
"""
|
||||
Validate the API key by fingerprinting a real audio file and looking it up.
|
||||
Falls back to a direct API call if no audio files are available.
|
||||
Validate the API key with a direct AcoustID lookup call. An invalid key
|
||||
is reported as invalid (error code 4); any other error means the key was
|
||||
accepted.
|
||||
|
||||
Returns:
|
||||
Tuple of (success, message)
|
||||
|
|
@ -294,24 +295,12 @@ class AcoustIDClient:
|
|||
import requests
|
||||
|
||||
try:
|
||||
# Try to find a real audio file to fingerprint for an end-to-end test
|
||||
test_file = self._find_test_audio_file()
|
||||
|
||||
if test_file and CHROMAPRINT_AVAILABLE:
|
||||
logger.info(f"Testing API key with real audio file: {test_file}")
|
||||
try:
|
||||
result = self.fingerprint_and_lookup(test_file)
|
||||
# If we get here without exception, the API key is valid
|
||||
# (invalid keys raise or return error before results)
|
||||
return True, "AcoustID API key is valid"
|
||||
except Exception as e:
|
||||
error_str = str(e).lower()
|
||||
if 'invalid' in error_str and 'api' in error_str:
|
||||
return False, "Invalid AcoustID API key - get one from https://acoustid.org/new-application"
|
||||
# Fingerprint/lookup failed for non-key reasons, fall through to direct test
|
||||
logger.warning(f"Real file test failed ({e}), trying direct API call")
|
||||
|
||||
# Fallback: direct API call with minimal fingerprint
|
||||
# Authoritative key check: a direct API lookup with a dummy
|
||||
# fingerprint. AcoustID validates the client key first, so an
|
||||
# invalid key returns error code 4 regardless of the fingerprint.
|
||||
# (The previous real-file path trusted "no exception = valid", but
|
||||
# fingerprint_and_lookup swallows the invalid-key error and returns
|
||||
# None — so it reported broken keys as valid. #756-adjacent.)
|
||||
url = 'https://api.acoustid.org/v2/lookup'
|
||||
params = {
|
||||
'client': self.api_key,
|
||||
|
|
@ -326,7 +315,6 @@ class AcoustIDClient:
|
|||
if data.get('status') == 'error':
|
||||
error = data.get('error', {})
|
||||
error_code = error.get('code', 0)
|
||||
error_msg = error.get('message', 'Unknown error')
|
||||
|
||||
# Error code 4 is specifically "invalid API key"
|
||||
if error_code == 4:
|
||||
|
|
@ -346,33 +334,33 @@ class AcoustIDClient:
|
|||
logger.error(f"Error testing AcoustID API key: {e}")
|
||||
return False, f"Error: {str(e)}"
|
||||
|
||||
def fingerprint_and_lookup(self, audio_file: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Generate fingerprint and look up recording in AcoustID.
|
||||
def lookup_with_status(self, audio_file: str) -> Dict[str, Any]:
|
||||
"""Fingerprint + AcoustID lookup returning a STRUCTURED result.
|
||||
|
||||
This is the main method - combines fingerprinting and lookup in one call.
|
||||
Unlike fingerprint_and_lookup() (which collapses every outcome into
|
||||
dict-or-None), this distinguishes a genuine no-match from an actual
|
||||
error — an invalid API key, rate limit, missing chromaprint, or a
|
||||
fingerprint failure. That distinction is what lets the UI show "AcoustID
|
||||
Error" (something is broken — fix it) instead of a benign-looking
|
||||
"Skipped" that silently hides a dead key.
|
||||
|
||||
Args:
|
||||
audio_file: Path to the audio file
|
||||
|
||||
Returns:
|
||||
Dict with:
|
||||
'recordings': list of dicts with 'mbid', 'title', 'artist', 'score'
|
||||
'best_score': float (highest score across all results)
|
||||
'recording_mbids': list of unique MBIDs (for backward compat)
|
||||
Or None on error.
|
||||
Returns dict with:
|
||||
'status': 'ok' | 'no_match' | 'error' | 'no_backend'
|
||||
| 'fingerprint_error' | 'unsupported' | 'unavailable'
|
||||
| 'not_found'
|
||||
'recordings': list (meaningful only for 'ok')
|
||||
'best_score': float
|
||||
'recording_mbids': list
|
||||
'error': human-readable detail for any non-'ok' status
|
||||
'invalid_key': bool (True when the API specifically rejected the key)
|
||||
"""
|
||||
if not ACOUSTID_AVAILABLE:
|
||||
logger.debug("Cannot lookup: pyacoustid not available")
|
||||
return None
|
||||
|
||||
return {'status': 'unavailable', 'recordings': [], 'error': 'pyacoustid library not installed'}
|
||||
if not self.api_key:
|
||||
logger.debug("Cannot lookup: no API key")
|
||||
return None
|
||||
|
||||
return {'status': 'unavailable', 'recordings': [], 'error': 'No AcoustID API key configured'}
|
||||
if not os.path.isfile(audio_file):
|
||||
logger.warning(f"Cannot lookup: file not found: {audio_file}")
|
||||
return None
|
||||
return {'status': 'not_found', 'recordings': [], 'error': f'File not found: {audio_file}'}
|
||||
|
||||
# Check channel count — chromaprint crashes (SIGABRT) on >2 channel files (e.g. 5.1 surround)
|
||||
try:
|
||||
|
|
@ -382,7 +370,8 @@ class AcoustIDClient:
|
|||
channels = getattr(mf.info, 'channels', 2)
|
||||
if channels and channels > 2:
|
||||
logger.warning(f"Skipping AcoustID: file has {channels} channels (surround audio): {audio_file}")
|
||||
return None
|
||||
return {'status': 'unsupported', 'recordings': [],
|
||||
'error': f'{channels}-channel (surround) audio not supported by chromaprint'}
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not check channel count, proceeding anyway: {e}")
|
||||
|
||||
|
|
@ -392,17 +381,12 @@ class AcoustIDClient:
|
|||
api_key_preview = f"{self.api_key[:8]}..." if self.api_key and len(self.api_key) > 8 else "NOT SET"
|
||||
logger.info(f"Fingerprinting and looking up: {audio_file} (API key: {api_key_preview})")
|
||||
|
||||
# Use match() which handles fingerprinting + lookup + parsing
|
||||
logger.debug("Running acoustid.match()...")
|
||||
recordings = []
|
||||
seen_mbids = set()
|
||||
best_score = 0.0
|
||||
|
||||
for result in acoustid.match(
|
||||
self.api_key,
|
||||
audio_file,
|
||||
parse=True
|
||||
):
|
||||
for result in acoustid.match(self.api_key, audio_file, parse=True):
|
||||
# match() with parse=True returns (score, recording_id, title, artist)
|
||||
if not isinstance(result, tuple) or len(result) < 2:
|
||||
logger.warning(f"Unexpected result format: {result}")
|
||||
|
|
@ -420,45 +404,57 @@ class AcoustIDClient:
|
|||
|
||||
if recording_id and recording_id not in seen_mbids:
|
||||
seen_mbids.add(recording_id)
|
||||
recordings.append({
|
||||
'mbid': recording_id,
|
||||
'title': title,
|
||||
'artist': artist,
|
||||
'score': score,
|
||||
})
|
||||
recordings.append({'mbid': recording_id, 'title': title, 'artist': artist, 'score': score})
|
||||
logger.debug(f"Found match: {title} by {artist} (MBID: {recording_id}, score: {score})")
|
||||
|
||||
if not recordings:
|
||||
logger.info(f"No AcoustID matches found for: {audio_file}")
|
||||
return None
|
||||
return {'status': 'no_match', 'recordings': [], 'best_score': best_score,
|
||||
'recording_mbids': [], 'error': 'Track not found in AcoustID database'}
|
||||
|
||||
logger.info(f"AcoustID found {len(recordings)} recording(s) (best score: {best_score:.2f})")
|
||||
return {
|
||||
'recordings': recordings,
|
||||
'best_score': best_score,
|
||||
'recording_mbids': list(seen_mbids),
|
||||
}
|
||||
return {'status': 'ok', 'recordings': recordings, 'best_score': best_score,
|
||||
'recording_mbids': list(seen_mbids)}
|
||||
|
||||
except acoustid.NoBackendError:
|
||||
logger.error("Chromaprint library not found and fpcalc not available")
|
||||
return None
|
||||
return {'status': 'no_backend', 'recordings': [],
|
||||
'error': 'Chromaprint/fpcalc not installed (install libchromaprint1)'}
|
||||
except acoustid.FingerprintGenerationError as e:
|
||||
logger.warning(f"Failed to fingerprint {audio_file}: {e}")
|
||||
return None
|
||||
return {'status': 'fingerprint_error', 'recordings': [], 'error': f'Could not fingerprint file: {e}'}
|
||||
except acoustid.WebServiceError as e:
|
||||
# Log more details about the API error
|
||||
api_key_preview = f"{self.api_key[:8]}..." if self.api_key and len(self.api_key) > 8 else "???"
|
||||
logger.warning(f"AcoustID API error (key: {api_key_preview}): {e}")
|
||||
# Check for common errors
|
||||
error_str = str(e).lower()
|
||||
if 'invalid' in error_str or 'unknown' in error_str:
|
||||
logger.error("API key appears to be invalid - check your AcoustID settings")
|
||||
# Old pyacoustid reports an invalid key as the bare "status: error"
|
||||
# (it drops the detail), so treat that as an invalid-key signal too.
|
||||
invalid = ('invalid' in error_str or 'unknown' in error_str or 'status: error' in error_str)
|
||||
if invalid:
|
||||
logger.error("AcoustID API key appears to be invalid — check your AcoustID settings")
|
||||
elif 'rate' in error_str or 'limit' in error_str:
|
||||
logger.warning("Rate limited by AcoustID - will retry later")
|
||||
return None
|
||||
logger.warning("Rate limited by AcoustID — will retry later")
|
||||
return {'status': 'error', 'recordings': [], 'invalid_key': invalid,
|
||||
'error': f'AcoustID API error: {e}'}
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error in AcoustID lookup: {e}", exc_info=True)
|
||||
return None
|
||||
return {'status': 'error', 'recordings': [], 'error': f'Unexpected error: {e}'}
|
||||
|
||||
def fingerprint_and_lookup(self, audio_file: str) -> Optional[Dict[str, Any]]:
|
||||
"""Legacy dict-or-None lookup. Returns the recordings dict on a confirmed
|
||||
match, else None. Kept for callers that only need "did we identify it"
|
||||
(library scanner, auto-import). Callers that must report WHY a lookup
|
||||
didn't match (verification badge, key test) should use
|
||||
``lookup_with_status`` so an error isn't mistaken for a no-match.
|
||||
"""
|
||||
res = self.lookup_with_status(audio_file)
|
||||
if res.get('status') == 'ok':
|
||||
return {
|
||||
'recordings': res['recordings'],
|
||||
'best_score': res.get('best_score', 0.0),
|
||||
'recording_mbids': res.get('recording_mbids', []),
|
||||
}
|
||||
return None
|
||||
|
||||
def refresh_config(self):
|
||||
"""Refresh cached config values (call after settings change)."""
|
||||
|
|
|
|||
|
|
@ -50,8 +50,9 @@ class VerificationResult(Enum):
|
|||
"""Possible outcomes of audio verification."""
|
||||
PASS = "pass" # Title/artist match - file is correct
|
||||
FAIL = "fail" # Title/artist mismatch - wrong file downloaded
|
||||
SKIP = "skip" # Could not verify (error or unavailable) - continue normally
|
||||
SKIP = "skip" # Genuinely couldn't verify (no match in DB) - continue normally
|
||||
DISABLED = "disabled" # Verification not enabled
|
||||
ERROR = "error" # Lookup errored (invalid key / rate limit / no backend) - continue, but flag it
|
||||
|
||||
|
||||
def _normalize(text: str) -> str:
|
||||
|
|
@ -399,18 +400,33 @@ class AcoustIDVerification:
|
|||
logger.debug(f"AcoustID verification skipped: {reason}")
|
||||
return VerificationResult.SKIP, reason
|
||||
|
||||
# Step 2: Fingerprint and lookup in AcoustID
|
||||
# Step 2: Fingerprint and lookup in AcoustID (structured so an
|
||||
# actual error — invalid key / rate limit / no chromaprint — is
|
||||
# reported distinctly from a genuine no-match, instead of both
|
||||
# silently surfacing as "Skipped").
|
||||
logger.info(f"Fingerprinting and looking up: {audio_file_path}")
|
||||
acoustid_result = self.acoustid_client.fingerprint_and_lookup(audio_file_path)
|
||||
lookup = self.acoustid_client.lookup_with_status(audio_file_path) or {}
|
||||
status = lookup.get('status')
|
||||
# Infer status by content when absent (a caller/stub that returned
|
||||
# just recordings): recordings => matched, none => no match.
|
||||
if status is None:
|
||||
status = 'ok' if lookup.get('recordings') else 'no_match'
|
||||
|
||||
if not acoustid_result:
|
||||
return VerificationResult.SKIP, "Track not found in AcoustID database"
|
||||
if status in ('error', 'no_backend', 'fingerprint_error', 'unavailable'):
|
||||
# Something is broken (not the track's fault) — never quarantine
|
||||
# on this; surface it so the user can fix it.
|
||||
return VerificationResult.ERROR, lookup.get('error', 'AcoustID lookup failed')
|
||||
|
||||
if status != 'ok':
|
||||
# no_match / unsupported / not_found — genuinely could not verify.
|
||||
return VerificationResult.SKIP, lookup.get('error', 'No match in AcoustID database')
|
||||
|
||||
acoustid_result = lookup
|
||||
recordings = acoustid_result.get('recordings', [])
|
||||
best_score = acoustid_result.get('best_score', 0)
|
||||
|
||||
if not recordings:
|
||||
return VerificationResult.SKIP, "AcoustID returned no recordings"
|
||||
return VerificationResult.SKIP, "No match in AcoustID database"
|
||||
|
||||
logger.debug(
|
||||
f"AcoustID returned {len(recordings)} recording(s) "
|
||||
|
|
|
|||
|
|
@ -70,7 +70,16 @@ _meta_cache_lock = threading.Lock()
|
|||
|
||||
|
||||
class AmazonClientError(RuntimeError):
|
||||
"""Raised on unrecoverable T2Tunes API errors."""
|
||||
"""Raised on unrecoverable T2Tunes API errors.
|
||||
|
||||
Carries the HTTP ``status_code`` when the failure was an HTTP error, so
|
||||
callers (the worker's outage detection) can tell a source outage (5xx) from
|
||||
a per-item miss without parsing the message.
|
||||
"""
|
||||
|
||||
def __init__(self, *args, status_code=None):
|
||||
super().__init__(*args)
|
||||
self.status_code = status_code
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -703,7 +712,8 @@ class AmazonClient:
|
|||
)
|
||||
continue
|
||||
raise AmazonClientError(
|
||||
f"HTTP {exc.response.status_code} for {url} — body: {body!r}"
|
||||
f"HTTP {exc.response.status_code} for {url} — body: {body!r}",
|
||||
status_code=exc.response.status_code,
|
||||
) from exc
|
||||
except requests.RequestException as exc:
|
||||
raise AmazonClientError(f"Request failed for {url}: {exc}") from exc
|
||||
|
|
|
|||
61
core/amazon_outage.py
Normal file
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.worker_utils import interruptible_sleep, set_album_api_track_count
|
||||
from core.enrichment.manual_match_honoring import honor_stored_match
|
||||
from core.amazon_outage import is_source_outage, next_poll_delay_seconds
|
||||
|
||||
logger = get_logger("amazon_worker")
|
||||
|
||||
|
|
@ -39,6 +40,11 @@ class AmazonWorker:
|
|||
self.retry_days = 30
|
||||
self.name_similarity_threshold = 0.80
|
||||
|
||||
# Source-outage circuit breaker: counts consecutive whole-source
|
||||
# failures (proxy down / "not initialized" / unreachable) so the loop
|
||||
# backs off instead of grinding the whole library item-by-item.
|
||||
self._outage_streak = 0
|
||||
|
||||
logger.info("Amazon background worker initialized")
|
||||
|
||||
def _ensure_amazon_schema(self, cursor) -> None:
|
||||
|
|
@ -151,7 +157,9 @@ class AmazonWorker:
|
|||
continue
|
||||
|
||||
self._process_item(item)
|
||||
interruptible_sleep(self._stop_event, 2)
|
||||
# Normal 2s cadence when healthy; escalating back-off (up to
|
||||
# 30 min) while the source is in an outage streak.
|
||||
interruptible_sleep(self._stop_event, next_poll_delay_seconds(self._outage_streak))
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in worker loop: {e}")
|
||||
|
|
@ -275,7 +283,32 @@ class AmazonWorker:
|
|||
elif item_type == 'track':
|
||||
self._process_track(item_id, item_name, item.get('artist', ''), item)
|
||||
|
||||
# The source answered (match or not_found) — clear any outage streak.
|
||||
if self._outage_streak:
|
||||
logger.info("Amazon source recovered after %d outage(s), resuming",
|
||||
self._outage_streak)
|
||||
self._outage_streak = 0
|
||||
|
||||
except Exception as e:
|
||||
if is_source_outage(e):
|
||||
# The whole source is down (proxy 5xx / "not initialized" /
|
||||
# unreachable). Do NOT mark the item 'error' — that would burn
|
||||
# the entire library to a state the retry tiers never re-attempt
|
||||
# for a transient outage. Leave it untouched so it's retried once
|
||||
# the instance recovers, and let the loop back off. Log once per
|
||||
# streak to avoid flooding.
|
||||
self._outage_streak += 1
|
||||
if self._outage_streak == 1:
|
||||
logger.warning("Amazon source unavailable — pausing enrichment "
|
||||
"until it recovers: %s", e)
|
||||
else:
|
||||
logger.debug("Amazon source still unavailable (streak=%d): %s",
|
||||
self._outage_streak, e)
|
||||
return
|
||||
# A non-outage error means the source actually answered (e.g. a
|
||||
# 404/parse error on a real response), so the outage is over —
|
||||
# clear the streak and handle this as a normal per-item error.
|
||||
self._outage_streak = 0
|
||||
logger.error(f"Error processing {item['type']} #{item['id']}: {e}")
|
||||
self.stats['errors'] += 1
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -49,6 +49,90 @@ class SyncDeps:
|
|||
sync_lock: Any # threading.Lock
|
||||
|
||||
|
||||
async def _database_only_find_track(spotify_track, candidate_pool=None):
|
||||
"""Database-only track matcher used when no media server is connected.
|
||||
|
||||
Patched onto sync_service._find_track_in_media_server. Accepts
|
||||
``candidate_pool`` for interface parity with the real matcher (sync_service
|
||||
calls it with candidate_pool=...); the DB path queries the library directly
|
||||
via check_track_exists, so it doesn't need the per-artist candidate cache —
|
||||
but it MUST accept the kwarg or sync raises "unexpected keyword argument
|
||||
'candidate_pool'". Module-level (not a nested closure) so it's importable
|
||||
and unit-tested.
|
||||
"""
|
||||
logger.info(f"Database-only search for: '{spotify_track.name}' by {spotify_track.artists}")
|
||||
try:
|
||||
from database.music_database import MusicDatabase
|
||||
from config.settings import config_manager
|
||||
|
||||
db = MusicDatabase()
|
||||
active_server = config_manager.get_active_media_server()
|
||||
original_title = spotify_track.name
|
||||
spotify_id = getattr(spotify_track, 'id', '') or ''
|
||||
|
||||
# --- Sync match cache fast-path ---
|
||||
if spotify_id:
|
||||
try:
|
||||
cached = db.read_sync_match_cache(spotify_id, active_server)
|
||||
if cached:
|
||||
db_track_check = db.get_track_by_id(cached['server_track_id'])
|
||||
if db_track_check:
|
||||
class DatabaseTrackCached:
|
||||
def __init__(self, db_t):
|
||||
self.ratingKey = db_t.id
|
||||
self.title = db_t.title
|
||||
self.id = db_t.id
|
||||
logger.debug(f"Sync cache hit: '{original_title}' → server track {cached['server_track_id']}")
|
||||
return DatabaseTrackCached(db_track_check), cached['confidence']
|
||||
logger.warning(f"Sync cache stale for '{original_title}' — track gone")
|
||||
except Exception as e:
|
||||
logger.debug("sync match cache fast-path failed: %s", e)
|
||||
# --- End cache fast-path ---
|
||||
|
||||
# Try each artist
|
||||
for artist in spotify_track.artists:
|
||||
if isinstance(artist, str):
|
||||
artist_name = artist
|
||||
elif isinstance(artist, dict) and 'name' in artist:
|
||||
artist_name = artist['name']
|
||||
else:
|
||||
artist_name = str(artist)
|
||||
|
||||
db_track, confidence = db.check_track_exists(
|
||||
original_title, artist_name,
|
||||
confidence_threshold=0.80,
|
||||
server_source=active_server
|
||||
)
|
||||
|
||||
if db_track and confidence >= 0.80:
|
||||
logger.info(f"Database match: '{db_track.title}' (confidence: {confidence:.2f})")
|
||||
if spotify_id:
|
||||
try:
|
||||
from core.matching_engine import MusicMatchingEngine
|
||||
me = MusicMatchingEngine()
|
||||
db.save_sync_match_cache(
|
||||
spotify_id, me.clean_title(original_title), me.clean_artist(artist_name),
|
||||
active_server, db_track.id, db_track.title, confidence
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug("save sync match cache failed: %s", e)
|
||||
|
||||
class DatabaseTrackMock:
|
||||
def __init__(self, db_track):
|
||||
self.ratingKey = db_track.id
|
||||
self.title = db_track.title
|
||||
self.id = db_track.id
|
||||
|
||||
return DatabaseTrackMock(db_track), confidence
|
||||
|
||||
logger.warning(f"No database match found for: '{original_title}'")
|
||||
return None, 0.0
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Database search error: {e}")
|
||||
return None, 0.0
|
||||
|
||||
|
||||
def run_sync_task(playlist_id, playlist_name, tracks_json, automation_id=None, profile_id=1, playlist_image_url='', deps: SyncDeps = None, sync_mode: str = 'replace'):
|
||||
"""The actual sync function that runs in the background thread."""
|
||||
sync_states = deps.sync_states
|
||||
|
|
@ -260,89 +344,9 @@ def run_sync_task(playlist_id, playlist_name, tracks_json, automation_id=None, p
|
|||
if media_client is None or not media_client.is_connected():
|
||||
logger.info("Media client not connected - patching sync service for database-only matching")
|
||||
|
||||
# Store original method
|
||||
original_find_track = sync_service._find_track_in_media_server
|
||||
|
||||
# Create database-only replacement method
|
||||
async def database_only_find_track(spotify_track):
|
||||
logger.info(f"Database-only search for: '{spotify_track.name}' by {spotify_track.artists}")
|
||||
try:
|
||||
from database.music_database import MusicDatabase
|
||||
from config.settings import config_manager
|
||||
|
||||
db = MusicDatabase()
|
||||
active_server = config_manager.get_active_media_server()
|
||||
original_title = spotify_track.name
|
||||
spotify_id = getattr(spotify_track, 'id', '') or ''
|
||||
|
||||
# --- Sync match cache fast-path ---
|
||||
if spotify_id:
|
||||
try:
|
||||
cached = db.read_sync_match_cache(spotify_id, active_server)
|
||||
if cached:
|
||||
db_track_check = db.get_track_by_id(cached['server_track_id'])
|
||||
if db_track_check:
|
||||
class DatabaseTrackCached:
|
||||
def __init__(self, db_t):
|
||||
self.ratingKey = db_t.id
|
||||
self.title = db_t.title
|
||||
self.id = db_t.id
|
||||
logger.debug(f"Sync cache hit: '{original_title}' → server track {cached['server_track_id']}")
|
||||
return DatabaseTrackCached(db_track_check), cached['confidence']
|
||||
logger.warning(f"Sync cache stale for '{original_title}' — track gone")
|
||||
except Exception as e:
|
||||
logger.debug("sync match cache fast-path failed: %s", e)
|
||||
# --- End cache fast-path ---
|
||||
|
||||
# Try each artist (same logic as original)
|
||||
for artist in spotify_track.artists:
|
||||
# Extract artist name from both string and dict formats
|
||||
if isinstance(artist, str):
|
||||
artist_name = artist
|
||||
elif isinstance(artist, dict) and 'name' in artist:
|
||||
artist_name = artist['name']
|
||||
else:
|
||||
artist_name = str(artist)
|
||||
|
||||
db_track, confidence = db.check_track_exists(
|
||||
original_title, artist_name,
|
||||
confidence_threshold=0.80,
|
||||
server_source=active_server
|
||||
)
|
||||
|
||||
if db_track and confidence >= 0.80:
|
||||
logger.info(f"Database match: '{db_track.title}' (confidence: {confidence:.2f})")
|
||||
|
||||
# Save to sync match cache
|
||||
if spotify_id:
|
||||
try:
|
||||
from core.matching_engine import MusicMatchingEngine
|
||||
me = MusicMatchingEngine()
|
||||
db.save_sync_match_cache(
|
||||
spotify_id, me.clean_title(original_title), me.clean_artist(artist_name),
|
||||
active_server, db_track.id, db_track.title, confidence
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug("save sync match cache failed: %s", e)
|
||||
|
||||
# Create mock track object for playlist creation
|
||||
class DatabaseTrackMock:
|
||||
def __init__(self, db_track):
|
||||
self.ratingKey = db_track.id
|
||||
self.title = db_track.title
|
||||
self.id = db_track.id
|
||||
|
||||
return DatabaseTrackMock(db_track), confidence
|
||||
|
||||
logger.warning(f"No database match found for: '{original_title}'")
|
||||
return None, 0.0
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Database search error: {e}")
|
||||
return None, 0.0
|
||||
|
||||
# Patch the method
|
||||
sync_service._find_track_in_media_server = database_only_find_track
|
||||
# Patch the matcher to the module-level database-only implementation
|
||||
# (importable + unit-tested; accepts candidate_pool for parity).
|
||||
sync_service._find_track_in_media_server = _database_only_find_track
|
||||
logger.info("Patched sync service to use database-only matching")
|
||||
|
||||
sync_start_time = time.time()
|
||||
|
|
|
|||
|
|
@ -21,8 +21,10 @@ folder scan.
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import shutil
|
||||
import time
|
||||
import unicodedata
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Iterable, Optional
|
||||
|
|
@ -32,6 +34,13 @@ from utils.logging_config import get_logger
|
|||
|
||||
logger = get_logger("download_plugins.album_bundle")
|
||||
|
||||
# Minimum album-title relevance a Prowlarr candidate must clear to be eligible
|
||||
# for an album-bundle download (#730). Prowlarr returns broad fuzzy matches — a
|
||||
# "Heroes" search also returns other Bowie albums — so without this gate the
|
||||
# most-popular result wins regardless of whether it's the right album. Below
|
||||
# this floor we refuse the bundle and let the caller fall back to per-track.
|
||||
_ALBUM_TITLE_RELEVANCE_FLOOR = 0.6
|
||||
|
||||
|
||||
# Album-pick size floor / ceiling. Single-track torrents (~10 MB)
|
||||
# are rejected when bigger candidates exist; anything past 3 GB is
|
||||
|
|
@ -92,10 +101,96 @@ def quality_score(title: str, quality_guess) -> int:
|
|||
return _QUALITY_SCORE.get(quality_guess(title) or '', 0)
|
||||
|
||||
|
||||
def pick_best_album_release(candidates, quality_guess) -> Optional[object]:
|
||||
def _normalize_release_text(text: str) -> str:
|
||||
"""Lowercase, fold accents (Björk -> bjork), strip punctuation to spaces.
|
||||
|
||||
NFKD-decompose then drop combining marks so accented characters fold to
|
||||
their base letter instead of fragmenting (the naive approach turned
|
||||
'Björk' into 'bj rk'). Collapses runs of whitespace.
|
||||
"""
|
||||
if not text:
|
||||
return ""
|
||||
decomposed = unicodedata.normalize("NFKD", text)
|
||||
stripped = "".join(c for c in decomposed if not unicodedata.combining(c))
|
||||
lowered = stripped.lower()
|
||||
# Punctuation -> space (so "heroes" matches "heroes:" / "heroes -"),
|
||||
# then collapse whitespace.
|
||||
cleaned = re.sub(r"[^a-z0-9]+", " ", lowered)
|
||||
return re.sub(r"\s+", " ", cleaned).strip()
|
||||
|
||||
|
||||
# Edition / format / qualifier words that appear in stored album names or
|
||||
# release titles but say nothing about WHICH album it is. Stripped before
|
||||
# scoring so "Currents" matches "Currents (Deluxe)" and "Heroes" matches
|
||||
# "Heroes (2017 Remaster)" — the #730 fix must not reject the RIGHT album just
|
||||
# because the DB name carries an edition suffix the torrent title lacks.
|
||||
_ALBUM_NOISE_WORDS = frozenset({
|
||||
"deluxe", "edition", "remaster", "remastered", "remasters", "remix",
|
||||
"expanded", "anniversary", "bonus", "version", "explicit", "clean",
|
||||
"reissue", "special", "limited", "collectors", "collector", "the",
|
||||
"ep", "lp", "album", "single", "disc", "cd", "vol", "volume",
|
||||
"flac", "mp3", "aac", "ogg", "wav", "alac", "m4a", "320", "256", "192",
|
||||
"web", "vinyl", "hi", "res", "hires", "24bit", "16bit", "original",
|
||||
"soundtrack", "ost",
|
||||
})
|
||||
|
||||
|
||||
def _significant_words(normalized: str) -> list:
|
||||
"""Words that actually identify an album: drop pure-digit tokens (years,
|
||||
bitrates) and edition/format noise. Keeps at least the raw words if the
|
||||
filter would empty it (e.g. an album literally named '1989' or 'Deluxe')."""
|
||||
words = [w for w in normalized.split()
|
||||
if w not in _ALBUM_NOISE_WORDS and not w.isdigit()]
|
||||
return words or normalized.split()
|
||||
|
||||
|
||||
def album_title_relevance(candidate_title: str, album_name: str) -> float:
|
||||
"""How well a release title matches the requested album, 0.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.
|
||||
|
||||
Heuristic, in priority order:
|
||||
0. Album-TITLE relevance gate (#730): drop candidates whose title doesn't
|
||||
sufficiently match the requested album. Prowlarr returns broad fuzzy
|
||||
matches, so without this the most-popular result wins even when it's a
|
||||
different album. When ``album_name`` is given and NOTHING clears the
|
||||
relevance floor, return None — the caller then falls back to per-track
|
||||
rather than downloading a confident mismatch.
|
||||
1. Reasonable album-ish size (40 MB – 3 GB) — drops single-track
|
||||
releases that snuck in and quarantines suspicious giants.
|
||||
2. Higher seeders > lower (dead torrents = dead downloads).
|
||||
|
|
@ -106,6 +201,24 @@ def pick_best_album_release(candidates, quality_guess) -> Optional[object]:
|
|||
"""
|
||||
if not candidates:
|
||||
return None
|
||||
|
||||
# 0. Title-relevance gate. Only applied when we know the album name; with
|
||||
# no name we can't judge relevance, so we don't gate (old behavior).
|
||||
if album_name:
|
||||
relevant = [
|
||||
c for c in candidates
|
||||
if album_title_relevance(c.title or "", album_name) >= _ALBUM_TITLE_RELEVANCE_FLOOR
|
||||
]
|
||||
if not relevant:
|
||||
logger.warning(
|
||||
"[Album Bundle] No candidate cleared the title-relevance floor "
|
||||
"for '%s' (%d candidates rejected as wrong album) — refusing the "
|
||||
"bundle so the caller falls back to per-track.",
|
||||
album_name, len(candidates),
|
||||
)
|
||||
return None
|
||||
candidates = relevant
|
||||
|
||||
sized = [c for c in candidates
|
||||
if ALBUM_PICK_MIN_BYTES <= (c.size or 0) <= ALBUM_PICK_MAX_BYTES]
|
||||
pool = sized or list(candidates)
|
||||
|
|
|
|||
|
|
@ -494,12 +494,25 @@ class TorrentDownloadPlugin(DownloadSourcePlugin):
|
|||
candidates = [r for r in search_results
|
||||
if r.protocol == 'torrent' and (r.magnet_uri or r.download_url)]
|
||||
if not candidates:
|
||||
# Album isn't available on this source. Mark the failure as
|
||||
# fallback-eligible so the dispatch returns to the per-track flow
|
||||
# instead of hard-failing the batch — in hybrid mode that lets the
|
||||
# next configured source take over. Without this flag a torrent-first
|
||||
# hybrid would get stuck at "searching" forever when Prowlarr
|
||||
# returns nothing, never trying the other sources.
|
||||
result['error'] = f'No torrent results found for "{query}"'
|
||||
result['fallback'] = True
|
||||
return result
|
||||
|
||||
picked = pick_best_album_release(candidates, _guess_quality_from_title)
|
||||
picked = pick_best_album_release(
|
||||
candidates, _guess_quality_from_title, album_name=album_name,
|
||||
)
|
||||
if picked is None:
|
||||
result['error'] = 'No suitable torrent candidate after filtering'
|
||||
# No candidate matched the requested album (or none passed filtering).
|
||||
# Fall back to the per-track flow rather than downloading a wrong
|
||||
# album (#730) — per-track searches each track individually.
|
||||
result['error'] = 'No torrent candidate matched the requested album'
|
||||
result['fallback'] = True
|
||||
return result
|
||||
|
||||
download_url = picked.magnet_uri or picked.download_url
|
||||
|
|
|
|||
|
|
@ -465,12 +465,22 @@ class UsenetDownloadPlugin(DownloadSourcePlugin):
|
|||
candidates = [r for r in search_results
|
||||
if r.protocol == 'usenet' and r.download_url]
|
||||
if not candidates:
|
||||
# Album isn't available on this source — fall back to the per-track
|
||||
# flow (next configured source in hybrid mode) rather than hard-
|
||||
# failing the whole batch. Mirrors the torrent plugin + soulseek's
|
||||
# default fallback contract.
|
||||
result['error'] = f'No usenet results found for "{query}"'
|
||||
result['fallback'] = True
|
||||
return result
|
||||
|
||||
picked = pick_best_album_release(candidates, _guess_quality_from_title)
|
||||
picked = pick_best_album_release(
|
||||
candidates, _guess_quality_from_title, album_name=album_name,
|
||||
)
|
||||
if picked is None:
|
||||
result['error'] = 'No suitable NZB candidate after filtering'
|
||||
# No candidate matched the requested album (or none passed filtering).
|
||||
# Fall back to per-track rather than grabbing a wrong album (#730).
|
||||
result['error'] = 'No NZB candidate matched the requested album'
|
||||
result['fallback'] = True
|
||||
return result
|
||||
|
||||
logger.info("[Usenet album] Picked '%s' (size=%.1fMB grabs=%s indexer=%s)",
|
||||
|
|
|
|||
|
|
@ -173,7 +173,22 @@ def try_dispatch(
|
|||
)
|
||||
except Exception as exc:
|
||||
logger.exception("[Album Bundle] %s plugin raised: %s", mode, exc)
|
||||
outcome = {'success': False, 'error': f'Plugin error: {exc}'}
|
||||
# An OSError means an I/O step failed after the source already had the
|
||||
# album — most importantly the staging dir not being writable (#760),
|
||||
# but also any transient filesystem error. Treat it as fallback-eligible
|
||||
# so we return to the per-track flow instead of hard-failing the whole
|
||||
# batch (the #715 symptom: files download, then the batch fails).
|
||||
# Programming errors (TypeError, KeyError, …) are NOT OSError and stay
|
||||
# terminal, so genuine bugs still fail loudly. (requests' network
|
||||
# exceptions also subclass OSError, but plugins normally catch those
|
||||
# internally and return an outcome rather than raising; if one does
|
||||
# surface here, falling back to per-track is still the safe choice.)
|
||||
is_io_failure = isinstance(exc, OSError)
|
||||
outcome = {
|
||||
'success': False,
|
||||
'error': f'Plugin error: {exc}',
|
||||
'fallback': is_io_failure,
|
||||
}
|
||||
|
||||
if not outcome.get('success'):
|
||||
err = outcome.get('error', 'Album bundle download failed')
|
||||
|
|
|
|||
|
|
@ -34,11 +34,22 @@ logger = logging.getLogger(__name__)
|
|||
def _private_album_bundle_staging_miss_reason(batch_id: Optional[str], deps: Any) -> Optional[str]:
|
||||
"""Return a user-facing miss reason when per-track search should stop.
|
||||
|
||||
Torrent / usenet / Soulseek album batches first download one private staged release,
|
||||
Torrent / usenet album batches first download one private staged release,
|
||||
then each track claims the matching staged file. If that claim fails after
|
||||
the release is already staged, falling through to the normal per-track
|
||||
search only retries release-level sources N times and can keep re-adding
|
||||
the same torrent. Treat the staged release as authoritative for this pass.
|
||||
the same torrent/NZB. For those two sources we treat the staged release as
|
||||
authoritative for this pass.
|
||||
|
||||
Soulseek is deliberately NOT short-circuited. A Soulseek album bundle stages
|
||||
whichever single folder scored best, and ``album_bundle_partial`` only
|
||||
reflects whether the files found IN that folder downloaded — not whether the
|
||||
folder actually contained every track the album needs. So a track the album
|
||||
needs but that wasn't in the chosen folder would otherwise be marked
|
||||
not_found with no fallback (#743). Unlike torrent/usenet, Soulseek per-track
|
||||
search is a genuine per-file network search — it doesn't re-add a release —
|
||||
so letting these misses fall through to the normal per-track flow (and, in
|
||||
hybrid mode, onward to the next source) is correct and cheap.
|
||||
"""
|
||||
if not batch_id:
|
||||
return None
|
||||
|
|
@ -60,7 +71,7 @@ def _private_album_bundle_staging_miss_reason(batch_id: Optional[str], deps: Any
|
|||
batch.get('album_bundle_private_staging')
|
||||
and batch.get('album_bundle_state') == 'staged'
|
||||
and not batch.get('album_bundle_partial')
|
||||
and source in ('torrent', 'usenet', 'soulseek')
|
||||
and source in ('torrent', 'usenet')
|
||||
and (mode == source or (mode == 'hybrid' and hybrid_first == source))
|
||||
):
|
||||
return f'Track was not found in the staged {source} album release'
|
||||
|
|
|
|||
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}")
|
||||
|
||||
declared_size = response.headers.get("Content-Length")
|
||||
expected_bytes = None
|
||||
try:
|
||||
if declared_size and int(declared_size) > self.max_download_bytes:
|
||||
raise ImageCacheError("Image exceeds configured size limit")
|
||||
if declared_size:
|
||||
expected_bytes = int(declared_size)
|
||||
if expected_bytes > self.max_download_bytes:
|
||||
raise ImageCacheError("Image exceeds configured size limit")
|
||||
except ValueError:
|
||||
pass
|
||||
expected_bytes = None
|
||||
|
||||
ext = mimetypes.guess_extension(mime_type) or ".img"
|
||||
if ext == ".jpe":
|
||||
|
|
@ -205,6 +208,22 @@ class ImageCache:
|
|||
if total <= 0:
|
||||
raise ImageCacheError("Image response was empty")
|
||||
|
||||
# Truncation guard (#750): a dropped/short connection makes
|
||||
# iter_content end early WITHOUT raising, so a partial image would
|
||||
# otherwise be committed as status='ok' and cached permanently —
|
||||
# rendering as a half-decoded cover (top strip, rest grey). If the
|
||||
# server declared a Content-Length and we got fewer bytes, treat it
|
||||
# as a failed download: discard the tmp file and don't cache it, so
|
||||
# the next request retries fresh instead of serving a broken file.
|
||||
if expected_bytes is not None and total < expected_bytes:
|
||||
try:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
except Exception as cleanup_exc:
|
||||
logger.debug("image_cache tmp cleanup failed: %s", cleanup_exc)
|
||||
raise ImageCacheError(
|
||||
f"Truncated image download: got {total} of {expected_bytes} bytes"
|
||||
)
|
||||
|
||||
os.replace(tmp_path, path)
|
||||
expires_at = now + self.ttl_seconds
|
||||
with self._db_lock:
|
||||
|
|
|
|||
|
|
@ -183,11 +183,29 @@ def check_audio_integrity(
|
|||
checks["actual_length_s"] = actual_length_s
|
||||
|
||||
if actual_length_s <= 0:
|
||||
# Length 0 is NOT proof of corruption here: the file already passed the
|
||||
# size gate, was identified as a real audio format, and has a valid
|
||||
# info block. A genuinely empty/truncated/stub file fails one of those
|
||||
# earlier checks instead. The real cause of a clean-but-zero-length
|
||||
# parse is "length unknown" — fragmented / streamed FLAC carries
|
||||
# total_samples=0 in its STREAMINFO even though every audio frame is
|
||||
# present and the file plays fine. HiFi is the common trigger: it
|
||||
# assembles FLAC from HLS segments and demuxes with `ffmpeg -c copy`,
|
||||
# which preserves total_samples=0, so mutagen computes length 0 and the
|
||||
# file was wrongly quarantined (#756). Treat it as unknown length:
|
||||
# accept the file and skip the duration cross-check we can't perform
|
||||
# without a length. mutagen never decoded/validated frame data anyway,
|
||||
# so accepting here doesn't weaken real corruption detection.
|
||||
logger.warning(
|
||||
"[Integrity] %s parsed cleanly (%d bytes, format=%s) but reports "
|
||||
"length 0 — treating as unknown length (likely streamed/fragmented "
|
||||
"FLAC), not rejecting",
|
||||
os.path.basename(file_path), size, type(audio).__name__,
|
||||
)
|
||||
return IntegrityResult(
|
||||
ok=False,
|
||||
reason="Mutagen reports zero-length audio — file has no playable "
|
||||
"audio data",
|
||||
checks={**checks, "mutagen_parse": "zero_length"},
|
||||
ok=True,
|
||||
checks={**checks, "mutagen_parse": "zero_length_unknown",
|
||||
"length_check": "skipped_unknown_length"},
|
||||
)
|
||||
|
||||
# --- Check 3: duration agreement (optional) ---
|
||||
|
|
|
|||
|
|
@ -92,12 +92,22 @@ def _should_skip_quarantine_check(context: dict, check_name: str) -> bool:
|
|||
def _mark_task_quarantined(context: dict, quarantine_path: str | None) -> None:
|
||||
if not quarantine_path:
|
||||
return
|
||||
entry_id = entry_id_from_quarantined_filename(quarantine_path)
|
||||
# Always stash the entry id on the context. The verification wrapper
|
||||
# (post_process_matched_download_with_verification) pops task_id/batch_id
|
||||
# OUT of the context before running the inner pipeline, so when a quarantine
|
||||
# happens inside that inner run context.get('task_id') is None here and the
|
||||
# direct write below no-ops. The wrapper restores task_id afterward and
|
||||
# applies this stashed id to the real task — without this, downloads that go
|
||||
# through the wrapper (album-bundle / staging path) quarantined with no
|
||||
# quarantine_entry_id, so the UI had no way to manage the file (#756-adjacent).
|
||||
context['_quarantine_entry_id'] = entry_id
|
||||
task_id = context.get('task_id')
|
||||
if not task_id:
|
||||
return
|
||||
with tasks_lock:
|
||||
if task_id in download_tasks:
|
||||
download_tasks[task_id]['quarantine_entry_id'] = entry_id_from_quarantined_filename(quarantine_path)
|
||||
download_tasks[task_id]['quarantine_entry_id'] = entry_id
|
||||
|
||||
|
||||
def build_import_pipeline_runtime(
|
||||
|
|
@ -948,6 +958,9 @@ def post_process_matched_download_with_verification(context_key, context, file_p
|
|||
if task_id in download_tasks:
|
||||
download_tasks[task_id]['status'] = 'failed'
|
||||
download_tasks[task_id]['error_message'] = f"AcoustID verification failed: {failure_msg}"
|
||||
_eid = context.get('_quarantine_entry_id')
|
||||
if _eid:
|
||||
download_tasks[task_id]['quarantine_entry_id'] = _eid
|
||||
with matched_context_lock:
|
||||
if context_key in matched_downloads_context:
|
||||
del matched_downloads_context[context_key]
|
||||
|
|
@ -1001,6 +1014,9 @@ def post_process_matched_download_with_verification(context_key, context, file_p
|
|||
download_tasks[task_id]['error_message'] = (
|
||||
f"File integrity check failed: {failure_msg}"
|
||||
)
|
||||
_eid = context.get('_quarantine_entry_id')
|
||||
if _eid:
|
||||
download_tasks[task_id]['quarantine_entry_id'] = _eid
|
||||
with matched_context_lock:
|
||||
if context_key in matched_downloads_context:
|
||||
del matched_downloads_context[context_key]
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ from __future__ import annotations
|
|||
import json
|
||||
import os
|
||||
import shutil
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
|
|
@ -289,6 +290,56 @@ def _restore_filename(quarantined_filename: str, sidecar_original: Optional[str]
|
|||
return base
|
||||
|
||||
|
||||
def get_quarantine_entry_stream_info(
|
||||
quarantine_dir: str, entry_id: str
|
||||
) -> Optional[Tuple[str, str]]:
|
||||
"""Resolve a quarantined entry to ``(file_path, original_extension)`` for
|
||||
in-app playback.
|
||||
|
||||
The on-disk file carries a ``.quarantined`` suffix, so its own extension is
|
||||
useless for picking an audio MIME type. Recover the real extension from the
|
||||
sidecar's ``original_filename`` when present, else from the quarantine
|
||||
filename convention. Returns None when the entry's file can't be found.
|
||||
"""
|
||||
file_path, sidecar_path = _resolve_entry_paths(quarantine_dir, entry_id)
|
||||
if not file_path or not os.path.isfile(file_path):
|
||||
return None
|
||||
|
||||
sidecar_original: Optional[str] = None
|
||||
if sidecar_path:
|
||||
try:
|
||||
with open(sidecar_path, encoding="utf-8") as f:
|
||||
sidecar_original = json.load(f).get("original_filename")
|
||||
except Exception as exc:
|
||||
logger.debug("stream-info: sidecar read failed for %s: %s", entry_id, exc)
|
||||
|
||||
original_name = _restore_filename(os.path.basename(file_path), sidecar_original)
|
||||
extension = os.path.splitext(original_name)[1].lower()
|
||||
return file_path, extension
|
||||
|
||||
|
||||
def _move_with_retry(src: str, dst: str, attempts: int = 4, delay: float = 0.4) -> bool:
|
||||
"""Move a file, retrying briefly on transient OS locks.
|
||||
|
||||
On Windows a still-open read handle (e.g. the in-app quarantine preview
|
||||
player that just streamed the file) makes shutil.move raise
|
||||
PermissionError / WinError 32 "file in use". The handle is released a beat
|
||||
after playback stops, so a few short retries clear the common case without
|
||||
failing the whole Approve/Recover. Returns True on success.
|
||||
"""
|
||||
last_exc: Optional[BaseException] = None
|
||||
for i in range(attempts):
|
||||
try:
|
||||
shutil.move(src, dst)
|
||||
return True
|
||||
except OSError as exc:
|
||||
last_exc = exc
|
||||
if i < attempts - 1:
|
||||
time.sleep(delay)
|
||||
logger.error("move failed after %d attempts: %s -> %s: %s", attempts, src, dst, last_exc)
|
||||
return False
|
||||
|
||||
|
||||
def approve_quarantine_entry(
|
||||
quarantine_dir: str,
|
||||
entry_id: str,
|
||||
|
|
@ -334,10 +385,9 @@ def approve_quarantine_entry(
|
|||
restored_path = os.path.join(restore_dir, original_name)
|
||||
restored_path = _ensure_unique_path(restored_path)
|
||||
|
||||
try:
|
||||
shutil.move(file_path, restored_path)
|
||||
except OSError as exc:
|
||||
logger.error("approve: failed to restore %s -> %s: %s", file_path, restored_path, exc)
|
||||
if not _move_with_retry(file_path, restored_path):
|
||||
logger.error("approve: failed to restore %s -> %s (file may still be in use)",
|
||||
file_path, restored_path)
|
||||
return None
|
||||
|
||||
try:
|
||||
|
|
@ -377,10 +427,8 @@ def recover_to_staging(
|
|||
os.makedirs(staging_dir, exist_ok=True)
|
||||
target = _ensure_unique_path(os.path.join(staging_dir, restored_name))
|
||||
|
||||
try:
|
||||
shutil.move(file_path, target)
|
||||
except OSError as exc:
|
||||
logger.error("recover: failed to move %s -> %s: %s", file_path, target, exc)
|
||||
if not _move_with_retry(file_path, target):
|
||||
logger.error("recover: failed to move %s -> %s (file may still be in use)", file_path, target)
|
||||
return None
|
||||
|
||||
if sidecar_path and os.path.isfile(sidecar_path):
|
||||
|
|
|
|||
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")
|
||||
|
||||
|
||||
def normalize_library_track_id(value: Any) -> Optional[str]:
|
||||
"""Normalize an incoming library_track_id to the opaque string id we store.
|
||||
|
||||
Library track ids are ``str(ratingKey)`` — numeric strings for Plex, but
|
||||
GUIDs/hashes for Jellyfin, Navidrome, and other Subsonic servers (see
|
||||
``tracks.id`` which is TEXT). They must NOT be coerced to int: doing so
|
||||
rejected every non-numeric id with "Invalid library track id". We only
|
||||
reject empty/None here; the caller validates existence against the DB.
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
text = str(value).strip()
|
||||
return text or None
|
||||
|
||||
|
||||
def save_match(
|
||||
db,
|
||||
profile_id: int,
|
||||
source: str,
|
||||
source_track_id: str,
|
||||
library_track_id: int,
|
||||
library_track_id: str,
|
||||
**meta,
|
||||
) -> bool:
|
||||
"""Save (insert or replace) a manual match."""
|
||||
|
|
|
|||
291
core/metadata/art_lookup.py
Normal file
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 urllib.parse import quote, urlparse
|
||||
|
||||
from core.imports.context import get_import_context_album
|
||||
from core.imports.context import get_import_context_album, get_import_context_artist
|
||||
from core.metadata.common import (
|
||||
get_config_manager,
|
||||
get_image_dimensions,
|
||||
|
|
@ -330,6 +330,35 @@ def _fetch_art_bytes(art_url: str):
|
|||
return None, None
|
||||
|
||||
|
||||
def _min_size_art_validator(min_px):
|
||||
"""Build a ``(validate, cache)`` pair for the preferred-art resolver.
|
||||
|
||||
``validate(source, url)`` fetches the candidate cover, caches its bytes (so
|
||||
the winning source isn't fetched twice), and accepts it only when its
|
||||
shortest side is at least ``min_px``. A too-small cover — e.g. a low-res
|
||||
Cover Art Archive upload — is rejected so the resolver falls through to the
|
||||
next source instead of letting it win on priority alone. Images whose
|
||||
dimensions can't be read are accepted (don't over-reject; the fallback is
|
||||
still today's art). ``min_px <= 0`` disables the size gate entirely.
|
||||
"""
|
||||
cache = {}
|
||||
|
||||
def validate(_source, url):
|
||||
res = _fetch_art_bytes(url)
|
||||
cache[url] = res
|
||||
data = res[0] if res else None
|
||||
if not data:
|
||||
return False
|
||||
if not min_px or min_px <= 0:
|
||||
return True
|
||||
dims = get_image_dimensions(data)
|
||||
if not dims:
|
||||
return True
|
||||
return min(dims[0] or 0, dims[1] or 0) >= min_px
|
||||
|
||||
return validate, cache
|
||||
|
||||
|
||||
def embed_album_art_metadata(audio_file, metadata: dict):
|
||||
cfg = get_config_manager()
|
||||
symbols = get_mutagen_symbols()
|
||||
|
|
@ -340,8 +369,31 @@ def embed_album_art_metadata(audio_file, metadata: dict):
|
|||
image_data = None
|
||||
mime_type = None
|
||||
|
||||
# User-preferred cover-art source. When album_art_order is a non-empty
|
||||
# list it is the SOLE authority for preferred art (put 'caa' in it to use
|
||||
# Cover Art Archive), and the legacy prefer_caa_art toggle below is
|
||||
# skipped. With no list this is a no-op and behavior is exactly as before.
|
||||
album_art_order = cfg.get("metadata_enhancement.album_art_order")
|
||||
art_list_active = isinstance(album_art_order, (list, tuple)) and len(album_art_order) > 0
|
||||
try:
|
||||
from core.metadata.art_lookup import select_preferred_art_url
|
||||
_validate, _art_cache = _min_size_art_validator(
|
||||
cfg.get("metadata_enhancement.min_art_size", 1000))
|
||||
preferred_url = select_preferred_art_url(
|
||||
metadata.get("album_artist") or metadata.get("artist"),
|
||||
metadata.get("album"),
|
||||
metadata,
|
||||
album_art_order,
|
||||
validate=_validate,
|
||||
)
|
||||
if preferred_url:
|
||||
cached = _art_cache.get(preferred_url)
|
||||
image_data, mime_type = cached if (cached and cached[0]) else _fetch_art_bytes(preferred_url)
|
||||
except Exception as exc:
|
||||
logger.debug("Preferred art-source selection failed: %s", exc)
|
||||
|
||||
release_mbid = metadata.get("musicbrainz_release_id")
|
||||
if release_mbid and cfg.get("metadata_enhancement.prefer_caa_art", False):
|
||||
if not image_data and not art_list_active and release_mbid and cfg.get("metadata_enhancement.prefer_caa_art", False):
|
||||
try:
|
||||
# 1200px CDN thumbnail, not the flaky bare /front original.
|
||||
caa_url = f"https://coverartarchive.org/release/{release_mbid}/front-1200"
|
||||
|
|
@ -395,7 +447,12 @@ def download_cover_art(album_info: dict, target_dir: str, context: dict = None):
|
|||
cover_path = os.path.join(target_dir, "cover.jpg")
|
||||
album_info = album_info or {}
|
||||
release_mbid = album_info.get("musicbrainz_release_id")
|
||||
prefer_caa = cfg.get("metadata_enhancement.prefer_caa_art", False)
|
||||
# When a preferred-art priority list is configured it is the sole
|
||||
# authority, so the legacy CAA toggle is neutralized for this whole
|
||||
# function (it gates the existing-file upgrade logic too).
|
||||
_art_order = cfg.get("metadata_enhancement.album_art_order")
|
||||
_art_list_active = isinstance(_art_order, (list, tuple)) and len(_art_order) > 0
|
||||
prefer_caa = cfg.get("metadata_enhancement.prefer_caa_art", False) and not _art_list_active
|
||||
|
||||
if os.path.exists(cover_path):
|
||||
if release_mbid and prefer_caa:
|
||||
|
|
@ -412,7 +469,31 @@ def download_cover_art(album_info: dict, target_dir: str, context: dict = None):
|
|||
is_upgrade = False
|
||||
|
||||
image_data = None
|
||||
if release_mbid and prefer_caa:
|
||||
|
||||
# User-preferred cover-art source (no-op unless album_art_order is set).
|
||||
# cover.jpg only supports the artist+album sources here (no MBID in
|
||||
# album_info), which matches today's CAA-only special-casing.
|
||||
try:
|
||||
from core.metadata.art_lookup import select_preferred_art_url
|
||||
artist_ctx = get_import_context_artist(context) if context else {}
|
||||
_validate, _art_cache = _min_size_art_validator(
|
||||
cfg.get("metadata_enhancement.min_art_size", 1000))
|
||||
preferred_url = select_preferred_art_url(
|
||||
(artist_ctx or {}).get("name"),
|
||||
album_info.get("album_name"),
|
||||
album_info,
|
||||
cfg.get("metadata_enhancement.album_art_order"),
|
||||
validate=_validate,
|
||||
)
|
||||
if preferred_url:
|
||||
cached = _art_cache.get(preferred_url)
|
||||
pref_data = cached[0] if (cached and cached[0]) else _fetch_art_bytes(preferred_url)[0]
|
||||
if pref_data and len(pref_data) > 1000:
|
||||
image_data = pref_data
|
||||
except Exception as exc:
|
||||
logger.debug("Preferred art-source selection failed: %s", exc)
|
||||
|
||||
if not image_data and release_mbid and prefer_caa:
|
||||
try:
|
||||
# 1200px CDN thumbnail, not the flaky bare /front original.
|
||||
caa_url = f"https://coverartarchive.org/release/{release_mbid}/front-1200"
|
||||
|
|
|
|||
|
|
@ -14,6 +14,26 @@ from typing import Optional, Dict, List, Tuple
|
|||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Hard ceiling on cached entity rows. TTL-only eviction has no upper bound, so
|
||||
# heavy discovery/enrichment within the TTL window let this table reach ~1.8M
|
||||
# rows / 7.6 GB (and helped bloat the main DB toward a corruption incident).
|
||||
# Beyond this cap we evict least-recently-ACCESSED rows (LRU) — the data to do
|
||||
# it (last_accessed_at) is already stored. Tunable via config; 0 disables.
|
||||
_DEFAULT_MAX_CACHE_ENTITIES = 250_000
|
||||
|
||||
|
||||
def entities_to_evict_for_capacity(total_rows: int, max_rows: int) -> int:
|
||||
"""Pure decision: how many LRU rows to drop to bring the cache to ``max_rows``.
|
||||
|
||||
Separated from SQL so it's unit-testable (mirrors core.db_integrity.
|
||||
prune_backups). ``max_rows <= 0`` means "no cap" -> never evict. Never
|
||||
returns negative.
|
||||
"""
|
||||
if max_rows <= 0:
|
||||
return 0
|
||||
return max(0, total_rows - max_rows)
|
||||
|
||||
|
||||
# Singleton
|
||||
_cache_instance = None
|
||||
_cache_lock = threading.Lock()
|
||||
|
|
@ -619,6 +639,43 @@ class MetadataCache:
|
|||
|
||||
return self._run_maintenance_write("Cache eviction", _operation)
|
||||
|
||||
def evict_over_capacity(self, max_rows: int = _DEFAULT_MAX_CACHE_ENTITIES) -> int:
|
||||
"""Enforce a hard row ceiling: if the entity cache exceeds ``max_rows``,
|
||||
delete the least-recently-ACCESSED rows down to the cap (LRU). This is
|
||||
the bound TTL-only eviction lacked. Returns the count evicted.
|
||||
|
||||
Runs AFTER evict_expired in the maintenance job, so TTL-expired and junk
|
||||
rows are already gone — this only trims a still-oversized healthy cache.
|
||||
"""
|
||||
def _operation(conn):
|
||||
cursor = conn.cursor()
|
||||
total = cursor.execute(
|
||||
"SELECT COUNT(*) FROM metadata_cache_entities"
|
||||
).fetchone()[0]
|
||||
to_evict = entities_to_evict_for_capacity(total, max_rows)
|
||||
if to_evict <= 0:
|
||||
return 0
|
||||
# Delete the oldest-accessed rows. NULL last_accessed_at sorts first
|
||||
# (evicted earliest) — those were never touched since insert.
|
||||
cursor.execute("""
|
||||
DELETE FROM metadata_cache_entities
|
||||
WHERE id IN (
|
||||
SELECT id FROM metadata_cache_entities
|
||||
ORDER BY last_accessed_at ASC
|
||||
LIMIT ?
|
||||
)
|
||||
""", (to_evict,))
|
||||
evicted = cursor.rowcount
|
||||
conn.commit()
|
||||
if evicted > 0:
|
||||
logger.info(
|
||||
"Cache over capacity (%d > %d) — evicted %d least-recently-used entities",
|
||||
total, max_rows, evicted,
|
||||
)
|
||||
return evicted
|
||||
|
||||
return self._run_maintenance_write("Cache capacity eviction", _operation)
|
||||
|
||||
def clean_junk_entities(self) -> int:
|
||||
"""Delete cached entities with empty/placeholder names."""
|
||||
def _operation(conn):
|
||||
|
|
|
|||
|
|
@ -7,6 +7,19 @@ from utils.logging_config import get_logger
|
|||
|
||||
logger = get_logger("musicbrainz_client")
|
||||
|
||||
# Lucene query-syntax characters that must be backslash-escaped when a
|
||||
# user-supplied value is interpolated into a query (e.g. artist names like
|
||||
# "Sunn O)))", "Anthony Green (Saosin)", "Therapy?", "!!!"). Without this an
|
||||
# unbalanced paren or a stray ?/* either breaks the field group (returning
|
||||
# unrelated results) or yields zero hits.
|
||||
_LUCENE_SPECIAL = set('+-&|!(){}[]^"~*?:\\/')
|
||||
|
||||
|
||||
def _escape_lucene(text: str) -> str:
|
||||
"""Backslash-escape Lucene special characters in a user-supplied term."""
|
||||
return ''.join('\\' + ch if ch in _LUCENE_SPECIAL else ch for ch in text)
|
||||
|
||||
|
||||
# Global rate limiting variables
|
||||
_last_api_call_time = 0
|
||||
_api_call_lock = threading.Lock()
|
||||
|
|
@ -158,14 +171,14 @@ class MusicBrainzClient:
|
|||
safe_artist = artist_name.replace('\\', '\\\\').replace('"', '\\"')
|
||||
query += f' AND artist:"{safe_artist}"'
|
||||
else:
|
||||
# Bare query — MB tokenizes against title + artist credit +
|
||||
# alias + sortname indexes together with diacritic folding.
|
||||
# Recovers cases like "Bjork" → "Björk" that strict phrase
|
||||
# queries miss.
|
||||
parts = [album_name]
|
||||
if artist_name:
|
||||
parts.append(artist_name)
|
||||
query = ' '.join(p for p in parts if p)
|
||||
# Loose title terms (no phrase quotes → diacritic folding and
|
||||
# alias/sortname recall, e.g. "Bjork" → "Björk"), but
|
||||
# FIELD-SCOPE the artist so it constrains rather than floating
|
||||
# as a free fuzzy term that lets unrelated releases whose titles
|
||||
# echo the artist name rank first (same root cause as #754).
|
||||
query = album_name
|
||||
if artist_name and artist_name.strip():
|
||||
query += f' AND artist:({_escape_lucene(artist_name.strip())})'
|
||||
|
||||
params = {
|
||||
'query': query,
|
||||
|
|
@ -223,18 +236,24 @@ class MusicBrainzClient:
|
|||
safe_artist = artist_name.replace('\\', '\\\\').replace('"', '\\"')
|
||||
query += f' AND artist:"{safe_artist}"'
|
||||
else:
|
||||
# Bare query — see search_release for rationale.
|
||||
parts = [track_name]
|
||||
if artist_name:
|
||||
parts.append(artist_name)
|
||||
query = ' '.join(p for p in parts if p)
|
||||
# Loose track terms (no phrase quotes → tolerant of bracketed
|
||||
# suffixes and diacritics, hitting MB's alias/sortname indexes),
|
||||
# but FIELD-SCOPE the artist so it actually constrains results.
|
||||
# A bare "track artist" blob let the artist float as a free
|
||||
# fuzzy term, so covers/karaoke whose TITLES contain the artist
|
||||
# name outranked the real recording (#754: "Sweet Child O Mine"
|
||||
# / "Guns N Roses" returned only covers). Scoping still folds
|
||||
# diacritics — artist:(Bjork) matches "Björk".
|
||||
query = track_name
|
||||
if artist_name and artist_name.strip():
|
||||
query += f' AND artist:({_escape_lucene(artist_name.strip())})'
|
||||
|
||||
params = {
|
||||
'query': query,
|
||||
'fmt': 'json',
|
||||
'limit': limit
|
||||
}
|
||||
|
||||
|
||||
response = self.session.get(
|
||||
f"{self.BASE_URL}/recording",
|
||||
params=params,
|
||||
|
|
|
|||
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)
|
||||
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
|
||||
|
|
|
|||
|
|
@ -1364,8 +1364,11 @@ class RepairWorker:
|
|||
return {'success': False, 'error': f'Selected track ID {keep_id} not found in duplicates'}
|
||||
best_id = keep_id
|
||||
else:
|
||||
# Auto-pick: highest bitrate, then longest duration, then highest track number (correct > 01)
|
||||
best = max(tracks, key=lambda t: (t.get('bitrate', 0) or 0, t.get('duration', 0) or 0, t.get('track_number', 0) or 0))
|
||||
# Auto-pick the keeper: lossless format first (so a FLAC beats an
|
||||
# MP3 even when the FLAC's bitrate is missing in the DB), then
|
||||
# bitrate, duration, and track number as tie-breakers.
|
||||
from core.library.duplicate_keep import pick_duplicate_to_keep
|
||||
best = pick_duplicate_to_keep(tracks)
|
||||
best_id = best.get('track_id') or best.get('id')
|
||||
|
||||
if not best_id:
|
||||
|
|
|
|||
140
core/streaming/state.py
Normal file
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:
|
||||
conn.close()
|
||||
|
||||
def record_web_player_play(self, event):
|
||||
"""Record a single SoulSync web-player play: insert the listening_history
|
||||
row AND bump tracks.play_count / last_played for the smart-radio recency
|
||||
signal. ``event`` is the dict from core.playback.play_log.build_play_event.
|
||||
|
||||
Returns True if the history row was newly inserted.
|
||||
"""
|
||||
if not event:
|
||||
return False
|
||||
inserted = self.insert_listening_events([event])
|
||||
db_id = event.get('db_track_id')
|
||||
if db_id is not None:
|
||||
conn = None
|
||||
try:
|
||||
conn = self._get_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
UPDATE tracks
|
||||
SET play_count = COALESCE(play_count, 0) + 1,
|
||||
last_played = ?
|
||||
WHERE id = ?
|
||||
""", (event.get('played_at'), db_id))
|
||||
conn.commit()
|
||||
except Exception as e:
|
||||
logger.error(f"Error bumping play_count for track {db_id}: {e}")
|
||||
finally:
|
||||
if conn:
|
||||
conn.close()
|
||||
return inserted > 0
|
||||
|
||||
def update_track_play_counts(self, counts):
|
||||
"""Update play_count and last_played on the tracks table.
|
||||
|
||||
|
|
@ -4237,9 +4267,17 @@ class MusicDatabase:
|
|||
def _add_metadata_cache_tables(self, cursor):
|
||||
"""Create metadata_cache_entities and metadata_cache_searches tables for universal API response caching"""
|
||||
try:
|
||||
# Skip only when the marker is set AND the tables actually exist.
|
||||
# A marker-only guard is fragile: if the `metadata` table survives a
|
||||
# corruption-recovery but the (large) cache tables don't, the stale
|
||||
# marker would permanently short-circuit creation and the metadata
|
||||
# cache would silently never work again (nothing lands in the
|
||||
# browser). Verifying the table presence makes this self-heal.
|
||||
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='metadata_cache_entities'")
|
||||
tables_present = cursor.fetchone() is not None
|
||||
cursor.execute("SELECT value FROM metadata WHERE key = 'metadata_cache_v1' LIMIT 1")
|
||||
if cursor.fetchone():
|
||||
return # Already migrated
|
||||
if cursor.fetchone() and tables_present:
|
||||
return # Already migrated and tables present
|
||||
|
||||
logger.info("Creating metadata cache tables...")
|
||||
|
||||
|
|
@ -4410,7 +4448,7 @@ class MusicDatabase:
|
|||
logger.error(f"Error creating manual_library_track_matches table: {e}")
|
||||
|
||||
def save_manual_library_match(self, profile_id: int, source: str, source_track_id: str,
|
||||
library_track_id: int, **meta) -> bool:
|
||||
library_track_id: str, **meta) -> bool:
|
||||
"""Insert or replace a manual match. meta keys: source_title, source_artist,
|
||||
source_album, source_context_json, server_source."""
|
||||
try:
|
||||
|
|
@ -12796,24 +12834,53 @@ class MusicDatabase:
|
|||
seed = dict(seed)
|
||||
artist_name = seed['artist_name']
|
||||
|
||||
# Build the set of IDs to exclude (seed + caller-supplied)
|
||||
excluded = {str(track_id)}
|
||||
# Selection decisions (dedup, caps, tag parsing, condition
|
||||
# building) live in core.radio.selection so they're unit-
|
||||
# testable without a live DB. The cursor work stays here.
|
||||
from core.radio.selection import (
|
||||
RadioCollector,
|
||||
build_like_conditions,
|
||||
merge_tags,
|
||||
parse_tags,
|
||||
same_artist_cap,
|
||||
)
|
||||
|
||||
# Seed + caller-supplied IDs to exclude (seeds the collector's
|
||||
# seen-set so excluded tracks never collect and the NOT IN
|
||||
# placeholders/values stay in sync).
|
||||
exclude_seed = [str(track_id)]
|
||||
if exclude_ids:
|
||||
excluded.update(str(eid) for eid in exclude_ids)
|
||||
exclude_seed.extend(str(eid) for eid in exclude_ids)
|
||||
collector = RadioCollector(limit, exclude_ids=exclude_seed)
|
||||
|
||||
collected: list[dict] = []
|
||||
seen_ids: set[str] = set(excluded)
|
||||
# Phase 2 smart radio: each tier pulls a generous RANDOM pool,
|
||||
# then core.radio.selection ranks it (play_count + lastfm
|
||||
# popularity, recency penalty, stable jitter) and the collector
|
||||
# keeps the best. Pool factor keeps SQL cheap while giving the
|
||||
# ranker real choice; bumped, then floored so small tiers still
|
||||
# over-fetch a little.
|
||||
_POOL_FACTOR = 4
|
||||
|
||||
def _exclude_placeholders():
|
||||
return ','.join('?' * len(seen_ids))
|
||||
def _pool(n):
|
||||
return max(n * _POOL_FACTOR, n + 10)
|
||||
|
||||
def _exclude_values():
|
||||
return list(seen_ids)
|
||||
# Ranking signals (play_count / lastfm_playcount) are added by a
|
||||
# migration, but probe for them so radio still works on a DB that
|
||||
# predates it — the ranker treats missing columns as score 0, so
|
||||
# we simply omit them from the SELECT when absent rather than
|
||||
# crashing on "no such column".
|
||||
cursor.execute("PRAGMA table_info(tracks)")
|
||||
_track_cols = {row[1] for row in cursor.fetchall()}
|
||||
_rank_cols = "".join(
|
||||
f"t.{c}, " for c in ("play_count", "lastfm_playcount")
|
||||
if c in _track_cols
|
||||
)
|
||||
|
||||
_track_select = """
|
||||
_track_select = f"""
|
||||
SELECT t.id, t.title, t.track_number, t.duration,
|
||||
t.file_path, t.bitrate,
|
||||
t.album_id, t.artist_id,
|
||||
{_rank_cols}
|
||||
al.title AS album,
|
||||
COALESCE(al.thumb_url, ar.thumb_url) AS image_url,
|
||||
ar.name AS artist
|
||||
|
|
@ -12824,98 +12891,72 @@ class MusicDatabase:
|
|||
# Only return tracks that have actual files on disk
|
||||
_file_filter = "t.file_path IS NOT NULL AND t.file_path != ''"
|
||||
|
||||
def _collect(rows, cap=None):
|
||||
"""Append rows to collected. Stop at cap or limit."""
|
||||
target = min(limit, (len(collected) + cap)) if cap else limit
|
||||
for row in rows:
|
||||
r = dict(row)
|
||||
rid = str(r['id'])
|
||||
if rid not in seen_ids:
|
||||
seen_ids.add(rid)
|
||||
collected.append(r)
|
||||
if len(collected) >= target:
|
||||
return True
|
||||
return len(collected) >= limit
|
||||
|
||||
def _parse_tags(raw_val):
|
||||
"""Parse a JSON array or comma-separated string into a list."""
|
||||
if not raw_val:
|
||||
return []
|
||||
try:
|
||||
parsed = json.loads(raw_val)
|
||||
return parsed if isinstance(parsed, list) else [str(parsed)]
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
return [t.strip() for t in raw_val.split(',') if t.strip()]
|
||||
|
||||
# --- 1. Same artist, different albums (capped at 30% of limit) ---
|
||||
same_artist_cap = max(5, limit * 3 // 10)
|
||||
artist_cap = same_artist_cap(limit)
|
||||
cursor.execute(f"""
|
||||
{_track_select}
|
||||
WHERE {_file_filter} AND ar.name = ? AND t.album_id != ? AND t.id NOT IN ({_exclude_placeholders()})
|
||||
WHERE {_file_filter} AND ar.name = ? AND t.album_id != ? AND t.id NOT IN ({collector.exclude_placeholders()})
|
||||
ORDER BY RANDOM()
|
||||
LIMIT ?
|
||||
""", [artist_name, seed['album_id']] + _exclude_values() + [same_artist_cap])
|
||||
_collect(cursor.fetchall(), cap=same_artist_cap)
|
||||
""", [artist_name, seed['album_id']] + collector.exclude_values() + [_pool(artist_cap)])
|
||||
collector.collect(cursor.fetchall(), cap=artist_cap, rank=True)
|
||||
|
||||
if len(collected) >= limit:
|
||||
return {'success': True, 'tracks': collected}
|
||||
if collector.filled:
|
||||
return {'success': True, 'tracks': collector.tracks}
|
||||
|
||||
# --- 2. Same genre (album genres + artist genres, other artists) ---
|
||||
genre_list = _parse_tags(seed.get('album_genres'))
|
||||
artist_genre_list = _parse_tags(seed.get('artist_genres'))
|
||||
all_genres = list(dict.fromkeys(genre_list + artist_genre_list)) # dedupe, preserve order
|
||||
|
||||
if all_genres:
|
||||
genre_conditions = ' OR '.join(
|
||||
['al.genres LIKE ?' for _ in all_genres] +
|
||||
['ar.genres LIKE ?' for _ in all_genres]
|
||||
)
|
||||
genre_params = [f'%{g}%' for g in all_genres] * 2
|
||||
all_genres = merge_tags(
|
||||
parse_tags(seed.get('album_genres')),
|
||||
parse_tags(seed.get('artist_genres')),
|
||||
)
|
||||
genre_conditions, genre_params = build_like_conditions(
|
||||
all_genres, ('al.genres', 'ar.genres')
|
||||
)
|
||||
if genre_conditions:
|
||||
cursor.execute(f"""
|
||||
{_track_select}
|
||||
WHERE {_file_filter} AND ({genre_conditions})
|
||||
AND ar.name != ?
|
||||
AND t.id NOT IN ({_exclude_placeholders()})
|
||||
AND t.id NOT IN ({collector.exclude_placeholders()})
|
||||
ORDER BY RANDOM()
|
||||
LIMIT ?
|
||||
""", genre_params + [artist_name] + _exclude_values() + [limit - len(collected)])
|
||||
if _collect(cursor.fetchall()):
|
||||
return {'success': True, 'tracks': collected}
|
||||
""", genre_params + [artist_name] + collector.exclude_values() + [_pool(collector.remaining())])
|
||||
if collector.collect(cursor.fetchall(), rank=True):
|
||||
return {'success': True, 'tracks': collector.tracks}
|
||||
|
||||
# --- 3. Same mood / style (album + artist level) ---
|
||||
for field_name in ('mood', 'style'):
|
||||
album_tags = _parse_tags(seed.get(f'album_{field_name}'))
|
||||
artist_tags = _parse_tags(seed.get(f'artist_{field_name}'))
|
||||
all_tags = list(dict.fromkeys(album_tags + artist_tags))
|
||||
|
||||
if all_tags:
|
||||
tag_conditions = ' OR '.join(
|
||||
[f'al.{field_name} LIKE ?' for _ in all_tags] +
|
||||
[f'ar.{field_name} LIKE ?' for _ in all_tags]
|
||||
)
|
||||
tag_params = [f'%{t}%' for t in all_tags] * 2
|
||||
all_tags = merge_tags(
|
||||
parse_tags(seed.get(f'album_{field_name}')),
|
||||
parse_tags(seed.get(f'artist_{field_name}')),
|
||||
)
|
||||
tag_conditions, tag_params = build_like_conditions(
|
||||
all_tags, (f'al.{field_name}', f'ar.{field_name}')
|
||||
)
|
||||
if tag_conditions:
|
||||
cursor.execute(f"""
|
||||
{_track_select}
|
||||
WHERE {_file_filter} AND ({tag_conditions})
|
||||
AND ar.name != ?
|
||||
AND t.id NOT IN ({_exclude_placeholders()})
|
||||
AND t.id NOT IN ({collector.exclude_placeholders()})
|
||||
ORDER BY RANDOM()
|
||||
LIMIT ?
|
||||
""", tag_params + [artist_name] + _exclude_values() + [limit - len(collected)])
|
||||
if _collect(cursor.fetchall()):
|
||||
return {'success': True, 'tracks': collected}
|
||||
""", tag_params + [artist_name] + collector.exclude_values() + [_pool(collector.remaining())])
|
||||
if collector.collect(cursor.fetchall(), rank=True):
|
||||
return {'success': True, 'tracks': collector.tracks}
|
||||
|
||||
# --- 4. Random library tracks ---
|
||||
if len(collected) < limit:
|
||||
# --- 4. Random library tracks (ranked: popular-but-unheard
|
||||
# beats pure noise even in the last-resort tier) ---
|
||||
if not collector.filled:
|
||||
cursor.execute(f"""
|
||||
{_track_select}
|
||||
WHERE {_file_filter} AND t.id NOT IN ({_exclude_placeholders()})
|
||||
WHERE {_file_filter} AND t.id NOT IN ({collector.exclude_placeholders()})
|
||||
ORDER BY RANDOM()
|
||||
LIMIT ?
|
||||
""", _exclude_values() + [limit - len(collected)])
|
||||
_collect(cursor.fetchall())
|
||||
""", collector.exclude_values() + [_pool(collector.remaining())])
|
||||
collector.collect(cursor.fetchall(), rank=True)
|
||||
|
||||
return {'success': True, 'tracks': collected}
|
||||
return {'success': True, 'tracks': collector.tracks}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting radio tracks for track {track_id}: {e}")
|
||||
|
|
|
|||
|
|
@ -81,15 +81,15 @@ chown soulsync:soulsync /app/config/settings.py 2>/dev/null || true
|
|||
# Pre-mid-2026 the chown line had `|| true` but mkdir didn't — combined
|
||||
# with `set -e`, a permission-denied mkdir crashed the container into a
|
||||
# restart loop. Both lines are now best-effort.
|
||||
mkdir -p /app/config /app/data /app/logs /app/downloads /app/Transfer /app/Staging /app/Stream 2>/dev/null || true
|
||||
chown soulsync:soulsync /app/config /app/data /app/logs /app/downloads /app/Transfer /app/Staging /app/Stream 2>/dev/null || true
|
||||
mkdir -p /app/config /app/data /app/logs /app/downloads /app/Transfer /app/Staging /app/Stream /app/storage /app/MusicVideos /app/scripts 2>/dev/null || true
|
||||
chown soulsync:soulsync /app/config /app/data /app/logs /app/downloads /app/Transfer /app/Staging /app/Stream /app/storage /app/MusicVideos /app/scripts 2>/dev/null || true
|
||||
|
||||
# Writability audit — surface a loud warning if any bind-mounted dir
|
||||
# isn't writable by the soulsync user. The restart-loop fix above makes
|
||||
# the container start regardless, but a non-writable Staging / downloads
|
||||
# / Transfer will fail silently inside the app (auto-import quarantine,
|
||||
# download writes). Better to log now than to debug missing files later.
|
||||
for dir in /app/config /app/data /app/logs /app/downloads /app/Transfer /app/Staging /app/Stream /app/MusicVideos /app/scripts; do
|
||||
for dir in /app/config /app/data /app/logs /app/downloads /app/Transfer /app/Staging /app/Stream /app/storage /app/MusicVideos /app/scripts; do
|
||||
if [ -d "$dir" ] && ! gosu soulsync test -w "$dir" 2>/dev/null; then
|
||||
echo "⚠️ WARNING: $dir is not writable by soulsync (uid $(id -u soulsync))."
|
||||
echo " Host bind-mount perms likely mismatch the PUID/PGID env vars."
|
||||
|
|
|
|||
43
revamp_plan.md
Normal file
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
|
||||
(which would try to initialize Spotify, Soulseek, Plex, etc.)."""
|
||||
|
||||
# ── TEST-DATABASE ISOLATION — MUST run before any other import ─────────────
|
||||
# Force every MusicDatabase()/get_database() call in the suite onto a throwaway
|
||||
# temp database so a test can NEVER open or write the real
|
||||
# database/music_library.db. MusicDatabase resolves its default path from
|
||||
# os.environ['DATABASE_PATH'] (see database/music_database.py); setting it here,
|
||||
# at conftest import (before any test module loads), redirects ALL default-path
|
||||
# DB access to /tmp.
|
||||
#
|
||||
# Why this is non-negotiable: tests exercise modules (e.g. album_mbid_cache)
|
||||
# that call get_database() with no path → the real DB. Running those writers
|
||||
# against the live DB over a WSL-mounted Windows drive corrupted a user's
|
||||
# library. This guarantees it can't recur — tests get their own disposable DB.
|
||||
import os as _os
|
||||
import tempfile as _tempfile
|
||||
import atexit as _atexit
|
||||
import shutil as _shutil
|
||||
|
||||
if not _os.environ.get('SOULSYNC_TEST_DB_READY'):
|
||||
_TEST_DB_DIR = _tempfile.mkdtemp(prefix='soulsync-testdb-')
|
||||
_os.environ['DATABASE_PATH'] = _os.path.join(_TEST_DB_DIR, 'test_music_library.db')
|
||||
_os.environ['SOULSYNC_TEST_DB_READY'] = '1'
|
||||
_atexit.register(lambda: _shutil.rmtree(_TEST_DB_DIR, ignore_errors=True))
|
||||
|
||||
import copy
|
||||
import pytest
|
||||
import threading
|
||||
|
|
|
|||
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
|
||||
|
||||
|
||||
def test_private_soulseek_album_staging_miss_skips_per_track_search():
|
||||
def test_private_usenet_album_staging_miss_skips_per_track_search():
|
||||
# Usenet keeps the short-circuit (the #743 change is Soulseek-only):
|
||||
# per-track NZB search re-adds the same release, so the staged release
|
||||
# stays authoritative.
|
||||
_seed_task(track_info={
|
||||
'id': 'sp-1', 'name': 'Song', 'artists': ['Artist'],
|
||||
'album': 'Album', 'duration_ms': 180000,
|
||||
|
|
@ -231,9 +234,9 @@ def test_private_soulseek_album_staging_miss_skips_per_track_search():
|
|||
download_batches['b1'] = {
|
||||
'album_bundle_private_staging': True,
|
||||
'album_bundle_state': 'staged',
|
||||
'album_bundle_source': 'soulseek',
|
||||
'album_bundle_source': 'usenet',
|
||||
}
|
||||
client = _FakeClient(results=['should-not-search'], mode='soulseek')
|
||||
client = _FakeClient(results=['should-not-search'], mode='usenet')
|
||||
rec = _Recorder()
|
||||
deps, _ = _build_deps(
|
||||
soulseek=client,
|
||||
|
|
@ -246,11 +249,43 @@ def test_private_soulseek_album_staging_miss_skips_per_track_search():
|
|||
|
||||
assert client.search_calls == []
|
||||
assert download_tasks['t1']['status'] == 'not_found'
|
||||
assert 'staged soulseek album release' in download_tasks['t1']['error_message']
|
||||
assert 'staged usenet album release' in download_tasks['t1']['error_message']
|
||||
assert ('done', ('b1', 't1', False), {}) in rec.calls
|
||||
|
||||
|
||||
def test_private_hybrid_first_soulseek_album_staging_miss_skips_per_track_search():
|
||||
def test_private_soulseek_album_staging_miss_falls_through_to_per_track_search():
|
||||
# #743: a track the album needs but that wasn't in the staged Soulseek
|
||||
# folder must NOT be short-circuited to not_found — it falls through to the
|
||||
# normal per-track Soulseek search (unlike torrent/usenet). Even with
|
||||
# album_bundle_partial unset (folder fully downloaded, just incomplete),
|
||||
# Soulseek now always falls through.
|
||||
_seed_task(track_info={
|
||||
'id': 'sp-1', 'name': 'Song', 'artists': ['Artist'],
|
||||
'album': 'Album', 'duration_ms': 180000,
|
||||
})
|
||||
download_batches['b1'] = {
|
||||
'album_bundle_private_staging': True,
|
||||
'album_bundle_state': 'staged',
|
||||
'album_bundle_source': 'soulseek',
|
||||
}
|
||||
client = _FakeClient(results=[], mode='soulseek')
|
||||
deps, _ = _build_deps(
|
||||
soulseek=client,
|
||||
matching=_FakeMatchEngine(queries=['Artist Song']),
|
||||
try_staging_match=lambda *a, **kw: False,
|
||||
)
|
||||
|
||||
tw.download_track_worker('t1', 'b1', deps)
|
||||
|
||||
assert client.search_calls # per-track search actually ran
|
||||
assert download_tasks['t1']['status'] == 'not_found' # nothing found, but via search
|
||||
assert 'staged soulseek album release' not in download_tasks['t1']['error_message']
|
||||
|
||||
|
||||
def test_private_hybrid_first_soulseek_album_staging_miss_falls_through_to_per_track_search():
|
||||
# Same as above but Soulseek is FIRST in a hybrid chain. The miss must fall
|
||||
# through to per-track search, which (in hybrid) can then reach later
|
||||
# sources — exactly the cross-source fallback #743 asks for.
|
||||
_seed_task(track_info={
|
||||
'id': 'sp-1', 'name': 'Song', 'artists': ['Artist'],
|
||||
'album': 'Album', 'duration_ms': 180000,
|
||||
|
|
@ -261,23 +296,21 @@ def test_private_hybrid_first_soulseek_album_staging_miss_skips_per_track_search
|
|||
'album_bundle_source': 'soulseek',
|
||||
}
|
||||
client = _FakeClient(
|
||||
results=['should-not-search'],
|
||||
results=[],
|
||||
mode='hybrid',
|
||||
subclients={'hybrid_order': ['soulseek', 'hifi']},
|
||||
)
|
||||
rec = _Recorder()
|
||||
deps, _ = _build_deps(
|
||||
soulseek=client,
|
||||
matching=_FakeMatchEngine(queries=['Artist Song']),
|
||||
try_staging_match=lambda *a, **kw: False,
|
||||
on_download_completed=rec('done'),
|
||||
)
|
||||
|
||||
tw.download_track_worker('t1', 'b1', deps)
|
||||
|
||||
assert client.search_calls == []
|
||||
assert client.search_calls # per-track search ran (not short-circuited)
|
||||
assert download_tasks['t1']['status'] == 'not_found'
|
||||
assert 'staged soulseek album release' in download_tasks['t1']['error_message']
|
||||
assert 'staged soulseek album release' not in download_tasks['t1']['error_message']
|
||||
|
||||
|
||||
def test_partial_private_hybrid_first_soulseek_album_staging_miss_allows_per_track_search():
|
||||
|
|
|
|||
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"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -221,3 +221,80 @@ def test_post_process_matched_download_forwards_separate_metadata_runtime(tmp_pa
|
|||
)
|
||||
|
||||
assert seen["runtime"] is metadata_runtime
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Quarantine entry-id propagation through the verification wrapper
|
||||
# (the wrapper pops task_id out of context, so _mark_task_quarantined can't
|
||||
# write to the task directly — it stashes on context and the wrapper applies it)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_mark_task_quarantined_stashes_entry_id_when_task_id_absent():
|
||||
ctx = {} # wrapper popped task_id before the inner pipeline ran
|
||||
import_pipeline._mark_task_quarantined(ctx, "/q/20260514_120000_song.flac.quarantined")
|
||||
assert ctx["_quarantine_entry_id"] == "20260514_120000_song"
|
||||
|
||||
|
||||
def test_mark_task_quarantined_sets_on_task_and_stashes_when_present():
|
||||
original = dict(runtime_state.download_tasks)
|
||||
try:
|
||||
runtime_state.download_tasks.clear()
|
||||
runtime_state.download_tasks["t1"] = {"status": "running"}
|
||||
ctx = {"task_id": "t1"}
|
||||
import_pipeline._mark_task_quarantined(ctx, "/q/20260514_120000_song.flac.quarantined")
|
||||
assert runtime_state.download_tasks["t1"]["quarantine_entry_id"] == "20260514_120000_song"
|
||||
assert ctx["_quarantine_entry_id"] == "20260514_120000_song"
|
||||
finally:
|
||||
runtime_state.download_tasks.clear()
|
||||
runtime_state.download_tasks.update(original)
|
||||
|
||||
|
||||
def test_mark_task_quarantined_noop_without_path():
|
||||
ctx = {"task_id": "t1"}
|
||||
import_pipeline._mark_task_quarantined(ctx, None)
|
||||
assert "_quarantine_entry_id" not in ctx
|
||||
|
||||
|
||||
def test_verification_wrapper_applies_quarantine_entry_id_on_integrity_failure(monkeypatch):
|
||||
# End-to-end of the fix: the inner pipeline (mocked) quarantines on
|
||||
# integrity failure and — because the wrapper popped task_id — stashes the
|
||||
# entry id on context. The wrapper must apply it to the real task so the UI
|
||||
# can manage the quarantined file.
|
||||
task_id, batch_id, context_key = "qtask-1", "qbatch-1", "qctx-1"
|
||||
context = {"track_info": {}, "task_id": task_id, "batch_id": batch_id}
|
||||
|
||||
def _fake_inner(ck, ctx, fp, runtime, metadata_runtime=None):
|
||||
ctx["_integrity_failure_msg"] = "Duration mismatch: file is 231.0s, expected 271.0s"
|
||||
ctx["_quarantine_entry_id"] = "20260514_120000_song"
|
||||
|
||||
monkeypatch.setattr(import_pipeline, "post_process_matched_download", _fake_inner)
|
||||
|
||||
original = dict(runtime_state.download_tasks)
|
||||
original_ctx = dict(runtime_state.matched_downloads_context)
|
||||
try:
|
||||
runtime_state.download_tasks.clear()
|
||||
runtime_state.download_tasks[task_id] = {"track_info": {}, "status": "running"}
|
||||
runtime_state.matched_downloads_context.clear()
|
||||
runtime_state.matched_downloads_context[context_key] = context
|
||||
|
||||
completion = []
|
||||
runtime = types.SimpleNamespace(
|
||||
automation_engine=None,
|
||||
on_download_completed=lambda b, t, success: completion.append((b, t, success)),
|
||||
web_scan_manager=None,
|
||||
repair_worker=None,
|
||||
)
|
||||
import_pipeline.post_process_matched_download_with_verification(
|
||||
context_key, context, "/tmp/source.flac", task_id, batch_id, runtime,
|
||||
)
|
||||
|
||||
t = runtime_state.download_tasks[task_id]
|
||||
assert t["status"] == "failed"
|
||||
assert t["error_message"] == "File integrity check failed: Duration mismatch: file is 231.0s, expected 271.0s"
|
||||
assert t["quarantine_entry_id"] == "20260514_120000_song" # the fix
|
||||
assert completion == [(batch_id, task_id, False)]
|
||||
finally:
|
||||
runtime_state.download_tasks.clear()
|
||||
runtime_state.download_tasks.update(original)
|
||||
runtime_state.matched_downloads_context.clear()
|
||||
runtime_state.matched_downloads_context.update(original_ctx)
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ from core.imports.quarantine import (
|
|||
approve_quarantine_entry,
|
||||
delete_quarantine_entry,
|
||||
entry_id_from_quarantined_filename,
|
||||
get_quarantine_entry_stream_info,
|
||||
get_quarantined_source_keys,
|
||||
list_quarantine_entries,
|
||||
recover_to_staging,
|
||||
|
|
@ -125,6 +126,41 @@ def test_list_handles_orphan_quarantined_file_without_sidecar(tmp_path):
|
|||
assert entries[0]["has_full_context"] is False
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# get_quarantine_entry_stream_info — in-app "Listen" support
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_stream_info_resolves_path_and_extension_from_sidecar(tmp_path):
|
||||
qfile, _ = _write_entry(tmp_path, "20260514_120000", "song.flac", with_context=True)
|
||||
entry_id = entry_id_from_quarantined_filename(qfile.name)
|
||||
|
||||
info = get_quarantine_entry_stream_info(str(tmp_path), entry_id)
|
||||
|
||||
assert info is not None
|
||||
file_path, ext = info
|
||||
assert file_path == str(qfile)
|
||||
assert ext == ".flac" # real audio ext, NOT ".quarantined"
|
||||
|
||||
|
||||
def test_stream_info_recovers_extension_without_sidecar(tmp_path):
|
||||
# Orphan .quarantined with no sidecar — extension comes from the filename
|
||||
# convention so playback still gets a correct Content-Type.
|
||||
qfile = tmp_path / "20260514_120000_orphan.mp3.quarantined"
|
||||
qfile.write_bytes(b"X" * 100)
|
||||
entry_id = entry_id_from_quarantined_filename(qfile.name)
|
||||
|
||||
info = get_quarantine_entry_stream_info(str(tmp_path), entry_id)
|
||||
|
||||
assert info is not None
|
||||
file_path, ext = info
|
||||
assert file_path == str(qfile)
|
||||
assert ext == ".mp3"
|
||||
|
||||
|
||||
def test_stream_info_returns_none_for_missing_entry(tmp_path):
|
||||
assert get_quarantine_entry_stream_info(str(tmp_path), "does_not_exist") is None
|
||||
|
||||
|
||||
def test_list_skips_orphan_sidecars_without_file(tmp_path):
|
||||
sidecar = tmp_path / "20260514_120000_only.json"
|
||||
sidecar.write_text(json.dumps({"original_filename": "only.flac", "quarantine_reason": "x"}))
|
||||
|
|
@ -398,3 +434,23 @@ def test_source_keys_dedup_repeated_sources(tmp_path):
|
|||
keys = get_quarantined_source_keys(str(tmp_path))
|
||||
|
||||
assert keys == {("peer", "dupe.flac")}
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# _move_with_retry — resilient move (Windows file-lock case)
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_move_with_retry_succeeds(tmp_path):
|
||||
from core.imports.quarantine import _move_with_retry
|
||||
src = tmp_path / "a.flac"; src.write_bytes(b"x" * 10)
|
||||
dst = tmp_path / "out" / "a.flac"
|
||||
(tmp_path / "out").mkdir()
|
||||
assert _move_with_retry(str(src), str(dst)) is True
|
||||
assert dst.exists() and not src.exists()
|
||||
|
||||
|
||||
def test_move_with_retry_returns_false_on_missing_source(tmp_path):
|
||||
from core.imports.quarantine import _move_with_retry
|
||||
# attempts=1 keeps the test fast (no retry sleeps)
|
||||
assert _move_with_retry(str(tmp_path / "nope.flac"), str(tmp_path / "dst.flac"),
|
||||
attempts=1, delay=0) is False
|
||||
|
|
|
|||
|
|
@ -140,7 +140,7 @@ class TestIssue442Regression:
|
|||
verifier, fake_service = stubbed_verifier
|
||||
|
||||
# AcoustID returns the recording with kanji artist
|
||||
verifier.acoustid_client.fingerprint_and_lookup.return_value = {
|
||||
verifier.acoustid_client.lookup_with_status.return_value = {
|
||||
'best_score': 0.95,
|
||||
'recordings': [
|
||||
{'title': 'YAMANAIAME', 'artist': '澤野弘之', 'mbid': 'rec-x'},
|
||||
|
|
@ -165,7 +165,7 @@ class TestIssue442Regression:
|
|||
"""Reporter's case 2 — Sergey Lazarev / Сергей Лазарев."""
|
||||
verifier, fake_service = stubbed_verifier
|
||||
|
||||
verifier.acoustid_client.fingerprint_and_lookup.return_value = {
|
||||
verifier.acoustid_client.lookup_with_status.return_value = {
|
||||
'best_score': 0.95,
|
||||
'recordings': [
|
||||
{'title': 'On the Other Side', 'artist': 'Sergey Lazarev', 'mbid': 'rec-y'},
|
||||
|
|
@ -196,7 +196,7 @@ class TestBackwardCompat:
|
|||
verifier, fake_service = stubbed_verifier
|
||||
|
||||
# Wrong artist entirely — Latin script both sides, sim ~0
|
||||
verifier.acoustid_client.fingerprint_and_lookup.return_value = {
|
||||
verifier.acoustid_client.lookup_with_status.return_value = {
|
||||
'best_score': 0.95,
|
||||
'recordings': [
|
||||
{'title': 'Some Track', 'artist': 'Khalil Turk & Friends'},
|
||||
|
|
@ -214,7 +214,7 @@ class TestBackwardCompat:
|
|||
"""Exact title + artist match → PASS regardless of aliases."""
|
||||
verifier, fake_service = stubbed_verifier
|
||||
|
||||
verifier.acoustid_client.fingerprint_and_lookup.return_value = {
|
||||
verifier.acoustid_client.lookup_with_status.return_value = {
|
||||
'best_score': 0.95,
|
||||
'recordings': [
|
||||
{'title': 'Dirty White Boy', 'artist': 'Foreigner'},
|
||||
|
|
@ -233,7 +233,7 @@ class TestBackwardCompat:
|
|||
verifier, fake_service = stubbed_verifier
|
||||
fake_service.lookup_artist_aliases.side_effect = Exception("MB down")
|
||||
|
||||
verifier.acoustid_client.fingerprint_and_lookup.return_value = {
|
||||
verifier.acoustid_client.lookup_with_status.return_value = {
|
||||
'best_score': 0.95,
|
||||
'recordings': [
|
||||
{'title': 'Dirty White Boy', 'artist': 'Foreigner'},
|
||||
|
|
@ -261,7 +261,7 @@ class TestAliasLookupCalledOncePerVerify:
|
|||
fire 60+ MB lookups (cached or not, that's wasteful)."""
|
||||
verifier, fake_service = stubbed_verifier
|
||||
|
||||
verifier.acoustid_client.fingerprint_and_lookup.return_value = {
|
||||
verifier.acoustid_client.lookup_with_status.return_value = {
|
||||
'best_score': 0.95,
|
||||
'recordings': [
|
||||
{'title': 'X', 'artist': '澤野弘之'},
|
||||
|
|
@ -295,7 +295,7 @@ class TestLazyAliasResolution:
|
|||
this PR."""
|
||||
verifier, fake_service = stubbed_verifier
|
||||
|
||||
verifier.acoustid_client.fingerprint_and_lookup.return_value = {
|
||||
verifier.acoustid_client.lookup_with_status.return_value = {
|
||||
'best_score': 0.95,
|
||||
'recordings': [
|
||||
{'title': 'Dirty White Boy', 'artist': 'Foreigner'},
|
||||
|
|
@ -317,7 +317,7 @@ class TestLazyAliasResolution:
|
|||
as expected."""
|
||||
verifier, fake_service = stubbed_verifier
|
||||
|
||||
verifier.acoustid_client.fingerprint_and_lookup.return_value = {
|
||||
verifier.acoustid_client.lookup_with_status.return_value = {
|
||||
'best_score': 0.95,
|
||||
'recordings': [
|
||||
{'title': 'YAMANAIAME', 'artist': '澤野弘之'},
|
||||
|
|
@ -343,7 +343,7 @@ class TestLazyAliasResolution:
|
|||
# Force a code path that hits multiple sites: title matches
|
||||
# several recordings but the best-match's artist sim is below
|
||||
# threshold (forces secondary scan path).
|
||||
verifier.acoustid_client.fingerprint_and_lookup.return_value = {
|
||||
verifier.acoustid_client.lookup_with_status.return_value = {
|
||||
'best_score': 0.95,
|
||||
'recordings': [
|
||||
{'title': 'X', 'artist': 'Different Latin Artist'}, # 0 alias hit
|
||||
|
|
|
|||
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
|
||||
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):
|
||||
return True, 'available'
|
||||
|
||||
def fingerprint_and_lookup(self, path):
|
||||
def lookup_with_status(self, path):
|
||||
# Each test injects its own desired return value via
|
||||
# monkeypatch on this method; default is empty.
|
||||
return None
|
||||
|
|
@ -62,8 +62,8 @@ def verifier(monkeypatch):
|
|||
|
||||
|
||||
def _stub_lookup(verifier, *, recordings, best_score):
|
||||
"""Make `fingerprint_and_lookup` return a fabricated AcoustID result."""
|
||||
verifier.acoustid_client.fingerprint_and_lookup = lambda path: {
|
||||
"""Make `lookup_with_status` return a fabricated AcoustID result."""
|
||||
verifier.acoustid_client.lookup_with_status = lambda path: {
|
||||
'recordings': recordings,
|
||||
'best_score': best_score,
|
||||
'recording_mbids': [r.get('id') for r in recordings if r.get('id')],
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ def verifier():
|
|||
def is_available(self):
|
||||
return True, "available"
|
||||
|
||||
def fingerprint_and_lookup(self, path):
|
||||
def lookup_with_status(self, path):
|
||||
return None # tests inject via _stub_lookup below
|
||||
|
||||
v.acoustid_client = _StubClient()
|
||||
|
|
@ -46,7 +46,7 @@ def verifier():
|
|||
|
||||
|
||||
def _stub_lookup(verifier, *, recordings, best_score=0.95):
|
||||
verifier.acoustid_client.fingerprint_and_lookup = lambda path: {
|
||||
verifier.acoustid_client.lookup_with_status = lambda path: {
|
||||
"recordings": recordings,
|
||||
"best_score": best_score,
|
||||
"recording_mbids": [r.get("id") for r in recordings if r.get("id")],
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ from core.download_plugins.album_bundle import (
|
|||
DEFAULT_POLL_TIMEOUT_SECONDS,
|
||||
atomic_copy_to_staging,
|
||||
copy_audio_files_atomically,
|
||||
album_title_relevance,
|
||||
get_completed_no_path_window_seconds,
|
||||
get_poll_interval,
|
||||
get_poll_timeout,
|
||||
|
|
@ -117,6 +118,110 @@ def test_picker_rejects_oversized_box_sets() -> None:
|
|||
assert pick_best_album_release([sane, box], _flac_quality_guess) is sane
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# #730 — album-title relevance gate
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_relevance_exact_match_is_full():
|
||||
assert album_title_relevance("David Bowie - Heroes (2017 Remaster) [FLAC]", "Heroes") == 1.0
|
||||
|
||||
|
||||
def test_relevance_word_boundary_not_substring():
|
||||
# "Heroes" must NOT be satisfied by "Superheroes" (the substring trap).
|
||||
assert album_title_relevance("Various - Superheroes Soundtrack", "Heroes") == 0.0
|
||||
|
||||
|
||||
def test_relevance_wrong_album_scores_zero():
|
||||
# The reporter's exact case: a "Heroes" request must not match a different
|
||||
# Bowie album that shares no title words.
|
||||
assert album_title_relevance("David Bowie - Scary Monsters and Super Creeps", "Heroes") == 0.0
|
||||
|
||||
|
||||
def test_relevance_accent_folding():
|
||||
# Björk folds to bjork, not "bj rk" — so an accented request still matches.
|
||||
assert album_title_relevance("Bjork - Homogenic [FLAC]", "Björk Homogenic") == 1.0
|
||||
|
||||
|
||||
def test_relevance_partial_word_coverage():
|
||||
# 1 of 2 album words present -> 0.5 (below the 0.6 floor).
|
||||
assert album_title_relevance("Artist - Dark Side [FLAC]", "Dark Moon") == 0.5
|
||||
|
||||
|
||||
def test_relevance_no_album_name_is_neutral():
|
||||
# Can't gate on nothing — preserves old behavior for callers w/o a title.
|
||||
assert album_title_relevance("anything at all", "") == 1.0
|
||||
|
||||
|
||||
def test_relevance_ignores_edition_suffix_on_album_name():
|
||||
"""The RIGHT torrent must not be rejected just because the stored album
|
||||
name carries an edition/remaster/format suffix the title lacks. (Caught in
|
||||
review — the naive 'all album words' version wrongly rejected these.)"""
|
||||
floor = 0.6
|
||||
assert album_title_relevance("Tame Impala - Currents [FLAC]", "Currents (Deluxe)") >= floor
|
||||
assert album_title_relevance("David Bowie - Heroes [FLAC]", "Heroes (2017 Remaster)") >= floor
|
||||
assert album_title_relevance("Daft Punk - Discovery [FLAC]", "Discovery (Remastered Edition)") >= floor
|
||||
|
||||
|
||||
def test_relevance_full_phrase_bonus():
|
||||
"""Full core phrase present intact -> high confidence (>=0.9) even when a
|
||||
long multi-word album name would otherwise drag token-coverage down.
|
||||
(Idea grafted from contributor PR #731.)"""
|
||||
# All core words present AND contiguous -> already 1.0; the bonus matters
|
||||
# most when extra album words aren't all matched. Construct that case:
|
||||
# album core = [in, rainbows, the, basement] but title has the phrase
|
||||
# "in rainbows" intact while missing 'basement'.
|
||||
# album core = [in, rainbows, from, basement] ('the' is noise); title has
|
||||
# 'in','rainbows' but not 'from'/'basement', and the full core phrase is
|
||||
# NOT intact -> pure coverage 2/4 = 0.5, no bonus.
|
||||
score = album_title_relevance(
|
||||
"Radiohead - In Rainbows [FLAC]", "In Rainbows from the Basement")
|
||||
assert score == 0.5
|
||||
# Full core phrase intact in the title -> bonus floors at 0.9+ (here it's
|
||||
# already 1.0 since both core words match).
|
||||
score2 = album_title_relevance(
|
||||
"Radiohead - In Rainbows Disc 2 [FLAC]", "In Rainbows")
|
||||
assert score2 >= 0.9
|
||||
# The bonus is word-boundary anchored: a phrase that's only a SUBSTRING of
|
||||
# a title word must NOT get it.
|
||||
assert album_title_relevance("Various - Superheroes", "Heroes") == 0.0
|
||||
|
||||
|
||||
def test_relevance_album_named_only_with_noise_or_number():
|
||||
# If the album name is JUST a noise/number word, don't strip it to nothing
|
||||
# and match everything — keep the literal word.
|
||||
assert album_title_relevance("Taylor Swift - 1989 [FLAC]", "1989") == 1.0
|
||||
assert album_title_relevance("Taylor Swift - Red [FLAC]", "1989") == 0.0
|
||||
assert album_title_relevance("Various - Deluxe [FLAC]", "Deluxe") == 1.0
|
||||
|
||||
|
||||
def test_picker_refuses_wrong_album_falls_back():
|
||||
"""The #730 scenario: a hugely-popular WRONG album must NOT be picked over
|
||||
a less-popular RIGHT one — and if nothing matches, return None so the
|
||||
caller falls back to per-track."""
|
||||
wrong_popular = _Release(title="David Bowie - Scary Monsters [FLAC]",
|
||||
size=400_000_000, seeders=16000)
|
||||
right_quiet = _Release(title='David Bowie - "Heroes" 2017 Remaster [FLAC]',
|
||||
size=400_000_000, seeders=10)
|
||||
picked = pick_best_album_release(
|
||||
[wrong_popular, right_quiet], _flac_quality_guess, album_name="Heroes")
|
||||
assert picked is right_quiet # relevance beats raw popularity
|
||||
|
||||
|
||||
def test_picker_returns_none_when_nothing_matches_album():
|
||||
# Only the wrong album is available -> refuse (None) -> per-track fallback.
|
||||
wrong = _Release(title="David Bowie - Scary Monsters [FLAC]",
|
||||
size=400_000_000, seeders=16000)
|
||||
assert pick_best_album_release([wrong], _flac_quality_guess, album_name="Heroes") is None
|
||||
|
||||
|
||||
def test_picker_without_album_name_unchanged():
|
||||
# No album_name passed -> no gating -> old popularity behavior intact.
|
||||
a = _Release(title="Whatever [FLAC]", size=400_000_000, seeders=5)
|
||||
b = _Release(title="Other [FLAC]", size=400_000_000, seeders=999)
|
||||
assert pick_best_album_release([a, b], _flac_quality_guess) is b
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# quality_score
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -275,6 +275,26 @@ def test_dispatch_plugin_exception_treated_as_failure() -> None:
|
|||
assert 'network down' in state.failed_with
|
||||
|
||||
|
||||
def test_dispatch_staging_oserror_falls_back_to_per_track():
|
||||
"""A filesystem/staging failure (e.g. #760's PermissionError creating the
|
||||
staging dir) means the album downloaded but couldn't be staged locally —
|
||||
fall back to the per-track flow rather than hard-failing the whole batch."""
|
||||
state = _FakeState()
|
||||
plugin = MagicMock()
|
||||
plugin.download_album_to_staging.side_effect = PermissionError(
|
||||
"[Errno 13] Permission denied: 'storage/album_bundle_staging'")
|
||||
result = try_dispatch(
|
||||
batch_id='b1', is_album=True,
|
||||
album_context={'name': 'Carnival'}, artist_context={'name': 'Some Artist'},
|
||||
config_get=_config({'download_source.mode': 'soulseek'}),
|
||||
plugin_resolver=lambda _name: plugin, state=state,
|
||||
)
|
||||
assert result is False # fell back; master continues per-track
|
||||
assert state.failed_with == '' # NOT hard-failed
|
||||
assert state.fields['phase'] == 'analysis'
|
||||
assert state.fields['album_bundle_state'] == 'fallback'
|
||||
|
||||
|
||||
def test_dispatch_strips_whitespace_from_names() -> None:
|
||||
"""Trailing whitespace in batch context shouldn't fail the
|
||||
eligibility predicate AND should be cleaned before passing to
|
||||
|
|
|
|||
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:
|
||||
def __init__(self, body: bytes, *, status_code: int = 200, content_type: str = "image/jpeg"):
|
||||
def __init__(self, body: bytes, *, status_code: int = 200, content_type: str = "image/jpeg",
|
||||
declared_length: int | None = None):
|
||||
self.body = body
|
||||
self.status_code = status_code
|
||||
# declared_length lets a test simulate a truncated download: the server
|
||||
# promises N bytes (Content-Length) but the body delivers fewer.
|
||||
length = len(body) if declared_length is None else declared_length
|
||||
self.headers = {
|
||||
"Content-Type": content_type,
|
||||
"Content-Length": str(len(body)),
|
||||
"Content-Length": str(length),
|
||||
}
|
||||
self.closed = False
|
||||
|
||||
|
|
@ -51,6 +55,73 @@ def test_get_url_fetches_once_then_serves_cached_file(tmp_path):
|
|||
assert len(calls) == 1
|
||||
|
||||
|
||||
def test_truncated_download_is_rejected_not_cached(tmp_path):
|
||||
"""#750: a short/dropped download (body shorter than the declared
|
||||
Content-Length) must NOT be committed as a good cache entry — otherwise the
|
||||
half-decoded cover (top strip, rest grey) is served forever. It should raise
|
||||
and leave nothing cached, so the next request retries fresh."""
|
||||
calls = []
|
||||
|
||||
def fetcher(url, **kwargs):
|
||||
calls.append(url)
|
||||
# Server promises 5000 bytes but only delivers 800 (connection dropped).
|
||||
return FakeResponse(b"x" * 800, declared_length=5000)
|
||||
|
||||
cache = ImageCache(tmp_path, fetcher=fetcher)
|
||||
url = "https://images.example.test/big-cover.jpg"
|
||||
|
||||
raised = False
|
||||
try:
|
||||
cache.get_url(url)
|
||||
except Exception as exc:
|
||||
raised = True
|
||||
assert "Truncated" in str(exc) or "truncated" in str(exc)
|
||||
assert raised, "Expected a truncated download to raise"
|
||||
|
||||
# Nothing partial left on disk for this key.
|
||||
import glob
|
||||
key = ImageCache.key_for_url(url)
|
||||
leftover = glob.glob(str(tmp_path / "**" / f"{key}*"), recursive=True)
|
||||
leftover = [p for p in leftover if not p.endswith(".sqlite3")]
|
||||
assert leftover == [], f"truncated file should not be cached, found: {leftover}"
|
||||
|
||||
# A subsequent SUCCESSFUL fetch works (not poisoned by the failed attempt).
|
||||
cache2 = ImageCache(
|
||||
tmp_path,
|
||||
fetcher=lambda u, **k: FakeResponse(b"complete-jpeg-bytes"),
|
||||
)
|
||||
result = cache2.get_url(url)
|
||||
assert result.path.read_bytes() == b"complete-jpeg-bytes"
|
||||
|
||||
|
||||
def test_complete_download_with_content_length_succeeds(tmp_path):
|
||||
"""Positive control: a full download whose body matches Content-Length is
|
||||
cached normally (the truncation guard doesn't false-positive)."""
|
||||
cache = ImageCache(
|
||||
tmp_path,
|
||||
fetcher=lambda u, **k: FakeResponse(b"a-real-cover"), # declared==actual
|
||||
)
|
||||
result = cache.get_url("https://images.example.test/ok-cover.jpg")
|
||||
assert result.path.read_bytes() == b"a-real-cover"
|
||||
assert result.status == "miss"
|
||||
|
||||
|
||||
def test_no_content_length_still_caches(tmp_path):
|
||||
"""Some CDNs omit Content-Length (chunked transfer). With no declared size
|
||||
we can't detect truncation, so we must NOT reject — cache as before."""
|
||||
class NoLengthResponse(FakeResponse):
|
||||
def __init__(self, body):
|
||||
super().__init__(body)
|
||||
del self.headers["Content-Length"]
|
||||
|
||||
cache = ImageCache(
|
||||
tmp_path,
|
||||
fetcher=lambda u, **k: NoLengthResponse(b"chunked-cover-bytes"),
|
||||
)
|
||||
result = cache.get_url("https://images.example.test/chunked.jpg")
|
||||
assert result.path.read_bytes() == b"chunked-cover-bytes"
|
||||
|
||||
|
||||
def test_get_url_rejects_non_image_responses(tmp_path):
|
||||
cache = ImageCache(
|
||||
tmp_path,
|
||||
|
|
|
|||
|
|
@ -16,6 +16,56 @@ def db(tmp_path):
|
|||
return MusicDatabase(str(tmp_path / "music.db"))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# normalize_library_track_id — regression guard for issue #754
|
||||
# ("Invalid library track id" on Jellyfin/Navidrome/Subsonic servers)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_normalize_passes_numeric_plex_id():
|
||||
# Plex ratingKeys are numeric strings — must survive unchanged (not int-ified).
|
||||
assert mlm.normalize_library_track_id("12345") == "12345"
|
||||
assert mlm.normalize_library_track_id(12345) == "12345"
|
||||
|
||||
|
||||
def test_normalize_passes_guid_id():
|
||||
# The #754 bug: Jellyfin/Navidrome ids are non-numeric. int() rejected them.
|
||||
assert mlm.normalize_library_track_id("a1b2c3d4-e5f6") == "a1b2c3d4-e5f6"
|
||||
assert mlm.normalize_library_track_id("Do I Wanna Know_opus") == "Do I Wanna Know_opus"
|
||||
|
||||
|
||||
def test_normalize_trims_whitespace():
|
||||
assert mlm.normalize_library_track_id(" guid-123 ") == "guid-123"
|
||||
|
||||
|
||||
def test_normalize_rejects_empty_and_none():
|
||||
assert mlm.normalize_library_track_id(None) is None
|
||||
assert mlm.normalize_library_track_id("") is None
|
||||
assert mlm.normalize_library_track_id(" ") is None
|
||||
|
||||
|
||||
def test_guid_library_track_id_round_trips(db):
|
||||
"""End-to-end regression for #754: a non-numeric library id must save,
|
||||
read back identically, and enrich — never get coerced or rejected."""
|
||||
guid = "a1b2c3d4e5f6-jellyfin"
|
||||
norm = mlm.normalize_library_track_id(guid)
|
||||
assert norm == guid
|
||||
ok = db.save_manual_library_match(1, "spotify", "src-track-1", norm,
|
||||
source_title="Do I Wanna Know?",
|
||||
source_artist="Arctic Monkeys")
|
||||
assert ok is True
|
||||
row = db.get_manual_library_match(1, "spotify", "src-track-1")
|
||||
assert row is not None
|
||||
assert row["library_track_id"] == guid # stored as-is, not mangled to int
|
||||
|
||||
# Enrichment must resolve the GUID against tracks.id (TEXT) without error.
|
||||
with patch.object(db, "api_get_tracks_by_ids", return_value=[
|
||||
{"title": "Do I Wanna Know?", "artist_name": "Arctic Monkeys",
|
||||
"album_title": "AM", "file_path": "/m/x.opus", "bitrate": 196}]) as mock_get:
|
||||
enriched = mlm._enrich_match(row, db)
|
||||
mock_get.assert_called_once_with([guid]) # passes the string id straight through
|
||||
assert enriched["library_title"] == "Do I Wanna Know?"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DB-layer tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
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 'No torrent results' in outcome['error']
|
||||
# Regression (Cezar): "no results" must be fallback-eligible so a
|
||||
# torrent-first hybrid returns to the per-track flow (next source)
|
||||
# instead of the dispatch marking the batch failed and freezing at
|
||||
# "Torrent searching for release 0%".
|
||||
assert outcome.get('fallback') is True
|
||||
fake_adapter.add_torrent.assert_not_called()
|
||||
|
||||
|
||||
def test_torrent_album_to_staging_no_results_flags_fallback(tmp_path: Path) -> None:
|
||||
"""Empty Prowlarr search → fallback-eligible failure, not terminal."""
|
||||
plugin = TorrentDownloadPlugin()
|
||||
fake_adapter = MagicMock()
|
||||
fake_adapter.is_configured.return_value = True
|
||||
with patch.object(plugin, 'is_configured', return_value=True), \
|
||||
patch.object(plugin._prowlarr, 'search', new=AsyncMock(return_value=[])), \
|
||||
patch('core.download_plugins.torrent.get_active_torrent_adapter', return_value=fake_adapter):
|
||||
outcome = plugin.download_album_to_staging('GNX', 'Kendrick Lamar', str(tmp_path))
|
||||
assert outcome['success'] is False
|
||||
assert 'No torrent results' in outcome['error']
|
||||
assert outcome.get('fallback') is True
|
||||
fake_adapter.add_torrent.assert_not_called()
|
||||
|
||||
|
||||
def test_usenet_album_to_staging_no_results_flags_fallback(tmp_path: Path) -> None:
|
||||
"""Same contract for usenet: an empty search must fall back to the
|
||||
per-track flow rather than hard-failing the album batch."""
|
||||
plugin = UsenetDownloadPlugin()
|
||||
fake_adapter = MagicMock()
|
||||
fake_adapter.is_configured.return_value = True
|
||||
with patch.object(plugin, 'is_configured', return_value=True), \
|
||||
patch.object(plugin._prowlarr, 'search', new=AsyncMock(return_value=[])), \
|
||||
patch('core.download_plugins.usenet.get_active_usenet_adapter', return_value=fake_adapter):
|
||||
outcome = plugin.download_album_to_staging('GNX', 'Kendrick Lamar', str(tmp_path))
|
||||
assert outcome['success'] is False
|
||||
assert 'No usenet results' in outcome['error']
|
||||
assert outcome.get('fallback') is True
|
||||
fake_adapter.add_nzb.assert_not_called()
|
||||
|
||||
|
||||
def test_registry_includes_torrent_and_usenet() -> None:
|
||||
"""The registry decides what shows up in the orchestrator's
|
||||
iteration helpers. If we forget to register a new plugin the
|
||||
|
|
|
|||
528
web_server.py
528
web_server.py
|
|
@ -40,7 +40,7 @@ logger = setup_logging(_log_level, _log_path)
|
|||
|
||||
# App version — single source of truth for backup metadata, system-info, update check, etc.
|
||||
# Semver: MAJOR.MINOR.PATCH. Bump at each dev→main release.
|
||||
_SOULSYNC_BASE_VERSION = "2.6.4"
|
||||
_SOULSYNC_BASE_VERSION = "2.6.5"
|
||||
|
||||
def _build_version_string():
|
||||
"""Append short commit hash to version when available (e.g. 2.35+abc1234)."""
|
||||
|
|
@ -744,16 +744,51 @@ logger.info("Core service initialization complete.")
|
|||
# modules and handlers were still referencing names that never got created.
|
||||
|
||||
# Global Streaming State Management
|
||||
stream_state = {
|
||||
"status": "stopped", # States: stopped, loading, queued, ready, error
|
||||
"progress": 0,
|
||||
"track_info": None,
|
||||
"file_path": None, # Path to the audio file in the Stream folder
|
||||
"error_message": None,
|
||||
}
|
||||
stream_lock = threading.Lock() # Prevent race conditions
|
||||
stream_background_task = None
|
||||
stream_executor = ThreadPoolExecutor(max_workers=1) # Only one stream at a time
|
||||
# Stream playback state. Lifted into core.streaming.state.StreamStateStore —
|
||||
# a per-session registry that's unit-tested and is the foundation for
|
||||
# multi-listener playback (player-revamp Phase 3). Today we use only the
|
||||
# DEFAULT session, so behavior is identical to the old single global: the
|
||||
# whole server still shares one "currently playing". The store just makes the
|
||||
# eventual per-listener split a wiring change instead of a rewrite.
|
||||
#
|
||||
# ``stream_state`` is the default session — dict-compatible (s["k"], s.get,
|
||||
# s.update) so the ~20 existing call sites work unchanged. ``stream_lock`` is
|
||||
# that session's own lock, so ``with stream_lock:`` guards exactly what it did.
|
||||
from core.streaming.state import StreamStateStore as _StreamStateStore
|
||||
from core.streaming.state import DEFAULT_SESSION as _DEFAULT_STREAM_SESSION
|
||||
stream_state_store = _StreamStateStore()
|
||||
stream_state = stream_state_store.get() # DEFAULT_SESSION (back-compat alias)
|
||||
stream_lock = stream_state.lock
|
||||
# Phase 3b — per-listener playback: each browser/device gets its own stream
|
||||
# session so two listeners no longer collide on one global. Background tasks are
|
||||
# tracked per session id. max_workers bumped so concurrent listeners don't queue
|
||||
# behind each other. Single-user behavior is unchanged (one cookie → one session).
|
||||
stream_background_task = None # legacy alias (default session)
|
||||
stream_tasks = {} # session_id -> Future
|
||||
stream_executor = ThreadPoolExecutor(max_workers=4, thread_name_prefix="StreamPrep")
|
||||
|
||||
|
||||
def _stream_session_id():
|
||||
"""Stable per-browser stream session id, from the Flask session cookie.
|
||||
|
||||
Falls back to the DEFAULT session when there's no request context or no
|
||||
cookie yet (e.g. the 1s socket broadcast thread) — so single-user behavior
|
||||
is identical to before. Each distinct browser/device gets its own id and
|
||||
therefore its own independent playback + Stream/<id> staging dir.
|
||||
"""
|
||||
try:
|
||||
sid = session.get('stream_sid')
|
||||
if not sid:
|
||||
sid = uuid.uuid4().hex[:16]
|
||||
session['stream_sid'] = sid
|
||||
return sid
|
||||
except Exception:
|
||||
return _DEFAULT_STREAM_SESSION
|
||||
|
||||
|
||||
def _current_stream_state():
|
||||
"""The StreamSession for the calling browser (dict-compatible)."""
|
||||
return stream_state_store.get(_stream_session_id())
|
||||
|
||||
# Global OAuth State Management
|
||||
# Store PKCE values for Tidal OAuth flow
|
||||
|
|
@ -1776,20 +1811,36 @@ _EDITION_BARE_RE = _re.compile(
|
|||
from core.streaming import prepare as _streaming_prepare
|
||||
|
||||
|
||||
def _build_prepare_stream_deps():
|
||||
"""Build the PrepareStreamDeps bundle from web_server.py globals on each call."""
|
||||
def _build_prepare_stream_deps(sess, sid):
|
||||
"""Build the PrepareStreamDeps bundle for a specific stream session.
|
||||
|
||||
``sess`` is the StreamSession (dict-compatible) for this listener; ``sid`` is
|
||||
its id, used to give each session its own ``Stream/<sid>`` staging subdir so
|
||||
concurrent listeners don't clear each other's files.
|
||||
"""
|
||||
def _get_stream_state():
|
||||
return stream_state
|
||||
return sess
|
||||
|
||||
def _set_stream_state(value):
|
||||
global stream_state
|
||||
stream_state = value
|
||||
# prepare.py only mutates in place (.update / [k]=) so this is
|
||||
# effectively dead — but if anything reassigns, route it through the
|
||||
# session's replace() so the store keeps the live object.
|
||||
if value is sess:
|
||||
return
|
||||
sess.replace(dict(value))
|
||||
|
||||
base_root = os.path.dirname(os.path.abspath(__file__))
|
||||
# prepare.py stages into <project_root>/Stream. Default session keeps the
|
||||
# historical flat Stream/; a named session stages under Stream/<sid>/Stream so
|
||||
# concurrent listeners never clear each other's files. (The served file_path
|
||||
# is absolute, so staging location only affects isolation/cleanup.)
|
||||
project_root = base_root if sid == _DEFAULT_STREAM_SESSION else os.path.join(base_root, 'Stream', sid)
|
||||
|
||||
return _streaming_prepare.PrepareStreamDeps(
|
||||
config_manager=config_manager,
|
||||
download_orchestrator=download_orchestrator,
|
||||
stream_lock=stream_lock,
|
||||
project_root=os.path.dirname(os.path.abspath(__file__)),
|
||||
stream_lock=sess.lock,
|
||||
project_root=project_root,
|
||||
docker_resolve_path=docker_resolve_path,
|
||||
find_streaming_download_in_all_downloads=_find_streaming_download_in_all_downloads,
|
||||
find_downloaded_file=_find_downloaded_file,
|
||||
|
|
@ -1800,8 +1851,8 @@ def _build_prepare_stream_deps():
|
|||
)
|
||||
|
||||
|
||||
def _prepare_stream_task(track_data):
|
||||
return _streaming_prepare.prepare_stream_task(track_data, _build_prepare_stream_deps())
|
||||
def _prepare_stream_task(track_data, sess, sid):
|
||||
return _streaming_prepare.prepare_stream_task(track_data, _build_prepare_stream_deps(sess, sid))
|
||||
|
||||
|
||||
def _find_streaming_download_in_all_downloads(all_downloads, track_data):
|
||||
|
|
@ -2945,6 +2996,28 @@ def handle_settings():
|
|||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/metadata/art-sources', methods=['GET'])
|
||||
def get_art_sources():
|
||||
"""Cover-art sources the current user can actually use, in default order.
|
||||
|
||||
Free sources (CAA/Deezer/iTunes/AudioDB) are always available; account
|
||||
sources (Spotify) only when connected. The settings UI uses this to offer
|
||||
only sources the user is set up with ("not everybody has every source").
|
||||
"""
|
||||
try:
|
||||
from core.metadata.art_lookup import available_art_sources
|
||||
labels = {
|
||||
'caa': 'Cover Art Archive', 'deezer': 'Deezer', 'itunes': 'iTunes',
|
||||
'spotify': 'Spotify', 'audiodb': 'TheAudioDB',
|
||||
}
|
||||
available = [{'id': s, 'name': labels.get(s, s.title())}
|
||||
for s in available_art_sources()]
|
||||
return jsonify({'available': available})
|
||||
except Exception as e:
|
||||
logger.error(f"Error listing art sources: {e}")
|
||||
return jsonify({'available': [], 'error': str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/dev-mode', methods=['GET', 'POST'])
|
||||
def handle_dev_mode():
|
||||
global dev_mode_enabled
|
||||
|
|
@ -6661,6 +6734,48 @@ def _list_available_download_sources() -> tuple:
|
|||
return download_mode, sources
|
||||
|
||||
|
||||
def _norm_track_key(s: str) -> str:
|
||||
"""Loose normalization for matching a task to its library_history row."""
|
||||
import re
|
||||
return re.sub(r'[^a-z0-9]+', '', (s or '').lower())
|
||||
|
||||
|
||||
@app.route('/api/downloads/task/<task_id>/detail', methods=['GET'])
|
||||
def get_task_detail(task_id):
|
||||
"""Full per-track detail for the track-detail modal: live task state merged
|
||||
with the durable library_history provenance (location, quality, AcoustID
|
||||
verdict, source, expected-vs-downloaded). Thin glue over build_track_detail."""
|
||||
try:
|
||||
from core.downloads.track_detail import build_track_detail
|
||||
with tasks_lock:
|
||||
t = download_tasks.get(task_id)
|
||||
task = dict(t) if isinstance(t, dict) else None
|
||||
if task is None:
|
||||
return jsonify({"success": False, "error": "Task not found"}), 404
|
||||
task['task_id'] = task_id
|
||||
|
||||
# Enrich from the most recent download-history row matching this track.
|
||||
history = None
|
||||
try:
|
||||
ti = task.get('track_info') if isinstance(task.get('track_info'), dict) else {}
|
||||
want_title = _norm_track_key(ti.get('name', ''))
|
||||
if want_title:
|
||||
db = get_database()
|
||||
entries, _ = db.get_library_history(event_type='download', page=1, limit=100)
|
||||
for e in entries:
|
||||
if _norm_track_key(e.get('title', '')) == want_title:
|
||||
history = e
|
||||
break
|
||||
except Exception as hist_err:
|
||||
logger.debug(f"track-detail history lookup failed: {hist_err}")
|
||||
|
||||
detail = build_track_detail(task, history)
|
||||
return jsonify({"success": True, "detail": detail})
|
||||
except Exception as e:
|
||||
logger.error(f"get_task_detail error: {e}")
|
||||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/downloads/task/<task_id>/candidates', methods=['GET'])
|
||||
def get_task_candidates(task_id):
|
||||
"""Returns the cached search candidates for a download task so the UI can show what was found."""
|
||||
|
|
@ -7066,20 +7181,58 @@ def approve_quarantine_item(entry_id):
|
|||
# for this one restored pass so multi-reason failures do not loop.
|
||||
context['_skip_quarantine_check'] = 'all'
|
||||
context['_approved_quarantine_trigger'] = trigger
|
||||
# Re-dispatch through the same pipeline. Run async so the HTTP
|
||||
# request returns quickly — UI polls /list to see the entry vanish.
|
||||
# If the caller (download-modal chooser) passed the originating task, run
|
||||
# the re-import through the verification WRAPPER with that task_id so the
|
||||
# task is marked completed on success — otherwise the modal row stays
|
||||
# stuck on "Quarantined" even though the file imported. The sidecar
|
||||
# context lost task_id/batch_id (the wrapper pops them before quarantine),
|
||||
# so we re-supply them here. Manager-tab approvals (no task_id) keep the
|
||||
# original inner-pipeline path.
|
||||
_req = request.get_json(silent=True) or {}
|
||||
_task_id = (_req.get('task_id') or '').strip() or None
|
||||
_batch_id = None
|
||||
if _task_id:
|
||||
with tasks_lock:
|
||||
_t = download_tasks.get(_task_id)
|
||||
if isinstance(_t, dict):
|
||||
_batch_id = _t.get('batch_id')
|
||||
context['task_id'] = _task_id
|
||||
if _batch_id:
|
||||
context['batch_id'] = _batch_id
|
||||
context_key = f"approve_{entry_id}_{int(time.time())}"
|
||||
threading.Thread(
|
||||
target=lambda: _post_process_matched_download(context_key, context, restored_path),
|
||||
daemon=True,
|
||||
).start()
|
||||
logger.info(f"[Quarantine] Approved {entry_id} (original_trigger={trigger}, bypass=all) → re-running pipeline")
|
||||
if _task_id:
|
||||
_reprocess = lambda: _post_process_matched_download_with_verification(
|
||||
context_key, context, restored_path, _task_id, _batch_id,
|
||||
)
|
||||
else:
|
||||
_reprocess = lambda: _post_process_matched_download(context_key, context, restored_path)
|
||||
threading.Thread(target=_reprocess, daemon=True).start()
|
||||
logger.info(f"[Quarantine] Approved {entry_id} (original_trigger={trigger}, bypass=all, task={_task_id}) → re-running pipeline")
|
||||
return jsonify({"success": True, "trigger_bypassed": "all", "original_trigger": trigger})
|
||||
except Exception as e:
|
||||
logger.error(f"[Quarantine] Error approving {entry_id}: {e}")
|
||||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/quarantine/<entry_id>/stream', methods=['GET'])
|
||||
def stream_quarantine_item(entry_id):
|
||||
"""Stream a quarantined audio file in-app (range-supported) so the user can
|
||||
listen before deciding to approve, search again, or delete it. The file
|
||||
lives in the quarantine dir with a `.quarantined` suffix, so the real audio
|
||||
extension (and thus Content-Type) is recovered from the sidecar."""
|
||||
try:
|
||||
from core.imports.quarantine import get_quarantine_entry_stream_info
|
||||
info = get_quarantine_entry_stream_info(_get_quarantine_dir(), entry_id)
|
||||
if info is None:
|
||||
return jsonify({"error": "Quarantined file not found"}), 404
|
||||
file_path, extension = info
|
||||
mimetype = _AUDIO_MIME_TYPES.get(extension, 'audio/mpeg')
|
||||
return _serve_audio_file_with_range(file_path, mimetype_override=mimetype)
|
||||
except Exception as e:
|
||||
logger.error(f"[Quarantine] Error streaming {entry_id}: {e}")
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/quarantine/<entry_id>/recover', methods=['POST'])
|
||||
def recover_quarantine_item(entry_id):
|
||||
"""Fallback for legacy thin sidecars: move file into Staging so the user
|
||||
|
|
@ -10577,9 +10730,10 @@ def library_play_track():
|
|||
|
||||
logger.info(f"Library play request: {os.path.basename(file_path)}")
|
||||
|
||||
# Set stream state to ready with the library file path directly
|
||||
with stream_lock:
|
||||
stream_state.update({
|
||||
# Set THIS listener's stream state to ready with the library file path.
|
||||
sess = _current_stream_state()
|
||||
with sess.lock:
|
||||
sess.update({
|
||||
"status": "ready",
|
||||
"progress": 100,
|
||||
"track_info": {
|
||||
|
|
@ -10598,6 +10752,30 @@ def library_play_track():
|
|||
logger.error(f"Error playing library track: {e}")
|
||||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/library/log-play', methods=['POST'])
|
||||
def library_log_play():
|
||||
"""Record a SoulSync web-player play into listening_history + bump the
|
||||
track's play_count/last_played (feeds 'recently played' and smart radio).
|
||||
|
||||
Fire-and-forget from the frontend ~10s into a track. Best-effort: a logging
|
||||
failure never affects playback, so we always return 200-ish.
|
||||
"""
|
||||
try:
|
||||
from core.playback.play_log import build_play_event
|
||||
data = request.get_json(silent=True) or {}
|
||||
track = data.get('track') or data
|
||||
played_at = datetime.now().isoformat()
|
||||
duration_ms = data.get('duration_ms', 0)
|
||||
event = build_play_event(track, played_at, duration_ms)
|
||||
if not event:
|
||||
return jsonify({"success": False, "skipped": True}), 200
|
||||
get_database().record_web_player_play(event)
|
||||
return jsonify({"success": True})
|
||||
except Exception as e:
|
||||
logger.debug(f"log-play failed (non-fatal): {e}")
|
||||
return jsonify({"success": False, "error": str(e)}), 200
|
||||
|
||||
_enrichment_locks = {svc: threading.Lock() for svc in ('audiodb', 'deezer', 'musicbrainz', 'spotify', 'itunes', 'lastfm', 'genius', 'tidal', 'qobuz', 'discogs')}
|
||||
|
||||
@app.route('/api/library/enrich', methods=['POST'])
|
||||
|
|
@ -11734,23 +11912,25 @@ def library_radio():
|
|||
|
||||
@app.route('/api/stream/start', methods=['POST'])
|
||||
def stream_start():
|
||||
"""Start streaming a track in the background"""
|
||||
"""Start streaming a track in the background (per-listener session)."""
|
||||
global stream_background_task
|
||||
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return jsonify({"success": False, "error": "No track data provided"}), 400
|
||||
|
||||
|
||||
logger.info(f"Web UI Stream request for: {data.get('filename')}")
|
||||
|
||||
|
||||
try:
|
||||
# Stop any existing streaming task
|
||||
if stream_background_task and not stream_background_task.done():
|
||||
stream_background_task.cancel()
|
||||
|
||||
# Reset stream state
|
||||
with stream_lock:
|
||||
stream_state.update({
|
||||
sid = _stream_session_id()
|
||||
sess = stream_state_store.get(sid)
|
||||
|
||||
# Stop only THIS listener's existing task — others keep playing.
|
||||
prev = stream_tasks.get(sid)
|
||||
if prev and not prev.done():
|
||||
prev.cancel()
|
||||
|
||||
with sess.lock:
|
||||
sess.update({
|
||||
"status": "stopped",
|
||||
"progress": 0,
|
||||
"track_info": None,
|
||||
|
|
@ -11758,27 +11938,30 @@ def stream_start():
|
|||
"error_message": None,
|
||||
"is_library": False
|
||||
})
|
||||
|
||||
# Start new background streaming task
|
||||
stream_background_task = stream_executor.submit(_prepare_stream_task, data)
|
||||
|
||||
|
||||
# Start new background streaming task for this session.
|
||||
fut = stream_executor.submit(_prepare_stream_task, data, sess, sid)
|
||||
stream_tasks[sid] = fut
|
||||
if sid == _DEFAULT_STREAM_SESSION:
|
||||
stream_background_task = fut # keep legacy alias in sync
|
||||
|
||||
return jsonify({"success": True, "message": "Streaming started"})
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error starting stream: {e}")
|
||||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
|
||||
@app.route('/api/stream/status')
|
||||
def stream_status():
|
||||
"""Get current streaming status and progress"""
|
||||
"""Get current streaming status and progress for THIS listener."""
|
||||
try:
|
||||
with stream_lock:
|
||||
# Return copy of current stream state
|
||||
sess = _current_stream_state()
|
||||
with sess.lock:
|
||||
return jsonify({
|
||||
"status": stream_state["status"],
|
||||
"progress": stream_state["progress"],
|
||||
"track_info": stream_state["track_info"],
|
||||
"error_message": stream_state["error_message"]
|
||||
"status": sess["status"],
|
||||
"progress": sess["progress"],
|
||||
"track_info": sess["track_info"],
|
||||
"error_message": sess["error_message"]
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting stream status: {e}")
|
||||
|
|
@ -11789,123 +11972,138 @@ def stream_status():
|
|||
"error_message": str(e)
|
||||
}), 500
|
||||
|
||||
_AUDIO_MIME_TYPES = {
|
||||
'.mp3': 'audio/mpeg', '.flac': 'audio/flac', '.ogg': 'audio/ogg',
|
||||
'.aac': 'audio/aac', '.m4a': 'audio/mp4', '.wav': 'audio/wav',
|
||||
'.opus': 'audio/ogg', '.webm': 'audio/webm', '.wma': 'audio/x-ms-wma',
|
||||
}
|
||||
|
||||
|
||||
def _serve_audio_file_with_range(file_path, mimetype_override=None):
|
||||
"""Serve an on-disk audio file with HTTP range support (HTML5 seeking).
|
||||
|
||||
Shared by /stream/audio (current track) and /stream/library-audio (the
|
||||
crossfade pre-loader, which plays the NEXT track on a second <audio>).
|
||||
|
||||
``mimetype_override`` lets callers supply the Content-Type explicitly when
|
||||
the on-disk extension isn't the audio extension — e.g. the quarantine
|
||||
streamer, whose files end in ``.quarantined`` (real type recovered from the
|
||||
sidecar). When None, the type is inferred from the file extension as before.
|
||||
"""
|
||||
if not os.path.exists(file_path):
|
||||
return jsonify({"error": "Audio file not found"}), 404
|
||||
|
||||
file_ext = os.path.splitext(file_path)[1].lower()
|
||||
mimetype = mimetype_override or _AUDIO_MIME_TYPES.get(file_ext, 'audio/mpeg')
|
||||
file_size = os.path.getsize(file_path)
|
||||
|
||||
range_header = request.headers.get('Range', None)
|
||||
if range_header:
|
||||
byte_start = 0
|
||||
byte_end = file_size - 1
|
||||
try:
|
||||
range_match = re.match(r'bytes=(\d*)-(\d*)', range_header)
|
||||
if range_match:
|
||||
start_str, end_str = range_match.groups()
|
||||
if start_str:
|
||||
byte_start = int(start_str)
|
||||
if end_str:
|
||||
byte_end = int(end_str)
|
||||
else:
|
||||
byte_end = file_size - 1
|
||||
except (ValueError, AttributeError):
|
||||
pass
|
||||
|
||||
byte_start = max(0, byte_start)
|
||||
byte_end = min(file_size - 1, byte_end)
|
||||
content_length = byte_end - byte_start + 1
|
||||
|
||||
def generate():
|
||||
with open(file_path, 'rb') as f:
|
||||
f.seek(byte_start)
|
||||
remaining = content_length
|
||||
while remaining:
|
||||
chunk_size = min(8192, remaining)
|
||||
chunk = f.read(chunk_size)
|
||||
if not chunk:
|
||||
break
|
||||
remaining -= len(chunk)
|
||||
yield chunk
|
||||
|
||||
response = Response(generate(), status=206, mimetype=mimetype, direct_passthrough=True)
|
||||
response.headers.add('Content-Range', f'bytes {byte_start}-{byte_end}/{file_size}')
|
||||
response.headers.add('Accept-Ranges', 'bytes')
|
||||
response.headers.add('Content-Length', str(content_length))
|
||||
response.headers.add('Cache-Control', 'no-cache')
|
||||
return response
|
||||
else:
|
||||
response = send_file(file_path, as_attachment=False, mimetype=mimetype)
|
||||
response.headers.add('Accept-Ranges', 'bytes')
|
||||
response.headers.add('Content-Length', str(file_size))
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
return response
|
||||
|
||||
|
||||
@app.route('/stream/audio')
|
||||
def stream_audio():
|
||||
"""Serve the audio file from the Stream folder with range request support"""
|
||||
"""Serve THIS listener's current audio file with range request support."""
|
||||
try:
|
||||
with stream_lock:
|
||||
if stream_state["status"] != "ready" or not stream_state["file_path"]:
|
||||
sess = _current_stream_state()
|
||||
with sess.lock:
|
||||
if sess["status"] != "ready" or not sess["file_path"]:
|
||||
return jsonify({"error": "No audio file ready for streaming"}), 404
|
||||
|
||||
file_path = stream_state["file_path"]
|
||||
|
||||
if not os.path.exists(file_path):
|
||||
return jsonify({"error": "Audio file not found"}), 404
|
||||
|
||||
file_path = sess["file_path"]
|
||||
|
||||
logger.info(f"Serving audio file: {os.path.basename(file_path)}")
|
||||
|
||||
# Determine MIME type based on file extension
|
||||
file_ext = os.path.splitext(file_path)[1].lower()
|
||||
mime_types = {
|
||||
'.mp3': 'audio/mpeg',
|
||||
'.flac': 'audio/flac',
|
||||
'.ogg': 'audio/ogg',
|
||||
'.aac': 'audio/aac',
|
||||
'.m4a': 'audio/mp4',
|
||||
'.wav': 'audio/wav',
|
||||
'.opus': 'audio/ogg',
|
||||
'.webm': 'audio/webm',
|
||||
'.wma': 'audio/x-ms-wma'
|
||||
}
|
||||
|
||||
mimetype = mime_types.get(file_ext, 'audio/mpeg')
|
||||
|
||||
# Get file size
|
||||
file_size = os.path.getsize(file_path)
|
||||
|
||||
# Handle range requests (important for HTML5 audio seeking)
|
||||
range_header = request.headers.get('Range', None)
|
||||
if range_header:
|
||||
byte_start = 0
|
||||
byte_end = file_size - 1
|
||||
|
||||
# Parse range header (format: "bytes=start-end")
|
||||
try:
|
||||
range_match = re.match(r'bytes=(\d*)-(\d*)', range_header)
|
||||
if range_match:
|
||||
start_str, end_str = range_match.groups()
|
||||
if start_str:
|
||||
byte_start = int(start_str)
|
||||
if end_str:
|
||||
byte_end = int(end_str)
|
||||
else:
|
||||
# If no end specified, serve from start to end of file
|
||||
byte_end = file_size - 1
|
||||
except (ValueError, AttributeError):
|
||||
# Invalid range header, serve full file
|
||||
pass
|
||||
|
||||
# Ensure valid range
|
||||
byte_start = max(0, byte_start)
|
||||
byte_end = min(file_size - 1, byte_end)
|
||||
content_length = byte_end - byte_start + 1
|
||||
|
||||
# Create response with partial content
|
||||
def generate():
|
||||
with open(file_path, 'rb') as f:
|
||||
f.seek(byte_start)
|
||||
remaining = content_length
|
||||
while remaining:
|
||||
chunk_size = min(8192, remaining) # 8KB chunks
|
||||
chunk = f.read(chunk_size)
|
||||
if not chunk:
|
||||
break
|
||||
remaining -= len(chunk)
|
||||
yield chunk
|
||||
|
||||
response = Response(generate(),
|
||||
status=206, # Partial Content
|
||||
mimetype=mimetype,
|
||||
direct_passthrough=True)
|
||||
|
||||
# Set range headers
|
||||
response.headers.add('Content-Range', f'bytes {byte_start}-{byte_end}/{file_size}')
|
||||
response.headers.add('Accept-Ranges', 'bytes')
|
||||
response.headers.add('Content-Length', str(content_length))
|
||||
response.headers.add('Cache-Control', 'no-cache')
|
||||
|
||||
return response
|
||||
else:
|
||||
# No range request, serve entire file
|
||||
response = send_file(file_path, as_attachment=False, mimetype=mimetype)
|
||||
response.headers.add('Accept-Ranges', 'bytes')
|
||||
response.headers.add('Content-Length', str(file_size))
|
||||
# Override the default static-cache max-age — streaming media
|
||||
# bypasses caching (range requests, mid-track seeks).
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
return response
|
||||
|
||||
return _serve_audio_file_with_range(file_path)
|
||||
except Exception as e:
|
||||
logger.error(f"Error serving audio file: {e}")
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/stream/library-audio')
|
||||
def stream_library_audio():
|
||||
"""Serve an arbitrary LIBRARY audio file by path, with range support.
|
||||
|
||||
Powers crossfade: the player preloads the NEXT queued track on a second
|
||||
<audio> element and fades between them. Security: the path is resolved
|
||||
through ``_resolve_library_file_path`` — the same validator /api/library/play
|
||||
uses — which only resolves files within the configured transfer/download/
|
||||
media-library directories, so this can't be used to read arbitrary disk.
|
||||
"""
|
||||
try:
|
||||
raw_path = request.args.get('path', '')
|
||||
if not raw_path:
|
||||
return jsonify({"error": "path is required"}), 400
|
||||
resolved = _resolve_library_file_path(raw_path)
|
||||
if not resolved or not os.path.exists(resolved):
|
||||
return jsonify({"error": _get_file_not_found_error(raw_path)}), 404
|
||||
return _serve_audio_file_with_range(resolved)
|
||||
except Exception as e:
|
||||
logger.error(f"Error serving library audio file: {e}")
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
@app.route('/api/stream/stop', methods=['POST'])
|
||||
def stream_stop():
|
||||
"""Stop streaming and clean up"""
|
||||
global stream_background_task
|
||||
|
||||
"""Stop THIS listener's stream and clean up its staging dir."""
|
||||
try:
|
||||
# Cancel background task
|
||||
if stream_background_task and not stream_background_task.done():
|
||||
stream_background_task.cancel()
|
||||
sid = _stream_session_id()
|
||||
sess = stream_state_store.get(sid)
|
||||
|
||||
# Only clear Stream folder if NOT playing a library file
|
||||
with stream_lock:
|
||||
is_library = stream_state.get("is_library", False)
|
||||
# Cancel only this session's background task.
|
||||
fut = stream_tasks.get(sid)
|
||||
if fut and not fut.done():
|
||||
fut.cancel()
|
||||
|
||||
# Only clear the (per-session) Stream folder if NOT a library file.
|
||||
with sess.lock:
|
||||
is_library = sess.get("is_library", False)
|
||||
|
||||
if not is_library:
|
||||
project_root = os.path.dirname(os.path.abspath(__file__))
|
||||
stream_folder = os.path.join(project_root, 'Stream')
|
||||
base_root = os.path.dirname(os.path.abspath(__file__))
|
||||
stream_folder = (os.path.join(base_root, 'Stream')
|
||||
if sid == _DEFAULT_STREAM_SESSION
|
||||
else os.path.join(base_root, 'Stream', sid, 'Stream'))
|
||||
|
||||
if os.path.exists(stream_folder):
|
||||
for filename in os.listdir(stream_folder):
|
||||
|
|
@ -11916,9 +12114,9 @@ def stream_stop():
|
|||
else:
|
||||
logger.info("Library playback stopped - skipping file deletion")
|
||||
|
||||
# Reset stream state
|
||||
with stream_lock:
|
||||
stream_state.update({
|
||||
# Reset this session's stream state
|
||||
with sess.lock:
|
||||
sess.update({
|
||||
"status": "stopped",
|
||||
"progress": 0,
|
||||
"track_info": None,
|
||||
|
|
@ -18517,13 +18715,9 @@ def mlm_save():
|
|||
data = request.get_json(force=True) or {}
|
||||
source = data.get('source', '').strip()
|
||||
source_track_id = data.get('source_track_id', '').strip()
|
||||
library_track_id = data.get('library_track_id')
|
||||
library_track_id = mlm.normalize_library_track_id(data.get('library_track_id'))
|
||||
if not source or not source_track_id or not library_track_id:
|
||||
return jsonify({"success": False, "error": "source, source_track_id, library_track_id required"}), 400
|
||||
try:
|
||||
library_track_id = int(library_track_id)
|
||||
except (TypeError, ValueError):
|
||||
return jsonify({"success": False, "error": "Invalid library track id"}), 400
|
||||
db = get_database()
|
||||
profile_id = get_current_profile_id()
|
||||
# Validate library track exists before saving
|
||||
|
|
@ -32705,9 +32899,13 @@ try:
|
|||
amazon_db = MusicDatabase()
|
||||
amazon_worker = AmazonWorker(database=amazon_db)
|
||||
amazon_worker.start()
|
||||
if config_manager.get('amazon_enrichment_paused', False):
|
||||
# Opt-in by default: Amazon enrichment depends on an external public proxy
|
||||
# (T2Tunes) that can be down, so it stays paused unless the user has
|
||||
# explicitly enabled it (amazon_enrichment_paused=False). This stops an
|
||||
# instance outage from grinding/log-flooding installs that never opted in.
|
||||
if config_manager.get('amazon_enrichment_paused', True):
|
||||
amazon_worker.pause()
|
||||
logger.info("Amazon enrichment worker initialized (paused — restored from config)")
|
||||
logger.info("Amazon enrichment worker initialized (paused — enable it in Settings)")
|
||||
else:
|
||||
logger.info("Amazon enrichment worker initialized and started")
|
||||
except Exception as e:
|
||||
|
|
|
|||
|
|
@ -2365,6 +2365,9 @@
|
|||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="adl-batch-summary" id="adl-batch-summary" style="display:none">
|
||||
<!-- Live aggregate (batches · downloading · speed · ETA) rendered by JS -->
|
||||
</div>
|
||||
<div class="adl-batch-active" id="adl-batch-active">
|
||||
<!-- Active batch cards rendered by JS -->
|
||||
</div>
|
||||
|
|
@ -5467,7 +5470,12 @@
|
|||
<input type="checkbox" id="prefer-caa-art">
|
||||
Use MusicBrainz Cover Art Archive for album art
|
||||
</label>
|
||||
<small>Higher resolution but quality may vary. When off, uses Spotify/iTunes/Deezer art (consistent 640x640).</small>
|
||||
<small>Higher resolution but quality may vary. When off, uses Spotify/iTunes/Deezer art (consistent 640x640). Ignored when a preferred album art source list is set below — add Cover Art Archive to that list instead.</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="checkbox-label" style="cursor:default;">Preferred album art sources</label>
|
||||
<small class="settings-hint">Order the sources to choose whose cover art is used. The first source that has a cover wins; misses fall through to the next, and if none match, your download's own art is kept. Only sources you're connected to are shown — leave all off to keep current behavior.</small>
|
||||
<div class="hybrid-source-list" id="art-source-list"></div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="checkbox-label">
|
||||
|
|
@ -7007,6 +7015,8 @@
|
|||
|
||||
<!-- Hidden HTML5 Audio Player for Streaming -->
|
||||
<audio id="audio-player" style="display: none;"></audio>
|
||||
<!-- Secondary audio element used only for crossfade preloading of the next track -->
|
||||
<audio id="audio-player-xfade" style="display: none;"></audio>
|
||||
|
||||
<!-- Expanded Now Playing Modal -->
|
||||
<div class="np-modal-overlay hidden" id="np-modal-overlay">
|
||||
|
|
@ -7017,15 +7027,24 @@
|
|||
<div class="np-body">
|
||||
<!-- Left: album art + track info -->
|
||||
<div class="np-left">
|
||||
<div class="np-album-art-container">
|
||||
<div class="np-album-art-container" id="np-album-art-container" title="Click for visualizer">
|
||||
<img class="np-album-art hidden" id="np-album-art" src="" alt="Album Art">
|
||||
<div class="np-album-art-placeholder" id="np-album-art-placeholder">
|
||||
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3"/><line x1="12" y1="2" x2="12" y2="5"/><line x1="12" y1="19" x2="12" y2="22"/>
|
||||
</svg>
|
||||
</div>
|
||||
<!-- Big music-synced visualizer takeover (Plexamp-style); click art to toggle -->
|
||||
<div class="np-art-viz" id="np-art-viz" aria-hidden="true"></div>
|
||||
<div class="np-art-hint" id="np-art-hint" title="Visualizer">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><rect x="3" y="10" width="3" height="11" rx="1"/><rect x="10.5" y="4" width="3" height="17" rx="1"/><rect x="18" y="13" width="3" height="8" rx="1"/></svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="np-track-info">
|
||||
<div class="np-play-context hidden" id="np-play-context">
|
||||
<span class="np-play-context-label">Playing from</span>
|
||||
<span class="np-play-context-name" id="np-play-context-name"></span>
|
||||
</div>
|
||||
<div class="np-track-title" id="np-track-title">No track</div>
|
||||
<div class="np-artist-name" id="np-artist-name">Unknown Artist</div>
|
||||
<div class="np-album-name" id="np-album-name">Unknown Album</div>
|
||||
|
|
@ -7041,12 +7060,23 @@
|
|||
</div>
|
||||
<!-- Right: controls -->
|
||||
<div class="np-right">
|
||||
<div class="np-util-row">
|
||||
<button class="np-util-btn" id="np-sleep-btn" title="Sleep timer">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
|
||||
<span id="np-sleep-label">Sleep</span>
|
||||
</button>
|
||||
<button class="np-util-btn" id="np-crossfade-btn" title="Crossfade between tracks">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M7 8l-4 4 4 4"/><path d="M17 8l4 4-4 4"/><line x1="3" y1="12" x2="21" y2="12"/></svg>
|
||||
<span>Crossfade</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="np-progress-section">
|
||||
<div class="np-progress-bar-container">
|
||||
<div class="np-progress-track">
|
||||
<div class="np-progress-fill" id="np-progress-fill"></div>
|
||||
</div>
|
||||
<input type="range" class="np-progress-bar" id="np-progress-bar" min="0" max="100" value="0" step="0.1">
|
||||
<div class="np-seek-tip" id="np-seek-tip">0:00</div>
|
||||
</div>
|
||||
<div class="np-time-display">
|
||||
<span class="np-current-time" id="np-current-time">0:00</span>
|
||||
|
|
@ -7085,6 +7115,15 @@
|
|||
<input type="range" class="np-volume-slider" id="np-volume-slider" min="0" max="100" value="70">
|
||||
</div>
|
||||
</div>
|
||||
<!-- Up-next peek (hidden when queue has no next track) -->
|
||||
<div class="np-upnext hidden" id="np-upnext">
|
||||
<span class="np-upnext-label">Next</span>
|
||||
<img class="np-upnext-art" id="np-upnext-art" alt="">
|
||||
<div class="np-upnext-info">
|
||||
<div class="np-upnext-title" id="np-upnext-title"></div>
|
||||
<div class="np-upnext-artist" id="np-upnext-artist"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="np-stop-row">
|
||||
<button class="np-btn np-btn-stop" id="np-stop-btn" title="Stop playback">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><rect x="4" y="4" width="16" height="16" rx="2"/></svg>
|
||||
|
|
@ -7981,6 +8020,7 @@
|
|||
<script src="{{ url_for('static', filename='search.js', v=static_v) }}"></script>
|
||||
<script src="{{ url_for('static', filename='sync-spotify.js', v=static_v) }}"></script>
|
||||
<script src="{{ url_for('static', filename='downloads.js', v=static_v) }}"></script>
|
||||
<script src="{{ url_for('static', filename='track-detail.js', v=static_v) }}"></script>
|
||||
<script src="{{ url_for('static', filename='wishlist-tools.js', v=static_v) }}"></script>
|
||||
<script src="{{ url_for('static', filename='sync-services.js', v=static_v) }}"></script>
|
||||
<script src="{{ url_for('static', filename='sync-listenbrainz.js', v=static_v) }}"></script>
|
||||
|
|
@ -8014,6 +8054,8 @@
|
|||
</div>
|
||||
<div class="gsearch-results" id="gsearch-results"></div>
|
||||
|
||||
{% include 'track-detail-modal.html' %}
|
||||
|
||||
<!-- Floating Mini Media Player — fixed bottom-right, above bell/help -->
|
||||
<div class="media-player idle" id="media-player">
|
||||
<!-- Thin interactive progress bar spanning full width at top -->
|
||||
|
|
@ -8040,6 +8082,9 @@
|
|||
<div class="artist-name" id="artist-name">Unknown Artist</div>
|
||||
</div>
|
||||
<div class="mini-player-controls">
|
||||
<button class="mini-toggle-btn" id="mini-shuffle-btn" title="Shuffle">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="13" height="13"><polyline points="16 3 21 3 21 8"/><line x1="4" y1="20" x2="21" y2="3"/><polyline points="21 16 21 21 16 21"/><line x1="15" y1="15" x2="21" y2="21"/><line x1="4" y1="4" x2="9" y2="9"/></svg>
|
||||
</button>
|
||||
<button class="mini-nav-btn" id="mini-prev-btn" title="Previous" disabled>
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" width="14" height="14"><path d="M6 6h2v12H6zm3.5 6l8.5 6V6z"/></svg>
|
||||
</button>
|
||||
|
|
@ -8050,6 +8095,10 @@
|
|||
<button class="mini-nav-btn" id="mini-next-btn" title="Next" disabled>
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" width="14" height="14"><path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z"/></svg>
|
||||
</button>
|
||||
<button class="mini-toggle-btn" id="mini-repeat-btn" title="Repeat">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="13" height="13"><polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/><polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/></svg>
|
||||
<span class="mini-repeat-one" id="mini-repeat-one-badge" style="display:none">1</span>
|
||||
</button>
|
||||
<button class="stop-button" id="stop-button" disabled>
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" width="10" height="10"><rect x="6" y="6" width="12" height="12" rx="1.5"/></svg>
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -2983,7 +2983,16 @@ function _ensureCandidatesClickListener(statusEl) {
|
|||
e.stopPropagation();
|
||||
_hideErrorTooltip();
|
||||
const taskId = this.dataset.taskId;
|
||||
if (taskId) showCandidatesModal(taskId);
|
||||
if (!taskId) return;
|
||||
// Decide at click-time from dataset set each render: completed and
|
||||
// quarantined rows open the rich track-detail modal (it carries the
|
||||
// play/listen + Accept/Search actions); plain failed/not-found go
|
||||
// straight to the search modal.
|
||||
if (this.dataset.detailOpen && typeof openTrackDetail === 'function') {
|
||||
openTrackDetail(taskId);
|
||||
} else {
|
||||
showCandidatesModal(taskId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -3344,6 +3353,7 @@ async function downloadCandidate(taskId, candidate, trackName) {
|
|||
|
||||
async function approveQuarantineFromDownloadRow(button) {
|
||||
const entryId = button?.dataset?.entryId || '';
|
||||
const taskId = button?.dataset?.taskId || '';
|
||||
if (!entryId) {
|
||||
showToast('Open Quarantine to approve this file.', 'warning');
|
||||
return;
|
||||
|
|
@ -3361,7 +3371,11 @@ async function approveQuarantineFromDownloadRow(button) {
|
|||
button.disabled = true;
|
||||
button.textContent = 'Approving...';
|
||||
try {
|
||||
const response = await fetch(`/api/quarantine/${encodeURIComponent(entryId)}/approve`, { method: 'POST' });
|
||||
const response = await fetch(`/api/quarantine/${encodeURIComponent(entryId)}/approve`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ task_id: taskId }),
|
||||
});
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
showToast('Approved quarantined file. Re-running post-processing.', 'success');
|
||||
|
|
@ -3377,6 +3391,10 @@ async function approveQuarantineFromDownloadRow(button) {
|
|||
}
|
||||
}
|
||||
|
||||
// Quarantine actions (Listen / Accept & Import / Search) now live in the
|
||||
// track-detail modal (static/track-detail.js), which a quarantined row opens
|
||||
// via _ensureCandidatesClickListener + dataset.detailOpen.
|
||||
|
||||
function closeCandidatesModal() {
|
||||
const overlay = document.getElementById('candidates-modal-overlay');
|
||||
if (overlay) {
|
||||
|
|
@ -3641,6 +3659,15 @@ function processModalStatusUpdate(playlistId, data) {
|
|||
statusEl.classList.remove('has-error-tooltip');
|
||||
statusEl.removeAttribute('title');
|
||||
statusEl.removeAttribute('data-error-msg');
|
||||
// Clear clickable/quarantine state each render; the failure
|
||||
// branch below re-adds it when still applicable. Without this a
|
||||
// task that flips failed/quarantined -> completed (e.g. after
|
||||
// Accept & Import) keeps a stale chooser on the cell.
|
||||
statusEl.classList.remove('has-candidates');
|
||||
delete statusEl.dataset.quarantineEntryId;
|
||||
delete statusEl.dataset.quarantineReason;
|
||||
delete statusEl.dataset.quarantineTrack;
|
||||
delete statusEl.dataset.detailOpen;
|
||||
statusEl.textContent = statusText;
|
||||
|
||||
if ((task.status === 'failed' || task.status === 'cancelled' || task.status === 'not_found') && task.error_message) {
|
||||
|
|
@ -3648,13 +3675,23 @@ function processModalStatusUpdate(playlistId, data) {
|
|||
statusEl.dataset.errorMsg = task.error_message;
|
||||
_ensureErrorTooltipListeners(statusEl);
|
||||
}
|
||||
// Make not_found / failed / cancelled cells clickable to open
|
||||
// the candidates modal. Always bind — even when no auto-search
|
||||
// candidates were cached — because the modal carries the manual
|
||||
// search bar, which is the user's recourse for empty results.
|
||||
if (task.status === 'not_found' || task.status === 'failed' || task.status === 'cancelled') {
|
||||
// Completed rows are clickable into the rich track-detail modal
|
||||
// (play, location, AcoustID verdict, provenance).
|
||||
if (task.status === 'completed') {
|
||||
statusEl.classList.add('has-candidates');
|
||||
statusEl.dataset.taskId = task.task_id;
|
||||
statusEl.dataset.detailOpen = '1';
|
||||
_ensureCandidatesClickListener(statusEl);
|
||||
} else if (task.status === 'not_found' || task.status === 'failed' || task.status === 'cancelled') {
|
||||
// Clickable to recover: quarantined -> track-detail modal
|
||||
// (Listen / Accept / Search); plain failed/not-found ->
|
||||
// straight to the search modal. detailOpen is set/cleared
|
||||
// each render so a row that changes kind stays correct.
|
||||
statusEl.classList.add('has-candidates');
|
||||
statusEl.dataset.taskId = task.task_id;
|
||||
if (isQuarantinedTask && task.quarantine_entry_id) {
|
||||
statusEl.dataset.detailOpen = '1';
|
||||
}
|
||||
_ensureCandidatesClickListener(statusEl);
|
||||
}
|
||||
console.debug(`✅ [Status Update] Updated track ${task.track_index} to: ${statusText}${isV2Task ? ' (V2)' : ''}`);
|
||||
|
|
@ -3674,7 +3711,7 @@ function processModalStatusUpdate(playlistId, data) {
|
|||
}
|
||||
} else if (actionsEl && task.status === 'failed' && isQuarantinedTask && task.quarantine_entry_id) {
|
||||
const entryId = escapeHtml(task.quarantine_entry_id);
|
||||
actionsEl.innerHTML = `<button class="approve-quarantine-inline-btn" data-entry-id="${entryId}" title="Approve quarantined file">Approve</button>`;
|
||||
actionsEl.innerHTML = `<button class="approve-quarantine-inline-btn" data-entry-id="${entryId}" data-task-id="${escapeHtml(task.task_id)}" title="Approve quarantined file">Approve</button>`;
|
||||
const approveBtn = actionsEl.querySelector('.approve-quarantine-inline-btn');
|
||||
if (approveBtn) {
|
||||
approveBtn.addEventListener('click', () => approveQuarantineFromDownloadRow(approveBtn));
|
||||
|
|
@ -5658,7 +5695,10 @@ let _gsController = null;
|
|||
if (clearBtn) clearBtn.style.display = q.length > 0 ? '' : 'none';
|
||||
if (_gsState.debounceTimer) clearTimeout(_gsState.debounceTimer);
|
||||
if (q.length < 2) { _gsHideResults(); return; }
|
||||
_gsState.debounceTimer = setTimeout(() => _gsController.submitQuery(q), 300);
|
||||
// 600ms (was 300) — coalesce a name being typed into one search
|
||||
// instead of one external-API search per letter (#751). Enter still
|
||||
// fires immediately via the keydown handler.
|
||||
_gsState.debounceTimer = setTimeout(() => _gsController.submitQuery(q), 600);
|
||||
});
|
||||
|
||||
if (clearBtn) {
|
||||
|
|
|
|||
|
|
@ -2859,12 +2859,21 @@ function _renderFindingDetail(f) {
|
|||
|
||||
case 'duplicate_tracks':
|
||||
if (!d.tracks || !d.tracks.length) return _gridRows([['Count', d.count || '?']]);
|
||||
// Determine best copy (same logic as backend: highest bitrate, then duration, then track number)
|
||||
// Determine best copy — same logic as the backend
|
||||
// (core/library/duplicate_keep.py): lossless format first, so a FLAC
|
||||
// beats an MP3 even when the FLAC's bitrate is missing, then bitrate,
|
||||
// duration, track number.
|
||||
const _dupFmtRank = (fp) => {
|
||||
const r = { flac: 10, wav: 9, aiff: 9, aif: 9, ape: 8, m4a: 7, ogg: 6, opus: 6, mp3: 5, aac: 5, wma: 3 };
|
||||
return r[String(fp || '').split('.').pop().toLowerCase()] || 1;
|
||||
};
|
||||
const _dupKey = (t) => [_dupFmtRank(t.file_path), t.bitrate || 0, t.duration || 0, t.track_number || 0];
|
||||
const bestDup = d.tracks.reduce((best, t) => {
|
||||
const bBr = best.bitrate || 0, tBr = t.bitrate || 0;
|
||||
const bDur = best.duration || 0, tDur = t.duration || 0;
|
||||
const bTn = best.track_number || 0, tTn = t.track_number || 0;
|
||||
return (tBr > bBr || (tBr === bBr && tDur > bDur) || (tBr === bBr && tDur === bDur && tTn > bTn)) ? t : best;
|
||||
const bk = _dupKey(best), tk = _dupKey(t);
|
||||
for (let i = 0; i < bk.length; i++) {
|
||||
if (tk[i] !== bk[i]) return tk[i] > bk[i] ? t : best;
|
||||
}
|
||||
return best;
|
||||
}, d.tracks[0]);
|
||||
const findingId = f.id;
|
||||
return media + `<div class="repair-detail-sublist">${d.tracks.map((t, i) => {
|
||||
|
|
|
|||
|
|
@ -4085,6 +4085,13 @@ function _buildTrackRow(track, album, admin) {
|
|||
const queueTd = document.createElement('td');
|
||||
queueTd.className = 'col-queue';
|
||||
if (!track._missingExpected && track.file_path) {
|
||||
const playNextBtn = document.createElement('button');
|
||||
playNextBtn.className = 'enhanced-playnext-btn';
|
||||
// Play-next glyph (queue-with-arrow feel)
|
||||
playNextBtn.innerHTML = '⇥'; // ⇥
|
||||
playNextBtn.title = 'Play next';
|
||||
queueTd.appendChild(playNextBtn);
|
||||
|
||||
const queueBtn = document.createElement('button');
|
||||
queueBtn.className = 'enhanced-queue-btn';
|
||||
queueBtn.innerHTML = '+';
|
||||
|
|
@ -4270,8 +4277,10 @@ function _attachTableDelegation(table, album) {
|
|||
}
|
||||
}
|
||||
|
||||
// Queue button
|
||||
if (target.closest('.enhanced-queue-btn')) {
|
||||
// Queue / Play-next buttons (share the same track payload)
|
||||
const isQueueBtn = target.closest('.enhanced-queue-btn');
|
||||
const isPlayNextBtn = target.closest('.enhanced-playnext-btn');
|
||||
if (isQueueBtn || isPlayNextBtn) {
|
||||
e.stopPropagation();
|
||||
if (track.file_path) {
|
||||
const artistName = artistDetailPageState.enhancedData ? artistDetailPageState.enhancedData.artist.name : '';
|
||||
|
|
@ -4279,7 +4288,7 @@ function _attachTableDelegation(table, album) {
|
|||
if (!albumArt && artistDetailPageState.enhancedData) {
|
||||
albumArt = artistDetailPageState.enhancedData.artist?.thumb_url;
|
||||
}
|
||||
addToQueue({
|
||||
const payload = {
|
||||
title: track.title || 'Unknown Track',
|
||||
artist: artistName || 'Unknown Artist',
|
||||
album: album.title || 'Unknown Album',
|
||||
|
|
@ -4292,7 +4301,9 @@ function _attachTableDelegation(table, album) {
|
|||
album_id: album.id,
|
||||
bitrate: track.bitrate,
|
||||
sample_rate: track.sample_rate
|
||||
});
|
||||
};
|
||||
if (isPlayNextBtn && typeof playNext === 'function') playNext(payload);
|
||||
else addToQueue(payload);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,14 +16,21 @@ function initializeMediaPlayer() {
|
|||
if (audioPlayer) {
|
||||
// Set up audio event listeners
|
||||
audioPlayer.addEventListener('timeupdate', updateAudioProgress);
|
||||
audioPlayer.addEventListener('timeupdate', npCrossfadeTick);
|
||||
audioPlayer.addEventListener('timeupdate', npThrottledPositionState);
|
||||
audioPlayer.addEventListener('timeupdate', npMaybeLogPlay);
|
||||
audioPlayer.addEventListener('ended', onAudioEnded);
|
||||
audioPlayer.addEventListener('error', onAudioError);
|
||||
audioPlayer.addEventListener('loadstart', onAudioLoadStart);
|
||||
audioPlayer.addEventListener('canplay', onAudioCanPlay);
|
||||
|
||||
// Set initial volume
|
||||
audioPlayer.volume = 0.7; // 70%
|
||||
if (volumeSlider) volumeSlider.value = 70;
|
||||
// Set initial volume — restore the saved level (Spotify-style), else 70%.
|
||||
const _savedVol = npLoadSavedVolume();
|
||||
const _initialVol = _savedVol === null ? 70 : _savedVol;
|
||||
audioPlayer.volume = _initialVol / 100;
|
||||
if (volumeSlider) volumeSlider.value = _initialVol;
|
||||
// Sync the modal slider/fill too once DOM is ready.
|
||||
syncVolumeUI(_initialVol);
|
||||
}
|
||||
|
||||
// Track title click handled by initExpandedPlayer's media-player click handler
|
||||
|
|
@ -54,6 +61,15 @@ function initializeMediaPlayer() {
|
|||
const miniNextBtn = document.getElementById('mini-next-btn');
|
||||
if (miniPrevBtn) miniPrevBtn.addEventListener('click', (e) => { e.stopPropagation(); playPreviousInQueue(); });
|
||||
if (miniNextBtn) miniNextBtn.addEventListener('click', (e) => { e.stopPropagation(); playNextInQueue(); });
|
||||
|
||||
// Mini shuffle / repeat — share the modal handlers (which now sync both UIs)
|
||||
const miniShuffleBtn = document.getElementById('mini-shuffle-btn');
|
||||
const miniRepeatBtn = document.getElementById('mini-repeat-btn');
|
||||
if (miniShuffleBtn) miniShuffleBtn.addEventListener('click', (e) => { e.stopPropagation(); handleNpShuffle(); });
|
||||
if (miniRepeatBtn) miniRepeatBtn.addEventListener('click', (e) => { e.stopPropagation(); handleNpRepeat(); });
|
||||
|
||||
// Restore a previously-saved queue (does not auto-play)
|
||||
npRestoreQueue();
|
||||
}
|
||||
|
||||
function toggleMediaPlayerExpansion() {
|
||||
|
|
@ -107,6 +123,7 @@ function _stripSourceIdPrefix(value) {
|
|||
|
||||
function setTrackInfo(track) {
|
||||
currentTrack = track;
|
||||
npPlayLogged = false; // new track — allow one play-log once it's heard a bit
|
||||
|
||||
const trackTitleElement = document.getElementById('track-title');
|
||||
const trackTitle = _stripSourceIdPrefix(track.title) || 'Unknown Track';
|
||||
|
|
@ -161,6 +178,8 @@ function setTrackInfo(track) {
|
|||
updateNpTrackInfo();
|
||||
updateMediaSessionMetadata();
|
||||
updateMediaSessionPlaybackState();
|
||||
// Reset the lock-screen scrubber when duration becomes known for the new track.
|
||||
if (audioPlayer) audioPlayer.addEventListener('loadedmetadata', updateMediaSessionPositionState, { once: true });
|
||||
|
||||
// Kick off lyrics fetch for the new track. The panel stays
|
||||
// collapsed by default — fetching in the background means the
|
||||
|
|
@ -205,6 +224,7 @@ function clearTrack() {
|
|||
// Clear track state
|
||||
currentTrack = null;
|
||||
isPlaying = false;
|
||||
npSetPlayContext(''); // hide "Playing from" when nothing's playing
|
||||
|
||||
const trackTitleElement = document.getElementById('track-title');
|
||||
trackTitleElement.innerHTML = '<span class="title-text">No track</span>';
|
||||
|
|
@ -296,6 +316,8 @@ async function handlePlayPause() {
|
|||
}
|
||||
|
||||
async function handleStop() {
|
||||
// Tear down any in-flight crossfade so its second audio doesn't keep playing.
|
||||
npCancelCrossfade();
|
||||
// Use new streaming system stop function
|
||||
await stopStream();
|
||||
clearTrack();
|
||||
|
|
@ -304,6 +326,7 @@ async function handleStop() {
|
|||
function handleVolumeChange(event) {
|
||||
const volume = event.target.value;
|
||||
updateVolumeSliderAppearance();
|
||||
npPersistVolume(volume);
|
||||
|
||||
// Update HTML5 audio player volume
|
||||
if (audioPlayer) {
|
||||
|
|
@ -1034,8 +1057,21 @@ function _npLyricsRenderSynced(lines) {
|
|||
}
|
||||
content.innerHTML = lines.map((line, idx) => {
|
||||
const safe = (line.text || '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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('');
|
||||
|
||||
// Click a line → seek playback to its timestamp (synced lyrics only).
|
||||
content.querySelectorAll('.np-lyrics-line').forEach(el => {
|
||||
el.addEventListener('click', () => {
|
||||
const idx = Number(el.dataset.idx);
|
||||
const line = _npLyricsState.lines[idx];
|
||||
if (!line || !audioPlayer || !isFinite(line.time)) return;
|
||||
try {
|
||||
audioPlayer.currentTime = line.time;
|
||||
if (audioPlayer.paused) audioPlayer.play().catch(() => {});
|
||||
} catch (_) {}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function _npLyricsRenderPlain(text) {
|
||||
|
|
@ -1186,6 +1222,11 @@ function onAudioEnded() {
|
|||
currentTimeElement.textContent = '0:00';
|
||||
}
|
||||
|
||||
// If a crossfade is mid-flight it OWNS the advance to the next track —
|
||||
// bail so we don't double-advance (crossfade's npFinishCrossfade →
|
||||
// playQueueItem already handles it).
|
||||
if (npXfadeActive) return;
|
||||
|
||||
// Repeat-one is handled by audioPlayer.loop (set in handleNpRepeat)
|
||||
// Auto-advance to next track if queue has a next item (guard against race conditions)
|
||||
if (npQueue.length > 0 && !npLoadingQueueItem) {
|
||||
|
|
@ -1382,6 +1423,9 @@ let npAnalyser = null;
|
|||
let npMediaSource = null;
|
||||
let npVizAnimFrame = null;
|
||||
let npVizInitialized = false;
|
||||
let npCrossfadeOn = false;
|
||||
let npSleepMinutes = 0; // 0 = off
|
||||
let npSleepTimerId = null;
|
||||
|
||||
function npQueueHasNext() {
|
||||
if (npQueue.length === 0) return false;
|
||||
|
|
@ -1425,6 +1469,13 @@ function npSetRadioMode(enabled, options = {}) {
|
|||
if (toast) {
|
||||
showToast(npRadioMode ? 'Radio mode on - similar tracks will auto-queue' : 'Radio mode off', 'success');
|
||||
}
|
||||
// Context label: only set the generic "Radio" if a more specific one (e.g.
|
||||
// "<Artist> Radio") wasn't already set by the caller.
|
||||
if (npRadioMode) {
|
||||
if (!npPlayContext || !/radio/i.test(npPlayContext)) npSetPlayContext('Radio');
|
||||
} else if (/radio/i.test(npPlayContext)) {
|
||||
npSetPlayContext('');
|
||||
}
|
||||
if (npRadioMode && fetchIfNeeded && currentTrack && currentTrack.id && !npLoadingQueueItem && !npQueueHasNext()) {
|
||||
npEnsureCurrentTrackInQueue();
|
||||
npFetchRadioTracks();
|
||||
|
|
@ -1451,6 +1502,32 @@ function initExpandedPlayer() {
|
|||
// Control handlers
|
||||
playBtn.addEventListener('click', () => { togglePlayback(); });
|
||||
stopBtn.addEventListener('click', async () => { await handleStop(); closeNowPlayingModal(); });
|
||||
|
||||
// Click album art → toggle the music-synced visualizer takeover
|
||||
const artContainer = document.getElementById('np-album-art-container');
|
||||
if (artContainer) {
|
||||
artContainer.addEventListener('click', () => {
|
||||
const on = artContainer.classList.toggle('viz-on');
|
||||
if (on) { npBuildArtViz(); npInitVisualizer(); npStartVisualizerLoop(); }
|
||||
});
|
||||
}
|
||||
|
||||
// Sleep timer — cycles off → 15 → 30 → 60 min → off
|
||||
const sleepBtn = document.getElementById('np-sleep-btn');
|
||||
if (sleepBtn) sleepBtn.addEventListener('click', npCycleSleepTimer);
|
||||
|
||||
// Crossfade toggle (real dual-audio crossfade for library tracks)
|
||||
const xfadeBtn = document.getElementById('np-crossfade-btn');
|
||||
if (xfadeBtn) {
|
||||
try { npCrossfadeOn = localStorage.getItem('soulsync-crossfade') === '1'; } catch (e) {}
|
||||
xfadeBtn.classList.toggle('active', npCrossfadeOn);
|
||||
xfadeBtn.addEventListener('click', () => {
|
||||
npCrossfadeOn = !npCrossfadeOn;
|
||||
xfadeBtn.classList.toggle('active', npCrossfadeOn);
|
||||
try { localStorage.setItem('soulsync-crossfade', npCrossfadeOn ? '1' : '0'); } catch (e) {}
|
||||
});
|
||||
}
|
||||
|
||||
shuffleBtn.addEventListener('click', handleNpShuffle);
|
||||
repeatBtn.addEventListener('click', handleNpRepeat);
|
||||
muteBtn.addEventListener('click', handleNpMuteToggle);
|
||||
|
|
@ -1460,6 +1537,23 @@ function initExpandedPlayer() {
|
|||
npProgressBar.addEventListener('mousedown', () => { npProgressBar.dataset.seeking = 'true'; });
|
||||
npProgressBar.addEventListener('mouseup', () => { delete npProgressBar.dataset.seeking; });
|
||||
|
||||
// Seek hover tooltip — shows the timestamp the cursor is over.
|
||||
const npSeekTip = document.getElementById('np-seek-tip');
|
||||
if (npSeekTip) {
|
||||
npProgressBar.addEventListener('mousemove', (e) => {
|
||||
if (!audioPlayer || !isFinite(audioPlayer.duration) || audioPlayer.duration <= 0) {
|
||||
npSeekTip.classList.remove('visible');
|
||||
return;
|
||||
}
|
||||
const rect = npProgressBar.getBoundingClientRect();
|
||||
const frac = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
|
||||
npSeekTip.textContent = formatTime(frac * audioPlayer.duration);
|
||||
npSeekTip.style.left = (frac * 100) + '%';
|
||||
npSeekTip.classList.add('visible');
|
||||
});
|
||||
npProgressBar.addEventListener('mouseleave', () => npSeekTip.classList.remove('visible'));
|
||||
}
|
||||
|
||||
// Progress bar (touch)
|
||||
npProgressBar.addEventListener('touchstart', () => { npProgressBar.dataset.seeking = 'true'; }, { passive: true });
|
||||
npProgressBar.addEventListener('touchmove', (e) => {
|
||||
|
|
@ -1588,6 +1682,10 @@ function syncExpandedPlayerUI() {
|
|||
const viz = document.getElementById('np-visualizer');
|
||||
if (viz) viz.classList.toggle('playing', isPlaying);
|
||||
|
||||
// Album-art scale-on-play (Phase A restyle — CSS keys off .np-modal.playing)
|
||||
const npModalEl = document.querySelector('.np-modal');
|
||||
if (npModalEl) npModalEl.classList.toggle('playing', isPlaying);
|
||||
|
||||
// Queue
|
||||
renderNpQueue();
|
||||
updateNpPrevNextButtons();
|
||||
|
|
@ -1706,24 +1804,46 @@ function npExtractAmbientColor(imgEl) {
|
|||
try {
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
canvas.width = 50;
|
||||
canvas.height = 50;
|
||||
ctx.drawImage(imgEl, 0, 0, 50, 50);
|
||||
const data = ctx.getImageData(0, 0, 50, 50).data;
|
||||
let rSum = 0, gSum = 0, bSum = 0, count = 0;
|
||||
canvas.width = 64;
|
||||
canvas.height = 64;
|
||||
ctx.drawImage(imgEl, 0, 0, 64, 64);
|
||||
const data = ctx.getImageData(0, 0, 64, 64).data;
|
||||
|
||||
// Dominant VIBRANT color, not a flat average (averaging muddies to
|
||||
// grey-brown). Bin colors into a coarse 4-bit-per-channel histogram,
|
||||
// weight each bin by saturation² × pixel-count so a punchy accent in
|
||||
// the cover wins over a large dull background. Apple-Music-style.
|
||||
const bins = new Map();
|
||||
for (let i = 0; i < data.length; i += 16) { // sample every 4th pixel
|
||||
const r = data[i], g = data[i + 1], b = data[i + 2];
|
||||
const r = data[i], g = data[i + 1], b = data[i + 2], a = data[i + 3];
|
||||
if (a < 128) continue;
|
||||
const max = Math.max(r, g, b), min = Math.min(r, g, b);
|
||||
const brightness = (r + g + b) / 3;
|
||||
if (brightness > 20 && brightness < 230) {
|
||||
rSum += r; gSum += g; bSum += b; count++;
|
||||
}
|
||||
if (brightness < 24 || brightness > 240) continue; // skip near-black/white
|
||||
const sat = max === 0 ? 0 : (max - min) / max; // 0..1
|
||||
const key = ((r >> 4) << 8) | ((g >> 4) << 4) | (b >> 4);
|
||||
const weight = (0.15 + sat * sat) ; // floor so greys still count a little
|
||||
const bin = bins.get(key);
|
||||
if (bin) { bin.r += r; bin.g += g; bin.b += b; bin.n++; bin.w += weight; }
|
||||
else bins.set(key, { r, g, b, n: 1, w: weight });
|
||||
}
|
||||
if (count > 0) {
|
||||
let best = null, bestScore = -1;
|
||||
for (const bin of bins.values()) {
|
||||
const score = bin.w; // saturation-weighted population
|
||||
if (score > bestScore) { bestScore = score; best = bin; }
|
||||
}
|
||||
if (best) {
|
||||
let r = Math.round(best.r / best.n);
|
||||
let g = Math.round(best.g / best.n);
|
||||
let b = Math.round(best.b / best.n);
|
||||
// Nudge toward vivid: lift saturation/brightness a touch so the
|
||||
// glow reads as a color, not a wash.
|
||||
[r, g, b] = npPunchUpColor(r, g, b);
|
||||
const modal = document.querySelector('.np-modal');
|
||||
if (modal) {
|
||||
modal.style.setProperty('--np-ambient-r', Math.round(rSum / count));
|
||||
modal.style.setProperty('--np-ambient-g', Math.round(gSum / count));
|
||||
modal.style.setProperty('--np-ambient-b', Math.round(bSum / count));
|
||||
modal.style.setProperty('--np-ambient-r', r);
|
||||
modal.style.setProperty('--np-ambient-g', g);
|
||||
modal.style.setProperty('--np-ambient-b', b);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
|
|
@ -1731,6 +1851,119 @@ function npExtractAmbientColor(imgEl) {
|
|||
}
|
||||
}
|
||||
|
||||
// Lift a color toward vividness for the ambient glow (boost saturation,
|
||||
// floor brightness) without fully desaturating dark/pastel covers.
|
||||
function npPunchUpColor(r, g, b) {
|
||||
const max = Math.max(r, g, b), min = Math.min(r, g, b);
|
||||
if (max === min) return [r, g, b]; // grey — leave it
|
||||
// Pull each channel away from the mid to boost perceived saturation ~1.3x.
|
||||
const mid = (max + min) / 2;
|
||||
const boost = 1.3;
|
||||
let nr = Math.round(mid + (r - mid) * boost);
|
||||
let ng = Math.round(mid + (g - mid) * boost);
|
||||
let nb = Math.round(mid + (b - mid) * boost);
|
||||
// Floor overall brightness so very dark covers still glow.
|
||||
const bright = (nr + ng + nb) / 3;
|
||||
if (bright < 70) { const lift = 70 / Math.max(bright, 1); nr *= lift; ng *= lift; nb *= lift; }
|
||||
const clamp = v => Math.max(0, Math.min(255, Math.round(v)));
|
||||
return [clamp(nr), clamp(ng), clamp(nb)];
|
||||
}
|
||||
|
||||
// ── Crossfade engine (library tracks only) ──
|
||||
// EXPERIMENTAL. Real crossfade needs two tracks playing at once; /stream/audio
|
||||
// only serves the ONE current track (single global stream_state), so we use a
|
||||
// dedicated /stream/library-audio endpoint + a second <audio> to play the NEXT
|
||||
// library track and ramp volumes. Streamed (non-library) tracks can't crossfade
|
||||
// and fall back to the normal hard cut.
|
||||
const NP_CROSSFADE_SECONDS = 6;
|
||||
let npXfadeAudio = null;
|
||||
let npXfadeActive = false;
|
||||
let npXfadeTimer = null;
|
||||
let npXfadeMainVol = null; // main-player volume to restore if a crossfade is aborted
|
||||
|
||||
// Abort an in-flight crossfade (manual skip / stop during the fade). Restores
|
||||
// the main player's volume and tears down the second audio element. Safe to
|
||||
// call when no crossfade is active (no-op).
|
||||
function npCancelCrossfade() {
|
||||
if (npXfadeTimer) { clearInterval(npXfadeTimer); npXfadeTimer = null; }
|
||||
if (npXfadeAudio) { try { npXfadeAudio.pause(); } catch (_) {} npXfadeAudio.src = ''; npXfadeAudio.volume = 0; npXfadeAudio = null; }
|
||||
if (npXfadeActive && audioPlayer && npXfadeMainVol !== null) {
|
||||
audioPlayer.volume = npXfadeMainVol; // undo any partial fade-down
|
||||
}
|
||||
npXfadeActive = false;
|
||||
npXfadeMainVol = null;
|
||||
}
|
||||
|
||||
function npCrossfadeTick() {
|
||||
if (!npCrossfadeOn || npXfadeActive || npRepeatMode === 'one') return;
|
||||
if (!audioPlayer || !audioPlayer.duration || !isFinite(audioPlayer.duration)) return;
|
||||
const remaining = audioPlayer.duration - audioPlayer.currentTime;
|
||||
if (remaining > NP_CROSSFADE_SECONDS || remaining <= 0.2) return;
|
||||
|
||||
// Determine the next track (respects shuffle/repeat-all the same way
|
||||
// playNextInQueue does, but we only crossfade plain sequential next).
|
||||
const nextIdx = npQueueIndex + 1;
|
||||
const next = npQueue[nextIdx];
|
||||
if (!next || !next.is_library || !next.file_path) return; // only library→library
|
||||
|
||||
npStartCrossfade(nextIdx, next);
|
||||
}
|
||||
|
||||
function npStartCrossfade(nextIdx, next) {
|
||||
npXfadeActive = true;
|
||||
const xa = document.getElementById('audio-player-xfade');
|
||||
if (!xa) { npXfadeActive = false; return; }
|
||||
npXfadeAudio = xa;
|
||||
|
||||
const targetVol = audioPlayer.volume; // fade the new track up to current level
|
||||
npXfadeMainVol = targetVol; // remember to restore on abort
|
||||
xa.src = `/stream/library-audio?path=${encodeURIComponent(next.file_path)}&t=${Date.now()}`;
|
||||
xa.volume = 0;
|
||||
xa.play().then(() => {
|
||||
const fadeMs = NP_CROSSFADE_SECONDS * 1000;
|
||||
const step = 60; // ms between volume steps
|
||||
const steps = Math.max(1, Math.floor(fadeMs / step));
|
||||
let n = 0;
|
||||
const startOutVol = audioPlayer.volume;
|
||||
npXfadeTimer = setInterval(() => {
|
||||
// A manual skip/stop may have cancelled us mid-fade.
|
||||
if (!npXfadeActive) { clearInterval(npXfadeTimer); npXfadeTimer = null; return; }
|
||||
n++;
|
||||
const t = Math.min(1, n / steps);
|
||||
audioPlayer.volume = Math.max(0, startOutVol * (1 - t));
|
||||
xa.volume = Math.min(targetVol, targetVol * t);
|
||||
if (t >= 1) {
|
||||
clearInterval(npXfadeTimer);
|
||||
npXfadeTimer = null;
|
||||
npFinishCrossfade(nextIdx, targetVol);
|
||||
}
|
||||
}, step);
|
||||
}).catch(() => {
|
||||
// Couldn't preload (e.g. endpoint/file issue) — abort gracefully, let
|
||||
// the normal 'ended' hard-cut advance handle it.
|
||||
npXfadeActive = false;
|
||||
npXfadeAudio = null;
|
||||
npXfadeMainVol = null;
|
||||
});
|
||||
}
|
||||
|
||||
function npFinishCrossfade(nextIdx, restoreVol) {
|
||||
// The crossfade audio has fully faded in; promote the queue index and let
|
||||
// the normal play path take over so all the usual state (track info, art,
|
||||
// visualizer, server stream_state) is set for the now-current track.
|
||||
const xa = npXfadeAudio;
|
||||
if (xa) { try { xa.pause(); } catch (_) {} xa.src = ''; xa.volume = 0; }
|
||||
npXfadeAudio = null;
|
||||
npXfadeActive = false;
|
||||
npXfadeMainVol = null;
|
||||
if (npXfadeTimer) { clearInterval(npXfadeTimer); npXfadeTimer = null; }
|
||||
if (audioPlayer) audioPlayer.volume = restoreVol;
|
||||
// playQueueItem re-points stream_state + reloads audioPlayer for the next
|
||||
// track; there's a brief silent reload, but the perceived crossfade already
|
||||
// happened. Honest trade-off of the single-stream-state design.
|
||||
playQueueItem(nextIdx);
|
||||
}
|
||||
|
||||
function npResetAmbientGlow() {
|
||||
const modal = document.querySelector('.np-modal');
|
||||
if (modal) {
|
||||
|
|
@ -1791,6 +2024,7 @@ function handleNpProgressBarChange(event) {
|
|||
|
||||
try {
|
||||
audioPlayer.currentTime = newTime;
|
||||
updateMediaSessionPositionState();
|
||||
|
||||
// Sync sidebar progress
|
||||
const sidebarBar = document.getElementById('progress-bar');
|
||||
|
|
@ -1815,6 +2049,7 @@ function handleNpProgressBarChange(event) {
|
|||
function handleNpVolumeChange(event) {
|
||||
const volume = parseInt(event.target.value);
|
||||
if (audioPlayer) audioPlayer.volume = volume / 100;
|
||||
npPersistVolume(volume);
|
||||
|
||||
// Sync sidebar volume slider
|
||||
const sidebarVol = document.getElementById('volume-slider');
|
||||
|
|
@ -1862,15 +2097,32 @@ function updateNpMuteIcon() {
|
|||
if (muteBtn) muteBtn.classList.toggle('muted', npMuted);
|
||||
}
|
||||
|
||||
// Reflect shuffle/repeat state on BOTH the modal and mini-player buttons.
|
||||
function syncShuffleRepeatUI() {
|
||||
const npShuffle = document.getElementById('np-shuffle-btn');
|
||||
const miniShuffle = document.getElementById('mini-shuffle-btn');
|
||||
if (npShuffle) npShuffle.classList.toggle('active', npShuffleOn);
|
||||
if (miniShuffle) miniShuffle.classList.toggle('active', npShuffleOn);
|
||||
|
||||
const repeatOn = npRepeatMode !== 'off';
|
||||
const repeatOne = npRepeatMode === 'one';
|
||||
const npRepeat = document.getElementById('np-repeat-btn');
|
||||
const miniRepeat = document.getElementById('mini-repeat-btn');
|
||||
if (npRepeat) npRepeat.classList.toggle('active', repeatOn);
|
||||
if (miniRepeat) miniRepeat.classList.toggle('active', repeatOn);
|
||||
const npBadge = document.getElementById('np-repeat-one-badge');
|
||||
const miniBadge = document.getElementById('mini-repeat-one-badge');
|
||||
if (npBadge) npBadge.classList.toggle('hidden', !repeatOne);
|
||||
if (miniBadge) miniBadge.style.display = repeatOne ? '' : 'none';
|
||||
}
|
||||
|
||||
function handleNpShuffle() {
|
||||
npShuffleOn = !npShuffleOn;
|
||||
const btn = document.getElementById('np-shuffle-btn');
|
||||
if (btn) btn.classList.toggle('active', npShuffleOn);
|
||||
syncShuffleRepeatUI();
|
||||
updateNpPrevNextButtons();
|
||||
}
|
||||
|
||||
function handleNpRepeat() {
|
||||
const badge = document.getElementById('np-repeat-one-badge');
|
||||
if (npRepeatMode === 'off') {
|
||||
npRepeatMode = 'all';
|
||||
if (audioPlayer) audioPlayer.loop = false;
|
||||
|
|
@ -1881,9 +2133,7 @@ function handleNpRepeat() {
|
|||
npRepeatMode = 'off';
|
||||
if (audioPlayer) audioPlayer.loop = false;
|
||||
}
|
||||
const btn = document.getElementById('np-repeat-btn');
|
||||
if (btn) btn.classList.toggle('active', npRepeatMode !== 'off');
|
||||
if (badge) badge.classList.toggle('hidden', npRepeatMode !== 'one');
|
||||
syncShuffleRepeatUI();
|
||||
updateNpPrevNextButtons();
|
||||
}
|
||||
|
||||
|
|
@ -1891,6 +2141,21 @@ function handleNpRepeat() {
|
|||
// QUEUE MANAGEMENT
|
||||
// ===============================
|
||||
|
||||
// "Playing from" context shown above the track title (Spotify-style).
|
||||
let npPlayContext = '';
|
||||
function npSetPlayContext(text) {
|
||||
npPlayContext = text || '';
|
||||
const box = document.getElementById('np-play-context');
|
||||
const nameEl = document.getElementById('np-play-context-name');
|
||||
if (!box || !nameEl) return;
|
||||
if (npPlayContext) {
|
||||
nameEl.textContent = npPlayContext;
|
||||
box.classList.remove('hidden');
|
||||
} else {
|
||||
box.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
function addToQueue(track) {
|
||||
npQueue.push(track);
|
||||
showToast('Added to queue', 'success');
|
||||
|
|
@ -1902,6 +2167,19 @@ function addToQueue(track) {
|
|||
}
|
||||
}
|
||||
|
||||
// Insert a track to play right after the current one (Spotify "Play next").
|
||||
function playNext(track) {
|
||||
if (npQueue.length === 0 || npQueueIndex < 0) {
|
||||
// Nothing queued / playing — same as add-to-queue (which auto-plays).
|
||||
addToQueue(track);
|
||||
return;
|
||||
}
|
||||
npQueue.splice(npQueueIndex + 1, 0, track);
|
||||
showToast('Playing next', 'success');
|
||||
renderNpQueue();
|
||||
updateNpPrevNextButtons();
|
||||
}
|
||||
|
||||
function removeFromQueue(index) {
|
||||
if (index < 0 || index >= npQueue.length) return;
|
||||
const wasCurrentTrack = (index === npQueueIndex);
|
||||
|
|
@ -1978,6 +2256,10 @@ function playPreviousInQueue() {
|
|||
async function playQueueItem(index) {
|
||||
if (index < 0 || index >= npQueue.length) return;
|
||||
if (npLoadingQueueItem) return; // Prevent race condition from double-advance
|
||||
// Manual skip / row-click during a crossfade: tear down the stray fade so it
|
||||
// can't fire npFinishCrossfade on top of this change. No-op for the
|
||||
// legitimate handoff (npFinishCrossfade already cleared the flag first).
|
||||
npCancelCrossfade();
|
||||
npLoadingQueueItem = true;
|
||||
npQueueIndex = index;
|
||||
const track = npQueue[index];
|
||||
|
|
@ -2074,6 +2356,26 @@ function renderNpQueue() {
|
|||
item.className = 'np-queue-item' + (i === npQueueIndex ? ' active' : '');
|
||||
item.onclick = () => playQueueItem(i);
|
||||
|
||||
// Drag-to-reorder
|
||||
item.draggable = true;
|
||||
item.dataset.qindex = i;
|
||||
item.addEventListener('dragstart', npQueueDragStart);
|
||||
item.addEventListener('dragover', npQueueDragOver);
|
||||
item.addEventListener('drop', npQueueDrop);
|
||||
item.addEventListener('dragend', npQueueDragEnd);
|
||||
|
||||
// Album thumbnail
|
||||
const art = document.createElement('img');
|
||||
art.className = 'np-queue-item-art';
|
||||
art.alt = '';
|
||||
if (track.image_url) {
|
||||
art.src = track.image_url;
|
||||
art.onerror = () => { art.style.visibility = 'hidden'; };
|
||||
} else {
|
||||
art.style.visibility = 'hidden';
|
||||
}
|
||||
item.appendChild(art);
|
||||
|
||||
const info = document.createElement('div');
|
||||
info.className = 'np-queue-item-info';
|
||||
|
||||
|
|
@ -2089,6 +2391,19 @@ function renderNpQueue() {
|
|||
info.appendChild(artist);
|
||||
item.appendChild(info);
|
||||
|
||||
// Active row → equalizer animation; others → duration
|
||||
if (i === npQueueIndex) {
|
||||
const eq = document.createElement('div');
|
||||
eq.className = 'np-queue-item-eq';
|
||||
eq.innerHTML = '<i></i><i></i><i></i>';
|
||||
item.appendChild(eq);
|
||||
} else if (track.duration) {
|
||||
const dur = document.createElement('span');
|
||||
dur.className = 'np-queue-item-duration';
|
||||
dur.textContent = formatTime(track.duration);
|
||||
item.appendChild(dur);
|
||||
}
|
||||
|
||||
const removeBtn = document.createElement('button');
|
||||
removeBtn.className = 'np-queue-item-remove';
|
||||
removeBtn.innerHTML = '✕';
|
||||
|
|
@ -2101,6 +2416,130 @@ function renderNpQueue() {
|
|||
|
||||
listEl.appendChild(item);
|
||||
});
|
||||
|
||||
npUpdateUpNext();
|
||||
npPersistQueue();
|
||||
}
|
||||
|
||||
// ── Queue persistence across page reloads (localStorage) ──
|
||||
const NP_QUEUE_STORAGE_KEY = 'soulsync-np-queue';
|
||||
|
||||
function npPersistQueue() {
|
||||
try {
|
||||
if (!npQueue.length) { localStorage.removeItem(NP_QUEUE_STORAGE_KEY); return; }
|
||||
localStorage.setItem(NP_QUEUE_STORAGE_KEY, JSON.stringify({
|
||||
queue: npQueue,
|
||||
index: npQueueIndex,
|
||||
savedAt: Date.now(),
|
||||
}));
|
||||
} catch (e) { /* quota / disabled storage — non-fatal */ }
|
||||
}
|
||||
|
||||
// Restore the saved queue into the panel WITHOUT auto-playing (the user
|
||||
// reloaded; resume playback is their choice via clicking a row).
|
||||
function npRestoreQueue() {
|
||||
try {
|
||||
const raw = localStorage.getItem(NP_QUEUE_STORAGE_KEY);
|
||||
if (!raw) return;
|
||||
const data = JSON.parse(raw);
|
||||
if (data && Array.isArray(data.queue) && data.queue.length) {
|
||||
npQueue = data.queue;
|
||||
// Don't claim a track is "playing" on a fresh load — nothing is.
|
||||
npQueueIndex = -1;
|
||||
renderNpQueue();
|
||||
updateNpPrevNextButtons();
|
||||
}
|
||||
} catch (e) { /* corrupt entry — ignore */ }
|
||||
}
|
||||
|
||||
// ── Queue drag-to-reorder ──
|
||||
let npDragFromIndex = null;
|
||||
|
||||
function npQueueDragStart(e) {
|
||||
npDragFromIndex = Number(e.currentTarget.dataset.qindex);
|
||||
e.currentTarget.classList.add('dragging');
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
// Firefox requires data to be set for drag to fire.
|
||||
try { e.dataTransfer.setData('text/plain', String(npDragFromIndex)); } catch (_) {}
|
||||
}
|
||||
|
||||
function npQueueDragOver(e) {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
const row = e.currentTarget;
|
||||
document.querySelectorAll('.np-queue-item.drag-over').forEach(r => r.classList.remove('drag-over'));
|
||||
row.classList.add('drag-over');
|
||||
}
|
||||
|
||||
function npQueueDrop(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const to = Number(e.currentTarget.dataset.qindex);
|
||||
npReorderQueue(npDragFromIndex, to);
|
||||
}
|
||||
|
||||
function npQueueDragEnd() {
|
||||
document.querySelectorAll('.np-queue-item').forEach(r => r.classList.remove('dragging', 'drag-over'));
|
||||
npDragFromIndex = null;
|
||||
}
|
||||
|
||||
// Move a queue item, keeping npQueueIndex pointed at the SAME playing track.
|
||||
function npReorderQueue(from, to) {
|
||||
if (from === null || from === to || from < 0 || to < 0) return;
|
||||
if (from >= npQueue.length || to >= npQueue.length) return;
|
||||
const [moved] = npQueue.splice(from, 1);
|
||||
npQueue.splice(to, 0, moved);
|
||||
|
||||
// Recompute which index now holds the currently-playing track.
|
||||
if (npQueueIndex === from) {
|
||||
npQueueIndex = to;
|
||||
} else if (from < npQueueIndex && to >= npQueueIndex) {
|
||||
npQueueIndex -= 1;
|
||||
} else if (from > npQueueIndex && to <= npQueueIndex) {
|
||||
npQueueIndex += 1;
|
||||
}
|
||||
renderNpQueue();
|
||||
updateNpPrevNextButtons();
|
||||
}
|
||||
|
||||
// Up-next peek: show the track that plays after the current one.
|
||||
function npUpdateUpNext() {
|
||||
const box = document.getElementById('np-upnext');
|
||||
if (!box) return;
|
||||
const next = npQueue[npQueueIndex + 1];
|
||||
if (!next) { box.classList.add('hidden'); return; }
|
||||
box.classList.remove('hidden');
|
||||
const art = document.getElementById('np-upnext-art');
|
||||
const title = document.getElementById('np-upnext-title');
|
||||
const artist = document.getElementById('np-upnext-artist');
|
||||
if (title) title.textContent = next.title || 'Unknown Track';
|
||||
if (artist) artist.textContent = next.artist || 'Unknown Artist';
|
||||
if (art) {
|
||||
if (next.image_url) { art.src = next.image_url; art.style.visibility = ''; art.onerror = () => { art.style.visibility = 'hidden'; }; }
|
||||
else { art.style.visibility = 'hidden'; }
|
||||
}
|
||||
}
|
||||
|
||||
// Sleep timer: cycle off → 15 → 30 → 60 → off; stops playback when it fires.
|
||||
function npCycleSleepTimer() {
|
||||
const steps = [0, 15, 30, 60];
|
||||
npSleepMinutes = steps[(steps.indexOf(npSleepMinutes) + 1) % steps.length];
|
||||
const btn = document.getElementById('np-sleep-btn');
|
||||
const label = document.getElementById('np-sleep-label');
|
||||
if (npSleepTimerId) { clearTimeout(npSleepTimerId); npSleepTimerId = null; }
|
||||
if (npSleepMinutes > 0) {
|
||||
if (label) label.textContent = `Sleep ${npSleepMinutes}m`;
|
||||
if (btn) btn.classList.add('active');
|
||||
npSleepTimerId = setTimeout(() => {
|
||||
handleStop();
|
||||
npSleepMinutes = 0;
|
||||
if (label) label.textContent = 'Sleep';
|
||||
if (btn) btn.classList.remove('active');
|
||||
}, npSleepMinutes * 60 * 1000);
|
||||
} else {
|
||||
if (label) label.textContent = 'Sleep';
|
||||
if (btn) btn.classList.remove('active');
|
||||
}
|
||||
}
|
||||
|
||||
function updateNpPrevNextButtons() {
|
||||
|
|
@ -2163,7 +2602,18 @@ function handlePlayerKeyboardShortcuts(event) {
|
|||
break;
|
||||
case 'm':
|
||||
case 'M':
|
||||
if (npModalOpen) handleNpMuteToggle();
|
||||
event.preventDefault();
|
||||
handleNpMuteToggle(); // works whether or not the modal is open
|
||||
break;
|
||||
case 'n':
|
||||
case 'N':
|
||||
event.preventDefault();
|
||||
if (npQueue.length > 0) playNextInQueue();
|
||||
break;
|
||||
case 'p':
|
||||
case 'P':
|
||||
event.preventDefault();
|
||||
playPreviousInQueue();
|
||||
break;
|
||||
case 'Escape':
|
||||
if (npModalOpen) closeNowPlayingModal();
|
||||
|
|
@ -2185,6 +2635,21 @@ function syncVolumeUI(volumePercent) {
|
|||
}
|
||||
if (npVol) npVol.value = volumePercent;
|
||||
if (npFill) npFill.style.width = volumePercent + '%';
|
||||
npPersistVolume(volumePercent);
|
||||
}
|
||||
|
||||
// Remember volume across reloads (Spotify-style). Stored 0..100.
|
||||
const NP_VOLUME_STORAGE_KEY = 'soulsync-volume';
|
||||
function npPersistVolume(percent) {
|
||||
try { localStorage.setItem(NP_VOLUME_STORAGE_KEY, String(Math.round(percent))); } catch (e) {}
|
||||
}
|
||||
function npLoadSavedVolume() {
|
||||
try {
|
||||
const raw = localStorage.getItem(NP_VOLUME_STORAGE_KEY);
|
||||
if (raw === null) return null;
|
||||
const v = parseInt(raw, 10);
|
||||
return (isFinite(v) && v >= 0 && v <= 100) ? v : null;
|
||||
} catch (e) { return null; }
|
||||
}
|
||||
|
||||
function getNpAlbumArtUrl() {
|
||||
|
|
@ -2220,6 +2685,19 @@ function npInitVisualizer() {
|
|||
}
|
||||
}
|
||||
|
||||
// Number of bars in the big album-art visualizer takeover.
|
||||
const NP_ART_VIZ_BAR_COUNT = 28;
|
||||
|
||||
function npBuildArtViz() {
|
||||
const container = document.getElementById('np-art-viz');
|
||||
if (!container || container.children.length > 0) return;
|
||||
for (let i = 0; i < NP_ART_VIZ_BAR_COUNT; i++) {
|
||||
const bar = document.createElement('div');
|
||||
bar.className = 'np-art-viz-bar';
|
||||
container.appendChild(bar);
|
||||
}
|
||||
}
|
||||
|
||||
function npStartVisualizerLoop() {
|
||||
if (npVizAnimFrame) return; // Already running
|
||||
if (!npAnalyser) return; // No analyser — CSS fallback handles it
|
||||
|
|
@ -2229,7 +2707,6 @@ function npStartVisualizerLoop() {
|
|||
}
|
||||
|
||||
const bars = document.querySelectorAll('.np-viz-bar');
|
||||
if (bars.length === 0) return;
|
||||
const bufferLength = npAnalyser.frequencyBinCount;
|
||||
const dataArray = new Uint8Array(bufferLength);
|
||||
|
||||
|
|
@ -2237,14 +2714,25 @@ function npStartVisualizerLoop() {
|
|||
npVizAnimFrame = requestAnimationFrame(draw);
|
||||
npAnalyser.getByteFrequencyData(dataArray);
|
||||
|
||||
// Map 7 bars to frequency bins (skip bin 0 which is DC offset)
|
||||
const binCount = Math.min(bufferLength - 1, 7);
|
||||
// Map 7 transport bars to frequency bins (skip bin 0 = DC offset)
|
||||
for (let i = 0; i < bars.length; i++) {
|
||||
const binIndex = Math.min(i + 1, bufferLength - 1);
|
||||
const value = dataArray[binIndex] / 255; // 0..1
|
||||
const scale = Math.max(0.08, value); // minimum visible height
|
||||
bars[i].style.transform = `scaleY(${scale})`;
|
||||
}
|
||||
|
||||
// Big album-art visualizer (when toggled on) — same real analyser,
|
||||
// spread across more bars for a fuller spectrum.
|
||||
const artBars = document.querySelectorAll('.np-art-viz-bar');
|
||||
if (artBars.length) {
|
||||
const span = bufferLength - 1;
|
||||
for (let i = 0; i < artBars.length; i++) {
|
||||
const binIndex = 1 + Math.floor((i / artBars.length) * span);
|
||||
const value = dataArray[Math.min(binIndex, bufferLength - 1)] / 255;
|
||||
artBars[i].style.height = Math.max(6, value * 100) + '%';
|
||||
}
|
||||
}
|
||||
}
|
||||
draw();
|
||||
}
|
||||
|
|
@ -2652,6 +3140,65 @@ function initMediaSession() {
|
|||
navigator.mediaSession.setActionHandler('nexttrack', () => {
|
||||
if (npQueue.length > 0) playNextInQueue();
|
||||
});
|
||||
// Scrub from the lock screen / notification scrubber.
|
||||
try {
|
||||
navigator.mediaSession.setActionHandler('seekto', (details) => {
|
||||
if (!audioPlayer || !isFinite(audioPlayer.duration)) return;
|
||||
if (details.fastSeek && 'fastSeek' in audioPlayer) {
|
||||
audioPlayer.fastSeek(details.seekTime);
|
||||
} else if (typeof details.seekTime === 'number') {
|
||||
audioPlayer.currentTime = details.seekTime;
|
||||
}
|
||||
updateMediaSessionPositionState();
|
||||
});
|
||||
} catch (e) { /* some browsers don't support seekto — handlers above still work */ }
|
||||
}
|
||||
|
||||
// Log a SoulSync play once a track has been heard ~10s (the standard "counts
|
||||
// as a play" threshold) — feeds 'recently played' + smart-radio recency.
|
||||
let npPlayLogged = false;
|
||||
function npMaybeLogPlay() {
|
||||
if (npPlayLogged || !currentTrack || !audioPlayer) return;
|
||||
if (!isFinite(audioPlayer.currentTime) || audioPlayer.currentTime < 10) return;
|
||||
npPlayLogged = true; // set first so a slow request can't double-fire
|
||||
try {
|
||||
fetch('/api/library/log-play', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
track: {
|
||||
id: currentTrack.id,
|
||||
title: currentTrack.title,
|
||||
artist: currentTrack.artist,
|
||||
album: currentTrack.album,
|
||||
},
|
||||
duration_ms: Math.round((audioPlayer.duration || 0) * 1000),
|
||||
}),
|
||||
}).catch(() => {}); // fire-and-forget; logging must never affect playback
|
||||
} catch (e) { /* non-fatal */ }
|
||||
}
|
||||
|
||||
// timeupdate fires ~4x/s; only push position to the OS ~1x/s.
|
||||
let _npPosStateLast = 0;
|
||||
function npThrottledPositionState() {
|
||||
const now = (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now();
|
||||
if (now - _npPosStateLast < 950) return;
|
||||
_npPosStateLast = now;
|
||||
updateMediaSessionPositionState();
|
||||
}
|
||||
|
||||
// Feeds the lock-screen scrubber its progress (elapsed / duration / rate).
|
||||
// Without this the OS shows a dead, position-less media control.
|
||||
function updateMediaSessionPositionState() {
|
||||
if (!('mediaSession' in navigator) || !('setPositionState' in navigator.mediaSession)) return;
|
||||
if (!audioPlayer || !isFinite(audioPlayer.duration) || audioPlayer.duration <= 0) return;
|
||||
try {
|
||||
navigator.mediaSession.setPositionState({
|
||||
duration: audioPlayer.duration,
|
||||
playbackRate: audioPlayer.playbackRate || 1,
|
||||
position: Math.min(audioPlayer.currentTime, audioPlayer.duration),
|
||||
});
|
||||
} catch (e) { /* invalid state (e.g. mid-load) — skip this tick */ }
|
||||
}
|
||||
|
||||
function updateMediaSessionMetadata() {
|
||||
|
|
|
|||
|
|
@ -2193,6 +2193,85 @@ function _getBatchColor(batchId) {
|
|||
return _batchColorMap[batchId];
|
||||
}
|
||||
|
||||
// Per-batch progress samples for a client-side ETA (no backend timing needed
|
||||
// for Phase A). batch_id -> [{t: ms, done: int}], capped to the recent window.
|
||||
const _adlRateSamples = {};
|
||||
const _ADL_RATE_WINDOW = 8;
|
||||
|
||||
function _adlSampleRate(batchId, done) {
|
||||
const arr = _adlRateSamples[batchId] || (_adlRateSamples[batchId] = []);
|
||||
const now = Date.now();
|
||||
const last = arr[arr.length - 1];
|
||||
if (!last || last.done !== done) arr.push({ t: now, done });
|
||||
while (arr.length > _ADL_RATE_WINDOW) arr.shift();
|
||||
// tracks/sec over the sampled window
|
||||
if (arr.length < 2) return 0;
|
||||
const first = arr[0];
|
||||
const dt = (arr[arr.length - 1].t - first.t) / 1000;
|
||||
const dd = arr[arr.length - 1].done - first.done;
|
||||
return dt > 0 && dd > 0 ? dd / dt : 0;
|
||||
}
|
||||
|
||||
function _adlFmtDuration(sec) {
|
||||
if (!sec || sec < 0 || !isFinite(sec)) return '';
|
||||
if (sec < 60) return `${Math.round(sec)}s`;
|
||||
if (sec < 3600) return `${Math.round(sec / 60)}m`;
|
||||
return `${Math.floor(sec / 3600)}h ${Math.round((sec % 3600) / 60)}m`;
|
||||
}
|
||||
|
||||
// ETA string for a batch's stat line. Album bundles use the downloader's own
|
||||
// speed/size; track batches use the client-side completion rate.
|
||||
function _adlBatchEta(batch) {
|
||||
if (batch.phase === 'album_downloading') {
|
||||
const ab = batch.album_bundle || {};
|
||||
const bits = [];
|
||||
if (ab.speed) bits.push(ab.speed);
|
||||
if (ab.downloaded && ab.size) bits.push(`${ab.downloaded} / ${ab.size}`);
|
||||
return bits.join(' · ');
|
||||
}
|
||||
if (batch.phase !== 'downloading') return '';
|
||||
const total = batch.total || 0;
|
||||
const done = (batch.completed || 0) + (batch.failed || 0);
|
||||
const remaining = total - done;
|
||||
if (remaining <= 0) return '';
|
||||
const rate = _adlSampleRate(batch.batch_id, done); // tracks/sec
|
||||
if (rate <= 0) return '';
|
||||
return `~${_adlFmtDuration(remaining / rate)} left`;
|
||||
}
|
||||
|
||||
// Glanceable aggregate strip atop the panel: batches · downloading · queued ·
|
||||
// speed · ETA. Hidden when nothing is active.
|
||||
function _adlRenderBatchSummary(activeBatches) {
|
||||
const el = document.getElementById('adl-batch-summary');
|
||||
if (!el) return;
|
||||
if (!activeBatches.length) { el.style.display = 'none'; return; }
|
||||
|
||||
let downloading = 0, queued = 0, remaining = 0, rate = 0, bundleSpeed = '';
|
||||
for (const b of activeBatches) {
|
||||
downloading += (b.active || 0);
|
||||
queued += (b.queued || 0);
|
||||
if (b.phase === 'downloading') {
|
||||
const done = (b.completed || 0) + (b.failed || 0);
|
||||
remaining += Math.max(0, (b.total || 0) - done);
|
||||
rate += _adlSampleRate(b.batch_id, done);
|
||||
}
|
||||
if (b.phase === 'album_downloading' && b.album_bundle && b.album_bundle.speed && !bundleSpeed) {
|
||||
bundleSpeed = b.album_bundle.speed;
|
||||
}
|
||||
}
|
||||
|
||||
const parts = [`${activeBatches.length} batch${activeBatches.length === 1 ? '' : 'es'}`];
|
||||
if (downloading) parts.push(`${downloading} downloading`);
|
||||
if (queued) parts.push(`${queued} queued`);
|
||||
if (bundleSpeed) parts.push(_adlEsc(bundleSpeed));
|
||||
const etaStr = (rate > 0 && remaining > 0) ? `~${_adlFmtDuration(remaining / rate)} left` : '';
|
||||
|
||||
el.style.display = '';
|
||||
el.innerHTML =
|
||||
`<span class="adl-batch-summary-main">${parts.join(' · ')}</span>` +
|
||||
(etaStr ? `<span class="adl-batch-summary-eta">${etaStr}</span>` : '');
|
||||
}
|
||||
|
||||
function loadActiveDownloadsPage() {
|
||||
_adlFetch();
|
||||
_adlFetchBatchHistory();
|
||||
|
|
@ -2553,17 +2632,27 @@ function _adlRenderBatchPanel() {
|
|||
return elapsed < _BATCH_FADE_SECONDS;
|
||||
});
|
||||
|
||||
const activeBatches = visibleBatches.filter(b => b.phase !== 'complete' && b.phase !== 'cancelled' && b.phase !== 'error');
|
||||
|
||||
// Update header with count
|
||||
if (headerTitle) {
|
||||
const activeCount = visibleBatches.filter(b => b.phase !== 'complete' && b.phase !== 'cancelled' && b.phase !== 'error').length;
|
||||
headerTitle.textContent = activeCount > 0 ? `Batches (${activeCount})` : 'Batches';
|
||||
headerTitle.textContent = activeBatches.length > 0 ? `Batches (${activeBatches.length})` : 'Batches';
|
||||
}
|
||||
|
||||
_adlRenderBatchSummary(activeBatches);
|
||||
|
||||
if (visibleBatches.length === 0) {
|
||||
container.innerHTML = `<div class="adl-batch-empty">
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.2" style="opacity:0.25;margin-bottom:6px"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
||||
<div>No active batches</div>
|
||||
<div style="font-size:0.7rem;margin-top:2px;opacity:0.5">Start a download from Search, Sync, or Wishlist</div>
|
||||
<div class="adl-batch-empty-icon">
|
||||
<svg width="34" height="34" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.3"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
||||
</div>
|
||||
<div class="adl-batch-empty-title">Nothing downloading</div>
|
||||
<div class="adl-batch-empty-sub">Batches show up here as they run.</div>
|
||||
<div class="adl-batch-empty-links">
|
||||
<a href="#" onclick="event.preventDefault(); navigateToPage('search')">Search</a>
|
||||
<a href="#" onclick="event.preventDefault(); navigateToPage('sync')">Sync</a>
|
||||
<a href="#" onclick="event.preventDefault(); navigateToPage('wishlist')">Wishlist</a>
|
||||
</div>
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
|
|
@ -2649,34 +2738,37 @@ function _adlRenderBatchPanel() {
|
|||
let tracksHtml = '';
|
||||
if (isExpanded) {
|
||||
if (batchTracks.length > 0) {
|
||||
tracksHtml = batchTracks.map(t => {
|
||||
tracksHtml = batchTracks.map((t, i) => {
|
||||
const cls = _adlStatusClass(t.status);
|
||||
const progress = t.progress || 0;
|
||||
const idx = (t.track_index != null ? t.track_index + 1 : i + 1);
|
||||
|
||||
// Status indicator with detail
|
||||
let statusHtml = '';
|
||||
// Right-aligned state: % / spinner / \u2713 / \u2717 / \u00B7 \u2014 color via row class.
|
||||
let stateHtml = '';
|
||||
if (t.status === 'downloading' && progress > 0) {
|
||||
statusHtml = `<span class="adl-batch-track-status active">${Math.round(progress)}%</span>`;
|
||||
stateHtml = `<span class="adl-batch-track-state">${Math.round(progress)}%</span>`;
|
||||
} else if (t.status === 'searching') {
|
||||
statusHtml = `<span class="adl-batch-track-status active"><span class="adl-spinner" style="width:8px;height:8px"></span></span>`;
|
||||
stateHtml = `<span class="adl-batch-track-state"><span class="adl-spinner" style="width:9px;height:9px"></span></span>`;
|
||||
} else if (t.status === 'post_processing') {
|
||||
statusHtml = `<span class="adl-batch-track-status active" title="Processing">proc</span>`;
|
||||
stateHtml = `<span class="adl-batch-track-state" title="Processing">proc</span>`;
|
||||
} else if (cls === 'completed') {
|
||||
statusHtml = `<span class="adl-batch-track-status completed">\u2713</span>`;
|
||||
stateHtml = `<span class="adl-batch-track-state">\u2713</span>`;
|
||||
} else if (cls === 'failed') {
|
||||
statusHtml = `<span class="adl-batch-track-status failed">\u2717</span>`;
|
||||
stateHtml = `<span class="adl-batch-track-state" title="${_adlEsc(t.error || 'Failed')}">\u2717</span>`;
|
||||
} else {
|
||||
statusHtml = `<span class="adl-batch-track-status queued">\u00B7</span>`;
|
||||
stateHtml = `<span class="adl-batch-track-state">\u00B7</span>`;
|
||||
}
|
||||
|
||||
// Mini progress bar for downloading tracks
|
||||
const miniBar = t.status === 'downloading' && progress > 0
|
||||
const isDownloading = (t.status === 'downloading' && progress > 0);
|
||||
const miniBar = isDownloading
|
||||
? `<div class="adl-batch-track-progress"><div class="adl-batch-track-progress-fill" style="width:${progress}%"></div></div>`
|
||||
: '';
|
||||
const sub = t.artist ? `<span class="adl-batch-track-sub">${_adlEsc(t.artist)}</span>` : '';
|
||||
|
||||
return `<div class="adl-batch-track-row">
|
||||
<span class="adl-batch-track-title">${_adlEsc(t.title || 'Unknown')}</span>
|
||||
${statusHtml}
|
||||
return `<div class="adl-batch-track-row ${cls}${isDownloading ? ' downloading' : ''}">
|
||||
<span class="adl-batch-track-idx">${idx}</span>
|
||||
<span class="adl-batch-track-text"><span class="adl-batch-track-title">${_adlEsc(t.title || 'Unknown')}</span>${sub}</span>
|
||||
${stateHtml}
|
||||
${miniBar}
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
|
@ -2687,19 +2779,50 @@ function _adlRenderBatchPanel() {
|
|||
}
|
||||
}
|
||||
|
||||
const cardClasses = ['adl-batch-card'];
|
||||
const cardClasses = ['adl-batch-card', `phase-${batch.phase}`];
|
||||
if (isExpanded) cardClasses.push('expanded');
|
||||
if (isActive) cardClasses.push('active-glow');
|
||||
if (isFiltered) cardClasses.push('filtered');
|
||||
|
||||
const playlistId = _adlEsc(batch.playlist_id || '');
|
||||
|
||||
// Segmented progress: done (green) / failed (red) / active (accent) of
|
||||
// total; the remaining width is the dim bar background (queued).
|
||||
let segDone = 0, segFail = 0, segActive = 0;
|
||||
if (batch.phase === 'album_downloading') {
|
||||
segActive = bundleProgress; // one release downloading
|
||||
} else {
|
||||
segDone = Math.max(0, Math.min(100, (batch.completed / total) * 100));
|
||||
segFail = Math.max(0, Math.min(100 - segDone, (batch.failed / total) * 100));
|
||||
segActive = Math.max(0, Math.min(100 - segDone - segFail, ((batch.active || 0) / total) * 100));
|
||||
}
|
||||
|
||||
// "Now downloading" — the live track on active batches.
|
||||
const nowTrack = isActive
|
||||
? (batchTracks.find(t => t.status === 'downloading') || batchTracks.find(t => t.status === 'searching'))
|
||||
: null;
|
||||
const nowHtml = (nowTrack && nowTrack.title)
|
||||
? `<div class="adl-batch-card-now"><span class="adl-batch-now-icon">↓</span> ${_adlEsc(nowTrack.title)}</div>`
|
||||
: '';
|
||||
|
||||
// Stat chips + ETA line.
|
||||
const chips = [];
|
||||
if (batch.completed) chips.push(`<span class="adl-chip adl-chip-done">✓ ${batch.completed}</span>`);
|
||||
if (batch.failed) chips.push(`<span class="adl-chip adl-chip-fail">✗ ${batch.failed}</span>`);
|
||||
if (batch.active) chips.push(`<span class="adl-chip adl-chip-active">↓ ${batch.active}</span>`);
|
||||
if (batch.queued) chips.push(`<span class="adl-chip adl-chip-queued">${batch.queued} queued</span>`);
|
||||
const etaText = _adlBatchEta(batch);
|
||||
const statLine = (chips.length || etaText)
|
||||
? `<div class="adl-batch-statline"><div class="adl-batch-chips">${chips.join('')}</div>${etaText ? `<span class="adl-batch-eta">${_adlEsc(etaText)}</span>` : ''}</div>`
|
||||
: '';
|
||||
|
||||
html += `<div class="${cardClasses.join(' ')}" style="${colorStyle}${fadeStyle}" data-batch-id="${batch.batch_id}" onclick="_adlToggleBatch('${batch.batch_id}')">
|
||||
<div class="adl-batch-card-top">
|
||||
${thumbHtml}
|
||||
<div class="adl-batch-card-info">
|
||||
<div class="adl-batch-card-name adl-batch-card-link" onclick="event.stopPropagation(); _adlOpenBatchModal('${batch.batch_id}', '${playlistId}', '${_adlEsc(batch.batch_name || 'Download')}')" title="Open download modal">${_adlEsc(batch.batch_name || 'Download')}</div>
|
||||
<div class="adl-batch-card-meta">${phaseIcon}${phaseText}</div>
|
||||
${nowHtml}
|
||||
</div>
|
||||
${sourceBadge}
|
||||
<div class="adl-batch-card-actions">
|
||||
|
|
@ -2709,11 +2832,17 @@ function _adlRenderBatchPanel() {
|
|||
${!isTerminal ? `<button class="adl-batch-card-cancel" onclick="event.stopPropagation(); _adlCancelBatch('${batch.batch_id}')" title="Cancel batch">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
</button>` : ''}
|
||||
<span class="adl-batch-card-chevron" aria-hidden="true">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="adl-batch-progress">
|
||||
<div class="adl-batch-progress-fill${hasFailed ? ' has-failed' : ''}" style="width:${pct}%"></div>
|
||||
<div class="adl-batch-segbar">
|
||||
<div class="adl-batch-seg seg-done" style="width:${segDone}%"></div>
|
||||
<div class="adl-batch-seg seg-fail" style="width:${segFail}%"></div>
|
||||
<div class="adl-batch-seg seg-active${isActive ? ' shimmer' : ''}" style="width:${segActive}%"></div>
|
||||
</div>
|
||||
${statLine}
|
||||
<div class="adl-batch-tracks">${tracksHtml}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -243,10 +243,13 @@ function initializeSearchModeToggle() {
|
|||
return;
|
||||
}
|
||||
|
||||
// Debounce search
|
||||
// Debounce search. 600ms (was 300) so a name being typed coalesces
|
||||
// into ONE search after the user pauses, instead of firing a new
|
||||
// external-API search per letter (#751). Enter still triggers an
|
||||
// immediate search via the keypress handler below.
|
||||
debounceTimer = setTimeout(() => {
|
||||
searchController.submitQuery(query);
|
||||
}, 300);
|
||||
}, 600);
|
||||
});
|
||||
|
||||
enhancedInput.addEventListener('keypress', (e) => {
|
||||
|
|
|
|||
|
|
@ -718,6 +718,116 @@ function getHybridOrder() {
|
|||
return _hybridSourceOrder.filter(s => _hybridSourceEnabled[s] !== false);
|
||||
}
|
||||
|
||||
// ---- Preferred album-art sources (reuses the hybrid-source-list styling) ----
|
||||
const ART_SOURCES = [
|
||||
{ id: 'caa', name: 'Cover Art Archive', icon: 'https://upload.wikimedia.org/wikipedia/commons/thumb/9/9e/MusicBrainz_Logo_%282016%29.svg/500px-MusicBrainz_Logo_%282016%29.svg.png', emoji: '🎨' },
|
||||
{ id: 'deezer', name: 'Deezer', icon: 'https://cdn.brandfetch.io/idEUKgCNtu/theme/dark/symbol.svg?c=1bxid64Mup7aczewSAYMX&t=1758260798610', emoji: '🎧' },
|
||||
{ id: 'itunes', name: 'iTunes', icon: 'https://upload.wikimedia.org/wikipedia/commons/thumb/d/df/ITunes_logo.svg/960px-ITunes_logo.svg.png', emoji: '🍎' },
|
||||
{ id: 'spotify', name: 'Spotify', icon: 'https://storage.googleapis.com/pr-newsroom-wp/1/2023/05/Spotify_Primary_Logo_RGB_Green.png', emoji: '🟢' },
|
||||
{ id: 'audiodb', name: 'TheAudioDB', icon: null, emoji: '💿' },
|
||||
];
|
||||
let _artSourceEnabled = {}; // id -> bool
|
||||
let _artVisualOrder = []; // available source ids, in display order
|
||||
let _artAvailable = []; // ids the user is connected to
|
||||
|
||||
function buildArtSourceList() {
|
||||
const container = document.getElementById('art-source-list');
|
||||
if (!container) return;
|
||||
container.innerHTML = '';
|
||||
if (!_artVisualOrder.length) {
|
||||
container.innerHTML = '<div style="padding:10px;color:var(--text-secondary,#888);font-size:13px;">No connected art sources available.</div>';
|
||||
return;
|
||||
}
|
||||
const enabledOrder = getArtOrder();
|
||||
_artVisualOrder.forEach((srcId) => {
|
||||
const src = ART_SOURCES.find(s => s.id === srcId);
|
||||
if (!src) return;
|
||||
const enabled = _artSourceEnabled[srcId] === true;
|
||||
const priorityNum = enabled ? enabledOrder.indexOf(srcId) + 1 : '';
|
||||
const item = document.createElement('div');
|
||||
item.className = `hybrid-source-item${enabled ? '' : ' disabled'}`;
|
||||
item.dataset.sourceId = srcId;
|
||||
item.innerHTML = `
|
||||
<span class="hybrid-source-arrows">
|
||||
<button class="hybrid-arrow-btn" onclick="moveArtSource('${srcId}', -1)" title="Move up">▲</button>
|
||||
<button class="hybrid-arrow-btn" onclick="moveArtSource('${srcId}', 1)" title="Move down">▼</button>
|
||||
</span>
|
||||
${src.icon
|
||||
? `<img class="hybrid-source-icon" src="${src.icon}" alt="${src.name}" onerror="this.outerHTML='<span class=\\'hybrid-source-icon emoji-icon\\'>${src.emoji}</span>'">`
|
||||
: `<span class="hybrid-source-icon emoji-icon">${src.emoji}</span>`
|
||||
}
|
||||
<span class="hybrid-source-name">${src.name}</span>
|
||||
<span class="hybrid-source-priority">${priorityNum}</span>
|
||||
<label class="hybrid-source-toggle">
|
||||
<input type="checkbox" ${enabled ? 'checked' : ''} onchange="toggleArtSource('${srcId}', this.checked)">
|
||||
<span class="toggle-track"></span>
|
||||
</label>
|
||||
`;
|
||||
container.appendChild(item);
|
||||
});
|
||||
}
|
||||
|
||||
function moveArtSource(srcId, direction) {
|
||||
const idx = _artVisualOrder.indexOf(srcId);
|
||||
if (idx < 0) return;
|
||||
const newIdx = idx + direction;
|
||||
if (newIdx < 0 || newIdx >= _artVisualOrder.length) return;
|
||||
[_artVisualOrder[idx], _artVisualOrder[newIdx]] = [_artVisualOrder[newIdx], _artVisualOrder[idx]];
|
||||
buildArtSourceList();
|
||||
if (typeof debouncedAutoSaveSettings === 'function') debouncedAutoSaveSettings();
|
||||
}
|
||||
|
||||
function toggleArtSource(srcId, enabled) {
|
||||
_artSourceEnabled[srcId] = enabled;
|
||||
buildArtSourceList();
|
||||
if (typeof debouncedAutoSaveSettings === 'function') debouncedAutoSaveSettings();
|
||||
}
|
||||
|
||||
// Saved value: enabled sources in their displayed order.
|
||||
function getArtOrder() {
|
||||
return _artVisualOrder.filter(id => _artSourceEnabled[id] === true);
|
||||
}
|
||||
|
||||
async function loadArtSourceOrder(settings) {
|
||||
const valid = new Set(ART_SOURCES.map(s => s.id));
|
||||
const saved = (settings && settings.metadata_enhancement
|
||||
&& Array.isArray(settings.metadata_enhancement.album_art_order))
|
||||
? settings.metadata_enhancement.album_art_order : [];
|
||||
|
||||
// Populate the saved order SYNCHRONOUSLY, filtered to known art sources
|
||||
// (NOT by availability). This guarantees a save that fires before the
|
||||
// availability fetch resolves — or while a saved source is temporarily
|
||||
// disconnected — can never wipe the user's saved order. The backend skips
|
||||
// any unavailable source at resolution time, and it re-activates on
|
||||
// reconnect, so keeping it in the list is safe and preserves intent.
|
||||
_artSourceEnabled = {};
|
||||
_artVisualOrder = [];
|
||||
saved.forEach(id => {
|
||||
if (valid.has(id) && !_artVisualOrder.includes(id)) {
|
||||
_artVisualOrder.push(id);
|
||||
_artSourceEnabled[id] = true;
|
||||
}
|
||||
});
|
||||
buildArtSourceList();
|
||||
|
||||
// Then fetch which sources are actually connected and append any that
|
||||
// aren't already listed (shown disabled, ready to enable).
|
||||
try {
|
||||
const resp = await fetch('/api/metadata/art-sources');
|
||||
const data = await resp.json();
|
||||
_artAvailable = (data.available || []).map(s => (s && s.id) ? s.id : s);
|
||||
} catch (e) {
|
||||
_artAvailable = [];
|
||||
}
|
||||
_artAvailable.forEach(id => {
|
||||
if (valid.has(id) && !_artVisualOrder.includes(id)) {
|
||||
_artVisualOrder.push(id);
|
||||
_artSourceEnabled[id] = false;
|
||||
}
|
||||
});
|
||||
buildArtSourceList();
|
||||
}
|
||||
|
||||
function loadHybridSourceOrder(settings) {
|
||||
const order = settings.download_source?.hybrid_order;
|
||||
const sourceStatus = settings._source_status || {};
|
||||
|
|
@ -943,6 +1053,7 @@ async function loadSettingsData() {
|
|||
document.getElementById('stream-source').value = settings.download_source?.stream_source || 'youtube';
|
||||
document.getElementById('max-concurrent-downloads').value = settings.download_source?.max_concurrent || '3';
|
||||
loadHybridSourceOrder(settings);
|
||||
loadArtSourceOrder(settings);
|
||||
document.getElementById('tidal-download-quality').value = settings.tidal_download?.quality || 'lossless';
|
||||
document.getElementById('tidal-allow-fallback').checked = settings.tidal_download?.allow_fallback !== false;
|
||||
document.getElementById('qobuz-quality').value = settings.qobuz?.quality || 'lossless';
|
||||
|
|
@ -2778,6 +2889,7 @@ async function saveSettings(quiet = false) {
|
|||
embed_album_art: document.getElementById('embed-album-art').checked,
|
||||
cover_art_download: document.getElementById('cover-art-download').checked,
|
||||
prefer_caa_art: document.getElementById('prefer-caa-art').checked,
|
||||
album_art_order: getArtOrder(),
|
||||
lrclib_enabled: document.getElementById('lrclib-enabled').checked,
|
||||
tags: {
|
||||
quality_tag: _getTagConfig('metadata_enhancement.tags.quality_tag'),
|
||||
|
|
|
|||
|
|
@ -518,10 +518,10 @@ function renderMirroredCard(p, container) {
|
|||
${phaseHtml}
|
||||
</div>
|
||||
</div>
|
||||
${disc > 0 ? `<button class="mirrored-card-clear" onclick="event.stopPropagation(); clearMirroredDiscovery(${p.id}, '${_escAttr(p.name)}')" title="Clear discovery data">↺</button>` : ''}
|
||||
<button class="mirrored-card-pipeline" onclick="event.stopPropagation(); runMirroredPlaylistPipeline(${p.id}, '${_escAttr(p.name)}')" title="Refresh, discover, sync, and queue missing tracks">Auto-Sync</button>
|
||||
<button class="mirrored-card-link" onclick="event.stopPropagation(); editMirroredSourceRef(${p.id}, '${_escAttr(p.name)}', '${_escAttr(p.source)}', '${_escAttr(sourceRef)}')" title="Edit original playlist link">🔗</button>
|
||||
<button class="mirrored-card-delete" onclick="event.stopPropagation(); deleteMirroredPlaylist(${p.id}, '${_escAttr(p.name)}')" title="Delete mirror">✕</button>
|
||||
${disc > 0 ? `<button class="mirrored-card-clear" onclick="event.stopPropagation(); clearMirroredDiscovery(${p.id}, '${_escJs(p.name)}')" title="Clear discovery data">↺</button>` : ''}
|
||||
<button class="mirrored-card-pipeline" onclick="event.stopPropagation(); runMirroredPlaylistPipeline(${p.id}, '${_escJs(p.name)}')" title="Refresh, discover, sync, and queue missing tracks">Auto-Sync</button>
|
||||
<button class="mirrored-card-link" onclick="event.stopPropagation(); editMirroredSourceRef(${p.id}, '${_escJs(p.name)}', '${_escJs(p.source)}', '${_escJs(sourceRef)}')" title="Edit original playlist link">🔗</button>
|
||||
<button class="mirrored-card-delete" onclick="event.stopPropagation(); deleteMirroredPlaylist(${p.id}, '${_escJs(p.name)}')" title="Delete mirror">✕</button>
|
||||
`;
|
||||
card.addEventListener('click', () => {
|
||||
const st = youtubePlaylistStates[hash];
|
||||
|
|
@ -829,52 +829,74 @@ async function openMirroredPlaylistModal(playlistId) {
|
|||
const tracks = data.tracks || [];
|
||||
const source = data.source || 'unknown';
|
||||
const sourceRef = getMirroredSourceRef(data);
|
||||
const sourceIcons = { spotify: '🎵', tidal: '🌊', youtube: '▶', beatport: '🎛' };
|
||||
const sourceIcons = { spotify: '🎵', spotify_public: '🎵', tidal: '🌊', youtube: '▶', beatport: '🎛', deezer: '🎧', qobuz: '♫' };
|
||||
const sourceLabels = { spotify: 'Spotify', spotify_public: 'Spotify', tidal: 'Tidal', youtube: 'YouTube', beatport: 'Beatport', deezer: 'Deezer', qobuz: 'Qobuz' };
|
||||
const sourceIcon = sourceIcons[source] || '📋';
|
||||
const srcLabel = sourceLabels[source] || source;
|
||||
|
||||
// Hero artwork: playlist cover → first track with art → gradient fallback.
|
||||
const heroArt = data.image_url || (tracks.find(t => t.image_url) || {}).image_url || '';
|
||||
|
||||
// Total runtime for the meta line.
|
||||
const totalMs = tracks.reduce((sum, t) => sum + (t.duration_ms || 0), 0);
|
||||
const totalMin = Math.round(totalMs / 60000);
|
||||
const totalLabel = totalMin >= 60 ? `${Math.floor(totalMin / 60)} hr ${totalMin % 60} min` : `${totalMin} min`;
|
||||
|
||||
const trackRows = tracks.map(t => {
|
||||
const dur = t.duration_ms ? `${Math.floor(t.duration_ms / 60000)}:${String(Math.floor((t.duration_ms % 60000) / 1000)).padStart(2, '0')}` : '';
|
||||
return `<div class="mirrored-track-row">
|
||||
<span class="track-pos">${t.position}</span>
|
||||
<span class="track-title">${_esc(t.track_name)}</span>
|
||||
<span class="track-artist">${_esc(t.artist_name)}</span>
|
||||
<span class="track-album">${_esc(t.album_name)}</span>
|
||||
<span class="track-duration">${dur}</span>
|
||||
const art = t.image_url
|
||||
? `<img class="mm-row-art" src="${_escAttr(t.image_url)}" loading="lazy" onerror="this.onerror=null;this.classList.add('mm-art-empty');this.removeAttribute('src');">`
|
||||
: `<span class="mm-row-art mm-art-empty"></span>`;
|
||||
return `<div class="mm-row">
|
||||
<span class="mm-row-pos">${t.position}</span>
|
||||
${art}
|
||||
<div class="mm-row-main">
|
||||
<span class="mm-row-title">${_esc(t.track_name)}</span>
|
||||
<span class="mm-row-artist">${_esc(t.artist_name)}</span>
|
||||
</div>
|
||||
<span class="mm-row-album">${_esc(t.album_name || '')}</span>
|
||||
<span class="mm-row-dur">${dur}</span>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
const heroCover = heroArt
|
||||
? `<div class="mm-cover" style="background-image:url('${_escAttr(heroArt)}')"></div>`
|
||||
: `<div class="mm-cover mm-cover-empty ${_escAttr(source)}">${sourceIcon}</div>`;
|
||||
|
||||
overlay.innerHTML = `
|
||||
<div class="mirrored-modal">
|
||||
<div class="mirrored-modal-header">
|
||||
<div class="mirrored-modal-hero">
|
||||
<div class="mirrored-modal-hero-icon ${_escAttr(source)}">${sourceIcon}</div>
|
||||
<div class="mirrored-modal-hero-info">
|
||||
<h2 class="mirrored-modal-hero-title">${_esc(data.name)}</h2>
|
||||
<div class="mirrored-modal-hero-subtitle">
|
||||
<span class="mirrored-modal-hero-badge">${_esc(source)}</span>
|
||||
<span>${tracks.length} tracks</span>
|
||||
<span>·</span>
|
||||
<span>Mirrored ${timeAgo(data.updated_at || data.mirrored_at)}</span>
|
||||
<div class="mm-hero">
|
||||
${heroArt ? `<div class="mm-hero-bg" style="background-image:url('${_escAttr(heroArt)}')"></div>` : ''}
|
||||
<div class="mm-hero-content">
|
||||
${heroCover}
|
||||
<div class="mm-hero-info">
|
||||
<span class="mm-eyebrow">Mirrored Playlist</span>
|
||||
<h2 class="mm-title">${_esc(data.name)}</h2>
|
||||
<div class="mm-meta">
|
||||
<span class="mm-source-pill ${_escAttr(source)}">${_esc(srcLabel)}</span>
|
||||
${data.owner ? `<span class="mm-meta-item">${_esc(data.owner)}</span><span class="mm-dot">·</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>
|
||||
<span class="mirrored-modal-close" onclick="closeMirroredModal()">×</span>
|
||||
<button class="mm-close" onclick="closeMirroredModal()" aria-label="Close">×</button>
|
||||
</div>
|
||||
<div class="mirrored-modal-tracks">
|
||||
<div class="mirrored-track-header">
|
||||
<span>#</span><span>Track</span><span>Artist</span><span>Album</span><span style="text-align:right">Time</span>
|
||||
<div class="mm-list">
|
||||
<div class="mm-list-head">
|
||||
<span>#</span><span></span><span>Title</span><span>Album</span><span class="mm-col-dur">Time</span>
|
||||
</div>
|
||||
${trackRows}
|
||||
${trackRows || '<div class="mm-empty">No tracks in this mirror yet.</div>'}
|
||||
</div>
|
||||
<div class="mirrored-modal-footer">
|
||||
<div class="mirrored-modal-footer-left">
|
||||
<button class="mirrored-btn-delete" onclick="closeMirroredModal(); deleteMirroredPlaylist(${playlistId}, '${_escAttr(data.name)}')">Delete Mirror</button>
|
||||
</div>
|
||||
<div class="mirrored-modal-footer-right" style="display:flex;gap:10px;">
|
||||
<button class="mirrored-btn-close" onclick="editMirroredSourceRef(${playlistId}, '${_escAttr(data.name)}', '${_escAttr(source)}', '${_escAttr(sourceRef)}')">Edit Source</button>
|
||||
<button class="mirrored-btn-pipeline" onclick="runMirroredPlaylistPipeline(${playlistId}, '${_escAttr(data.name)}')">Auto-Sync</button>
|
||||
<button class="mirrored-btn-close" onclick="closeMirroredModal()">Close</button>
|
||||
<button class="mirrored-btn-discover" onclick="discoverMirroredPlaylist(${playlistId})">Discover</button>
|
||||
<div class="mm-actions">
|
||||
<button class="mm-btn mm-btn-danger" onclick="closeMirroredModal(); deleteMirroredPlaylist(${playlistId}, '${_escJs(data.name)}')">Delete Mirror</button>
|
||||
<div class="mm-actions-right">
|
||||
<button class="mm-btn mm-btn-ghost" onclick="editMirroredSourceRef(${playlistId}, '${_escJs(data.name)}', '${_escJs(source)}', '${_escJs(sourceRef)}')">Edit Source</button>
|
||||
<button class="mm-btn mm-btn-secondary" onclick="runMirroredPlaylistPipeline(${playlistId}, '${_escJs(data.name)}')">Auto-Sync</button>
|
||||
<button class="mm-btn mm-btn-ghost" onclick="closeMirroredModal()">Close</button>
|
||||
<button class="mm-btn mm-btn-primary" onclick="discoverMirroredPlaylist(${playlistId})">Discover</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1176,7 +1198,7 @@ function renderPoolList() {
|
|||
<span class="pool-track-playlist-badge">${_esc(t.playlist_name)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="playlist-modal-btn playlist-modal-btn-primary pool-fix-btn" onclick="openPoolFixModal(${t.id}, '${_escAttr(t.track_name)}', '${_escAttr(t.artist_name)}')">Fix Match</button>
|
||||
<button class="playlist-modal-btn playlist-modal-btn-primary pool-fix-btn" onclick="openPoolFixModal(${t.id}, '${_escJs(t.track_name)}', '${_escJs(t.artist_name)}')">Fix Match</button>
|
||||
</div>
|
||||
`).join('');
|
||||
} else {
|
||||
|
|
@ -1218,7 +1240,7 @@ function renderPoolList() {
|
|||
</div>
|
||||
<span class="pool-confidence-badge ${confClass}">${conf}%</span>
|
||||
<span class="pool-use-count">${e.use_count}×</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>
|
||||
</div>
|
||||
`;
|
||||
|
|
@ -2141,13 +2163,13 @@ function _buildAutomationSection(id, label, automations, useGrid, options = {})
|
|||
const allEnabled = enabledCount === automations.length;
|
||||
actionsHtml = `
|
||||
<div class="section-actions" onclick="event.stopPropagation();">
|
||||
<button class="section-action-btn" title="${allEnabled ? 'Disable all' : 'Enable all'}" onclick="_bulkToggleGroup('${_escAttr(groupName)}', ${allEnabled})">
|
||||
<button class="section-action-btn" title="${allEnabled ? 'Disable all' : 'Enable all'}" onclick="_bulkToggleGroup('${_escJs(groupName)}', ${allEnabled})">
|
||||
${allEnabled ? '⏸' : '▶'}
|
||||
</button>
|
||||
<button class="section-action-btn" title="Rename group" onclick="_startRenameGroup('${_escAttr(groupName)}', this)">
|
||||
<button class="section-action-btn" title="Rename group" onclick="_startRenameGroup('${_escJs(groupName)}', this)">
|
||||
✏️
|
||||
</button>
|
||||
<button class="section-action-btn section-action-danger" title="Delete group" onclick="_deleteGroup('${_escAttr(groupName)}')">
|
||||
<button class="section-action-btn section-action-danger" title="Delete group" onclick="_deleteGroup('${_escJs(groupName)}')">
|
||||
🗑️
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -3108,7 +3130,7 @@ function _showGroupDropdown(event, autoId, currentGroup) {
|
|||
}
|
||||
allGroups.forEach(g => {
|
||||
const isActive = g === currentGroup;
|
||||
html += `<div class="auto-group-option${isActive ? ' active' : ''}" onclick="_assignGroup(${autoId}, '${_escAttr(g)}')">${_esc(g)}</div>`;
|
||||
html += `<div class="auto-group-option${isActive ? ' active' : ''}" onclick="_assignGroup(${autoId}, '${_escJs(g)}')">${_esc(g)}</div>`;
|
||||
});
|
||||
if (allGroups.size) html += '<div class="auto-group-divider"></div>';
|
||||
html += `<input class="auto-group-input" placeholder="New group name..." onkeydown="if(event.key==='Enter'){_assignGroup(${autoId}, this.value.trim()); event.preventDefault();}">`;
|
||||
|
|
@ -3204,15 +3226,15 @@ function renderAutomationCard(a) {
|
|||
const _timerTriggers = ['schedule', 'daily_time', 'weekly_time'];
|
||||
if (a.next_run && a.enabled && _timerTriggers.includes(a.trigger_type)) metaParts.push('<span class="auto-next-run" data-next="' + _escAttr(a.next_run) + '">Next: ' + _autoTimeUntil(a.next_run) + '</span>');
|
||||
if (!_timerTriggers.includes(a.trigger_type) && a.enabled) metaParts.push('Listening');
|
||||
if (a.run_count) metaParts.push('<span class="auto-runs-link" onclick="event.stopPropagation(); showAutomationHistory(' + a.id + ', \'' + _escAttr(a.name) + '\', \'' + _escAttr(a.action_type || '') + '\')" title="View run history">Runs: ' + a.run_count + '</span>');
|
||||
if (a.run_count) metaParts.push('<span class="auto-runs-link" onclick="event.stopPropagation(); showAutomationHistory(' + a.id + ', \'' + _escJs(a.name) + '\', \'' + _escJs(a.action_type || '') + '\')" title="View run history">Runs: ' + a.run_count + '</span>');
|
||||
if (a.last_error) metaParts.push('Error: ' + _esc(a.last_error));
|
||||
|
||||
const dupeBtn = a.is_system ? '' :
|
||||
`<button class="automation-dupe-btn" title="Duplicate" onclick="event.stopPropagation(); duplicateAutomation(${a.id})">📋</button>`;
|
||||
const groupBtn = a.is_system ? '' :
|
||||
`<button class="automation-group-btn${a.group_name ? ' grouped' : ''}" data-group="${_escAttr(a.group_name || '')}" title="${a.group_name ? 'Group: ' + _escAttr(a.group_name) : 'Assign group'}" onclick="event.stopPropagation(); _showGroupDropdown(event, ${a.id}, ${a.group_name ? "'" + _escAttr(a.group_name) + "'" : 'null'})">📁</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 ? '' :
|
||||
`<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 = `
|
||||
<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, '>');
|
||||
}
|
||||
|
||||
// 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 =====
|
||||
|
||||
let _enhanceQualityData = null;
|
||||
|
|
@ -4740,9 +4785,12 @@ async function playArtistRadio() {
|
|||
audioPlayer.pause();
|
||||
}
|
||||
|
||||
// Play the track first, then enable radio mode after a short delay
|
||||
// so currentTrack is set and the radio queue fill triggers
|
||||
playLibraryTrack({
|
||||
// Play the track and WAIT for it to resolve (playLibraryTrack is async
|
||||
// and fetches the canonical DB row before setting currentTrack) — so
|
||||
// currentTrack.id is reliably set before we fetch the radio queue. A
|
||||
// fixed setTimeout raced this and often fired before the id existed,
|
||||
// leaving the queue empty until the song ended.
|
||||
await playLibraryTrack({
|
||||
id: random.track.id,
|
||||
title: random.track.title,
|
||||
file_path: random.track.file_path,
|
||||
|
|
@ -4751,12 +4799,17 @@ async function playArtistRadio() {
|
|||
album_id: random.album.id,
|
||||
}, random.album.title || '', artistName);
|
||||
|
||||
// Enable radio mode after track starts loading
|
||||
setTimeout(() => {
|
||||
// Enable radio mode + immediately seed the queue with similar tracks —
|
||||
// same path the modal's Radio button uses (fetchIfNeeded also adds the
|
||||
// current track to the queue first).
|
||||
if (typeof npSetPlayContext === 'function') {
|
||||
npSetPlayContext(`${artistName || 'Artist'} Radio`);
|
||||
}
|
||||
if (typeof npSetRadioMode === 'function') {
|
||||
npSetRadioMode(true, { toast: false, fetchIfNeeded: true });
|
||||
} else {
|
||||
npRadioMode = true;
|
||||
const radioBtn = document.querySelector('.np-radio-btn');
|
||||
if (radioBtn) radioBtn.classList.add('active');
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
showToast(`Playing ${artistName} radio — similar tracks will auto-queue`, 'success');
|
||||
} catch (e) {
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
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