Compare commits

..

No commits in common. "main" and "2.8.0" have entirely different histories.
main ... 2.8.0

79 changed files with 1415 additions and 5990 deletions

View file

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

View file

@ -1,13 +0,0 @@
**SoulSync 2.8.0** is out 🎉 a quality + reliability release.
🧹 **The Unverified queue, finally under control** — if you saw thousands of "unverified" rows piling up, this is for you. the AcoustID scan stops duplicating history rows, a one-time reconcile on startup clears the existing backlog from your library (no re-scan), and a new 🧹 *Clean orphaned* button sweeps dead rows whose file is gone. (#934 — thanks @nick2000713 for #938)
✂️ **Preview Clip Cleanup** — a new Tools job that finds the ~30s preview clips the HiFi source sometimes hands back instead of the full song, then deletes them and re-wishlists the real version. each finding has a ▶ Play button so you can confirm before approving.
💿 **Album Completeness handles split albums** — an album split across multiple library rows no longer shows every fragment as falsely "incomplete"; it groups the validated fragments into one correct finding. (#936 — thanks @ragnarlotus)
🐛 **Fixes** — pasted YouTube cookies no longer throw `unsupported browser: "custom"` on Docker (thanks HellRa1SeR); longer remasters aren't quarantined as "truncated" anymore (#937, thanks @diegocade1); "Add to Wishlist" from a discography went from ~1530s *per track* to instant; wishlist art renders for re-downloads; and **Clear Completed** is back on the Downloads page.
**Performance** — trimmed the dashboard GPU usage that was hammering Firefox/Zen (and Background Particles are OFF by default now), plus bounded the runaway memory growth that could lock the app up on big libraries. (#935 / #802)
enjoy! 🎶

View file

@ -1,15 +0,0 @@
**SoulSync 2.8.1** is out 🎉 a feature + reliability release.
🎧 **Export playlists to Spotify & Deezer** — the mirrored-playlist export now has **Sync to Spotify** and **Sync to Deezer** next to the ListenBrainz / JSPF options. it builds a playlist in your account from the IDs soulsync already has (the discovery cache first, then your library), so an already-discovered playlist exports **instantly with zero API calls**. re-exporting updates the same playlist instead of duplicating it, and an optional *"match missing tracks"* toggle confidently searches for the stragglers — a wrong-artist or karaoke version is left out, never guessed. the first Spotify export asks permission once. (#945)
🏷️ **Library Reorganize — Rename only** — a lighter action that just **renames your files** to your naming scheme: no re-tag, no quality/AcoustID re-check, no copy-to-staging. much faster on a NAS, and only touches files whose path actually changes. pick it from the new Action dropdown. (#875 — thanks @tsoulard / @Tacobell444)
💿 **Broader lossless handling** — lossy-copy now covers **all lossless formats**, not just FLAC (#941); and **DSD** (`.dsf`/`.dff`) is recognized as lossless instead of false-flagged "truncated" (#939).
🐛 **Download + search fixes** — an unbalanced bracket no longer false-fails as "file not found"; a file we couldn't quarantine is left for retry instead of deleted; "file not found" errors are actionable now; pasted Qobuz/Tidal links inject the exact track into manual search (#932); the Wing It pool "Fix Match" works again.
**Reduce visual effects, refined** — it no longer freezes functional motion (spinners, progress), only the expensive GPU stuff (blur, shadows, glow). worker orbs default OFF on Firefox and run at ~30fps under reduce-effects. plus a jellyfin scan watchdog fix for big libraries.
🔧 **Under the hood** — settings cleanup (#943, @nick2000713), spotify oauth hardening (#942) + npm security fixes (#944, HellRa1SeR).
enjoy! 🎶

View file

@ -1,9 +0,0 @@
**SoulSync 2.8.2** is out 🎉 a stability + performance release.
🎧 **Spotify, reliably** — the Docker boot hang is fixed: with Spotify as your primary source, an unreachable Spotify API could block startup so the container bound `:8008` but never served the UI. auth probes are now deferred during boot + capped with a timeout. the "re-auth didn't stick" bug is fixed too (the OAuth callback and the app were reading different token caches), and **Sync to Spotify** now works — it asks for playlist-write permission once, on-demand, leaving your normal login untouched. (#949 — thanks HellRa1SeR)
**The "slow after update" fix** — the post-update lag wasn't SoulSync, it was browser password managers (Bitwarden/1Password/etc.) rebuilding their autofill overlay on *every* DOM change. non-credential fields are now marked so they skip them — **~110× less main-thread blocking** in the reporter's benchmark. plus a new **Max Performance** mode (Settings → Appearance) that kills every effect for no-GPU / Docker setups. (#948 — thanks @nick2000713)
📥 **Large-library imports no longer time out** — dropping a whole library into staging used to make the import page scan every file synchronously and never load. the scan runs in the background now with a live "Scanning N of M…" progress, and fills in when done. (#947 — thanks @ramonskie)
enjoy! 🎶

View file

@ -1,27 +0,0 @@
"""Boot-phase guard for non-blocking container startup.
While the gunicorn worker is importing ``web_server`` (module-level client and
worker initialization), external provider API probes must not block startup.
Network validation is deferred until ``mark_boot_complete()`` runs at the end
of that import pass.
"""
from __future__ import annotations
import threading
_boot_lock = threading.Lock()
_boot_active = True
def is_boot_phase() -> bool:
"""Return True while module import must avoid blocking provider API calls."""
with _boot_lock:
return _boot_active
def mark_boot_complete() -> None:
"""End the boot phase — provider clients may perform network probes again."""
global _boot_active
with _boot_lock:
_boot_active = False

View file

@ -121,7 +121,6 @@ class DeezerDownloadClient(DownloadSourcePlugin):
self._license_token = None
self._user_data = None
self._authenticated = False
self._pending_arl: Optional[str] = None
# Quality preference
self._quality = quality_tier_for_source('deezer', default='flac')
@ -129,12 +128,7 @@ class DeezerDownloadClient(DownloadSourcePlugin):
# Try to authenticate on init if ARL is configured
arl = config_manager.get('deezer_download.arl', '')
if arl:
from core.boot_phase import is_boot_phase
if is_boot_phase():
self._pending_arl = arl
logger.debug("Deezer ARL present — authentication deferred until after boot")
else:
self._authenticate(arl)
self._authenticate(arl)
logger.info(f"Deezer download client initialized (download path: {self.download_path})")
@ -233,66 +227,12 @@ class DeezerDownloadClient(DownloadSourcePlugin):
return self._authenticated
def is_authenticated(self) -> bool:
if self._pending_arl and not self._authenticated:
from core.boot_phase import is_boot_phase
if not is_boot_phase():
self._authenticate(self._pending_arl)
self._pending_arl = None
return self._authenticated
async def check_connection(self) -> bool:
loop = asyncio.get_event_loop()
return await loop.run_in_executor(None, self.is_available)
# ─── Playlist export (#945) ──────────────────────────────────
#
# UNOFFICIAL: rides the private gw-light gateway with the ARL session already used
# for downloads. Deezer shut their public developer API, so this is the only write
# path — and it's fragile by nature (breaks when Deezer changes internals).
def create_or_update_playlist(self, name, track_ids, *, existing_id=None,
public=False, description=""):
"""Create a Deezer playlist (or append to an existing one) from a mirrored
playlist's tracks. ``track_ids`` are stored ``deezer_id`` values per library track.
``existing_id`` set add to that playlist (idempotent re-export reuses the stored
target); unset create a new one. Returns
``{success, playlist_id, url, added, error}``."""
if not self._authenticated:
return {"success": False, "error": "Deezer is not connected (ARL)"}
song_ids = [str(t) for t in (track_ids or []) if t]
if not song_ids:
return {"success": False, "error": "No matching Deezer tracks to export"}
try:
songs = [[sid, i] for i, sid in enumerate(song_ids)]
if existing_id:
res = self._gw_call("playlist.addSongs",
{"playlist_id": int(existing_id), "songs": songs})
if res is None:
return {"success": False, "error": "Deezer rejected the playlist update"}
playlist_id = existing_id
else:
res = self._gw_call("playlist.create", {
"title": name, "description": description,
"is_public": bool(public), "songs": songs,
})
if res is None:
return {"success": False, "error": "Deezer rejected the playlist create"}
# gw 'playlist.create' returns the new playlist id (int) as `results`.
if isinstance(res, dict):
playlist_id = res.get("PLAYLIST_ID") or res.get("id")
else:
playlist_id = res
if not playlist_id:
return {"success": False, "error": "Deezer did not return a playlist id"}
return {
"success": True,
"playlist_id": str(playlist_id),
"url": f"https://www.deezer.com/playlist/{playlist_id}",
"added": len(song_ids),
}
except Exception as e:
return {"success": False, "error": str(e)}
def reconnect(self, arl: str = None) -> bool:
"""Re-authenticate with a new or existing ARL."""
if arl is None:

View file

@ -77,16 +77,7 @@ def _normalize_for_finding(text: str) -> str:
return ""
text = unidecode(text).lower()
text = re.sub(r'[._/]', ' ', text)
# Strip ONLY balanced bracket pairs (tags like "[FLAC]", "(Remastered 2016)").
# The old combined pattern r'[\[\(].*?[\]\)]' allowed MISMATCHED delimiters, so a
# lone unbalanced '[' — slskd reports "[34 - You & Me (Flume Remix)" but saves the
# file as "34 - You & Me (Flume Remix)" — matched from that '[' all the way to the
# next ')', eating the entire title and collapsing the search target to "flac". The
# file then scored 0.40 against the real on-disk name and was reported "not found"
# despite sitting right there. Per-delimiter pairs can't over-consume; a stray
# unbalanced bracket simply survives to the alphanumeric strip below.
text = re.sub(r'\[[^\]]*\]', '', text)
text = re.sub(r'\([^)]*\)', '', text)
text = re.sub(r'[\[\(].*?[\]\)]', '', text)
text = re.sub(r'[^a-z0-9\s-]', '', text)
return ' '.join(text.split()).strip()

View file

@ -453,19 +453,7 @@ def run_post_processing_worker(task_id: str, batch_id: str, deps: PostProcessDep
logger.error(f"[Post-Processing] Task {task_id} was completed by stream processor - not marking as failed")
return
download_tasks[task_id]['status'] = 'failed'
# slskd reported the transfer complete, but the finder never located
# the file under the configured download folder. Name the folder we
# searched and the two real causes — "still being written" (timing)
# or "SoulSync's download path doesn't match slskd's" (the classic
# standalone config mismatch) — so the user can self-diagnose instead
# of getting an opaque "not found". (Discord: Shdjfgatdif.)
_searched_name = os.path.basename((task_filename or '').replace('\\', '/')) or task_filename
download_tasks[task_id]['error_message'] = (
f"slskd reported '{_searched_name}' downloaded, but it never appeared "
f"under the download folder ({download_dir}) after {_file_search_max_retries} "
f"checks. Either it's still being written, or SoulSync's download path "
f"doesn't match slskd's download directory — they must point at the same folder."
)
download_tasks[task_id]['error_message'] = f'File not found on disk after {_file_search_max_retries} search attempts. Expected: {os.path.basename(task_filename)}'
deps.on_download_completed(batch_id, task_id, False)
return

View file

@ -38,23 +38,6 @@ def bubble_linked_track_first(tracks: List[Any], link_track_id: str) -> List[Any
target = str(link_track_id)
return sorted(tracks, key=lambda t: linked_track_id(t) != target)
def inject_linked_track_first(
tracks: List[Any], linked_result: Any, link_track_id: str
) -> List[Any]:
"""Put the EXACT linked track first.
When ``linked_result`` is the track fetched directly by id, prepend it and
drop any search duplicate of it so an obscure track a text search never
surfaced is still present and downloadable (#932). When it's None (the source
can't fetch one), fall back to bubbling a matching search result. Pure."""
if not link_track_id:
return tracks
target = str(link_track_id)
if linked_result is not None:
return [linked_result] + [t for t in tracks if linked_track_id(t) != target]
return bubble_linked_track_first(tracks, target)
# host substring → download source id. Only ID-downloadable streaming sources.
_HOSTS = (
('tidal.com', 'tidal'),

View file

@ -16,9 +16,8 @@ writes a fresh non-cache hit back to the cache so the next export of the same so
from __future__ import annotations
import json
import threading
from typing import Any, Callable, Dict, List, Optional, Tuple
from typing import Callable, Optional, Tuple
from utils.logging_config import get_logger
@ -73,185 +72,6 @@ def db_recording_mbid(artist: str, title: str) -> Optional[str]:
return _db_match(artist, title)[0]
# Service → the tracks-table column carrying that service's track ID (set by enrichment).
# Trusted constants — never user input — so safe to interpolate into the SELECT below.
_SERVICE_ID_COLUMNS = {"spotify": "spotify_track_id", "deezer": "deezer_id"}
def db_service_track_id(artist: str, title: str, service: str) -> Optional[str]:
"""The service track ID (``spotify_track_id`` / ``deezer_id``) stored on a matched
library track what lets a mirrored playlist be exported BACK to Spotify/Deezer
without re-searching, since enrichment already pinned it (#945). Text-matches by
(artist, title), same as the MBID resolver. Fail-safe: any miss/error returns None."""
column = _SERVICE_ID_COLUMNS.get((service or "").lower())
if not column or not title:
return None
try:
from database.music_database import get_database
db = get_database()
conn = db._get_connection()
try:
cur = conn.cursor()
cur.execute(
f"SELECT t.{column} FROM tracks t JOIN artists a ON t.artist_id = a.id "
"WHERE LOWER(t.title) = LOWER(?) AND LOWER(a.name) = LOWER(?) LIMIT 1",
(title, artist),
)
row = cur.fetchone()
if not row:
return None
val = row[0] if not hasattr(row, "keys") else row[column]
return val or None
finally:
try:
conn.close()
except Exception: # noqa: S110
pass
except Exception as exc:
logger.debug(f"export service-id lookup failed for '{artist} - {title}' ({service}): {exc}")
return None
def build_service_resolve_fn(service: str) -> Callable[[str, str], Tuple[Optional[str], Optional[str]]]:
"""resolve_fn for service-playlist export: ``(artist, title) -> (service_track_id, 'library')``.
Plugs into ``resolve_playlist_tracks(..., id_key='service_track_id')`` exactly like the
MBID resolver plugs in for ListenBrainz."""
def resolve_fn(artist: str, title: str) -> Tuple[Optional[str], Optional[str]]:
tid = db_service_track_id(artist, title, service)
return (tid, "library" if tid else None)
return resolve_fn
def service_id_from_extra_data(track: Any, service: str) -> Optional[str]:
"""The target-service track ID the DISCOVERY step already resolved for this mirrored
track, read from its ``extra_data`` blob (#945 — Boulder: "all 50 are discovered to
Deezer already, it's not using any of that"). This is free (no API call) and reliable
(it's the same id used to mirror the track).
Only trusted when the track was discovered ON the export's target service — a
Deezer-discovered track carries a Deezer id under ``matched_data['id']``, and its
``provider`` is the service name. A ``wing_it_fallback`` provider (the low-confidence
guess path) deliberately does NOT match here, so those fall through to the library/
none path rather than risk a wrong track in the exported playlist."""
raw = track.get("extra_data") if isinstance(track, dict) else None
if not raw:
return None
try:
data = json.loads(raw) if isinstance(raw, str) else raw
except Exception:
return None
if not isinstance(data, dict) or not data.get("discovered"):
return None
if str(data.get("provider") or "").lower() != str(service or "").lower():
return None
matched = data.get("matched_data")
tid = matched.get("id") if isinstance(matched, dict) else None
return str(tid) if tid else None
def _track_field(track: Dict[str, Any], *names: str) -> str:
for n in names:
v = track.get(n)
if v:
return str(v)
return ""
def resolve_service_track_ids(
tracks: List[Dict[str, Any]],
service: str,
*,
db_fn: Optional[Callable[[str, str, str], Optional[str]]] = None,
search_id_fn: Optional[Callable[[str, str], Optional[str]]] = None,
on_progress: Optional[Callable[[int, int, Dict[str, Any]], None]] = None,
) -> Dict[str, Any]:
"""Resolve a mirrored playlist's tracks to target-service track IDs for export.
Waterfall per track: the discovery cache (``extra_data`` free + already confidently
matched) the library track's stored service id → (only when ``search_id_fn`` is
given, i.e. the opt-in backfill toggle) a confident live-search match. A track that
clears none of these is reported unmatched (caller skips it never a guessed/wrong
id). Returns ``{"resolved": [{artist, title, album, service_track_id}], "stats":
{...}}`` with ``from_cache`` / ``from_library`` / ``from_search`` / ``unmatched``
tallies for the status display.
"""
db_fn = db_fn or db_service_track_id
total = len(tracks or [])
resolved: List[Dict[str, Any]] = []
stats: Dict[str, Any] = {
"total": total, "resolved": 0, "unmatched": 0,
"from_cache": 0, "from_library": 0, "from_search": 0,
}
for i, t in enumerate(tracks or []):
if not isinstance(t, dict):
t = {}
artist = _track_field(t, "artist", "artist_name", "creator")
title = _track_field(t, "title", "track_name", "name")
album = _track_field(t, "album", "album_name", "release_name")
tid = service_id_from_extra_data(t, service)
if tid:
stats["from_cache"] += 1
else:
tid = db_fn(artist, title, service)
if tid:
stats["from_library"] += 1
elif search_id_fn is not None:
tid = search_id_fn(artist, title)
if tid:
stats["from_search"] += 1
resolved.append({"artist": artist, "title": title, "album": album,
"service_track_id": tid or None})
stats["resolved" if tid else "unmatched"] += 1
if on_progress is not None:
try:
on_progress(i + 1, total, stats)
except Exception: # noqa: S110 — a progress error must never fail the export
pass
return {"resolved": resolved, "stats": stats}
# Confidence floor for backfill, on the score_track scale (~1.5 = exact title + exact
# artist, thanks to the 1.5x artist boost). A cover/karaoke (x0.05) or a wrong-artist hit
# (no boost, caps ~1.0) can't clear this — so backfill never adds a guessed/wrong version.
BACKFILL_MIN_SCORE = 1.2
def search_service_track_id(
artist: str,
title: str,
*,
search_fn: Callable[[str], List[Any]],
min_score: float = BACKFILL_MIN_SCORE,
) -> Optional[str]:
"""Confident live-search match for export backfill (#945): search the target service
for (artist, title), rerank by relevance, and return the top match's id ONLY if it
clears the confidence floor. Below the floor None: the track is left out of the
export rather than risk a wrong/cover/karaoke version (the whole point of backfill is
coverage WITHOUT the wrong-track risk). ``search_fn(query) -> List[Track]`` is injected
so this is unit-testable without a live service."""
if not title:
return None
from core.metadata.relevance import build_combined_search_query, filter_and_rerank
query = build_combined_search_query(title, artist)
try:
candidates = list(search_fn(query) or [])
except Exception as exc:
logger.debug(f"export backfill search failed for '{artist} - {title}': {exc}")
return None
if not candidates:
return None
ranked = filter_and_rerank(
candidates, expected_title=title, expected_artist=artist, min_score=min_score,
)
if not ranked:
return None
tid = getattr(ranked[0], "id", None)
return str(tid) if tid else None
def file_recording_mbid(artist: str, title: str) -> Optional[str]:
"""Recording MBID read from the matched track's file tag (set on import post-processing)."""
_mbid, fpath = _db_match(artist, title)

View file

@ -36,22 +36,16 @@ def resolve_playlist_tracks(
resolve_fn: ResolveFn,
*,
on_progress: Optional[ProgressFn] = None,
id_key: str = "recording_mbid",
) -> Dict[str, Any]:
"""Resolve every track to an ID and build the export pseudo-playlist.
``resolve_fn(artist, title) -> (id, source)`` returns whatever ID the target needs
a MusicBrainz recording MBID for ListenBrainz/JSPF (the default), or a Spotify/Deezer
track ID for service export. ``id_key`` names the field that ID lands under in each
resolved entry (defaults to ``recording_mbid`` so existing LB/JSPF callers are
untouched). The dedup + stats + ordering logic is identical regardless of ID type.
"""Resolve every track to a recording MBID and build the export pseudo-playlist.
``tracks`` items may use ``artist``/``artist_name`` and ``title``/``track_name`` and
``album``/``album_name`` (both the mirrored-playlist and LB-cache shapes are accepted).
Returns ``{"resolved": [...], "stats": {...}}`` where each resolved entry is
``{artist, title, album, <id_key>}`` (the ID is None when unmatched), in original
order, and stats carries ``total, resolved, unmatched, deduped, by_source``.
``{artist, title, album, recording_mbid}`` (recording_mbid is None when unmatched),
in original order, and stats carries ``total, resolved, unmatched, deduped,
by_source`` for the live display.
"""
total = len(tracks or [])
memo: Dict[str, Tuple[Optional[str], Optional[str]]] = {}
@ -77,7 +71,7 @@ def resolve_playlist_tracks(
memo[key] = (mbid, source)
fresh = True
resolved.append({"artist": artist, "title": title, "album": album, id_key: mbid})
resolved.append({"artist": artist, "title": title, "album": album, "recording_mbid": mbid})
if mbid:
stats["resolved"] += 1

View file

@ -360,22 +360,6 @@ def probe_audio_quality(file_path: str):
sample_rate=getattr(audio.info, 'sample_rate', None),
)
if ext in ('dsf', 'dff'):
# DSD (DSD Stream File / DSDIFF) — 1-bit hi-res lossless (#939). mutagen
# reads .dsf (rate/bitrate/bit_depth); .dff has no mutagen reader, so it
# still classifies as the lossless 'dsf' tier just without measured detail.
sr = bd = br = None
if ext == 'dsf':
try:
from mutagen.dsf import DSF
info = DSF(file_path).info
sr = getattr(info, 'sample_rate', None)
bd = getattr(info, 'bits_per_sample', None)
br = info.bitrate // 1000 if getattr(info, 'bitrate', None) else None
except Exception: # noqa: S110 — unreadable DSF still classifies lossless, just without measured detail
pass
return AudioQuality(format='dsf', bitrate=br, sample_rate=sr, bit_depth=bd)
return None
except Exception as e:
logger.debug("probe_audio_quality failed for %s: %s", file_path, e)
@ -524,29 +508,14 @@ def downsample_hires_flac(final_path, context):
return None
def m4a_codec(path):
"""Codec of an .m4a/.mp4 container ('alac', 'aac', …) or None — lets the
lossless check tell ALAC (lossless) from AAC (lossy), since both are .m4a."""
try:
from mutagen.mp4 import MP4
return (getattr(MP4(path).info, 'codec', '') or '').lower() or None
except Exception:
return None
def create_lossy_copy(final_path):
"""Convert a lossless file (FLAC / ALAC / WAV / AIFF / DSD) to a lossy copy
using the configured codec. Non-lossless inputs are skipped (#941)."""
from core.quality.lossless import (
is_lossless_audio_path,
lossy_output_would_overwrite_source,
)
"""Convert a FLAC file to a lossy copy using the configured codec."""
from mutagen.flac import FLAC
if not config_manager.get("lossy_copy.enabled", False):
return None
# Was FLAC-only; now any lossless source. .m4a is probed (ALAC vs AAC).
if not is_lossless_audio_path(final_path, probe_codec=m4a_codec):
if os.path.splitext(final_path)[1].lower() != ".flac":
return None
codec = config_manager.get("lossy_copy.codec", "mp3").lower()
@ -575,16 +544,6 @@ def create_lossy_copy(final_path):
out_basename = out_basename.replace(original_quality, quality_label)
out_path = os.path.join(os.path.dirname(out_path), out_basename)
# Safety invariant: never write the lossy copy over its own source (an .m4a
# ALAC source + AAC target lands on the same .m4a path). ffmpeg runs with -y,
# so this guard MUST precede it — the later delete-original guard is too late.
if lossy_output_would_overwrite_source(final_path, out_path):
logger.info(
f"[Lossy Copy] Skipping — {codec.upper()} output would overwrite the "
f"source: {os.path.basename(final_path)}"
)
return None
ffmpeg_bin = shutil.which("ffmpeg")
if not ffmpeg_bin:
local = os.path.join(os.path.dirname(__file__), "tools", "ffmpeg")

View file

@ -325,14 +325,11 @@ def post_process_matched_download(context_key, context, file_path, runtime, meta
_mark_task_quarantined(context, quarantine_path)
logger.error(f"File quarantined due to integrity failure: {quarantine_path}")
except Exception as quarantine_error:
# Quarantine MOVE failed (e.g. cross-device / permission on a NAS).
# Do NOT delete — destroying a download we couldn't even quarantine is
# data loss and forces a re-download. Leave it in place so it can be
# retried; the task is still marked failed below either way (#kettui).
logger.error(
f"Quarantine failed ({quarantine_error}) — leaving file in place "
f"for retry (not deleting): {file_path}"
)
logger.error(f"Quarantine failed ({quarantine_error}), deleting broken file: {file_path}")
try:
os.remove(file_path)
except Exception as del_error:
logger.error(f"Could not delete broken file either: {del_error}")
with matched_context_lock:
if context_key in matched_downloads_context:
@ -386,12 +383,11 @@ def post_process_matched_download(context_key, context, file_path, runtime, meta
_mark_task_quarantined(context, quarantine_path)
logger.warning("File quarantined — incomplete/silent audio: %s", quarantine_path)
except Exception as quarantine_error:
# Don't delete a file we couldn't quarantine — leave it for retry
# instead of forcing a re-download (data loss). See integrity block.
logger.error(
f"Quarantine failed ({quarantine_error}) — leaving file in place "
f"for retry (not deleting): {file_path}"
)
logger.error(f"Quarantine failed ({quarantine_error}), deleting file: {file_path}")
try:
os.remove(file_path)
except Exception as del_error:
logger.debug("delete broken file fallback: %s", del_error)
with matched_context_lock:
matched_downloads_context.pop(context_key, None)
@ -441,12 +437,11 @@ def post_process_matched_download(context_key, context, file_path, runtime, meta
_mark_task_quarantined(context, quarantine_path)
logger.info(f"File quarantined due to quality mismatch: {quarantine_path}")
except Exception as quarantine_error:
# Don't delete a file we couldn't quarantine — leave it for retry
# instead of forcing a re-download (data loss). See integrity block.
logger.error(
f"Quarantine failed ({quarantine_error}) — leaving file in place "
f"for retry (not deleting): {file_path}"
)
logger.error(f"Quarantine failed ({quarantine_error}), deleting file: {file_path}")
try:
os.remove(file_path)
except Exception as e:
logger.debug("delete quarantine fallback: %s", e)
context['_bitdepth_rejected'] = True
task_id = context.get('task_id')
@ -540,13 +535,12 @@ def post_process_matched_download(context_key, context, file_path, runtime, meta
_mark_task_quarantined(context, quarantine_path)
logger.error(f"File quarantined due to verification failure: {quarantine_path}")
except Exception as quarantine_error:
# Don't delete a file we couldn't quarantine — leave it for
# retry instead of forcing a re-download (data loss). The
# task is still marked failed / requeued below. See integrity.
logger.error(
f"Quarantine failed ({quarantine_error}) — leaving file "
f"in place for retry (not deleting): {file_path}"
)
logger.error(f"Quarantine failed ({quarantine_error}), deleting wrong file: {file_path}")
logger.error(f"Quarantine failed, deleting wrong file: {file_path}")
try:
os.remove(file_path)
except Exception as del_error:
logger.error(f"Could not delete wrong file either: {del_error}")
context['_acoustid_quarantined'] = True
context['_acoustid_failure_msg'] = verification_msg

View file

@ -3,12 +3,10 @@
from __future__ import annotations
import os
import threading
import time
import uuid
from concurrent.futures import as_completed
from dataclasses import dataclass
from typing import Any, Callable, Dict, Optional
from typing import Any, Callable, Dict
from core.imports.album import build_album_import_context, build_album_import_match_payload, resolve_album_artist_context
from core.imports.context import get_import_context_artist, get_import_track_info, normalize_import_context
@ -73,219 +71,36 @@ class ImportRouteRuntime:
logger: Any = module_logger
# ── Shared staging scan ──────────────────────────────────────────────────────
# Opening the Import page fires staging files/groups/hints together; each used to
# os.walk the whole staging folder AND mutagen-read every file independently — 3×
# the directory walk + 3× the tag I/O on every page open (the import-page scan
# storm + memory spike, issue #935). They all need the same per-file tag data, so
# scan ONCE and let all three derive their views in-memory. A short TTL + a lock
# means the three near-simultaneous page-open requests (and any concurrent caller)
# share a single scan instead of each kicking off a full re-read.
_STAGING_SCAN_LOCK = threading.Lock()
_STAGING_SCAN_TTL = 6.0 # seconds — covers the page-open burst; re-scans after
_staging_scan_cache: Dict[str, Any] = {"path": None, "ts": 0.0, "records": None}
# Bumped by invalidate_staging_scan_cache() so a background scan that finishes after an
# import doesn't re-commit stale (pre-import) records (see the generation guard above).
_staging_scan_generation: Dict[str, int] = {"value": 0}
# Background-scan plumbing: a large staging folder (whole-library migration, #947) makes
# the synchronous scan exceed gunicorn's 120s request timeout. The runner moves the SAME
# scan off the request thread; the endpoints report progress instead of blocking.
_staging_scan_status: Dict[str, Any] = {
"status": "idle", "scanned": 0, "total": 0, "path": None, "error": None,
}
_staging_scan_status_lock = threading.Lock()
def _staging_cache_hit(staging_path: str) -> Optional[list]:
"""The cached records for ``staging_path`` if still fresh, else None (no scan triggered)."""
c = _staging_scan_cache
if (c["records"] is not None and c["path"] == staging_path
and (time.time() - c["ts"]) < _STAGING_SCAN_TTL):
return c["records"]
return None
def ensure_background_staging_scan(runtime: ImportRouteRuntime, staging_path: str) -> None:
"""Start a background scan for ``staging_path`` unless the cache is warm or a scan for
this path is already running. Idempotent safe to call on every request."""
if _staging_cache_hit(staging_path) is not None:
return
with _staging_scan_status_lock:
if (_staging_scan_status["status"] == "scanning"
and _staging_scan_status["path"] == staging_path):
return
_staging_scan_status.update({"status": "scanning", "scanned": 0, "total": 0,
"path": staging_path, "error": None})
def _run() -> None:
try:
_scan_staging_records(runtime, staging_path, progress=_staging_scan_status)
with _staging_scan_status_lock:
if _staging_scan_status["path"] == staging_path:
_staging_scan_status["status"] = "done"
except Exception as exc: # noqa: BLE001 — surface any scan error to the poller
with _staging_scan_status_lock:
_staging_scan_status.update({"status": "error", "error": str(exc)})
threading.Thread(target=_run, name="staging-scan", daemon=True).start()
def get_staging_records_or_status(runtime: ImportRouteRuntime, staging_path: str,
*, grace_seconds: float = 3.0) -> tuple[str, Any]:
"""Non-blocking staging access for the page endpoints. Returns ``("ready", records)``
when the cache is warm or the scan completes within ``grace_seconds`` (so small/normal
folders still answer in a single request), otherwise ``("scanning", status_dict)`` after
making sure a background scan is running."""
records = _staging_cache_hit(staging_path)
if records is not None:
return ("ready", records)
ensure_background_staging_scan(runtime, staging_path)
deadline = time.time() + max(0.0, grace_seconds)
while True:
records = _staging_cache_hit(staging_path)
if records is not None:
return ("ready", records)
with _staging_scan_status_lock:
status = dict(_staging_scan_status)
if status.get("status") == "error":
return ("error", status)
if time.time() >= deadline:
return ("scanning", status)
time.sleep(0.05)
def _records_or_scanning_payload(runtime: ImportRouteRuntime, staging_path: str):
"""Shared helper for the page endpoints: returns ``(records, None)`` when the scan is
ready, or ``(None, payload)`` when a background scan is still running the caller
returns that payload so the page polls + shows progress instead of blocking/timing out.
A scan error is re-raised so the endpoint's own try/except logs + returns it exactly as
when the scan ran inline (preserves the existing error contract)."""
state, val = get_staging_records_or_status(runtime, staging_path)
if state == "error":
raise RuntimeError(val.get("error") or "staging scan failed")
if state == "scanning":
return None, {"success": True, "scanning": True,
"progress": {"scanned": val.get("scanned", 0),
"total": val.get("total", 0)}}
return val, None
def staging_scan_status(runtime: ImportRouteRuntime) -> tuple[Dict[str, Any], int]:
"""Lightweight, instant scan-progress poll for the page (no grace-wait, no file I/O) —
``ready`` true once the cache is warm and the files/groups/hints calls will answer fast."""
try:
staging_path = runtime.get_staging_path()
except Exception as exc:
return {"success": False, "error": str(exc)}, 500
with _staging_scan_status_lock:
st = dict(_staging_scan_status)
return {
"success": True,
"ready": _staging_cache_hit(staging_path) is not None,
"status": st.get("status", "idle"),
"scanned": st.get("scanned", 0),
"total": st.get("total", 0),
"error": st.get("error"),
}, 200
def _scan_staging_records(runtime: ImportRouteRuntime, staging_path: str,
*, progress: Optional[Dict[str, Any]] = None) -> list[Dict[str, Any]]:
"""Walk staging + read each audio file's tags ONCE, returning per-file records
that staging files/groups/hints all derive from. Briefly cached + locked so the
page-open trio shares a single scan rather than each re-walking and re-reading.
``progress`` (optional, default None = unchanged behaviour) is a dict the scan
updates live with ``total`` (audio-file count, from a fast first pass) and ``scanned``
(tag-reads done so far) so a background runner can report progress. A generation guard
keeps a scan that finishes AFTER an import (which bumped ``_staging_scan_generation``)
from committing stale records to the cache."""
now = time.time()
cached = _staging_scan_cache
if (cached["records"] is not None and cached["path"] == staging_path
and (now - cached["ts"]) < _STAGING_SCAN_TTL):
return cached["records"]
with _STAGING_SCAN_LOCK:
# Double-check: another request may have filled the cache while we waited.
now = time.time()
if (cached["records"] is not None and cached["path"] == staging_path
and (now - cached["ts"]) < _STAGING_SCAN_TTL):
return cached["records"]
start_generation = _staging_scan_generation["value"]
# Pass 1 (fast): collect the audio-file list — no tag I/O — so we know the total.
audio_files: list[tuple[str, str, Optional[str]]] = []
if os.path.isdir(staging_path):
for root, _dirs, filenames in os.walk(staging_path):
rel_dir = os.path.relpath(root, staging_path)
top_folder = rel_dir.split(os.sep)[0] if rel_dir != "." else None
for fname in filenames:
if os.path.splitext(fname)[1].lower() in AUDIO_EXTENSIONS:
audio_files.append((root, fname, top_folder))
if progress is not None:
progress["total"] = len(audio_files)
progress["scanned"] = 0
# Pass 2 (slow): read each file's tags, updating progress as we go.
records: list[Dict[str, Any]] = []
for root, fname, top_folder in audio_files:
full_path = os.path.join(root, fname)
rel_path = os.path.relpath(full_path, staging_path)
meta = runtime.read_staging_file_metadata(full_path, rel_path)
records.append({
"filename": fname, "rel_path": rel_path, "full_path": full_path,
"extension": os.path.splitext(fname)[1].lower(),
"title": meta["title"], "album": meta["album"],
"artist": meta["artist"], "albumartist": meta["albumartist"],
"track_number": meta["track_number"], "disc_number": meta["disc_number"],
"top_folder": top_folder,
})
if progress is not None:
progress["scanned"] += 1
# Generation guard: if an import invalidated the cache mid-scan, these records are
# stale — return them to this caller but do NOT commit them as the shared cache.
if _staging_scan_generation["value"] == start_generation:
_staging_scan_cache.update({"path": staging_path, "ts": time.time(), "records": records})
return records
def invalidate_staging_scan_cache() -> None:
"""Drop the cached staging scan (call after an import moves/removes files so the
next files/groups/hints request reflects the new state immediately). Also bumps the
scan generation so an in-flight background scan won't re-commit pre-import records."""
_staging_scan_generation["value"] += 1
_staging_scan_cache.update({"path": None, "ts": 0.0, "records": None})
def staging_files(runtime: ImportRouteRuntime) -> tuple[Dict[str, Any], int]:
"""Scan the staging folder and return audio files with tag metadata."""
try:
staging_path = runtime.get_staging_path()
os.makedirs(staging_path, exist_ok=True)
records, scanning = _records_or_scanning_payload(runtime, staging_path)
if scanning is not None:
return scanning, 200
files = []
for root, _dirs, filenames in os.walk(staging_path):
for fname in filenames:
ext = os.path.splitext(fname)[1].lower()
if ext not in AUDIO_EXTENSIONS:
continue
full_path = os.path.join(root, fname)
rel_path = os.path.relpath(full_path, staging_path)
files = [
{
"filename": r["filename"],
"rel_path": r["rel_path"],
"full_path": r["full_path"],
"title": r["title"],
"artist": r["albumartist"] or r["artist"] or "Unknown Artist",
"album": r["album"],
"track_number": r["track_number"],
"disc_number": r["disc_number"],
"extension": r["extension"],
}
for r in records
]
meta = runtime.read_staging_file_metadata(full_path, rel_path)
files.append(
{
"filename": fname,
"rel_path": rel_path,
"full_path": full_path,
"title": meta["title"],
"artist": meta["albumartist"] or meta["artist"] or "Unknown Artist",
"album": meta["album"],
"track_number": meta["track_number"],
"disc_number": meta["disc_number"],
"extension": ext,
}
)
files.sort(key=lambda f: f["filename"].lower())
return {"success": True, "files": files, "staging_path": staging_path}, 200
@ -301,28 +116,32 @@ def staging_groups(runtime: ImportRouteRuntime) -> tuple[Dict[str, Any], int]:
if not os.path.isdir(staging_path):
return {"success": True, "groups": []}, 200
records, scanning = _records_or_scanning_payload(runtime, staging_path)
if scanning is not None:
return scanning, 200
album_groups = {}
for r in records:
album = r["album"]
artist = r["albumartist"] or r["artist"]
if not album or not artist:
continue
for root, _dirs, filenames in os.walk(staging_path):
for fname in filenames:
ext = os.path.splitext(fname)[1].lower()
if ext not in AUDIO_EXTENSIONS:
continue
full_path = os.path.join(root, fname)
rel_path = os.path.relpath(full_path, staging_path)
key = (album.lower().strip(), artist.lower().strip())
if key not in album_groups:
album_groups[key] = {"album": album.strip(), "artist": artist.strip(), "files": []}
album_groups[key]["files"].append(
{
"filename": r["filename"],
"full_path": r["full_path"],
"title": r["title"],
"track_number": r["track_number"],
}
)
meta = runtime.read_staging_file_metadata(full_path, rel_path)
album = meta["album"]
artist = meta["albumartist"] or meta["artist"]
if not album or not artist:
continue
key = (album.lower().strip(), artist.lower().strip())
if key not in album_groups:
album_groups[key] = {"album": album.strip(), "artist": artist.strip(), "files": []}
album_groups[key]["files"].append(
{
"filename": fname,
"full_path": full_path,
"title": meta["title"],
"track_number": meta["track_number"],
}
)
groups = []
for group in album_groups.values():
@ -352,21 +171,30 @@ def staging_hints(runtime: ImportRouteRuntime) -> tuple[Dict[str, Any], int]:
if not os.path.isdir(staging_path):
return {"success": True, "hints": []}, 200
records, scanning = _records_or_scanning_payload(runtime, staging_path)
if scanning is not None:
return scanning, 200
tag_albums = {}
folder_hints = {}
for r in records:
if r["top_folder"]:
folder_hints[r["top_folder"]] = folder_hints.get(r["top_folder"], 0) + 1
for root, _dirs, filenames in os.walk(staging_path):
audio_files = [f for f in filenames if os.path.splitext(f)[1].lower() in AUDIO_EXTENSIONS]
if not audio_files:
continue
album = r["album"]
artist = r["artist"] or r["albumartist"]
if album:
key = (album.strip(), (artist or "").strip())
tag_albums[key] = tag_albums.get(key, 0) + 1
rel_dir = os.path.relpath(root, staging_path)
if rel_dir != ".":
top_folder = rel_dir.split(os.sep)[0]
folder_hints[top_folder] = folder_hints.get(top_folder, 0) + len(audio_files)
for fname in audio_files:
full_path = os.path.join(root, fname)
try:
tags = runtime.read_tags(full_path)
if tags:
album = (tags.get("album") or [None])[0]
artist = (tags.get("artist") or (tags.get("albumartist") or [None]))[0]
if album:
key = (album.strip(), (artist or "").strip())
tag_albums[key] = tag_albums.get(key, 0) + 1
except Exception as exc:
runtime.logger.debug("tag read failed: %s", exc)
queries = []
seen_queries_lower = set()
@ -543,11 +371,6 @@ def album_process(runtime: ImportRouteRuntime, data: Dict[str, Any]) -> tuple[Di
)
runtime.refresh_import_suggestions_cache()
# Files just left staging — drop the shared scan so the next files/groups/hints
# reflects reality immediately instead of waiting out the cache TTL.
if processed > 0:
invalidate_staging_scan_cache()
return {"success": True, "processed": processed, "total": len(matches), "errors": errors}, 200
except Exception as exc:
runtime.logger.error("Error processing album import: %s", exc)
@ -683,10 +506,6 @@ def singles_process(runtime: ImportRouteRuntime, files: list[Dict[str, Any]]) ->
)
runtime.refresh_import_suggestions_cache()
# Files just left staging — drop the shared scan so the list updates immediately.
if processed > 0:
invalidate_staging_scan_cache()
return {"success": True, "processed": processed, "total": len(files), "errors": errors}, 200
except Exception as exc:
runtime.logger.error("Error processing singles import: %s", exc)

View file

@ -18,7 +18,6 @@ run, so a tooling problem never blocks a legitimate import.
from __future__ import annotations
import os
import re
import subprocess
from typing import Optional
@ -158,14 +157,6 @@ def measured_duration_from_astats(astats_stderr: str, sample_rate: int) -> Optio
return int(m.group(1)) / float(sample_rate)
def is_dsd_path(file_path: str) -> bool:
"""True for DSD audio (.dsf / .dff). The decoded-samples truncation check is
invalid for DSD: ffmpeg decodes DSD to PCM at a different rate than the DSD
container's 2.8 MHz, so samples ÷ container-sample-rate massively under-counts
and would falsely report the file as truncated (#939)."""
return os.path.splitext(str(file_path or ''))[1].lower() in ('.dsf', '.dff')
def incomplete_audio_reason(
measured_s: Optional[float],
container_s: Optional[float],
@ -276,14 +267,11 @@ def detect_broken_audio(
stderr = proc.stderr.decode("utf-8", errors="replace") if proc.stderr else ""
# Truncation check first (real audio far shorter than the container) — but
# NOT for DSD: the astats sample-count ÷ DSD-rate math is invalid there and
# would always false-positive (#939). Silence detection below still applies.
if not is_dsd_path(file_path):
measured_s = measured_duration_from_astats(stderr, sample_rate)
reason = incomplete_audio_reason(measured_s, container_s, min_ratio=min_ratio)
if reason:
return reason
# Truncation check first (real audio far shorter than the container).
measured_s = measured_duration_from_astats(stderr, sample_rate)
reason = incomplete_audio_reason(measured_s, container_s, min_ratio=min_ratio)
if reason:
return reason
# Then silence-padding (mostly-silent file).
return is_mostly_silent_reason(stderr, container_s, threshold=threshold)

View file

@ -5,7 +5,6 @@ from datetime import datetime
import json
from utils.logging_config import get_logger
from config.settings import config_manager
from core.library.bulk_paginate import paginate_all_items
# Shared dataclasses live in the neutral media_server package — every
# server client used to define a near-identical XTrackInfo /
@ -513,8 +512,12 @@ class JellyfinClient(MediaServerClient):
try:
# SIMPLIFIED APPROACH: Fetch all tracks, then all albums separately (robust and fast)
logger.info("Fetching all tracks in bulk...")
def _fetch_tracks_page(start_index, limit):
all_tracks = []
start_index = 0
limit = 10000
consecutive_failures = 0
while True:
params = {
'ParentId': self.music_library_id,
'IncludeItemTypes': 'Audio',
@ -525,19 +528,41 @@ class JellyfinClient(MediaServerClient):
'StartIndex': start_index,
'Limit': limit
}
response = self._make_request(f'/Users/{self.user_id}/Items', params)
return response.get('Items', []) if response else None # None = failed page
# Page in modest chunks so progress is reported every page — a single
# huge silent request used to trip the 300s no-progress watchdog on
# slow servers even though it was alive (see bulk_paginate docstring).
all_tracks = paginate_all_items(
_fetch_tracks_page,
report_progress=self._progress_callback,
label="tracks",
on_retry_wait=lambda: time.sleep(5),
)
if not response:
consecutive_failures += 1
# Wait before retrying — the server may still be processing the timed-out request
time.sleep(5)
if limit > 1000:
limit = limit // 2
consecutive_failures = 0 # Reset — give the smaller batch a fair chance
logger.warning(f"Track fetch failed - reducing batch size to {limit}")
continue
elif consecutive_failures >= 2:
logger.warning("Multiple track fetch failures at minimum batch size - stopping")
break
else:
logger.warning("Track fetch failed at minimum batch size - retrying once")
continue
consecutive_failures = 0
batch_tracks = response.get('Items', [])
if not batch_tracks:
break
all_tracks.extend(batch_tracks)
if len(batch_tracks) < limit:
break
start_index += limit
progress_msg = f"Fetched {len(all_tracks)} tracks so far..."
logger.info(f" {progress_msg} (batch size: {limit})")
if self._progress_callback:
self._progress_callback(progress_msg)
# Group tracks by album ID for instant lookup
self._track_cache = {}
for track_data in all_tracks:
@ -553,8 +578,12 @@ class JellyfinClient(MediaServerClient):
# STEP 2: Fetch all albums in bulk (same proven pattern)
logger.info("Fetching all albums in bulk...")
def _fetch_albums_page(start_index, limit):
all_albums = []
start_index = 0
limit = 10000
consecutive_failures = 0
while True:
params = {
'ParentId': self.music_library_id,
'IncludeItemTypes': 'MusicAlbum',
@ -565,16 +594,41 @@ class JellyfinClient(MediaServerClient):
'StartIndex': start_index,
'Limit': limit
}
response = self._make_request(f'/Users/{self.user_id}/Items', params)
return response.get('Items', []) if response else None # None = failed page
all_albums = paginate_all_items(
_fetch_albums_page,
report_progress=self._progress_callback,
label="albums",
on_retry_wait=lambda: time.sleep(5),
)
if not response:
consecutive_failures += 1
# Wait before retrying — the server may still be processing the timed-out request
time.sleep(5)
if limit > 1000:
limit = limit // 2
consecutive_failures = 0 # Reset — give the smaller batch a fair chance
logger.warning(f"Album fetch failed - reducing batch size to {limit}")
continue
elif consecutive_failures >= 2:
logger.warning("Multiple album fetch failures at minimum batch size - stopping")
break
else:
logger.warning("Album fetch failed at minimum batch size - retrying once")
continue
consecutive_failures = 0
batch_albums = response.get('Items', [])
if not batch_albums:
break
all_albums.extend(batch_albums)
if len(batch_albums) < limit:
break
start_index += limit
progress_msg = f"Fetched {len(all_albums)} albums so far..."
logger.info(f" {progress_msg} (batch size: {limit})")
if self._progress_callback:
self._progress_callback(progress_msg)
# Group albums by artist ID for instant lookup
self._album_cache = {}
for album_data in all_albums:

View file

@ -1,93 +0,0 @@
"""Paginate a bulk media-server fetch while feeding the no-progress watchdog.
The library scan fetches every track/album by paging a server API. The DB-update
watchdog (``core/database_update_health.py``) kills a job that reports no progress
for 300s. The old Jellyfin fetch used a single 10 000-item page, so a whole
library came back in ONE request that emitted NO progress while it was in flight
on a slow server that single request exceeded 300s and the watchdog declared the
job "stuck" even though it was alive, not hung (Discord: DXP4800 NAS, 7148 tracks,
"Fetching all tracks in bulk…").
``paginate_all_items`` pages at a size chosen so progress is emitted on a cadence
set by the PAGE SIZE, not the library size the watchdog is fed every page, so it
can never starve mid-fetch regardless of how big the library is. It is pure: all
I/O lives in the injected ``fetch_page``, so the pagination + progress + failure-
shrink logic is unit-testable without a server.
This does NOT change WHAT is fetched (same query, same fields, same items) only
how it's paged and that every page reports progress (the old loop skipped progress
on the final/only page, which is the entire bug for a sub-page-size library).
"""
from __future__ import annotations
from typing import Any, Callable, List, Optional
# Page size for bulk library fetches. Small enough that a single request stays
# well under the 300s no-progress watchdog even on a slow NAS, and that progress
# is reported every page. NOT a performance knob — a resilience/observability one.
DEFAULT_PAGE_SIZE = 1000
# Floor the failure-shrink can reach before giving up — a server that can't return
# even this many items in one request is genuinely struggling.
DEFAULT_MIN_PAGE_SIZE = 250
def paginate_all_items(
fetch_page: Callable[[int, int], Optional[List[Any]]],
*,
report_progress: Optional[Callable[[str], None]] = None,
label: str = "items",
page_size: int = DEFAULT_PAGE_SIZE,
min_page_size: int = DEFAULT_MIN_PAGE_SIZE,
on_retry_wait: Optional[Callable[[], None]] = None,
) -> List[Any]:
"""Page through ``fetch_page(start_index, limit)`` until the server is drained.
``fetch_page`` returns the page's items (a list, possibly empty = end), or
``None`` to signal a FAILED request (timeout/error) on failure the page size
is halved down to ``min_page_size`` and retried, then abandoned after two
consecutive failures at the floor.
Progress is reported after EVERY non-empty page (including the final/only one),
so a no-progress watchdog is fed on a cadence set by ``page_size`` never by
the total library size. Returns every item gathered.
"""
items: List[Any] = []
start_index = 0
limit = page_size
consecutive_failures = 0
while True:
batch = fetch_page(start_index, limit)
if batch is None: # failed request
consecutive_failures += 1
if on_retry_wait is not None:
on_retry_wait()
if limit > min_page_size:
limit = max(min_page_size, limit // 2)
consecutive_failures = 0 # give the smaller batch a fair chance
continue
if consecutive_failures >= 2:
break # struggling at the floor — stop with what we have
continue
consecutive_failures = 0
if not batch:
break # drained
items.extend(batch)
# Feed the watchdog on EVERY page — this is the line the old loop only ran
# when there was a *next* page, so a sub-page-size library reported nothing.
if report_progress is not None:
report_progress(f"Fetched {len(items)} {label} so far...")
if len(batch) < limit:
break # last (partial) page
start_index += limit
return items
__all__ = ["paginate_all_items", "DEFAULT_PAGE_SIZE", "DEFAULT_MIN_PAGE_SIZE"]

View file

@ -26,7 +26,6 @@ without a source ID are reported back to the caller and skipped
entirely.
"""
import errno
import os
import re
import shutil
@ -35,7 +34,7 @@ import time
import uuid
from concurrent.futures import FIRST_COMPLETED, ThreadPoolExecutor, wait
from dataclasses import dataclass, field
from typing import Any, Callable, Dict, List, Optional, Set, Tuple
from typing import Any, Callable, Dict, List, Optional, Set
# Per-album track concurrency. Matches the download workers' per-batch
# concurrency (3) so reorganize feels comparable to a fresh download.
@ -1102,12 +1101,6 @@ def preview_album_reorganize(
'track_number': track.get('track_number', 0),
'current_path': _trim_to_transfer(db_path, resolved, transfer_dir),
'new_path': '',
# Absolute on-disk paths (additive). `current_path`/`new_path` above are
# display-trimmed; these carry the real paths so the rename-only executor
# acts on EXACTLY what the preview computed — no separate path logic that
# could drift from what the user saw (#875).
'current_path_abs': resolved or '',
'new_path_abs': '',
'file_exists': resolved is not None,
'unchanged': False,
'collision': False,
@ -1155,7 +1148,6 @@ def preview_album_reorganize(
new_full, _ok = build_final_path_fn(
context, spotify_artist, album_info, file_ext, create_dirs=False
)
item['new_path_abs'] = new_full or ''
item['new_path'] = (
os.path.relpath(new_full, transfer_dir)
if transfer_dir and new_full and new_full.startswith(transfer_dir)
@ -1815,150 +1807,6 @@ def reorganize_album(
return summary
def _rename_track_in_place(current_abs: str, new_abs: str) -> Tuple[bool, Optional[str]]:
"""Move ONE file from ``current_abs`` to ``new_abs`` in place — no copy, no re-tag,
no post-processing. Creates the destination folder, carries sibling-format files
(e.g. a lossy ``.opus`` alongside the ``.flac``) along with the renamed stem, and
falls back to a cross-device move when the rename crosses a filesystem boundary.
Refuses to overwrite a DIFFERENT existing file at the destination (returns an error
instead) never silent data loss. Returns ``(ok, error_message)``.
"""
try:
if current_abs and not os.path.exists(current_abs):
return False, 'source file no longer on disk'
same = os.path.normpath(current_abs) == os.path.normpath(new_abs)
if os.path.exists(new_abs) and not same:
return False, 'destination already exists'
os.makedirs(os.path.dirname(new_abs), exist_ok=True)
# Carry sibling-format audio to the same destination with the renamed stem —
# mirrors _finalize_track so lossy-copy pairs don't get orphaned.
for sibling_src in _find_sibling_audio_files(current_abs):
_move_sibling_to_destination(sibling_src, new_abs)
try:
os.rename(current_abs, new_abs)
except OSError as e:
if getattr(e, 'errno', None) == errno.EXDEV:
shutil.move(current_abs, new_abs) # crosses a filesystem boundary
else:
raise
return True, None
except Exception as e:
return False, str(e)
def reorganize_album_rename_only(
*,
album_id: str,
db,
transfer_dir: str,
resolve_file_path_fn: Callable[[Optional[str]], Optional[str]],
build_final_path_fn: Callable,
update_track_path_fn: Optional[Callable[[object, str], None]] = None,
cleanup_empty_dir_fn: Optional[Callable[[str], None]] = None,
on_progress: Optional[Callable[[dict], None]] = None,
primary_source: Optional[str] = None,
strict_source: bool = False,
metadata_source: str = 'api',
stop_check: Optional[Callable[[], bool]] = None,
preview_fn: Optional[Callable] = None,
) -> dict:
"""RENAME-ONLY reorganize (#875): move each track's file to the path the current
naming scheme dictates, and nothing else no copy-to-staging, no re-tag, no
quality/AcoustID checks.
It acts on EXACTLY what :func:`preview_album_reorganize` computed (injected via
``preview_fn`` for testability), so the apply can never disagree with what the user
saw, and ONLY files whose path actually changes are touched files marked
``unchanged`` are skipped, which is what keeps a rename from rewriting the whole
album (the #875 complaint). Tags and audio are left byte-for-byte alone.
Returns the same summary shape as :func:`reorganize_album`.
"""
preview_fn = preview_fn or preview_album_reorganize
summary = {
'status': 'completed', 'source': None, 'total': 0,
'moved': 0, 'skipped': 0, 'failed': 0, 'errors': [],
}
def _emit(**updates):
if on_progress is None:
return
try:
on_progress(updates)
except Exception as e:
logger.debug("[Reorganize/rename] progress emit failed: %s", e)
preview = preview_fn(
album_id=album_id, db=db, transfer_dir=transfer_dir,
resolve_file_path_fn=resolve_file_path_fn,
build_final_path_fn=build_final_path_fn,
primary_source=primary_source, strict_source=strict_source,
metadata_source=metadata_source,
)
summary['source'] = preview.get('source')
if not preview.get('success'):
summary['status'] = preview.get('status', 'error')
return summary
tracks = preview.get('tracks', [])
summary['total'] = len(tracks)
src_dirs_touched: Set[str] = set()
for t in tracks:
if stop_check and stop_check():
break
title = t.get('title', 'Unknown')
_emit(current_track=title)
# Skip anything that isn't a real, changing move. `unchanged` is the key one —
# it's why a rename no longer rewrites files whose name didn't change.
if (not t.get('matched') or t.get('unchanged')
or t.get('collision') or not t.get('new_path_abs')):
summary['skipped'] += 1
_emit(skipped=summary['skipped'])
continue
current_abs = t.get('current_path_abs')
new_abs = t.get('new_path_abs')
ok, err = _rename_track_in_place(current_abs, new_abs)
if not ok:
summary['failed'] += 1
summary['errors'].append({
'track_id': t.get('track_id'), 'title': title,
'error': err or 'rename failed',
})
_emit(failed=summary['failed'], errors=list(summary['errors']))
continue
# File is at its new home — update the DB directly (authoritative; no need to
# round-trip through a server scan to learn what we just did). On DB failure the
# file still moved; a library scan reconciles it, so we don't fail the track.
if update_track_path_fn:
try:
update_track_path_fn(t.get('track_id'), new_abs)
except Exception as db_err:
logger.warning(
"[Reorganize/rename] DB path update failed for %s: %s "
"(file moved to %s; a scan will reconcile)",
t.get('track_id'), db_err, new_abs,
)
if current_abs:
src_dirs_touched.add(os.path.dirname(current_abs))
summary['moved'] += 1
_emit(moved=summary['moved'],
processed=summary['moved'] + summary['skipped'] + summary['failed'])
if cleanup_empty_dir_fn:
for src_dir in src_dirs_touched:
try:
cleanup_empty_dir_fn(src_dir)
except Exception as e:
logger.debug("[Reorganize/rename] cleanup of %s failed: %s", src_dir, e)
return summary
def _find_artist_dir(dest_path: str, transfer_dir: str) -> Optional[str]:
"""Walk up from ``dest_path`` until the parent equals ``transfer_dir``;
the directory at that point is the artist folder. Returns None if

View file

@ -291,13 +291,6 @@ class ListeningStatsWorker:
if not (top_artists or top_albums or top_tracks):
return
# Normalize image URLs HERE, at cache-build time, not on every /api/stats/cached
# read. normalize_image_url registers each URL in the image cache (a SQLite write
# under a lock) — doing that per-request made the "instant" stats endpoint take ~20s
# on HDD-backed installs (#935). Done once per background rebuild it's off the hot path,
# and the read just returns the already-browser-safe URLs.
from core.metadata import normalize_image_url as _fix_image
conn = None
try:
conn = self.db._get_connection()
@ -331,7 +324,7 @@ class ListeningStatsWorker:
key = (artist.get('name') or '').lower()
r = artist_rows.get(key)
if r:
artist['image_url'] = _fix_image(r[1]) or None
artist['image_url'] = r[1] or None
artist['id'] = r[2]
artist['global_listeners'] = r[3]
artist['global_playcount'] = r[4]
@ -363,7 +356,7 @@ class ListeningStatsWorker:
key = (album.get('name') or '').lower()
r = album_rows.get(key)
if r:
album['image_url'] = _fix_image(r[1]) or None
album['image_url'] = r[1] or None
album['id'] = r[2]
album['artist_id'] = r[3]
@ -402,7 +395,7 @@ class ListeningStatsWorker:
(track.get('artist') or '').lower())
r = track_rows.get(key)
if r:
track['image_url'] = _fix_image(r[2]) or None
track['image_url'] = r[2] or None
track['id'] = r[3]
track['artist_id'] = r[4]
except Exception as e:

View file

@ -230,20 +230,15 @@ def get_spotify_client_for_profile(profile_id: Optional[int] = None):
return client
try:
from core.spotify_client import SpotifyClient, normalize_spotify_oauth_config, SPOTIFY_OAUTH_SCOPE
from core.spotify_client import SpotifyClient
from spotipy.oauth2 import SpotifyOAuth
import spotipy
normalized_creds = normalize_spotify_oauth_config({
"client_id": client_id,
"client_secret": client_secret,
"redirect_uri": redirect_uri,
})
auth_manager = SpotifyOAuth(
client_id=normalized_creds.get("client_id", client_id),
client_secret=normalized_creds.get("client_secret", client_secret),
redirect_uri=normalized_creds.get("redirect_uri", redirect_uri),
scope=SPOTIFY_OAUTH_SCOPE,
client_id=client_id,
client_secret=client_secret,
redirect_uri=redirect_uri,
scope="user-library-read user-read-private playlist-read-private playlist-read-collaborative user-read-email user-follow-read",
cache_path=cache_path,
state=f"profile_{profile_id}",
)
@ -357,26 +352,10 @@ def get_hydrabase_client(allow_fallback: bool = True, require_enabled: bool = Tr
return None
def get_configured_primary_source() -> str:
"""Return metadata.fallback_source as stored in config, without runtime downgrade.
Unlike get_primary_source(), this never probes Spotify auth. Use at boot and
anywhere blocking network I/O must be avoided (gunicorn worker import, Docker
cold start when Spotify is unreachable).
"""
_default = METADATA_SOURCE_PRIORITY[0]
return _get_config_value("metadata.fallback_source", _default) or _default
def get_primary_source(spotify_client_factory: Optional[MetadataClientFactory] = None) -> str:
"""Return configured primary metadata source."""
from core.boot_phase import is_boot_phase
if is_boot_phase():
return get_configured_primary_source()
_default = METADATA_SOURCE_PRIORITY[0]
source = get_configured_primary_source()
source = _get_config_value("metadata.fallback_source", _default) or _default
if source == "spotify":
try:
@ -463,19 +442,7 @@ def get_primary_source_status(
musicbrainz_client_factory: Optional[MetadataClientFactory] = None,
) -> Dict[str, Any]:
"""Return a generic status snapshot for the active primary metadata source."""
from core.boot_phase import is_boot_phase
source = _get_config_value("metadata.fallback_source", "deezer") or "deezer"
if is_boot_phase():
display_source = source
if source == "spotify" and _get_config_value("metadata.spotify_free", False):
display_source = "spotify_free"
return {
"source": display_source,
"connected": False,
"response_time": 0,
}
started = time.time()
connected = False
@ -543,13 +510,9 @@ def get_client_for_source(
musicbrainz_client_factory: Optional[MetadataClientFactory] = None,
):
"""Return exact client for a source, or None if unavailable."""
from core.boot_phase import is_boot_phase
if source == "spotify":
try:
client = get_spotify_client(client_factory=spotify_client_factory)
if is_boot_phase():
return client if client and getattr(client, "sp", None) else None
if client and client.is_spotify_authenticated():
return client
except Exception as e:

View file

@ -52,27 +52,6 @@ from typing import List, Optional, Sequence
from core.metadata.types import Track
def build_combined_search_query(track: str = '', artist: str = '',
legacy: str = '') -> str:
"""Combine a track + artist into a PLAIN, source-agnostic search query.
Deliberately NOT field-scoped (``track:"X" artist:"Y"``). That Spotify/Lucene
filter syntax leaks to non-Spotify sources when a search falls back: Deezer
closed the connection on it outright (``RemoteDisconnected``), and even sources
that parse it over-constrain and miss the canonical cut (the iTunes/Deezer search
endpoints already dropped it for that reason). The matching ``/search_tracks``
endpoints rerank by expected title/artist afterward, so precision is recovered
without the brittle field syntax.
Falls back to ``legacy`` when neither track nor artist is given. Returns ``''``
when there's nothing to search (caller decides how to handle empty).
"""
parts = [p.strip() for p in (track, artist) if p and p.strip()]
if parts:
return ' '.join(parts)
return (legacy or '').strip()
# ---------------------------------------------------------------------------
# Pattern tables — public so tests can introspect, callers can extend
# ---------------------------------------------------------------------------

View file

@ -49,7 +49,6 @@ from core.metadata.registry import (
get_discogs_client,
get_hydrabase_client,
get_itunes_client,
get_configured_primary_source,
get_primary_client,
get_primary_source,
get_primary_source_label,
@ -118,7 +117,6 @@ __all__ = [
"get_metadata_service",
"get_musicmap_similar_artists",
"get_primary_client",
"get_configured_primary_source",
"get_primary_source",
"get_primary_source_label",
"get_spotify_client_for_profile",

View file

@ -164,8 +164,6 @@ class QobuzClient(DownloadSourcePlugin):
def _restore_session(self):
"""Try to restore saved session from config."""
from core.boot_phase import is_boot_phase
saved = config_manager.get('qobuz.session', {})
app_id = saved.get('app_id', '')
app_secret = saved.get('app_secret', '')
@ -180,10 +178,6 @@ class QobuzClient(DownloadSourcePlugin):
'X-User-Auth-Token': self.user_auth_token,
})
if is_boot_phase():
logger.info("Loaded Qobuz session from config (verification deferred until after boot)")
return
# Verify the token is still valid
try:
resp = self.session.get(
@ -721,26 +715,6 @@ class QobuzClient(DownloadSourcePlugin):
logger.error(f"Error getting Qobuz track {track_id}: {e}")
return None
def get_track_result(self, track_id):
"""Fetch ONE track by ID and convert it to a downloadable TrackResult.
Used when a pasted Qobuz link must inject the EXACT track: a text search
for an obscure track's name often doesn't surface it at all, so bubbling
the matching id is a no-op (#932). Returns None on any failure so the
caller falls back to the text-search path."""
try:
track = self.get_track(track_id)
if not track:
return None
quality_key = quality_tier_for_source('qobuz', default='lossless')
quality_info = QOBUZ_QUALITY_MAP.get(quality_key, QOBUZ_QUALITY_MAP['lossless'])
# require_streamable=False: this is the exact track the user linked — don't
# let a missing/absent 'streamable' flag in track/get drop it (#932).
return self._qobuz_to_track_result(track, quality_info, require_streamable=False)
except Exception as e:
logger.debug(f"get_track_result failed for Qobuz {track_id}: {e}")
return None
# ===================== Playlists & Favorites =====================
#
# Qobuz playlist sync surface — mirrors the Tidal client contract
@ -1038,20 +1012,14 @@ class QobuzClient(DownloadSourcePlugin):
traceback.print_exc()
return ([], [])
def _qobuz_to_track_result(self, track: Dict, quality_info: dict,
require_streamable: bool = True) -> Optional[TrackResult]:
"""Convert Qobuz track dict to TrackResult (Soulseek-compatible format).
``require_streamable`` gates bulk SEARCH results on the ``streamable`` flag
(filters noise). For a track the user explicitly pasted a link to, pass
False: ``track/get`` may omit the flag (which would default-False and wrongly
drop the exact track they asked for), and they should get to try it (#932)."""
def _qobuz_to_track_result(self, track: Dict, quality_info: dict) -> Optional[TrackResult]:
"""Convert Qobuz track dict to TrackResult (Soulseek-compatible format)."""
track_id = track.get('id')
if not track_id:
return None
# Check if track is streamable (skipped for explicit by-id link fetches).
if require_streamable and not track.get('streamable', False):
# Check if track is streamable
if not track.get('streamable', False):
return None
performer = track.get('performer', {})

View file

@ -1,103 +0,0 @@
"""Single source of truth for "is this audio lossless?" + the lossy-copy
overwrite invariant.
The "create a lossy copy of lossless tracks" feature lives in two places (the
import post-processing path and the Lossy Converter repair job). Both used to
hardcode ``.flac``, which is exactly how ALAC/WAV/DSD ended up being quality-
profile options but NOT lossy-copy sources (#941). The knowledge of which
formats are lossless now lives HERE, derived from the same format names the
quality model ranks, so adding a format lights it up in both sites at once and
they can never drift.
Two seams, both pure (no I/O) so they're unit-testable without real files:
* :func:`is_lossless_format` / :func:`is_lossless_audio_path` eligibility.
``.m4a``/``.mp4`` are ambiguous (ALAC=lossless, AAC=lossy) and can only be told
apart by codec, so the path check delegates them to an *injected* ``probe_codec``
callable the file I/O stays at the edge, the decision stays pure.
* :func:`lossy_output_would_overwrite_source` the safety invariant: a lossy copy
must NEVER be written over its own source (which becomes possible once ``.m4a``
is eligible and the target codec is AAC same ``.m4a`` path).
"""
from __future__ import annotations
import os
from typing import Any, Callable, Optional
from core.quality.source_map import AUDIO_EXTENSIONS, format_from_extension
# Canonical lossless format set. MUST stay in sync with the lossless tiers in
# core.quality.model.tier_score and the frontend RT_LOSSLESS_FORMATS — the
# consistency test in tests/quality/test_lossless.py pins the tier agreement.
LOSSLESS_FORMATS = frozenset({'flac', 'alac', 'wav', 'dsf'})
# Container extensions that may hold EITHER lossless (ALAC) or lossy (AAC) audio —
# only a codec probe can decide, so they're never lossless on extension alone.
_AMBIGUOUS_EXTS = frozenset({'m4a', 'mp4'})
def is_lossless_format(fmt: Any) -> bool:
"""True when a unified format name (as returned by ``format_from_extension``)
is lossless. ``'aac'`` is False here an ALAC-in-m4a file reports format
``'aac'`` by extension and must be resolved by codec, not by name."""
return str(fmt or '').lower() in LOSSLESS_FORMATS
# Extensions worth checking as possibly-lossless (a caller can pre-filter SQL by
# these, then confirm each via is_lossless_audio_path). Derived from the format
# map so it can't drift: every extension whose format is lossless, plus the
# ambiguous ALAC containers. Leading dot included (e.g. '.flac', '.m4a').
LOSSLESS_CANDIDATE_EXTENSIONS = frozenset(
e for e in AUDIO_EXTENSIONS
if is_lossless_format(format_from_extension(e)) or e.lstrip('.') in _AMBIGUOUS_EXTS
)
def _ext(path: Any) -> str:
return os.path.splitext(str(path or ''))[1].lower().lstrip('.')
def is_lossless_audio_path(
path: Any,
*,
probe_codec: Optional[Callable[[str], Optional[str]]] = None,
) -> bool:
"""True when the file at ``path`` is lossless.
Unambiguous extensions (flac/wav/aiff/dsf/dff/alac) are decided by extension.
``.m4a``/``.mp4`` are decided by ``probe_codec(path)`` (returns the codec, e.g.
``'alac'`` or ``'aac'``) without a probe they're treated as NOT lossless, so
a missing probe can never misclassify an AAC file as lossless.
"""
ext = _ext(path)
if is_lossless_format(format_from_extension(ext)):
return True
if ext in _AMBIGUOUS_EXTS and probe_codec is not None:
try:
codec = (probe_codec(str(path)) or '').lower()
except Exception:
return False
return 'alac' in codec
return False
def lossy_output_would_overwrite_source(source_path: Any, output_path: Any) -> bool:
"""True when the computed lossy-copy output path is the source file itself.
Safety invariant: the converter must skip (never run ffmpeg with ``-y``) when
this is True, or it would destroy the original. Happens when an ``.m4a`` ALAC
source is converted with the AAC codec (output is also ``.m4a``)."""
if not source_path or not output_path:
return False
a = os.path.normcase(os.path.normpath(str(source_path)))
b = os.path.normcase(os.path.normpath(str(output_path)))
return a == b
__all__ = [
'LOSSLESS_FORMATS',
'is_lossless_format',
'is_lossless_audio_path',
'lossy_output_would_overwrite_source',
]

View file

@ -39,7 +39,6 @@ class AudioQuality:
# matched target. Cross-format PRIORITY is decided solely by the user's
# ranked-target list (target index), never by these numbers.
format_base: dict[str, float] = {
'dsf': 102.0, # DSD — 1-bit hi-res lossless, ranks at/above FLAC (#939)
'flac': 100.0,
'alac': 98.0, # lossless (Apple)
'wav': 95.0,

View file

@ -44,9 +44,6 @@ _EXTENSION_FORMAT_MAP = {
'ogg': 'ogg', 'oga': 'ogg',
'opus': 'opus',
'wma': 'wma',
# DSD (DSD Stream File / DSDIFF) — 1-bit hi-res lossless (e.g. DSD64 ≈ 11 Mbps).
# Both container types map to the single 'dsf' tier (#939).
'dsf': 'dsf', 'dff': 'dsf',
}
# Audio extensions worth probing/classifying at all — derived from the map so

View file

@ -70,9 +70,6 @@ class QueueItem:
# 'tags' = read each file's embedded tags as the source
# of truth (issue #592). Zero API calls.
metadata_source: str = 'api'
# Rename-only mode (#875): move files to the current naming scheme WITHOUT the
# copy + post-processing (re-tag / quality / AcoustID) the full flow runs.
rename_only: bool = False
status: str = 'queued' # queued | running | done | failed | cancelled
started_at: Optional[float] = None
finished_at: Optional[float] = None
@ -99,7 +96,6 @@ class QueueItem:
'artist_name': self.artist_name,
'source': self.source,
'metadata_source': self.metadata_source,
'rename_only': self.rename_only,
'enqueued_at': self.enqueued_at,
'started_at': self.started_at,
'finished_at': self.finished_at,
@ -165,7 +161,6 @@ class ReorganizeQueue:
artist_name: str,
source: Optional[str] = None,
metadata_source: str = 'api',
rename_only: bool = False,
) -> dict:
"""Add an album to the queue. Returns a result dict:
@ -195,7 +190,6 @@ class ReorganizeQueue:
source=source,
enqueued_at=time.time(),
metadata_source=metadata_source or 'api',
rename_only=bool(rename_only),
)
self._items.append(item)
position = sum(1 for i in self._items if i.status == 'queued')

View file

@ -37,7 +37,6 @@ def build_runner(
is_shutting_down_fn: Callable[[], bool],
get_download_path: Callable[[], str],
get_transfer_path: Callable[[], str],
build_final_path_fn: Optional[Callable] = None,
) -> Callable[[object], dict]:
"""Return the closure the queue worker invokes per item.
@ -60,7 +59,7 @@ def build_runner(
A callable ``runner(item)`` suitable for
:meth:`core.reorganize_queue.ReorganizeQueue.set_runner`.
"""
from core.library_reorganize import reorganize_album, reorganize_album_rename_only
from core.library_reorganize import reorganize_album
from core.reorganize_queue import get_queue
def _update_track_path(track_id, new_path):
@ -81,6 +80,17 @@ def build_runner(
# server restart.
download_dir = get_download_path()
transfer_dir = get_transfer_path()
staging_root = os.path.join(download_dir, 'ssync_staging')
try:
os.makedirs(staging_root, exist_ok=True)
except OSError as mk_err:
logger.error(f"[Reorganize] Cannot create staging dir {staging_root}: {mk_err}")
return {
'status': 'setup_failed',
'source': None,
'total': 0, 'moved': 0, 'skipped': 0, 'failed': 0,
'errors': [{'error': f'Could not create staging dir: {mk_err}'}],
}
def _cleanup_empty(src_dir):
try:
@ -95,42 +105,6 @@ def build_runner(
# Progress fan-out failures must never break a run.
logger.debug("reorganize progress fan-out: %s", e)
# Rename-only mode (#875): just move files to the current scheme — no staging,
# no copy, no post-processing. Falls through to the full pipeline otherwise.
if getattr(item, 'rename_only', False):
if build_final_path_fn is None:
return {
'status': 'setup_failed', 'source': None,
'total': 0, 'moved': 0, 'skipped': 0, 'failed': 0,
'errors': [{'error': 'Rename-only mode unavailable (no path builder)'}],
}
return reorganize_album_rename_only(
album_id=item.album_id,
db=get_database(),
transfer_dir=transfer_dir,
resolve_file_path_fn=resolve_file_path_fn,
build_final_path_fn=build_final_path_fn,
update_track_path_fn=_update_track_path,
cleanup_empty_dir_fn=_cleanup_empty,
on_progress=_on_progress,
primary_source=item.source,
strict_source=bool(item.source),
metadata_source=getattr(item, 'metadata_source', 'api') or 'api',
stop_check=is_shutting_down_fn,
)
staging_root = os.path.join(download_dir, 'ssync_staging')
try:
os.makedirs(staging_root, exist_ok=True)
except OSError as mk_err:
logger.error(f"[Reorganize] Cannot create staging dir {staging_root}: {mk_err}")
return {
'status': 'setup_failed',
'source': None,
'total': 0, 'moved': 0, 'skipped': 0, 'failed': 0,
'errors': [{'error': f'Could not create staging dir: {mk_err}'}],
}
return reorganize_album(
album_id=item.album_id,
db=get_database(),

View file

@ -1,19 +1,13 @@
"""Lossy Converter Job — finds lossless files that don't have a lossy copy.
"""Lossy Converter Job — finds FLAC files that don't have a lossy copy.
Scans the library for lossless files without a corresponding lossy copy alongside
Scans the library for FLAC files without a corresponding lossy copy alongside
them, and creates a finding for each. The fix action converts the file using
ffmpeg with the user's configured codec/bitrate settings.
"""
import os
from core.imports.file_ops import m4a_codec
from core.library.path_resolver import resolve_library_file_path
from core.quality.lossless import (
LOSSLESS_CANDIDATE_EXTENSIONS,
is_lossless_audio_path,
lossy_output_would_overwrite_source,
)
from core.repair_jobs import register_job
from core.repair_jobs.base import JobContext, JobResult, RepairJob
from utils.logging_config import get_logger
@ -27,16 +21,6 @@ CODEC_MAP = {
}
def _lossless_ext_where(col: str) -> str:
"""SQL pre-filter matching files whose extension *might* be lossless. The
final decision (including ALAC-in-.m4a, which needs a codec probe) is made
per-file by is_lossless_audio_path. Extensions are trusted constants from the
quality model, never user input safe to interpolate."""
return '(' + ' OR '.join(
f"LOWER({col}) LIKE '%{ext}'" for ext in sorted(LOSSLESS_CANDIDATE_EXTENSIONS)
) + ')'
def _resolve_file_path(file_path, transfer_folder, download_folder=None, config_manager=None):
"""Backwards-compat wrapper. Use ``resolve_library_file_path`` directly."""
return resolve_library_file_path(
@ -51,15 +35,15 @@ def _resolve_file_path(file_path, transfer_folder, download_folder=None, config_
class LossyConverterJob(RepairJob):
job_id = 'lossy_converter'
display_name = 'Lossy Converter'
description = 'Finds lossless files without a lossy copy'
description = 'Finds FLAC files without a lossy copy'
help_text = (
'Scans your library for lossless files (FLAC/ALAC/WAV/AIFF/DSD) that don\'t already have a lossy copy '
'Scans your library for FLAC files that don\'t already have a lossy copy '
'(MP3, Opus, or AAC) alongside them.\n\n'
'Uses the codec setting from your Lossy Copy configuration on the Settings '
'page. Enable Lossy Copy in Settings first, then run this job to find FLAC '
'files missing a lossy copy.\n\n'
'Each finding can be fixed individually or in bulk — the fix action converts '
'the lossless file using ffmpeg at your configured bitrate.\n\n'
'the FLAC file using ffmpeg at your configured bitrate.\n\n'
'Requires ffmpeg to be installed.'
)
icon = 'repair-icon-lossy'
@ -97,14 +81,14 @@ class LossyConverterJob(RepairJob):
try:
conn = context.db._get_connection()
cursor = conn.cursor()
cursor.execute(f"""
cursor.execute("""
SELECT t.id, t.title, ar.name, al.title, t.file_path,
al.thumb_url, ar.thumb_url
FROM tracks t
LEFT JOIN artists ar ON ar.id = t.artist_id
LEFT JOIN albums al ON al.id = t.album_id
WHERE t.file_path IS NOT NULL AND t.file_path != ''
AND {_lossless_ext_where('t.file_path')}
AND LOWER(t.file_path) LIKE '%.flac'
""")
tracks = cursor.fetchall()
except Exception as e:
@ -120,7 +104,7 @@ class LossyConverterJob(RepairJob):
context.update_progress(0, total)
if context.report_progress:
context.report_progress(
phase=f'Scanning {total} lossless files for missing {quality_label} copies...',
phase=f'Scanning {total} FLAC files for missing {quality_label} copies...',
total=total
)
@ -151,17 +135,8 @@ class LossyConverterJob(RepairJob):
if not resolved or not os.path.exists(resolved):
continue
# Confirm it's actually lossless — the SQL pre-filter lets .m4a through,
# which is ALAC (lossless) OR AAC (lossy); only a codec probe decides.
if not is_lossless_audio_path(resolved, probe_codec=m4a_codec):
continue
# Check if lossy copy already exists
out_path = os.path.splitext(resolved)[0] + out_ext
# Never offer to convert a file onto itself (e.g. .m4a ALAC + AAC target
# lands on the same path) — that conversion would destroy the original.
if lossy_output_would_overwrite_source(resolved, out_path):
continue
if os.path.exists(out_path):
continue
@ -184,7 +159,7 @@ class LossyConverterJob(RepairJob):
file_path=file_path,
title=f'No {quality_label} copy: {title or "Unknown"}',
description=(
f'Lossless file "{title}" by {artist_name or "Unknown"} does not have '
f'FLAC file "{title}" by {artist_name or "Unknown"} does not have '
f'a {quality_label} copy alongside it'
),
details={
@ -220,7 +195,7 @@ class LossyConverterJob(RepairJob):
context.report_progress(
scanned=total, total=total,
phase='Complete',
log_line=f'Found {result.findings_created} lossless files without {quality_label} copies',
log_line=f'Found {result.findings_created} FLAC files without {quality_label} copies',
log_type='success' if result.findings_created == 0 else 'info'
)
@ -233,10 +208,10 @@ class LossyConverterJob(RepairJob):
try:
conn = context.db._get_connection()
cursor = conn.cursor()
cursor.execute(f"""
cursor.execute("""
SELECT COUNT(*) FROM tracks
WHERE file_path IS NOT NULL AND file_path != ''
AND {_lossless_ext_where('file_path')}
AND LOWER(file_path) LIKE '%.flac'
""")
row = cursor.fetchone()
return row[0] if row else 0

View file

@ -3373,14 +3373,6 @@ class RepairWorker:
return {'success': False, 'error': f'Source file not found: {file_path}'}
out_path = os.path.splitext(resolved)[0] + out_ext
# Safety invariant: ffmpeg runs with -y, so refuse to convert a file onto
# itself (an .m4a ALAC source + AAC target shares the .m4a path) — that
# would destroy the original lossless file (#941).
from core.quality.lossless import lossy_output_would_overwrite_source
if lossy_output_would_overwrite_source(resolved, out_path):
return {'success': False,
'error': f'{codec.upper()} output would overwrite the source file; '
f'choose a different lossy codec'}
if os.path.exists(out_path):
return {'success': True, 'action': 'already_exists',
'message': f'{quality_label} copy already exists'}

View file

@ -13,61 +13,6 @@ from core.metadata.cache import get_metadata_cache
logger = get_logger("spotify_client")
# Single source of truth for the Spotify OAuth scope. Used by EVERY SpotifyOAuth
# construction (the client, the per-profile registry, and all web_server callbacks) so
# the authorize URL and token exchange can never request different scopes — a mismatch
# silently re-prompts or denies.
#
# IMPORTANT — do NOT add scopes here lightly. Spotipy's validate_token treats a cached
# token as invalid the moment the requested scope is no longer a subset of the token's
# granted scope, so GROWING this string invalidates EVERY existing user's token and forces
# a re-auth on upgrade. `playlist-modify-*` (for exporting a playlist back to Spotify, #945)
# was pulled back out for exactly that reason — it broke all Spotify users on upgrade. The
# Spotify export must request write access on-demand (incremental auth) instead.
SPOTIFY_OAUTH_SCOPE = (
"user-library-read user-read-private playlist-read-private "
"playlist-read-collaborative user-read-email user-follow-read"
)
# The export scope = the normal login scope PLUS playlist write. Requested ONLY by the
# on-demand export-auth route (/auth/spotify/export) when a user chooses to export a playlist
# to Spotify — NEVER by the normal login. That's the whole safety property: the global scope
# above is unchanged, so no existing token is invalidated. The token Spotify returns from the
# export flow is a SUPERSET of the read scope, so it still passes the normal auth check
# (read ⊆ read+write) — one account, one token, just with write added for the opt-in user.
SPOTIFY_EXPORT_SCOPE = (
SPOTIFY_OAUTH_SCOPE + " playlist-modify-public playlist-modify-private"
)
def normalize_spotify_oauth_config(config: Optional[Dict[str, Any]]) -> Dict[str, Any]:
"""Normalize Spotify OAuth config before building an auth manager.
Spotify rejects values that include surrounding whitespace or quotes, and the
settings UI can paste values carrying such formatting, so we trim those they
can never be part of a real credential.
We deliberately do NOT strip a trailing slash from ``redirect_uri``: Spotify
matches the redirect URI EXACTLY against the app's dashboard registration, and
a trailing slash is a legitimate part of a URI. Stripping it would silently
break anyone who registered ``/callback/`` (we'd send ``…/callback`` →
"INVALID_CLIENT: Invalid redirect URI"). So the value is preserved verbatim
apart from the unambiguous whitespace/quote garbage (#942 follow-up).
"""
if not isinstance(config, dict):
return {}
normalized = {}
for key in ("client_id", "client_secret", "redirect_uri"):
value = config.get(key, "")
if isinstance(value, str):
normalized[key] = value.strip().strip('"').strip("'")
else:
normalized[key] = value
return normalized
def _upgrade_spotify_image_url(url: str) -> str:
"""Upgrade a Spotify CDN image URL to the highest available resolution.
@ -752,7 +697,7 @@ class SpotifyClient:
self._setup_client()
def _setup_client(self):
config = normalize_spotify_oauth_config(config_manager.get_spotify_config())
config = config_manager.get_spotify_config()
if not config.get('client_id') or not config.get('client_secret'):
logger.warning("Spotify credentials not configured")
@ -771,7 +716,7 @@ class SpotifyClient:
client_id=config['client_id'],
client_secret=config['client_secret'],
redirect_uri=config.get('redirect_uri', "http://127.0.0.1:8888/callback"),
scope=SPOTIFY_OAUTH_SCOPE,
scope="user-library-read user-read-private playlist-read-private playlist-read-collaborative user-read-email user-follow-read",
cache_handler=DatabaseTokenCache(config_manager)
)
@ -806,16 +751,6 @@ class SpotifyClient:
self._auth_cached_result = None
self._auth_cache_time = 0
def _has_cached_oauth_token(self) -> bool:
"""Return True when a persisted OAuth token exists (no network I/O)."""
if self.sp is None:
return False
try:
cache_handler = getattr(self.sp.auth_manager, 'cache_handler', None)
return bool(cache_handler and cache_handler.get_cached_token() is not None)
except Exception:
return False
def is_spotify_authenticated(self) -> bool:
"""Check if Spotify client is specifically authenticated (not just iTunes fallback).
Results are cached for 60 seconds to avoid excessive API calls.
@ -891,26 +826,6 @@ class SpotifyClient:
logger.debug("publish_spotify_status cache hit: %s", e)
return self._auth_cached_result
from core.boot_phase import is_boot_phase
if is_boot_phase():
result = self._has_cached_oauth_token()
with self._auth_cache_lock:
self._auth_cached_result = result
self._auth_cache_time = time.time()
try:
from core.metadata.status import publish_spotify_status
publish_spotify_status(
connected=result,
authenticated=result,
rate_limited=False,
rate_limit=None,
post_ban_cooldown=None,
)
except Exception as e:
logger.debug("publish_spotify_status boot-phase: %s", e)
return result
# Cache miss — make API call outside the lock.
# Safety: if there's no cached token, return False immediately.
# Without this guard, spotipy's auth_manager will try to start an interactive
@ -942,8 +857,7 @@ class SpotifyClient:
# Use a dedicated probe client (retries=0) so a 429 here propagates
# immediately and we can detect long Retry-After bans.
try:
probe = spotipy.Spotify(
auth_manager=self.sp.auth_manager, retries=0, requests_timeout=15)
probe = spotipy.Spotify(auth_manager=self.sp.auth_manager, retries=0)
probe.current_user()
result = True
except Exception as e:
@ -1194,69 +1108,6 @@ class SpotifyClient:
return playlists
return []
def has_write_scope(self) -> bool:
"""True when the cached Spotify token carries playlist-modify (the export write scope).
The export endpoint uses this to decide whether to run, or to first send the user
through the on-demand export-auth flow. Fail-safe: any error False (not authorized)."""
if self.sp is None:
return False
try:
cache_handler = getattr(self.sp.auth_manager, "cache_handler", None)
token = cache_handler.get_cached_token() if cache_handler else None
return "playlist-modify" in ((token or {}).get("scope") or "")
except Exception:
return False
def create_or_update_playlist(self, name, track_ids, *, existing_id=None,
public=False, description=""):
"""Create a Spotify playlist owned by the authed user (or replace an existing
one's tracks in place), for exporting a mirrored playlist back to Spotify (#945).
``track_ids`` are Spotify track IDs (the stored ``spotify_track_id`` per library
track). ``existing_id`` set replace that playlist's contents (idempotent
re-export); unset create a new playlist. Requires the ``playlist-modify-*``
scope a token issued before that scope was added gets a clear "reconnect"
error rather than a raw 403. Returns
``{success, playlist_id, url, added, error}``.
"""
if not self.is_spotify_authenticated():
return {"success": False, "error": "Spotify is not connected"}
uris = [f"spotify:track:{t}" for t in (track_ids or []) if t]
if not uris:
return {"success": False, "error": "No matching Spotify tracks to export"}
try:
playlist_id = existing_id
if playlist_id:
# Replace contents (re-export). replace_items caps at 100 — seed with the
# first 100, then append the rest in 100-track chunks.
self.sp.playlist_replace_items(playlist_id, uris[:100])
for i in range(100, len(uris), 100):
self.sp.playlist_add_items(playlist_id, uris[i:i + 100])
else:
user_id = (self.sp.current_user() or {}).get("id")
created = self.sp.user_playlist_create(
user_id, name, public=public, description=description,
)
playlist_id = (created or {}).get("id")
if not playlist_id:
return {"success": False, "error": "Spotify did not return a playlist id"}
for i in range(0, len(uris), 100):
self.sp.playlist_add_items(playlist_id, uris[i:i + 100])
return {
"success": True,
"playlist_id": playlist_id,
"url": f"https://open.spotify.com/playlist/{playlist_id}",
"added": len(uris),
}
except Exception as e:
msg = str(e)
if "403" in msg or "scope" in msg.lower() or "insufficient" in msg.lower():
return {"success": False,
"error": "Reconnect Spotify to grant playlist write access "
"(Settings → reconnect Spotify)."}
_detect_and_set_rate_limit(e, "create_or_update_playlist")
return {"success": False, "error": msg}
@rate_limited
def get_saved_tracks_count(self) -> int:
"""Get the total count of user's saved/liked songs without fetching all tracks"""

View file

@ -520,18 +520,8 @@ class TidalClient:
return True
def is_authenticated(self) -> bool:
def is_authenticated(self):
"""Check if client is authenticated, refreshing expired tokens if possible"""
from core.boot_phase import is_boot_phase
if is_boot_phase():
if self.access_token and time.time() < self.token_expires_at:
return True
return bool(self.access_token and self.refresh_token)
# Token still valid — no refresh needed. (Restored: #949 moved this short-circuit
# into the boot-phase branch only, so every post-boot call fell through to the
# refresh below — a constant silent-refresh loop on a perfectly valid token.)
if self.access_token and time.time() < self.token_expires_at:
return True

View file

@ -134,7 +134,6 @@ class TidalDownloadClient(DownloadSourcePlugin):
self._device_auth_future = None
self._device_auth_link = None
self._boot_session_tokens: Optional[dict] = None
# Engine reference is populated by set_engine() at registration
# time. Until then dispatch returns None — orchestrator wires
@ -164,14 +163,6 @@ class TidalDownloadClient(DownloadSourcePlugin):
expiry_time = saved.get('expiry_time', 0)
if token_type and access_token:
from core.boot_phase import is_boot_phase
if is_boot_phase():
self._boot_session_tokens = saved
logger.info(
"Loaded Tidal download session from config (verification deferred until after boot)"
)
return
try:
expiry_dt = datetime.fromtimestamp(expiry_time, tz=timezone.utc) if expiry_time else None
@ -190,39 +181,6 @@ class TidalDownloadClient(DownloadSourcePlugin):
except Exception as e:
logger.warning(f"Could not restore Tidal session: {e}")
def _complete_deferred_session(self) -> bool:
"""Finish restoring a session that was deferred during boot."""
pending = getattr(self, '_boot_session_tokens', None)
if not pending or tidalapi is None:
self._boot_session_tokens = None
return False
if not self.session:
self.session = tidalapi.Session()
token_type = pending.get('token_type', '')
access_token = pending.get('access_token', '')
refresh_token = pending.get('refresh_token', '')
expiry_time = pending.get('expiry_time', 0)
self._boot_session_tokens = None
try:
expiry_dt = datetime.fromtimestamp(expiry_time, tz=timezone.utc) if expiry_time else None
restored = self.session.load_oauth_session(
token_type=token_type,
access_token=access_token,
refresh_token=refresh_token,
expiry_time=expiry_dt,
)
if restored and self.session.check_login():
logger.info("Restored Tidal download session from saved tokens")
self._save_session()
return True
logger.warning("Saved Tidal session tokens are invalid/expired")
except Exception as e:
logger.warning(f"Could not restore Tidal session: {e}")
return False
def _save_session(self):
if not self.session:
return
@ -234,14 +192,6 @@ class TidalDownloadClient(DownloadSourcePlugin):
})
def is_authenticated(self) -> bool:
from core.boot_phase import is_boot_phase
if is_boot_phase():
pending = getattr(self, '_boot_session_tokens', None)
return bool(pending and pending.get('access_token'))
if getattr(self, '_boot_session_tokens', None):
return self._complete_deferred_session()
if not self.session:
return False
try:

View file

@ -1,40 +0,0 @@
"""Pure decisions for UI-appearance defaults.
Kept here (importable, no Flask/config coupling) so the rules are unit-testable in
isolation; web_server does only the request/config plumbing around them.
Worker orbs are a blurred 60fps canvas the main remaining Firefox lag source after
the #935 sweep. So for a FIRST-TIME user (no saved preference) we default them OFF on
Firefox and ON everywhere else: a smooth first impression where it's needed, full
polish where the browser handles it. An explicit saved choice ALWAYS wins this only
picks the default when the user hasn't chosen.
"""
from __future__ import annotations
from typing import Optional
def is_firefox_user_agent(user_agent: Optional[str]) -> bool:
"""True when a User-Agent string is Firefox.
Used ONLY to pick a performance-friendly default never to gate functionality
so a spoofed or unusual UA simply gets the default and the user can toggle. Chrome,
Edge, Safari, Opera, Brave do not carry 'firefox' in their UA; Firefox does
('… Gecko/… Firefox/<ver>')."""
return 'firefox' in str(user_agent or '').lower()
def resolve_worker_orbs_default(explicit: object, is_firefox: bool) -> bool:
"""Whether worker orbs should be on.
``explicit`` is the saved config value: ``True``/``False`` when the user has chosen,
``None`` when unset. An explicit choice always wins; when unset, default OFF on
Firefox (perf) and ON elsewhere.
"""
if explicit is None:
return not is_firefox
return explicit is not False
__all__ = ['is_firefox_user_agent', 'resolve_worker_orbs_default']

View file

@ -1,21 +1,44 @@
# soulsync 2.8.2`dev``main`
# soulsync 2.8.0`dev``main`
a stability + performance release. the headline is **Spotify reliability** (the Docker boot hang and the "logged out / re-auth won't stick" issues are fixed), a big **performance** win that explains the "slow after update" reports, and **large-library imports** that no longer time out the import page.
mostly a quality + reliability release. the headline is a big cleanup of the **Unverified review queue** (it stops inflating and self-heals), a new **Preview Clip Cleanup** tool, smarter **Album Completeness** for split/fragmented albums, and a real pass on **dashboard performance** (especially Firefox/Zen). plus a pile of reported fixes.
---
## what's new
### 🎧 Spotify reliability
- **Docker boot hang fixed (#949 — thanks HellRa1SeR)** — with Spotify set as your primary metadata source, an unreachable Spotify API could block the gunicorn worker during startup, so the container bound port 8008 but never actually served the Web UI. provider auth probes are now deferred during boot (and capped with a timeout), so startup can't hang on a slow Spotify. same guard added for Qobuz / Deezer / Tidal.
- **"re-auth didn't stick" fixed** — the OAuth callback wrote your token to one cache while the app read another, so re-authenticating could silently fail validation (and trip an `Address already in use` on the callback port). unified on one token store — re-auth takes effect now.
- **Sync to Spotify works** — exporting a mirrored playlist to Spotify now asks for playlist-write permission **once**, on-demand, the first time you use it. your normal Spotify login is untouched, so upgrading never forces a re-auth.
### Preview Clip Cleanup (new Tools job)
the HiFi source sometimes hands back a ~30-second **preview clip** instead of the full song, and it lands looking like a normal track. the new job scans your short tracks, checks how long each one *should* be from its metadata source, and flags the previews. approve a finding and it deletes the clip, drops it from the library, and re-wishlists the full version. each finding has a **▶ Play** button + a file-length-vs-real-length readout so you can confirm it's busted before approving. conservative by design — genuine short tracks and anything it can't verify are left alone.
### ⚡ Performance — the "slow after update" fix
- **Password-manager autofill storm fixed (#948 — thanks @nick2000713)** — the real cause of the post-update lag wasn't SoulSync rendering, it was browser password managers (Bitwarden / 1Password / etc.) rebuilding their autofill overlay on **every** DOM change — and SoulSync mutates the DOM constantly (live status, progress bars, countdowns). non-credential fields are now marked so managers skip them (your login fields are left alone). the reporter measured **~110× less main-thread blocking** and ~20 → ~96 FPS.
- **Max Performance mode (new)** — Settings → Appearance. one switch kills the worker orbs, particles, all blur/shadows, and every animation/transition, and greys out the individual effect toggles so it's clearly in charge. for software-rendered / no-GPU setups (Docker, remote desktop) where even simple animations cost real CPU.
### the Unverified queue stops inflating + self-heals (#934)
big one for anyone who saw thousands of "unverified" rows. the AcoustID scan was creating a fresh history row every run and leaving already-verified files stuck as "unverified" (a frozen import-time path that stopped matching once the file moved). now:
- scans no longer duplicate rows and heal on the spot, and
- a one-time **reconcile** on startup clears the existing backlog from your library's truth — no re-scan needed, including human-verified files a scan skips, and
- a **🧹 Clean orphaned** button removes dead rows whose file is genuinely gone (with a safety gate that refuses to run if your library looks offline).
the Unverified review rows also got the nicer Quarantine-style cards (artwork, inline details). *(thanks @nick2000713 for #938.)*
### 📥 Large-library imports no longer time out (#947 — thanks @ramonskie)
- dropping a whole library into your staging folder used to make the import page scan every file synchronously and blow past the request timeout — so the page never loaded, and every reload re-timed-out. the scan now runs in the **background** with a live **"Scanning N of M…"** progress, and the page fills in automatically when it's done. (auto-import remains the hands-off path for the actual matching.)
### Album Completeness handles split albums (#936, #929, #931)
a physical album split across multiple library rows used to show every fragment as falsely "incomplete." it now groups the validated fragments into one logical album and emits a single correct finding — grouping by a shared id **and** validating at the track level, so unrelated rows never get fused. also recognizes MusicBrainz as a readable album source. *(thanks @ragnarlotus.)*
enjoy 🎶
### Clear Completed is back on the Downloads page
since completed downloads now persist across restart, the **Clear Completed** button had gone missing for them. it's back — it clears the live list *and* the persisted history so the page actually empties and stays empty (your files are untouched).
---
## fixes
- **pasted YouTube cookies threw `unsupported browser: "custom"` (Docker)** — the client passed the "Paste cookies.txt" mode through as a browser name instead of using the cookies file. now it loads the pasted `cookies.txt` correctly — the only auth path that works on a headless/Docker box. *(thanks HellRa1SeR.)*
- **longer remasters quarantined as "truncated" (#937)** — the duration check was symmetric, so a remaster running a few seconds *longer* than the metadata got rejected like a truncated download. it's asymmetric now: short files stay strict, longer versions get room. *(thanks @diegocade1.)*
- **"Add to Wishlist" from an artist discography was painfully slow** — ~1530s *per track* on a large library, because the per-track library-ownership check fell through to a full-table fuzzy scan. it now matches in-memory against the artist's tracks once — effectively instant.
- **wishlist art was blank for re-downloads / preview re-fetches** — library-sourced items stored a relative media-server path that doesn't render in a browser. they're normalized on read now, so album + artist art show up (fixes already-saved items too).
- **watchlist didn't record automatic scans (#933)** + **watchlist fused different editions of an album as one.**
- **manual search:** a pasted Qobuz/Tidal track now floats to the top of results (#932).
- **Popular Picks came up empty on Deezer** — a popularity-threshold scale mismatch.
---
## performance + UI
- **dashboard GPU usage, especially on Firefox/Zen (#935)** — frosted-glass blur, cursor-glow blobs, and the worker-orb animation were repainting every frame. trimmed the worst offenders, made the orb loop hold a steady framerate on Firefox instead of dropping to ~1fps, and set **Background Particles OFF by default**. the dashboard system-memory tile now also shows SoulSync's own RAM.
- **bounded memory growth (#802)** — browsing every page used to climb RSS into the GBs (plexapi's XML trees deferring GC) and could lock the app up. a lightweight sweeper now collects + hands memory back to the OS as it grows, so it sawtooths and settles instead of climbing.
---

View file

@ -170,12 +170,7 @@ def test_file_not_found_after_retries_marks_failed(monkeypatch):
deps, rec = _build_deps()
pp.run_post_processing_worker('t1', 'b1', deps)
assert download_tasks['t1']['status'] == 'failed'
# Actionable failure: names the folder searched + the two real causes, so a
# standalone user with a path mismatch can self-diagnose (Discord: Shdjfgatdif).
msg = download_tasks['t1']['error_message']
assert './downloads' in msg # the folder we actually searched
assert "download path doesn't match slskd" in msg # the config-mismatch hint
assert 'song.flac' in msg # the file slskd reported
assert 'File not found on disk' in download_tasks['t1']['error_message']
assert ('on_complete', ('b1', 't1', False), {}) in rec.calls

View file

@ -396,56 +396,3 @@ def test_real_soulseek_path_still_basenamed(tmp_path):
str(downloads), r'shared\Billy Ocean\Very Best Of\01 - Suddenly.flac',
)
assert found == str(target)
# ---------------------------------------------------------------------------
# Unbalanced bracket — slskd REPORTS "[34 - Title.flac" but SAVES the file as
# "34 - Title.flac" (it sanitises the leading '['). The normaliser's old combined
# bracket-strip r'[\[\(].*?[\]\)]' matched from that lone '[' all the way to the
# next ')', eating the whole title and collapsing the search target to just "flac"
# → 0.40 fuzzy score → "File not found on disk" despite the file sitting right
# there. (Discord: Shdjfgatdif — "You & Me (Flume Remix)".)
# ---------------------------------------------------------------------------
def test_finds_file_when_slskd_strips_a_leading_bracket(tmp_path):
downloads = tmp_path / 'downloads'
# On disk: no leading '['. API filename (slskd-reported): has the '['.
target = downloads / 'Disclosure' / '34 - You & Me (Flume Remix).flac'
_touch(target)
found, location = find_completed_audio_file(
str(downloads), r'Music\Disclosure\[34 - You & Me (Flume Remix).flac',
)
assert found == str(target), \
'the lone "[" used to collapse the target to "flac" and miss the file'
assert location == 'downloads'
def test_balanced_bracket_tags_still_stripped(tmp_path):
"""No regression: balanced "[FLAC]" / "(Remastered 2016)" tags in a Soulseek
filename must still be stripped so it matches the clean saved file."""
downloads = tmp_path / 'downloads'
target = downloads / 'Song.mp3'
_touch(target)
found, _ = find_completed_audio_file(
str(downloads), r'shared\Artist\Album\Song [FLAC] (Remastered 2016).mp3',
)
assert found == str(target)
def test_stray_closing_bracket_does_not_break_match(tmp_path):
"""The other shape in the wild (Discord: "Abort, Retry, Fail_]1-01 …") — a
stray ']' must not wreck the match either."""
downloads = tmp_path / 'downloads'
target = downloads / 'White Town' / "Fail_]1-01 Your Woman.flac"
_touch(target)
found, _ = find_completed_audio_file(
str(downloads), r"@@digadom\Music\White Town\Fail_]1-01 Your Woman.flac",
)
assert found == str(target)

View file

@ -122,112 +122,3 @@ def test_bubble_noop_when_nothing_matches_or_empty():
assert bubble_linked_track_first([a, b], '296427754') == [a, b] # unchanged
assert bubble_linked_track_first([], '296427754') == []
assert bubble_linked_track_first([a, b], '') == [a, b]
# ── inject the EXACT fetched track first (#932 reopen: text search misses obscure tracks) ──
from core.downloads.track_link import inject_linked_track_first
def test_inject_puts_fetched_track_first_when_search_missed_it():
# The reported case: the linked track isn't among the fuzzy text-search
# results at all, so the directly-fetched result is injected at the top.
fetched = _result('296427754')
search = [_result('111'), _result('222')] # unrelated lookalikes
out = inject_linked_track_first(search, fetched, '296427754')
assert out[0] is fetched
assert len(out) == 3
def test_inject_dedups_a_search_copy_of_the_linked_track():
fetched = _result('296427754')
dup = _result('296427754') # search also returned it
search = [_result('111'), dup, _result('222')]
out = inject_linked_track_first(search, fetched, '296427754')
assert out[0] is fetched
# exactly one element carries the linked id, and it's the injected one
assert [linked_track_id(t) for t in out].count('296427754') == 1
assert all(t is not dup for t in out) # the search copy (by identity) was dropped
assert len(out) == 3
def test_inject_falls_back_to_bubble_without_a_fetched_result():
exact = _result('296427754')
out = inject_linked_track_first([_result('111'), exact, _result('222')], None, '296427754')
assert linked_track_id(out[0]) == '296427754' # bubbled, not injected
assert len(out) == 3
def test_inject_is_noop_without_a_link_id():
search = [_result('111')]
assert inject_linked_track_first(search, _result('x'), '') == search
def test_inject_int_track_id_is_str_safe():
fetched = _result('296427754')
out = inject_linked_track_first([_result('111')], fetched, 296427754)
assert out[0] is fetched
# ── QobuzClient.get_track_result: fetch by id → downloadable TrackResult ──
from core.qobuz_client import QobuzClient
def _bare_qobuz():
return QobuzClient.__new__(QobuzClient) # no network / __init__
def test_get_track_result_converts_fetched_track(monkeypatch):
client = _bare_qobuz()
sentinel = object()
monkeypatch.setattr(client, 'get_track', lambda tid: {'id': tid, 'streamable': True})
monkeypatch.setattr(client, '_qobuz_to_track_result',
lambda track, qi, require_streamable=True: sentinel)
assert client.get_track_result('296427754') is sentinel
def test_get_track_result_none_when_track_unavailable(monkeypatch):
client = _bare_qobuz()
monkeypatch.setattr(client, 'get_track', lambda tid: None)
assert client.get_track_result('nope') is None
def test_get_track_result_none_on_exception(monkeypatch):
client = _bare_qobuz()
def _boom(_tid):
raise RuntimeError('api down')
monkeypatch.setattr(client, 'get_track', _boom)
assert client.get_track_result('x') is None
# ── #932 hardening: a pasted-link fetch must not be dropped by a missing
# 'streamable' flag (track/get may omit it). Exercises the REAL converter. ──
_QOBUZ_TRACK = {'id': 296427754, 'title': 'foreign lavennew',
'performer': {'name': 'colacola'}, 'duration': 180}
def test_converter_rejects_non_streamable_on_search_path():
# Default (bulk search) still filters on streamable — unchanged behaviour.
client = _bare_qobuz()
qi = {'codec': 'flac', 'bitrate': 1411}
assert client._qobuz_to_track_result(dict(_QOBUZ_TRACK), qi) is None # no streamable flag
def test_converter_builds_for_link_fetch_without_streamable():
client = _bare_qobuz()
qi = {'codec': 'flac', 'bitrate': 1411}
r = client._qobuz_to_track_result(dict(_QOBUZ_TRACK), qi, require_streamable=False)
assert r is not None
assert linked_track_id(r) == '296427754' # carries the id → injectable + downloadable
def test_get_track_result_survives_missing_streamable_flag(monkeypatch):
# The feared track/get shape (no 'streamable') must STILL yield the track —
# this is the end-to-end gap that couldn't be confirmed against a live API.
client = _bare_qobuz()
monkeypatch.setattr(client, 'get_track', lambda _tid: dict(_QOBUZ_TRACK))
r = client.get_track_result('296427754')
assert r is not None
assert linked_track_id(r) == '296427754'

View file

@ -57,182 +57,3 @@ def test_all_miss_returns_none_and_no_write():
fn, recorded = _wire()
assert fn("A", "T") == (None, None)
assert recorded == {}
# ── service track-id resolver (#945 export to Spotify/Deezer) ──
from core.exports.export_sources import (
db_service_track_id,
build_service_resolve_fn,
_SERVICE_ID_COLUMNS,
)
def test_service_id_column_mapping():
assert _SERVICE_ID_COLUMNS == {'spotify': 'spotify_track_id', 'deezer': 'deezer_id'}
def test_db_service_track_id_unknown_service_is_none():
assert db_service_track_id('A', 'X', 'tidal') is None
assert db_service_track_id('A', 'X', '') is None
def test_db_service_track_id_no_title_is_none():
assert db_service_track_id('A', '', 'spotify') is None
def test_build_service_resolve_fn_returns_id_and_source(monkeypatch):
import core.exports.export_sources as es
monkeypatch.setattr(es, 'db_service_track_id',
lambda a, t, s: 'spid-99' if t == 'Hit' else None)
fn = build_service_resolve_fn('spotify')
assert fn('Artist', 'Hit') == ('spid-99', 'library')
assert fn('Artist', 'Miss') == (None, None)
def test_db_service_track_id_real_sql_executes(tmp_path, monkeypatch):
"""Run the ACTUAL query against a real (temp) tracks/artists schema — the broad
exceptNone in db_service_track_id would otherwise mask a column/join typo as
'no match' for every track (#945 verification)."""
import sqlite3
import types
import core.exports.export_sources as es
dbfile = tmp_path / "lib.db"
con = sqlite3.connect(str(dbfile))
con.executescript(
"CREATE TABLE artists (id TEXT PRIMARY KEY, name TEXT);"
"CREATE TABLE tracks (id TEXT, artist_id TEXT, title TEXT, "
"spotify_track_id TEXT, deezer_id TEXT);"
"INSERT INTO artists VALUES ('a1','Kendrick Lamar');"
"INSERT INTO tracks VALUES ('t1','a1','Not Like Us','spid-NLU','dz-NLU');"
)
con.commit()
con.close()
# fresh connection per call (db_service_track_id closes it in finally)
fake_db = types.SimpleNamespace(_get_connection=lambda: sqlite3.connect(str(dbfile)))
monkeypatch.setattr("database.music_database.get_database", lambda: fake_db)
assert es.db_service_track_id("Kendrick Lamar", "Not Like Us", "spotify") == "spid-NLU"
assert es.db_service_track_id("kendrick lamar", "not like us", "deezer") == "dz-NLU" # case-insensitive
assert es.db_service_track_id("Kendrick Lamar", "Unknown Song", "spotify") is None
# ── discovery-cache resolution (#945: use the already-discovered IDs, no API call) ──
import json as _json
from core.exports.export_sources import (
service_id_from_extra_data,
resolve_service_track_ids,
)
def _extra(service, tid, discovered=True, provider=None):
return {'extra_data': _json.dumps({'discovered': discovered,
'provider': provider or service,
'matched_data': {'id': tid}})}
def test_extra_data_id_when_discovered_to_that_service():
assert service_id_from_extra_data(_extra('deezer', 111), 'deezer') == '111'
# dict (not str) extra_data also works
raw = {'extra_data': {'discovered': True, 'provider': 'spotify', 'matched_data': {'id': 'spX'}}}
assert service_id_from_extra_data(raw, 'spotify') == 'spX'
def test_extra_data_provider_must_match_service():
# discovered to Spotify, exporting to Deezer → don't reuse the (wrong-service) id
assert service_id_from_extra_data(_extra('spotify', 111), 'deezer') is None
def test_extra_data_wing_it_fallback_is_not_trusted():
track = _extra('deezer', 111, provider='wing_it_fallback')
assert service_id_from_extra_data(track, 'deezer') is None
def test_extra_data_misc_none_cases():
assert service_id_from_extra_data({}, 'deezer') is None # no extra_data
assert service_id_from_extra_data({'extra_data': 'not json{'}, 'deezer') is None # bad json
assert service_id_from_extra_data(_extra('deezer', 111, discovered=False), 'deezer') is None
def test_resolve_waterfall_cache_then_library_then_unmatched():
tracks = [
_extra('deezer', 111) | {'artist_name': 'A', 'track_name': 'Cached'}, # cache hit
{'artist_name': 'A', 'track_name': 'InLib'}, # library hit (db_fn)
{'artist_name': 'A', 'track_name': 'Nowhere'}, # unmatched
]
db_fn = lambda a, t, s: 'lib-222' if t == 'InLib' else None
out = resolve_service_track_ids(tracks, 'deezer', db_fn=db_fn)
ids = [r['service_track_id'] for r in out['resolved']]
assert ids == ['111', 'lib-222', None]
s = out['stats']
assert s == {'total': 3, 'resolved': 2, 'unmatched': 1, 'from_cache': 1,
'from_library': 1, 'from_search': 0}
# ── backfill: confident live-search match for the un-cached/un-enriched tail (#945) ──
from core.metadata.types import Track as _Track
from core.exports.export_sources import search_service_track_id, BACKFILL_MIN_SCORE
def _cand(name, artist, tid, album_type='album'):
return _Track(id=tid, name=name, artists=[artist], album='A',
duration_ms=200000, album_type=album_type)
def test_backfill_exact_match_returned():
search = lambda q: [_cand('Not Like Us', 'Kendrick Lamar', 'dz-NLU')]
assert search_service_track_id('Kendrick Lamar', 'Not Like Us', search_fn=search) == 'dz-NLU'
def test_backfill_wrong_artist_rejected():
"""SAFETY: an exact-title hit by the WRONG artist scores below the floor (no 1.5x exact-
artist boost) None, so backfill never adds someone else's same-named track."""
search = lambda q: [_cand('Not Like Us', 'Some Other Guy', 'wrong-id')]
assert search_service_track_id('Kendrick Lamar', 'Not Like Us', search_fn=search) is None
def test_backfill_karaoke_cover_rejected():
"""SAFETY: a karaoke/cover version is buried (x0.05) below the floor → None."""
search = lambda q: [_cand('Not Like Us (Karaoke Version)', 'Karaoke All Stars', 'kar-id')]
assert search_service_track_id('Kendrick Lamar', 'Not Like Us', search_fn=search) is None
def test_backfill_picks_real_over_cover():
search = lambda q: [
_cand('Not Like Us (Karaoke Version)', 'Karaoke All Stars', 'kar-id'),
_cand('Not Like Us', 'Kendrick Lamar', 'real-id'),
]
assert search_service_track_id('Kendrick Lamar', 'Not Like Us', search_fn=search) == 'real-id'
def test_backfill_empty_and_error_and_no_title():
assert search_service_track_id('A', 'X', search_fn=lambda q: []) is None
def boom(q):
raise RuntimeError('deezer flaked')
assert search_service_track_id('A', 'X', search_fn=boom) is None # fail-safe
assert search_service_track_id('A', '', search_fn=lambda q: [_cand('X', 'A', 'i')]) is None
def test_resolve_waterfall_uses_search_only_when_cache_and_library_miss():
tracks = [
_extra('deezer', 111) | {'artist_name': 'A', 'track_name': 'Cached'},
{'artist_name': 'A', 'track_name': 'InLib'},
{'artist_name': 'A', 'track_name': 'OnlyOnSvc'},
]
db_fn = lambda a, t, s: 'lib-2' if t == 'InLib' else None
search_id_fn = lambda a, t: 'srch-3' if t == 'OnlyOnSvc' else None
out = resolve_service_track_ids(tracks, 'deezer', db_fn=db_fn, search_id_fn=search_id_fn)
assert [r['service_track_id'] for r in out['resolved']] == ['111', 'lib-2', 'srch-3']
s = out['stats']
assert (s['from_cache'], s['from_library'], s['from_search'], s['unmatched']) == (1, 1, 1, 0)
def test_resolve_no_search_fn_leaves_tail_unmatched():
out = resolve_service_track_ids([{'artist_name': 'A', 'track_name': 'X'}], 'deezer',
db_fn=lambda a, t, s: None) # search_id_fn omitted
assert out['resolved'][0]['service_track_id'] is None
assert out['stats']['unmatched'] == 1 and out['stats']['from_search'] == 0

View file

@ -72,31 +72,3 @@ def test_empty_playlist():
out = resolve_playlist_tracks([], lambda a, t: (None, None))
assert out["resolved"] == []
assert out["stats"]["total"] == 0
# ── id_key generalization (#945 service export reuses the LB resolver) ──
from core.exports.playlist_export import resolve_playlist_tracks as _rpt
def _const_resolver(mapping):
return lambda artist, title: mapping.get((artist, title), (None, None))
def test_default_id_key_is_recording_mbid_unchanged():
# ListenBrainz/JSPF callers must be byte-for-byte unaffected by the generalization.
out = _rpt([{'artist': 'A', 'title': 'X'}], _const_resolver({('A', 'X'): ('mbid-1', 'db')}))
assert out['resolved'][0]['recording_mbid'] == 'mbid-1'
assert 'service_track_id' not in out['resolved'][0]
def test_custom_id_key_carries_service_id():
out = _rpt(
[{'artist': 'A', 'title': 'X'}, {'artist': 'B', 'title': 'Y'}],
_const_resolver({('A', 'X'): ('spid-1', 'library')}), # B/Y unmatched
id_key='service_track_id',
)
assert out['resolved'][0]['service_track_id'] == 'spid-1'
assert out['resolved'][1]['service_track_id'] is None
assert 'recording_mbid' not in out['resolved'][0]
assert out['stats']['resolved'] == 1 and out['stats']['unmatched'] == 1

View file

@ -244,61 +244,3 @@ def test_atomic_helper_cleans_temp_and_keeps_source_on_failure(tmp_path, monkeyp
assert src.exists() # source preserved on failure
assert not dst.exists() # no partial final file
assert not list(dstdir.glob(".*ssync-tmp")) # temp cleaned up
# ── #941: create_lossy_copy now accepts all lossless sources + never overwrites them ──
import core.imports.file_ops as _fo
def _enable_lossy(monkeypatch, codec="mp3", bitrate="320"):
cfg = {"lossy_copy.enabled": True, "lossy_copy.codec": codec,
"lossy_copy.bitrate": bitrate, "lossy_copy.delete_original": False}
monkeypatch.setattr(_fo.config_manager, "get", lambda k, d=None: cfg.get(k, d))
monkeypatch.setattr(_fo, "get_audio_quality_string", lambda _p: None)
def test_create_lossy_copy_rejects_non_lossless(monkeypatch, tmp_path):
_enable_lossy(monkeypatch)
src = tmp_path / "song.mp3"
src.write_bytes(b"id3")
assert _fo.create_lossy_copy(str(src)) is None # lossy input → nothing to do
def test_create_lossy_copy_now_accepts_wav(monkeypatch, tmp_path):
"""Was FLAC-only; a WAV must now pass the gate and convert (#941)."""
_enable_lossy(monkeypatch, codec="mp3")
monkeypatch.setattr(_fo.shutil, "which", lambda _name: "/usr/bin/ffmpeg")
monkeypatch.setattr("mutagen.File", lambda *_a, **_k: None) # skip tag write
seen = {}
def _fake_run(cmd, **_kw):
seen["cmd"] = cmd
open(cmd[-1], "wb").write(b"fake-mp3") # ffmpeg "writes" the output
return types.SimpleNamespace(returncode=0, stderr="")
monkeypatch.setattr(_fo.subprocess, "run", _fake_run)
src = tmp_path / "song.wav"
src.write_bytes(b"RIFF....WAVE")
out = _fo.create_lossy_copy(str(src))
assert out and out.endswith(".mp3") # WAV passed the gate + converted
assert str(src) in seen["cmd"] # ffmpeg got the .wav input
def test_create_lossy_copy_skips_when_output_would_overwrite_source(monkeypatch, tmp_path):
"""REGRESSION: .m4a ALAC source + AAC codec → output is the same .m4a path.
ffmpeg (-y) must NEVER run, or it would destroy the original lossless file."""
_enable_lossy(monkeypatch, codec="aac", bitrate="256")
monkeypatch.setattr(_fo, "m4a_codec", lambda _p: "alac") # source IS ALAC (lossless)
ran = {"called": False}
monkeypatch.setattr(_fo.subprocess, "run",
lambda *_a, **_k: ran.__setitem__("called", True))
src = tmp_path / "track.m4a"
src.write_bytes(b"....ALAC....")
out = _fo.create_lossy_copy(str(src))
assert out is None # skipped — output would overwrite source
assert ran["called"] is False # the original was never touched

View file

@ -711,73 +711,3 @@ def test_exhaustive_single_source_exhausted_fails(monkeypatch):
assert task["status"] == "failed"
assert submitted == []
assert completion == [("rbatch", "rtask", False)]
def test_quarantine_failure_preserves_file_instead_of_deleting(tmp_path, monkeypatch):
"""REGRESSION: when move_to_quarantine itself FAILS (e.g. a cross-device move on
a NAS), the rejected file must be LEFT IN PLACE for retry never deleted.
Deleting a download we couldn't even quarantine is data loss that forces a
re-download (Discord: Shdjfgatdif). The task is still marked failed + the batch
still notified only the destructive os.remove is gone. Drives the real pipeline
through the integrity-rejection path with quarantine forced to raise."""
source_path = tmp_path / "source.flac"
source_path.write_bytes(b"audio")
context_key, task_id, batch_id = "ctx-q", "task-q", "batch-q"
context = {
"search_result": {"is_simple_download": True, "filename": "Album/source.flac", "album": "Album"},
"track_info": {}, "original_search_result": {}, "is_album_download": False,
"task_id": task_id, "batch_id": batch_id,
}
completion_calls = []
snap = (dict(runtime_state.matched_downloads_context), dict(runtime_state.download_tasks),
dict(runtime_state.download_batches), set(runtime_state.processed_download_ids),
dict(runtime_state.post_process_locks))
for d in (runtime_state.matched_downloads_context, runtime_state.download_tasks,
runtime_state.download_batches, runtime_state.processed_download_ids,
runtime_state.post_process_locks):
d.clear()
runtime = types.SimpleNamespace(
automation_engine=None,
on_download_completed=lambda b, t, success: completion_calls.append((b, t, success)),
web_scan_manager=types.SimpleNamespace(request_scan=lambda r: None),
repair_worker=None,
)
fake_acoustid = types.ModuleType("core.acoustid_verification")
fake_acoustid.AcoustIDVerification = _FakeAcoustidVerifier
fake_acoustid.VerificationResult = types.SimpleNamespace(FAIL="FAIL")
monkeypatch.setitem(sys.modules, "core.acoustid_verification", fake_acoustid)
from core.imports.file_integrity import IntegrityResult
# Integrity FAILS → enters the quarantine block.
monkeypatch.setattr(import_pipeline, "check_audio_integrity",
lambda *_a, **_k: IntegrityResult(ok=False, reason="broken (test)", checks={}))
# The quarantine MOVE itself raises → exercises the except branch (the fix).
def _boom(*_a, **_k):
raise OSError("cross-device link not permitted (simulated NAS)")
monkeypatch.setattr(import_pipeline, "move_to_quarantine", _boom)
monkeypatch.setattr(import_paths, "_get_config_manager", lambda: _Config(str(tmp_path / "Transfer")))
monkeypatch.setattr(import_pipeline, "add_activity_item", lambda *a, **k: None)
runtime_state.matched_downloads_context[context_key] = context
runtime_state.download_tasks[task_id] = {"track_info": {}, "status": "running"}
try:
import_pipeline.post_process_matched_download_with_verification(
context_key, context, str(source_path), task_id, batch_id, runtime)
# THE regression: a file we couldn't quarantine is preserved, not deleted.
assert source_path.exists(), "file must be LEFT IN PLACE when quarantine fails"
# Downstream still correct — task failed, batch notified of failure.
assert runtime_state.download_tasks[task_id]["status"] == "failed"
assert completion_calls == [(batch_id, task_id, False)]
finally:
for d, original in zip(
(runtime_state.matched_downloads_context, runtime_state.download_tasks,
runtime_state.download_batches, runtime_state.processed_download_ids,
runtime_state.post_process_locks), snap):
d.clear()
d.update(original)

View file

@ -1,8 +1,6 @@
import os
from concurrent.futures import Future
import pytest
import core.imports.routes as import_routes
from core.imports.routes import (
ImportRouteRuntime,
@ -19,15 +17,6 @@ from core.imports.routes import (
)
@pytest.fixture(autouse=True)
def _clear_staging_scan_cache():
# The shared staging scan is cached at module level; clear it between tests so
# one test's scan can't satisfy another within the TTL.
import_routes.invalidate_staging_scan_cache()
yield
import_routes.invalidate_staging_scan_cache()
class _FakeLogger:
def __init__(self):
self.debug_messages = []
@ -192,21 +181,14 @@ def test_staging_hints_prefers_tag_queries_then_folder_queries(tmp_path):
_touch(tmp_path / "Folder_Album" / "02.mp3")
_touch(tmp_path / "Loose" / "track.flac")
def _empty(artist="", album="", track_number=0):
return {"title": "", "artist": artist, "albumartist": "",
"album": album, "track_number": track_number, "disc_number": 1}
# hints now derives from the shared staging scan (read_staging_file_metadata),
# the same reader files/groups use — not a separate read_tags pass.
metadata = {
os.path.join("Folder_Album", "01.mp3"): _empty(artist="Tagged Artist", album="Tagged Album", track_number=1),
os.path.join("Folder_Album", "02.mp3"): _empty(artist="Tagged Artist", album="Tagged Album", track_number=2),
os.path.join("Loose", "track.flac"): _empty(),
}
def _read_tags(file_path):
if file_path.endswith("01.mp3") or file_path.endswith("02.mp3"):
return {"album": ["Tagged Album"], "artist": ["Tagged Artist"]}
return {}
runtime = ImportRouteRuntime(
get_staging_path=lambda: str(tmp_path),
read_staging_file_metadata=_metadata_for(metadata),
read_tags=_read_tags,
logger=_FakeLogger(),
)
@ -624,31 +606,3 @@ def test_singles_process_requires_files():
assert status == 400
assert payload == {"success": False, "error": "No files provided"}
def test_staging_scan_is_shared_across_files_groups_hints(tmp_path):
"""#935: opening Import fires files+groups+hints together; they must share ONE
staging scan (one walk + one tag read per file), not re-read every file 3×."""
_touch(tmp_path / "Album" / "01.mp3")
_touch(tmp_path / "Album" / "02.mp3")
reads = []
def _meta(full_path, rel_path):
reads.append(rel_path)
return {"title": "T", "artist": "Artist", "albumartist": "Artist",
"album": "Album", "track_number": 1, "disc_number": 1}
runtime = ImportRouteRuntime(
get_staging_path=lambda: str(tmp_path),
read_staging_file_metadata=_meta,
logger=_FakeLogger(),
)
# All three page-open endpoints, back to back (within the cache TTL).
staging_files(runtime)
staging_groups(runtime)
staging_hints(runtime)
# 2 files × ONE shared scan = 2 reads — not 6 (which is 2 files × 3 endpoints).
assert sorted(reads) == [os.path.join("Album", "01.mp3"), os.path.join("Album", "02.mp3")]

View file

@ -6,10 +6,7 @@ parsers are tested here; the ffmpeg call is integration.
import pytest
import core.imports.silence as silence_mod
from core.imports.silence import (
detect_broken_audio,
is_dsd_path,
silence_ratio_from_output,
is_mostly_silent_reason,
measured_duration_from_astats,
@ -105,47 +102,3 @@ def test_no_incomplete_reason_for_full_file():
def test_no_incomplete_reason_when_unmeasurable():
assert incomplete_audio_reason(None, 188.4, min_ratio=0.85) is None
assert incomplete_audio_reason(30.0, 0, min_ratio=0.85) is None
# ── DSD (#939): the samples÷rate truncation math is invalid for DSD, so it must
# be skipped for .dsf/.dff (silence detection still applies). ──
def test_is_dsd_path():
assert is_dsd_path("/m/Album/01. Song.dsf") is True
assert is_dsd_path("/m/Album/01. Song.DFF") is True # case-insensitive
assert is_dsd_path("/m/Album/01. Song.flac") is False
assert is_dsd_path("") is False
assert is_dsd_path(None) is False
class _FakeProc:
def __init__(self, stderr):
self.stderr = stderr.encode("utf-8")
class _FakeInfo:
length = 330.0 # container says 330s
sample_rate = 44100
def _patch_broken_pipeline(monkeypatch, astats_stderr):
"""Make detect_broken_audio run against a canned 'truncated' ffmpeg result."""
monkeypatch.setattr(silence_mod, "_ffmpeg_available", lambda: True)
monkeypatch.setattr("mutagen.File", lambda *_a, **_k: type("A", (), {"info": _FakeInfo()})())
monkeypatch.setattr(silence_mod.subprocess, "run", lambda *_a, **_k: _FakeProc(astats_stderr))
def test_truncation_flagged_for_normal_file(monkeypatch):
# ~40s decoded of a 330s container (12%) → a normal file IS flagged truncated.
astats = "[Parsed_astats_0 @ 0x55] Number of samples: 1764000\n" # 1764000/44100 ≈ 40s
_patch_broken_pipeline(monkeypatch, astats)
reason = detect_broken_audio("/m/Album/01. Song.flac", min_ratio=0.85)
assert reason and "Incomplete audio" in reason
def test_truncation_skipped_for_dsd(monkeypatch):
# Same 12%-decoding numbers, but a .dsf file must NOT be flagged — the math is
# invalid for DSD (ffmpeg decodes DSD to PCM at a different rate). #939
astats = "[Parsed_astats_0 @ 0x55] Number of samples: 1764000\n"
_patch_broken_pipeline(monkeypatch, astats)
assert detect_broken_audio("/m/Album/01. Song.dsf", min_ratio=0.85) is None

View file

@ -1,163 +0,0 @@
"""Async/background staging scan (#947): a whole-library migration makes the synchronous
scan exceed gunicorn's 120s timeout. The runner moves the SAME scan off the request thread
with progress + a generation guard. Metadata reads are injected so no real audio is needed."""
import os
import time
import types
import pytest
import core.imports.routes as routes
def _meta(_full, _rel):
return {"title": "t", "album": "Alb", "artist": "Art", "albumartist": "Art",
"track_number": None, "disc_number": None}
def _runtime(read=_meta):
return types.SimpleNamespace(read_staging_file_metadata=read)
def _staging(tmp_path, n=3, subdir="Artist/Album"):
d = tmp_path / "staging"
for part in subdir.split("/"):
d = d / part
d.mkdir(parents=True, exist_ok=True)
for i in range(n):
(d / f"{i:02d}.flac").write_text("x")
return str(tmp_path / "staging")
@pytest.fixture(autouse=True)
def _reset():
routes.invalidate_staging_scan_cache()
routes._staging_scan_status.update({"status": "idle", "scanned": 0, "total": 0,
"path": None, "error": None})
yield
routes.invalidate_staging_scan_cache()
def _await_done(timeout=5.0):
end = time.time() + timeout
while time.time() < end and routes._staging_scan_status["status"] == "scanning":
time.sleep(0.03)
def test_scan_reports_progress(tmp_path):
sp = _staging(tmp_path, 3)
prog = {}
recs = routes._scan_staging_records(_runtime(), sp, progress=prog)
assert len(recs) == 3
assert prog["total"] == 3 and prog["scanned"] == 3
def test_default_scan_behaviour_unchanged(tmp_path):
sp = _staging(tmp_path, 2)
assert len(routes._scan_staging_records(_runtime(), sp)) == 2 # no progress arg = as before
def test_accessor_ready_for_small_folder(tmp_path):
sp = _staging(tmp_path, 2)
state, val = routes.get_staging_records_or_status(_runtime(), sp, grace_seconds=3.0)
assert state == "ready" and len(val) == 2
def test_accessor_scanning_when_scan_exceeds_grace(tmp_path):
sp = _staging(tmp_path, 2)
def slow(_f, _r):
time.sleep(0.4)
return _meta(_f, _r)
state, val = routes.get_staging_records_or_status(_runtime(slow), sp, grace_seconds=0.1)
assert state == "scanning"
assert val["status"] == "scanning" and val["total"] in (0, 2)
_await_done()
def test_ensure_scan_is_idempotent(tmp_path):
sp = _staging(tmp_path, 2)
calls = {"n": 0}
def counting(_f, _r):
calls["n"] += 1
time.sleep(0.15)
return _meta(_f, _r)
rt = _runtime(counting)
routes.ensure_background_staging_scan(rt, sp)
routes.ensure_background_staging_scan(rt, sp) # must NOT start a second scan
_await_done()
assert calls["n"] == 2 # 2 files read once, not 4
def test_generation_guard_discards_stale_records(tmp_path):
sp = _staging(tmp_path, 2)
def read_then_import(_f, _r):
routes.invalidate_staging_scan_cache() # simulate an import landing mid-scan
return _meta(_f, _r)
recs = routes._scan_staging_records(_runtime(read_then_import), sp)
assert len(recs) == 2 # caller still gets its records
assert routes._staging_scan_cache["records"] is None # but stale set NOT committed to cache
def _full_runtime(staging_path, read=_meta):
return types.SimpleNamespace(
get_staging_path=lambda: staging_path,
read_staging_file_metadata=read,
logger=types.SimpleNamespace(error=lambda *a, **k: None),
)
def test_helper_passes_records_through_when_ready(monkeypatch):
monkeypatch.setattr(routes, 'get_staging_records_or_status',
lambda rt, sp: ("ready", [{"x": 1}]))
records, scanning = routes._records_or_scanning_payload(_runtime(), "/x")
assert scanning is None and records == [{"x": 1}]
def test_helper_builds_scanning_payload(monkeypatch):
monkeypatch.setattr(routes, 'get_staging_records_or_status',
lambda rt, sp: ("scanning", {"scanned": 5, "total": 20, "status": "scanning"}))
records, scanning = routes._records_or_scanning_payload(_runtime(), "/x")
assert records is None
assert scanning == {"success": True, "scanning": True,
"progress": {"scanned": 5, "total": 20}}
def test_staging_files_endpoint_ready(tmp_path):
payload, status = routes.staging_files(_full_runtime(_staging(tmp_path, 2)))
assert status == 200 and payload["success"] and len(payload["files"]) == 2
def test_staging_files_endpoint_returns_scanning(tmp_path, monkeypatch):
monkeypatch.setattr(routes, 'get_staging_records_or_status',
lambda r, p: ("scanning", {"scanned": 3, "total": 10}))
payload, status = routes.staging_files(_full_runtime(_staging(tmp_path, 2)))
assert status == 200 and payload.get("scanning") is True
assert payload["progress"] == {"scanned": 3, "total": 10}
def test_staging_groups_endpoint_returns_scanning(tmp_path, monkeypatch):
monkeypatch.setattr(routes, 'get_staging_records_or_status',
lambda r, p: ("scanning", {"scanned": 1, "total": 9}))
payload, status = routes.staging_groups(_full_runtime(_staging(tmp_path, 2)))
assert payload.get("scanning") is True
def test_scan_status_ready_after_warm(tmp_path):
sp = _staging(tmp_path, 2)
routes._scan_staging_records(_runtime(), sp) # warm the cache
payload, status = routes.staging_scan_status(_full_runtime(sp))
assert status == 200 and payload["success"] and payload["ready"] is True
def test_scan_status_not_ready_when_cold(tmp_path):
sp = _staging(tmp_path, 2) # cold (autouse reset)
payload, _ = routes.staging_scan_status(_full_runtime(sp))
assert payload["ready"] is False
assert "scanned" in payload and "total" in payload

View file

@ -1,95 +0,0 @@
"""Bulk-fetch pagination seam — pages a server fetch while feeding the no-progress
watchdog every page (the DXP4800 "Fetching all tracks in bulk… stuck 300s" bug).
Pure: a fake fetch_page stands in for the server, a list records progress calls."""
import math
from core.library.bulk_paginate import (
paginate_all_items,
DEFAULT_PAGE_SIZE,
)
def _server(total, *, fail_at=None, fail_times=0):
"""A fake server holding `total` items. Returns the right page slice for
(start_index, limit). Optionally returns None (a failed request) the first
`fail_times` times it's called at offset `fail_at`."""
items = list(range(total))
state = {"fails": 0}
def fetch_page(start_index, limit):
if fail_at is not None and start_index == fail_at and state["fails"] < fail_times:
state["fails"] += 1
return None
return items[start_index:start_index + limit]
return fetch_page
def test_returns_every_item_across_pages():
out = paginate_all_items(_server(7148), page_size=1000)
assert out == list(range(7148))
def test_progress_fed_every_page_not_once_for_whole_library():
# The watchdog-feed invariant: progress count scales with N/page_size, so the
# gap between progress beats is one page — never the whole library. A single
# 7148-track library used to emit ZERO progress (one 10k page) and stall.
calls = []
paginate_all_items(_server(7148), page_size=1000, report_progress=calls.append)
assert len(calls) == math.ceil(7148 / 1000) == 8
def test_sub_page_library_still_reports_progress():
# Regression: the old loop skipped progress on the final/only page, so a
# library smaller than one page reported nothing → watchdog starved.
calls = []
out = paginate_all_items(_server(500), page_size=1000, report_progress=calls.append)
assert out == list(range(500))
assert len(calls) == 1 # was 0 before the fix
def test_exact_multiple_of_page_size():
calls = []
out = paginate_all_items(_server(2000), page_size=1000, report_progress=calls.append)
assert out == list(range(2000))
assert len(calls) == 2 # two full pages, both reported
def test_empty_library():
calls = []
out = paginate_all_items(_server(0), page_size=1000, report_progress=calls.append)
assert out == []
assert calls == []
def test_no_progress_callback_is_safe():
assert paginate_all_items(_server(2500), page_size=1000) == list(range(2500))
def test_failed_page_shrinks_then_succeeds():
# First request at offset 0 fails once; the helper halves the page size, retries,
# and still returns everything — the slow-server resilience path.
waits = []
out = paginate_all_items(
_server(1500, fail_at=0, fail_times=1),
page_size=1000, min_page_size=250,
on_retry_wait=lambda: waits.append(1),
)
assert out == list(range(1500))
assert waits == [1] # waited once before the retry
def test_gives_up_after_repeated_failures_at_floor():
# A page that always fails at the floor must terminate (not loop forever) and
# return what was gathered — here nothing, since it fails on the first page.
def always_fail(_start, _limit):
return None
out = paginate_all_items(always_fail, page_size=250, min_page_size=250)
assert out == []
def test_default_page_size_is_watchdog_safe():
# A guard on the constant itself: the default must be far below a library size
# that would fit in one request, so progress is always paged.
assert DEFAULT_PAGE_SIZE <= 1000

View file

@ -1,77 +0,0 @@
"""Boot-phase guards must defer blocking provider network probes."""
from unittest.mock import MagicMock, patch
from core.boot_phase import is_boot_phase, mark_boot_complete
from core.metadata import registry
def setup_function():
mark_boot_complete()
def teardown_function():
mark_boot_complete()
def test_get_primary_source_skips_spotify_probe_during_boot(monkeypatch):
import core.boot_phase as boot_phase
boot_phase._boot_active = True
monkeypatch.setattr(registry, "get_configured_primary_source", lambda: "spotify")
with patch.object(registry, "get_spotify_client") as get_client:
assert registry.get_primary_source() == "spotify"
get_client.assert_not_called()
def test_get_primary_source_status_skips_client_probe_during_boot(monkeypatch):
import core.boot_phase as boot_phase
boot_phase._boot_active = True
monkeypatch.setattr(
registry, "_get_config_value",
lambda key, default=None: "spotify" if key == "metadata.fallback_source" else default,
)
with patch.object(registry, "get_client_for_source") as get_client:
status = registry.get_primary_source_status()
get_client.assert_not_called()
assert status["source"] == "spotify"
assert status["connected"] is False
def test_spotify_auth_uses_token_presence_only_during_boot(monkeypatch):
import core.boot_phase as boot_phase
from core.spotify_client import SpotifyClient
boot_phase._boot_active = True
client = SpotifyClient.__new__(SpotifyClient)
client.sp = MagicMock()
client._auth_cache_lock = __import__('threading').Lock()
client._auth_cached_result = None
client._auth_cache_time = 0
client._AUTH_CACHE_TTL = 900
monkeypatch.setattr(client, "_has_cached_oauth_token", lambda: True)
with patch("spotipy.Spotify") as spotify_cls:
assert client.is_spotify_authenticated() is True
spotify_cls.assert_not_called()
def test_deezer_download_defers_arl_auth_during_boot(monkeypatch):
import core.boot_phase as boot_phase
from core.deezer_download_client import DeezerDownloadClient
boot_phase._boot_active = True
monkeypatch.setattr(
"config.settings.config_manager.get",
lambda key, default=None: "fake-arl" if key == "deezer_download.arl" else default,
)
with patch.object(DeezerDownloadClient, "_authenticate") as authenticate:
client = DeezerDownloadClient(download_path="/tmp/deezer-test")
authenticate.assert_not_called()
assert client._pending_arl == "fake-arl"
assert client.is_authenticated() is False

View file

@ -1,32 +0,0 @@
"""Tests for boot-safe configured primary source lookup."""
from unittest.mock import MagicMock, patch
from core.boot_phase import mark_boot_complete
from core.metadata import registry
def setup_function():
mark_boot_complete()
def test_get_configured_primary_source_reads_config_without_auth_probe(monkeypatch):
monkeypatch.setattr(
registry,
"_get_config_value",
lambda key, default=None: "spotify" if key == "metadata.fallback_source" else default,
)
with patch.object(registry, "get_spotify_client") as get_client:
assert registry.get_configured_primary_source() == "spotify"
get_client.assert_not_called()
def test_get_primary_source_still_downgrades_unauthenticated_spotify(monkeypatch):
monkeypatch.setattr(registry, "get_configured_primary_source", lambda: "spotify")
spotify = MagicMock()
spotify.is_spotify_authenticated.return_value = False
monkeypatch.setattr(registry, "get_spotify_client", lambda **_: spotify)
assert registry.get_primary_source() == registry.METADATA_SOURCE_PRIORITY[0]

View file

@ -447,36 +447,3 @@ class TestFilterAndRerank:
# Karaoke pattern reduces score by 0.05x — well below 0.5
assert all(t.id != 'karaoke-id' for t in result)
assert any(t.id == 'real-id' for t in result)
# ── build_combined_search_query: plain, source-agnostic queries (pool-fix bug) ──
from core.metadata.relevance import build_combined_search_query as _bcsq
def test_combined_query_is_plain_not_field_scoped():
# THE fix: must NOT emit Spotify `track:`/`artist:` syntax — that leaked to Deezer
# (which aborted the connection) and other fallbacks that can't parse it.
q = _bcsq('Not Like Us', 'Kendrick Lamar')
assert q == 'Not Like Us Kendrick Lamar'
assert 'track:' not in q and 'artist:' not in q
def test_combined_query_track_or_artist_alone():
assert _bcsq('Not Like Us', '') == 'Not Like Us'
assert _bcsq('', 'Kendrick Lamar') == 'Kendrick Lamar'
def test_combined_query_trims_whitespace():
assert _bcsq(' Not Like Us ', ' Kendrick Lamar ') == 'Not Like Us Kendrick Lamar'
def test_combined_query_falls_back_to_legacy():
assert _bcsq('', '', 'free text search') == 'free text search'
# track/artist win over legacy when present
assert _bcsq('A', 'B', 'ignored') == 'A B'
def test_combined_query_empty_when_nothing():
assert _bcsq('', '', '') == ''
assert _bcsq(' ', '', ' ') == ''

View file

@ -1,86 +0,0 @@
import unittest
from unittest.mock import patch, MagicMock
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from core.metadata.registry import get_spotify_client_for_profile
# Mock config_manager as it's a global dependency
class MockConfigManager:
def __init__(self):
self.store = {}
def get(self, key, default=None):
return self.store.get(key, default)
def get_spotify_config(self):
return self.store.get('spotify', {})
def set(self, key, value):
self.store[key] = value
class TestSpotifyOAuthIntegration(unittest.TestCase):
@patch('core.metadata.registry.get_spotify_client')
@patch('core.metadata.registry._profile_spotify_credentials_provider')
@patch('core.metadata.registry._get_config_value')
@patch('spotipy.oauth2.SpotifyOAuth')
@patch('core.spotify_client.normalize_spotify_oauth_config')
def test_get_spotify_client_for_profile_uses_normalized_config(self, mock_normalize, mock_spotify_oauth, mock_get_config, mock_creds_provider, mock_get_global):
# Set up mock config values
mock_creds_provider.return_value = {
"client_id": " original_client_id ",
"client_secret": " original_client_secret ",
"redirect_uri": "http://example.com/callback/"
}
# Make sure the file exists check passes
with patch('os.path.exists', return_value=True):
# Set up mock for normalize_spotify_oauth_config to return cleaned values
mock_normalize.return_value = {
"client_id": "cleaned_client_id",
"client_secret": "cleaned_client_secret",
"redirect_uri": "http://example.com/callback"
}
# Call the function under test with profile_id=2 (to bypass global client)
get_spotify_client_for_profile(profile_id=2)
# Assert that normalize_spotify_oauth_config was called with the original config
mock_normalize.assert_any_call({
"client_id": " original_client_id ",
"client_secret": " original_client_secret ",
"redirect_uri": "http://example.com/callback/"
})
# Assert that SpotifyOAuth was initialized with the normalized config
mock_spotify_oauth.assert_called_once()
args, kwargs = mock_spotify_oauth.call_args
self.assertEqual(kwargs['client_id'], "cleaned_client_id")
self.assertEqual(kwargs['client_secret'], "cleaned_client_secret")
self.assertEqual(kwargs['redirect_uri'], "http://example.com/callback")
self.assertEqual(kwargs['state'], 'profile_2')
@patch('core.metadata.registry.get_spotify_client')
@patch('core.metadata.registry._profile_spotify_credentials_provider')
@patch('core.metadata.registry._get_config_value')
@patch('spotipy.oauth2.SpotifyOAuth')
@patch('core.spotify_client.normalize_spotify_oauth_config')
def test_get_spotify_client_for_profile_handles_no_config(self, mock_normalize, mock_spotify_oauth, mock_get_config, mock_creds_provider, mock_get_global):
# Simulate no spotify config
mock_creds_provider.return_value = {}
mock_get_config.side_effect = lambda key, default: "" if "client" in key else "http://127.0.0.1:8888/callback"
# Ensure os.path.exists returns False so it doesn't try to use cache
with patch('os.path.exists', return_value=False):
# Call the function under test
get_spotify_client_for_profile(profile_id=2)
# It still reaches get_spotify_client() which is mocked.
# In registry.py, the fallbacks for client_id/client_secret result in calls to get_spotify_client().
mock_get_global.assert_called()
if __name__ == '__main__':
unittest.main()

View file

@ -1,163 +0,0 @@
"""The canonical "is this lossless?" seam + the lossy-copy overwrite invariant
(#941). All pure — no files, no ffmpeg — so the decision and the safety guard are
unit-testable in isolation. Both the import path (create_lossy_copy) and the Lossy
Converter repair job route through these, so the same rules drive both."""
from core.quality.lossless import (
LOSSLESS_FORMATS,
LOSSLESS_CANDIDATE_EXTENSIONS,
is_lossless_format,
is_lossless_audio_path,
lossy_output_would_overwrite_source,
)
from core.quality.model import AudioQuality
from core.repair_jobs.lossy_converter import _lossless_ext_where
# ── is_lossless_format ──
def test_lossless_formats_recognized():
for fmt in ('flac', 'alac', 'wav', 'dsf', 'FLAC', 'Dsf'):
assert is_lossless_format(fmt) is True
def test_lossy_formats_not_lossless():
for fmt in ('mp3', 'aac', 'ogg', 'opus', 'wma', 'unknown', '', None):
assert is_lossless_format(fmt) is False
# ── is_lossless_audio_path (the ambiguity is the whole point) ──
def test_unambiguous_extensions_decided_by_extension():
for path in ('/m/a.flac', '/m/a.wav', '/m/a.wave', '/m/a.aiff', '/m/a.aif',
'/m/a.dsf', '/m/a.dff', '/m/a.alac', '/m/A.FLAC'):
assert is_lossless_audio_path(path) is True
def test_lossy_extensions_are_not_lossless():
for path in ('/m/a.mp3', '/m/a.ogg', '/m/a.opus', '/m/a.wma', '/m/a.aac'):
assert is_lossless_audio_path(path) is False
def test_m4a_without_probe_is_not_lossless():
# The safe default: with no codec probe, an .m4a can't be proven lossless, so
# an AAC file is never misclassified as lossless and converted/deleted.
assert is_lossless_audio_path('/m/a.m4a') is False
assert is_lossless_audio_path('/m/a.mp4') is False
def test_m4a_alac_is_lossless_via_probe():
assert is_lossless_audio_path('/m/a.m4a', probe_codec=lambda _p: 'alac') is True
assert is_lossless_audio_path('/m/a.mp4', probe_codec=lambda _p: 'ALAC') is True
def test_m4a_aac_is_not_lossless_via_probe():
assert is_lossless_audio_path('/m/a.m4a', probe_codec=lambda _p: 'mp4a.40.2') is False
def test_probe_exception_is_not_lossless():
def _boom(_p):
raise RuntimeError("probe failed")
assert is_lossless_audio_path('/m/a.m4a', probe_codec=_boom) is False
# ── LOSSLESS_CANDIDATE_EXTENSIONS (the SQL pre-filter set) ──
def test_candidate_extensions_cover_lossless_plus_ambiguous():
for e in ('.flac', '.wav', '.aiff', '.dsf', '.dff', '.alac', '.m4a', '.mp4'):
assert e in LOSSLESS_CANDIDATE_EXTENSIONS
# raw lossy extensions must NOT be candidates
for e in ('.mp3', '.aac', '.ogg', '.opus', '.wma'):
assert e not in LOSSLESS_CANDIDATE_EXTENSIONS
def test_sql_where_clause_matches_candidates_only():
where = _lossless_ext_where('t.file_path')
assert "LIKE '%.flac'" in where and "LIKE '%.dsf'" in where and "LIKE '%.m4a'" in where
assert "LIKE '%.mp3'" not in where and "LIKE '%.aac'" not in where
# ── lossy_output_would_overwrite_source (the safety invariant) ──
def test_overwrite_detected_when_paths_equal():
assert lossy_output_would_overwrite_source('/m/Album/01.m4a', '/m/Album/01.m4a') is True
def test_overwrite_detected_after_normalization():
assert lossy_output_would_overwrite_source('/m/Album/../Album/01.m4a', '/m/Album/01.m4a') is True
def test_no_overwrite_for_different_extension():
assert lossy_output_would_overwrite_source('/m/Album/01.flac', '/m/Album/01.mp3') is False
assert lossy_output_would_overwrite_source('/m/Album/01.m4a', '/m/Album/01.mp3') is False
def test_overwrite_guard_handles_empty():
assert lossy_output_would_overwrite_source('', '/x.mp3') is False
assert lossy_output_would_overwrite_source('/x.flac', '') is False
# ── anti-drift: the seam must agree with the quality tier model ──
def test_lossless_set_consistent_with_tier_model():
"""If a format is in LOSSLESS_FORMATS it must out-rank every lossy format in
tier_score guards against the two lists drifting apart (the whole reason
this seam exists)."""
lossy = ('mp3', 'aac', 'ogg', 'opus', 'wma')
worst_lossless = min(AudioQuality(f, bitrate=11290, sample_rate=44100, bit_depth=16).tier_score()
for f in LOSSLESS_FORMATS)
best_lossy = max(AudioQuality(f, bitrate=320).tier_score() for f in lossy)
assert worst_lossless > best_lossy
# ── regression: the exact bug class this guards (overwrite the original) ──
def test_regression_m4a_alac_to_aac_would_overwrite_and_is_blocked():
"""An .m4a ALAC source converted with the AAC codec lands on the SAME .m4a
path. The guard must catch it so ffmpeg -y never destroys the original."""
src = '/library/Sade/Diamond Life/01. Smooth Operator.m4a' # ALAC
out = src # AAC target → .m4a
assert is_lossless_audio_path(src, probe_codec=lambda _p: 'alac') is True
assert lossy_output_would_overwrite_source(src, out) is True # → callers skip
# ── cross-language drift guard: the frontend lossless lists must match the backend ──
# (#941's root cause: a format added to the quality profile but not the lossy-copy
# side. The lists are physically separate by choice — runtime-fetching a yearly-
# changing set isn't worth the coupling — so a test makes the drift impossible to
# ship silently instead.)
import re
from pathlib import Path
_ROOT = Path(__file__).resolve().parent.parent.parent
_SETTINGS_JS = _ROOT / "webui" / "static" / "settings.js"
_INDEX_HTML = _ROOT / "webui" / "index.html"
def _js_array(text, name):
m = re.search(rf"{name}\s*=\s*\[([^\]]*)\]", text)
assert m, f"{name} not found"
return set(re.findall(r"'([^']+)'", m.group(1)))
def test_frontend_rt_lossless_formats_matches_backend():
js = _SETTINGS_JS.read_text(encoding="utf-8")
frontend = _js_array(js, "RT_LOSSLESS_FORMATS")
assert frontend == set(LOSSLESS_FORMATS), (
f"settings.js RT_LOSSLESS_FORMATS {frontend} != backend LOSSLESS_FORMATS "
f"{set(LOSSLESS_FORMATS)} — add the new format to BOTH"
)
def test_quality_profile_dropdown_offers_every_lossless_format():
html = _INDEX_HTML.read_text(encoding="utf-8")
m = re.search(r'<optgroup label="Lossless">(.*?)</optgroup>', html, re.DOTALL)
assert m, "Lossless optgroup not found in index.html"
# concrete per-format option values (skip the 'group:lossless' convenience entry)
option_values = {v for v in re.findall(r'value="([^"]+)"', m.group(1))
if not v.startswith("group:")}
assert set(LOSSLESS_FORMATS) <= option_values, (
f"index.html lossless dropdown {option_values} is missing "
f"{set(LOSSLESS_FORMATS) - option_values}"
)

View file

@ -128,22 +128,3 @@ def test_v2_to_v3_preserves_order_and_maps_fields():
assert formats == ['flac', 'mp3'] # disabled mp3_192 omitted
assert targets[0]['bit_depth'] == 24
assert targets[1]['min_bitrate'] == 320
# ── DSD (#939): DSF must rank as lossless, never "Low Quality" below MP3 ──
def test_dsf_ranks_in_lossless_range():
dsf = AudioQuality('dsf', bitrate=11290).tier_score()
flac_cd = AudioQuality('flac', sample_rate=44100, bit_depth=16).tier_score()
mp3_320 = AudioQuality('mp3', bitrate=320).tier_score()
# DSD64 is hi-res lossless — at/above CD FLAC and well above any lossy format.
assert dsf >= flac_cd
assert dsf > mp3_320
def test_dsf_without_measured_bitrate_still_lossless():
# .dff has no mutagen reader, so it classifies as 'dsf' with no measured detail —
# it must still land in the lossless tier, not the 'unknown' floor.
dsf = AudioQuality('dsf').tier_score()
assert dsf > AudioQuality('mp3', bitrate=320).tier_score()
assert dsf > AudioQuality('unknown').tier_score()

View file

@ -30,7 +30,6 @@ from core.quality.source_map import (
("aiff", "wav"), ("aif", "wav"), # PCM → wav tier
("wma", "wma"),
("alac", "alac"),
("dsf", "dsf"), (".dsf", "dsf"), ("dff", "dsf"), # DSD → dsf tier (#939)
("xyz", "unknown"), ("", "unknown"), (None, "unknown"),
])
def test_format_from_extension(ext, fmt):

View file

@ -1,56 +0,0 @@
"""Deezer playlist export via the gw-light gateway (#945) — the write half of
'sync a mirrored playlist back to Deezer'. Mocks the gateway call (the live API is the
unofficial ARL gw-light path; we test the wiring, not Deezer)."""
from core.deezer_download_client import DeezerDownloadClient
def _client(gw, authed=True):
c = DeezerDownloadClient.__new__(DeezerDownloadClient)
c._authenticated = authed
c._gw_call = gw
return c
def test_create_new_playlist():
calls = []
def gw(method, params):
calls.append((method, params))
return 12345 # gw returns the new playlist id
res = _client(gw).create_or_update_playlist('My Mix', ['100', '200'])
assert res['success'] and res['playlist_id'] == '12345'
assert res['url'] == 'https://www.deezer.com/playlist/12345'
assert res['added'] == 2
method, params = calls[0]
assert method == 'playlist.create'
assert params['title'] == 'My Mix'
assert params['songs'] == [['100', 0], ['200', 1]]
def test_update_existing_appends_no_create():
calls = []
def gw(method, params):
calls.append((method, params))
return {}
res = _client(gw).create_or_update_playlist('My Mix', ['100'], existing_id='999')
assert res['success'] and res['playlist_id'] == '999'
assert calls[0][0] == 'playlist.addSongs'
assert calls[0][1]['playlist_id'] == 999
assert not any(m == 'playlist.create' for m, _ in calls)
def test_empty_tracks_errors_no_gw_call():
calls = []
res = _client(lambda *a: calls.append(a)).create_or_update_playlist('X', [])
assert not res['success'] and 'No matching' in res['error']
assert calls == []
def test_not_authed_errors():
res = _client(lambda *a: None, authed=False).create_or_update_playlist('X', ['1'])
assert not res['success'] and 'not connected' in res['error']
def test_gw_rejection_is_an_error():
res = _client(lambda *a: None).create_or_update_playlist('X', ['1'])
assert not res['success'] and 'rejected' in res['error']

View file

@ -218,44 +218,13 @@ class TestEnrichStatsItems:
album_by_name = {a["name"]: a for a in cache["top_albums"]}
assert album_by_name["First Album"]["id"] == "al1"
# image_url is normalized at build time now (#935) — it's enriched (truthy);
# exact form depends on the image-cache config, so don't pin the raw value.
assert album_by_name["First Album"]["image_url"]
assert album_by_name["First Album"]["image_url"] == "http://img/al1.jpg"
track_by_name = {t["name"]: t for t in cache["top_tracks"]}
assert track_by_name["Alpha"]["id"] == "t1"
assert track_by_name["Bravo"]["id"] == "t2"
assert track_by_name["Alpha"]["artist_id"] == "a1"
def test_image_urls_normalized_at_build_time(self, db, worker, monkeypatch):
"""#935: image URLs are run through the fixer HERE, at cache-build time, so the
/api/stats/cached read does zero per-image image-cache writes on the hot path
(those writes were the ~20s stats hang on HDD-backed installs). Deterministic
via a stub fixer so it doesn't depend on the real image-cache config."""
_insert_track(db, "t1", "Alpha", "a1", "Band One", "al1", "First Album")
seen = []
def _fake_fix(url):
seen.append(url)
return f"/api/image-cache/fixed::{url}"
monkeypatch.setattr("core.metadata.normalize_image_url", _fake_fix)
cache = {
"top_artists": [{"name": "Band One"}],
"top_albums": [{"name": "First Album"}],
"top_tracks": [{"name": "Alpha", "artist": "Band One"}],
}
worker._enrich_stats_items(cache)
# every section's raw thumb_url was passed through the fixer, and the cached
# value is the fixed (browser-safe) url — so the read path has nothing to do.
assert cache["top_albums"][0]["image_url"] == "/api/image-cache/fixed::http://img/al1.jpg"
assert cache["top_artists"][0]["image_url"].startswith("/api/image-cache/fixed::")
assert cache["top_tracks"][0]["image_url"].startswith("/api/image-cache/fixed::")
assert any("al1" in str(s) for s in seen)
def test_unknown_entries_left_untouched(self, db, worker):
_insert_track(db, "t1", "Real", "a1", "Real Band", "al1", "Real Album")

View file

@ -1,39 +0,0 @@
"""Guard the reduce-visual-effects CSS contract.
Performance mode must kill the GPU-EXPENSIVE properties (backdrop-filter / box-shadow
/ filter the real lag, esp. on Firefox) but must NOT blanket-freeze animations:
`animation: none` on every element stuck the dash-header worker-service spinners
mid-rotation, which read as "broken" (Discord/Boulder). Pins the surgical rule so a
future edit can't silently re-freeze the functional spinners or kill cheap hover
feedback."""
import re
from pathlib import Path
_STYLE = Path(__file__).resolve().parent.parent / "webui" / "static" / "style.css"
def _reduce_effects_global_body() -> str:
css = _STYLE.read_text(encoding="utf-8")
m = re.search(r"body\.reduce-effects \*,.*?\{([^}]*)\}", css, re.DOTALL)
assert m, "global 'body.reduce-effects *' rule not found"
return m.group(1)
def test_reduce_effects_still_kills_expensive_gpu_properties():
body = _reduce_effects_global_body()
for prop in ("backdrop-filter: none", "box-shadow: none", "filter: none"):
assert prop in body, f"reduce-effects must still force {prop} (the real lag source)"
def test_reduce_effects_does_not_blanket_freeze_animations():
body = _reduce_effects_global_body()
# `animation: none` on * froze the dash-header worker-service spinners mid-spin;
# cheap transform/opacity motion must survive so a spinner still reads as "working".
assert "animation: none" not in body, (
"blanket 'animation: none' re-freezes the worker spinners — keep motion alive, "
"the expensive properties are already neutralized above"
)
assert "transition-duration: 0s" not in body, (
"blanket 'transition-duration: 0s' kills the cheap Quick Actions hover feedback"
)

View file

@ -1,168 +0,0 @@
"""Rename-only reorganize (#875): move files to the current naming scheme with NO
copy / re-tag / post-processing. The headline guarantee is that it acts on exactly
what the preview computed and ONLY touches files whose path actually changes files
the preview marked `unchanged` are left alone (the "every file got modified" bug).
"""
import os
from core.library_reorganize import (
_rename_track_in_place,
reorganize_album_rename_only,
)
# ── _rename_track_in_place ──
def test_rename_moves_file_and_creates_dest_dir(tmp_path):
src = tmp_path / "old" / "01 - Song.flac"
src.parent.mkdir(parents=True)
src.write_bytes(b"audio")
dst = tmp_path / "new" / "Song - Artist.flac"
ok, err = _rename_track_in_place(str(src), str(dst))
assert ok and err is None
assert dst.exists() and dst.read_bytes() == b"audio"
assert not src.exists()
def test_rename_refuses_to_overwrite_a_different_file(tmp_path):
src = tmp_path / "a.flac"
src.write_bytes(b"source")
dst = tmp_path / "b.flac"
dst.write_bytes(b"someone else") # a DIFFERENT existing file
ok, err = _rename_track_in_place(str(src), str(dst))
assert not ok and "exists" in err
assert src.exists() and dst.read_bytes() == b"someone else" # nothing destroyed
def test_rename_missing_source_errors(tmp_path):
ok, err = _rename_track_in_place(str(tmp_path / "gone.flac"), str(tmp_path / "x.flac"))
assert not ok and "no longer on disk" in err
def test_rename_same_path_is_noop_ok(tmp_path):
f = tmp_path / "x.flac"
f.write_bytes(b"a")
ok, err = _rename_track_in_place(str(f), str(f))
assert ok and f.exists()
def test_rename_carries_sibling_format_file(tmp_path):
# lossy-copy pair: canonical .flac + sibling .opus in the same folder
src = tmp_path / "old" / "01 - Song.flac"
src.parent.mkdir(parents=True)
src.write_bytes(b"flac")
sib = tmp_path / "old" / "01 - Song.opus"
sib.write_bytes(b"opus")
dst = tmp_path / "new" / "Song.flac"
ok, _ = _rename_track_in_place(str(src), str(dst))
assert ok
assert dst.exists()
assert (tmp_path / "new" / "Song.opus").exists() # sibling came along, renamed stem
# ── reorganize_album_rename_only (fake preview injected) ──
def _fake_preview(tracks, *, success=True, status="planned", source="deezer"):
def _preview(**_kw):
return {"success": success, "status": status, "source": source, "tracks": tracks}
return _preview
def _run(tracks, *, update=None, cleanup=None, stop=None, **preview_kw):
return reorganize_album_rename_only(
album_id="A1", db=None, transfer_dir="/x",
resolve_file_path_fn=lambda p: p,
build_final_path_fn=lambda *a, **k: (None, True),
update_track_path_fn=update,
cleanup_empty_dir_fn=cleanup,
stop_check=stop,
preview_fn=_fake_preview(tracks, **preview_kw),
)
def test_moves_changed_and_skips_unchanged(tmp_path):
"""THE regression: a changed track moves + DB updates; an `unchanged` track is
left completely alone (not re-touched). This is the #875 fix in one test."""
src = tmp_path / "old" / "01 - A.flac"
src.parent.mkdir(parents=True)
src.write_bytes(b"a")
new = tmp_path / "new" / "A - Artist.flac"
keep = tmp_path / "keep" / "B.flac"
keep.parent.mkdir(parents=True)
keep.write_bytes(b"b")
updates = []
summary = _run(
[
{"track_id": "t1", "title": "A", "matched": True, "unchanged": False,
"collision": False, "current_path_abs": str(src), "new_path_abs": str(new)},
{"track_id": "t2", "title": "B", "matched": True, "unchanged": True,
"collision": False, "current_path_abs": str(keep), "new_path_abs": str(keep)},
],
update=lambda tid, path: updates.append((tid, path)),
)
assert summary["moved"] == 1 and summary["skipped"] == 1 and summary["failed"] == 0
assert new.exists() and not src.exists() # changed track moved
assert keep.exists() and keep.read_bytes() == b"b" # unchanged: untouched
assert updates == [("t1", str(new))] # DB updated ONLY for the moved one
def test_collision_and_unmatched_are_skipped(tmp_path):
summary = _run([
{"track_id": "c", "title": "C", "matched": True, "unchanged": False,
"collision": True, "current_path_abs": "/a", "new_path_abs": "/b"},
{"track_id": "u", "title": "U", "matched": False, "unchanged": False,
"collision": False, "current_path_abs": "/a", "new_path_abs": "/b"},
])
assert summary["skipped"] == 2 and summary["moved"] == 0 and summary["failed"] == 0
def test_failed_rename_is_counted_not_fatal(tmp_path):
src = tmp_path / "a.flac"
src.write_bytes(b"src")
dst = tmp_path / "taken.flac"
dst.write_bytes(b"occupied") # forces "destination already exists"
summary = _run([
{"track_id": "t", "title": "T", "matched": True, "unchanged": False,
"collision": False, "current_path_abs": str(src), "new_path_abs": str(dst)},
])
assert summary["failed"] == 1 and summary["moved"] == 0
assert summary["errors"] and summary["errors"][0]["track_id"] == "t"
assert src.exists() and dst.read_bytes() == b"occupied" # nothing lost
def test_preview_failure_returns_its_status():
summary = _run([], success=False, status="no_source_id")
assert summary["status"] == "no_source_id"
assert summary["moved"] == 0
def test_stop_check_aborts_early(tmp_path):
src = tmp_path / "a.flac"
src.write_bytes(b"a")
summary = _run(
[{"track_id": "t", "title": "T", "matched": True, "unchanged": False,
"collision": False, "current_path_abs": str(src), "new_path_abs": str(tmp_path / "b.flac")}],
stop=lambda: True,
)
assert summary["moved"] == 0 # aborted before processing
assert src.exists()
def test_cleanup_called_for_emptied_source_dirs(tmp_path):
src = tmp_path / "old" / "01 - A.flac"
src.parent.mkdir(parents=True)
src.write_bytes(b"a")
cleaned = []
_run(
[{"track_id": "t1", "title": "A", "matched": True, "unchanged": False,
"collision": False, "current_path_abs": str(src),
"new_path_abs": str(tmp_path / "new" / "A.flac")}],
cleanup=lambda d: cleaned.append(d),
)
assert str(tmp_path / "old") in cleaned

View file

@ -73,9 +73,6 @@ def _make_item(*, queue_id='qid-1', album_id='alb-1', source=None):
item.queue_id = queue_id
item.album_id = album_id
item.source = source
# Match the real QueueItem default: a bare MagicMock would return a truthy
# mock for .rename_only and wrongly take the rename-only branch (#875).
item.rename_only = False
return item
@ -236,57 +233,3 @@ def test_runner_progress_callback_forwards_to_queue(monkeypatch, tmp_path):
# The progress fan-out happened *while* the item was running. The
# final snapshot shows the worker-set values — what we're really
# asserting is that progress callbacks didn't raise.
def test_rename_only_item_routes_to_rename_executor(monkeypatch, tmp_path):
"""#875: an item with rename_only=True invokes the rename-only executor (NOT the
full reorganize_album), and never creates a staging dir."""
captured = {}
def fake_rename_only(**kwargs):
captured.update(kwargs)
return {'status': 'completed', 'source': 'deezer',
'total': 1, 'moved': 1, 'skipped': 0, 'failed': 0, 'errors': []}
def fail_full(**kwargs):
raise AssertionError("full reorganize_album must NOT run for rename_only")
monkeypatch.setattr('core.library_reorganize.reorganize_album', fail_full, raising=True)
monkeypatch.setattr('core.library_reorganize.reorganize_album_rename_only',
fake_rename_only, raising=True)
runner = build_runner(
get_database=lambda: object(),
resolve_file_path_fn=lambda p: p,
post_process_fn=lambda *a, **k: None,
cleanup_empty_directories_fn=lambda *a, **k: None,
is_shutting_down_fn=lambda: False,
get_download_path=lambda: str(tmp_path),
get_transfer_path=lambda: str(tmp_path / 'transfer'),
build_final_path_fn=lambda *a, **k: (None, True),
)
item = _make_item(album_id='alb-R', source='deezer')
item.rename_only = True
summary = runner(item)
assert summary['status'] == 'completed' and summary['moved'] == 1
assert captured['album_id'] == 'alb-R'
assert callable(captured['build_final_path_fn'])
assert not (tmp_path / 'ssync_staging').exists() # no staging for rename-only
def test_rename_only_without_path_builder_fails_cleanly(monkeypatch, tmp_path):
# Defensive: build_final_path_fn omitted → rename-only can't run, returns setup_failed
# instead of crashing.
runner = build_runner(
get_database=lambda: object(),
resolve_file_path_fn=lambda p: p,
post_process_fn=lambda *a, **k: None,
cleanup_empty_directories_fn=lambda *a, **k: None,
is_shutting_down_fn=lambda: False,
get_download_path=lambda: str(tmp_path),
get_transfer_path=lambda: str(tmp_path / 'transfer'),
)
item = _make_item()
item.rename_only = True
assert runner(item)['status'] == 'setup_failed'

View file

@ -1,154 +0,0 @@
"""_run_service_export orchestration (#945): resolve mirrored tracks → service IDs
(discovery cache library) push store the target for idempotent re-export. Deps
injected so this needs no real DB or live Spotify/Deezer."""
import json
import web_server as ws
class _FakeDB:
def __init__(self, tracks, existing=None):
self._tracks, self._existing, self.set_calls = tracks, existing, []
def get_mirrored_playlist_tracks(self, pid):
return self._tracks
def get_playlist_export_target(self, pid, service):
return self._existing
def set_playlist_export_target(self, pid, service, target):
self.set_calls.append((pid, service, target))
class _FakeClient:
def __init__(self, result):
self.result, self.calls = result, []
def create_or_update_playlist(self, title, ids, existing_id=None):
self.calls.append((title, list(ids), existing_id))
return self.result
def _fake_resolver(ids, seen=None):
"""resolve_ids_fn stub returning the given service ids. When ``seen`` is given, records
the search_id_fn it was called with (to assert the backfill toggle wiring)."""
def fn(tracks, service, search_id_fn=None, on_progress=None):
if seen is not None:
seen['search_id_fn'] = search_id_fn
resolved = [{'artist': 'A', 'title': f't{i}', 'service_track_id': s}
for i, s in enumerate(ids)]
matched = sum(1 for s in ids if s)
return {'resolved': resolved,
'stats': {'total': len(ids), 'resolved': matched, 'unmatched': len(ids) - matched}}
return fn
def _discovered(artist, title, service, tid):
return {'artist_name': artist, 'track_name': title,
'extra_data': json.dumps({'discovered': True, 'provider': service,
'matched_data': {'id': tid}})}
def test_success_resolves_from_discovery_cache_and_stores_target():
"""Real resolver: both tracks were discovered to Deezer, so their IDs come straight
from extra_data (the cache) with no DB/API the gap Boulder spotted."""
job = {}
db = _FakeDB([_discovered('A', 'X', 'deezer', 111), _discovered('A', 'Y', 'deezer', 222)])
client = _FakeClient({'success': True, 'playlist_id': 'pl-1', 'added': 2})
ws._run_service_export(job, db, 5, 'My PL', 'deezer', client) # real resolve_service_track_ids
assert job['phase'] == 'done'
assert client.calls[0] == ('My PL', ['111', '222'], None)
assert db.set_calls == [(5, 'deezer', 'pl-1')]
assert job['stats']['from_cache'] == 2 and job['stats']['unmatched'] == 0
def test_no_match_errors_no_push():
job = {}
client = _FakeClient({'success': True})
ws._run_service_export(job, _FakeDB([{}]), 5, 'PL', 'deezer', client, _fake_resolver([None]))
assert job['phase'] == 'error' and 'nothing to export' in job['error']
assert client.calls == []
def test_client_none_errors():
job = {}
ws._run_service_export(job, _FakeDB([{}]), 5, 'PL', 'spotify', None, _fake_resolver(['sx']))
assert job['phase'] == 'error' and 'not connected' in job['error']
def test_push_failure_surfaces_error_no_target_store():
job = {}
db = _FakeDB([{}])
client = _FakeClient({'success': False, 'error': 'Reconnect Spotify'})
ws._run_service_export(job, db, 5, 'PL', 'spotify', client, _fake_resolver(['sx']))
assert job['phase'] == 'error' and job['error'] == 'Reconnect Spotify'
assert db.set_calls == []
def test_reexport_passes_existing_target():
job = {}
db = _FakeDB([{}], existing='pl-old')
client = _FakeClient({'success': True, 'playlist_id': 'pl-old', 'added': 1})
ws._run_service_export(job, db, 5, 'PL', 'deezer', client, _fake_resolver(['dz']))
assert client.calls[0][2] == 'pl-old'
def test_backfill_off_passes_no_search_fn():
job = {} # no 'backfill' key → off
seen = {}
ws._run_service_export(job, _FakeDB([{}]), 5, 'PL', 'deezer',
_FakeClient({'success': True, 'playlist_id': 'p', 'added': 1}),
_fake_resolver(['dz'], seen))
assert seen['search_id_fn'] is None
def test_backfill_on_wires_search_fn(monkeypatch):
job = {'backfill': True}
seen = {}
monkeypatch.setattr(ws, '_build_service_search_id_fn', lambda service: 'SEARCH_FN')
ws._run_service_export(job, _FakeDB([{}]), 5, 'PL', 'spotify',
_FakeClient({'success': True, 'playlist_id': 'p', 'added': 1}),
_fake_resolver(['sx'], seen))
assert seen['search_id_fn'] == 'SEARCH_FN'
def test_spotify_backfill_search_disables_cross_service_fallback(monkeypatch):
"""REGRESSION: Spotify's search_tracks falls back to iTunes/Deezer (non-Spotify ids) under
rate-limit/free. The backfill MUST disable that or it pushes wrong ids into the Spotify
playlist. Assert the search is invoked with allow_fallback=False."""
seen = {}
class _FakeSpotify:
def search_tracks(self, q, limit=10, allow_fallback=True):
seen['allow_fallback'] = allow_fallback
return []
monkeypatch.setattr(ws, 'get_spotify_client', lambda: _FakeSpotify())
fn = ws._build_service_search_id_fn('spotify')
assert fn is not None
fn('Kendrick Lamar', 'Not Like Us') # drives the search
assert seen['allow_fallback'] is False
def test_spotify_export_endpoint_demands_auth_when_no_write_scope(monkeypatch):
"""The export endpoint must return needs_auth (not start a doomed job) when the Spotify
token lacks write scope and it must short-circuit BEFORE touching the DB."""
import types
monkeypatch.setattr(ws, 'spotify_client',
types.SimpleNamespace(has_write_scope=lambda: False))
resp = ws.app.test_client().post('/api/playlists/5/export/service/spotify')
data = resp.get_json()
assert data['needs_auth'] is True
assert data['auth_url'] == '/auth/spotify/export'
assert data['success'] is False
def test_spotify_export_endpoint_proceeds_when_write_scope_present(monkeypatch):
"""With write scope, the spotify path must NOT short-circuit on needs_auth (it goes on to
start a job here it just must not be a needs_auth response)."""
import types
monkeypatch.setattr(ws, 'spotify_client',
types.SimpleNamespace(has_write_scope=lambda: True))
resp = ws.app.test_client().post('/api/playlists/999999/export/service/spotify')
data = resp.get_json()
assert not data.get('needs_auth')

View file

@ -1,240 +0,0 @@
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from core.spotify_client import normalize_spotify_oauth_config
def test_normalization():
# Whitespace + quotes are stripped (paste garbage); the redirect_uri's
# trailing slash is PRESERVED — Spotify matches it exactly against the app
# dashboard, so stripping it could break a valid registration (#942 follow-up).
config = {
"client_id": ' "client_id" ',
"client_secret": " client_secret ",
"redirect_uri": " http://127.0.0.1:8888/callback/ "
}
expected = {
"client_id": "client_id",
"client_secret": "client_secret",
"redirect_uri": "http://127.0.0.1:8888/callback/" # slash kept
}
assert normalize_spotify_oauth_config(config) == expected
def test_trailing_slash_on_redirect_uri_is_preserved():
"""Regression guard: Spotify requires an EXACT redirect-URI match against the
app dashboard, so a trailing slash a user registered must NOT be stripped
stripping it would send '…/callback' and trigger INVALID_CLIENT (#942)."""
with_slash = {"client_id": "x", "client_secret": "y",
"redirect_uri": "http://127.0.0.1:8888/callback/"}
without_slash = {"client_id": "x", "client_secret": "y",
"redirect_uri": "http://127.0.0.1:8888/callback"}
assert normalize_spotify_oauth_config(with_slash)["redirect_uri"] == "http://127.0.0.1:8888/callback/"
assert normalize_spotify_oauth_config(without_slash)["redirect_uri"] == "http://127.0.0.1:8888/callback"
def test_empty_values():
# Empty input values
config = {
"client_id": "",
"client_secret": None,
"redirect_uri": ""
}
# When value is None, it falls into the else branch: normalized[key] = value
# value is None, so expected is None for client_secret
expected = {
"client_id": "",
"client_secret": None,
"redirect_uri": ""
}
assert normalize_spotify_oauth_config(config) == expected
def test_missing_keys():
# Input dictionary with missing keys
config = {
"client_id": "client_id"
}
# .get(key, "") means missing keys become ""
expected = {
"client_id": "client_id",
"client_secret": "",
"redirect_uri": ""
}
assert normalize_spotify_oauth_config(config) == expected
def test_non_string_values():
# Input dictionary with non-string values for the keys
config = {
"client_id": 123,
"client_secret": True,
"redirect_uri": None
}
# When value is not a string, it falls into the else branch: normalized[key] = value
expected = {
"client_id": 123,
"client_secret": True,
"redirect_uri": None
}
assert normalize_spotify_oauth_config(config) == expected
def test_no_input():
# Empty input dictionary
config = {}
# .get(key, "") means missing keys become ""
expected = {
"client_id": "",
"client_secret": "",
"redirect_uri": ""
}
assert normalize_spotify_oauth_config(None) == {}
assert normalize_spotify_oauth_config(config) == expected
# ── create_or_update_playlist: export a mirrored playlist back to Spotify (#945) ──
from core.spotify_client import SpotifyClient as _SpotifyClient
class _FakeSp:
def __init__(self):
self.calls = []
def current_user(self):
self.calls.append(('current_user',))
return {'id': 'user-1'}
def user_playlist_create(self, user_id, name, public=False, description=''):
self.calls.append(('create', user_id, name, public))
return {'id': 'pl-new'}
def playlist_add_items(self, pid, uris):
self.calls.append(('add', pid, list(uris)))
def playlist_replace_items(self, pid, uris):
self.calls.append(('replace', pid, list(uris)))
def _spotify_with(sp, authed=True):
c = _SpotifyClient.__new__(_SpotifyClient)
c.sp = sp
c.is_spotify_authenticated = lambda: authed
return c
def test_create_new_playlist_adds_tracks():
sp = _FakeSp()
res = _spotify_with(sp).create_or_update_playlist('My Mix', ['a', 'b', 'c'])
assert res['success'] and res['playlist_id'] == 'pl-new'
assert res['url'] == 'https://open.spotify.com/playlist/pl-new'
assert res['added'] == 3
assert ('create', 'user-1', 'My Mix', False) in sp.calls
assert ('add', 'pl-new', ['spotify:track:a', 'spotify:track:b', 'spotify:track:c']) in sp.calls
def test_update_existing_replaces_no_create():
sp = _FakeSp()
res = _spotify_with(sp).create_or_update_playlist('My Mix', ['a', 'b'], existing_id='pl-x')
assert res['success'] and res['playlist_id'] == 'pl-x'
assert ('replace', 'pl-x', ['spotify:track:a', 'spotify:track:b']) in sp.calls
assert not any(c[0] == 'create' for c in sp.calls)
def test_chunks_over_100_tracks():
sp = _FakeSp()
res = _spotify_with(sp).create_or_update_playlist('Big', [str(i) for i in range(250)])
assert res['added'] == 250
adds = [c for c in sp.calls if c[0] == 'add']
assert len(adds) == 3 and len(adds[0][2]) == 100 and len(adds[2][2]) == 50
def test_empty_tracks_errors_no_api_calls():
sp = _FakeSp()
res = _spotify_with(sp).create_or_update_playlist('X', [])
assert not res['success'] and 'No matching' in res['error']
assert sp.calls == []
def test_not_authed_errors():
res = _spotify_with(_FakeSp(), authed=False).create_or_update_playlist('X', ['a'])
assert not res['success'] and 'not connected' in res['error']
def test_insufficient_scope_says_reconnect():
class _ScopeErr(_FakeSp):
def user_playlist_create(self, *a, **k):
raise Exception('403 Forbidden: insufficient client scope')
res = _spotify_with(_ScopeErr()).create_or_update_playlist('X', ['a'])
assert not res['success'] and 'Reconnect Spotify' in res['error']
# ── Spotify auth regression hotfix: scope must not force re-auth; callbacks must write
# the DB store the client reads (else a re-auth never takes effect) ──
import os as _os
def test_oauth_scope_has_no_write_scope_that_forces_reauth():
"""Spotipy invalidates a cached token the moment the requested scope stops being a subset
of the token's granted scope — so GROWING the global scope forces every user to re-auth on
upgrade (it broke all Spotify users). The write scope (playlist-modify) must NOT live in the
global scope; request it on-demand instead."""
from core.spotify_client import SPOTIFY_OAUTH_SCOPE
assert 'playlist-modify' not in SPOTIFY_OAUTH_SCOPE
# the read scopes existing tokens already carry must stay
for s in ('user-library-read', 'user-read-private', 'playlist-read-private',
'playlist-read-collaborative', 'user-read-email', 'user-follow-read'):
assert s in SPOTIFY_OAUTH_SCOPE
def test_global_oauth_callbacks_use_db_token_cache_not_file():
"""The OAuth callbacks wrote the new token to the legacy file cache while the client reads
DatabaseTokenCache, so a re-auth never reached the client ("validation failed" despite a good
exchange). The global callbacks must write the same DB-backed store the client uses."""
root = _os.path.dirname(_os.path.dirname(_os.path.abspath(__file__)))
src = open(_os.path.join(root, 'web_server.py'), encoding='utf-8').read()
assert "cache_path='config/.spotify_cache'" not in src # no global file-cache writes
assert src.count('cache_handler=DatabaseTokenCache(config_manager)') >= 2
# ── on-demand Spotify export write-auth (#945 follow-up) ──
def test_export_scope_is_global_plus_write_and_global_stays_readonly():
"""The export scope adds playlist-modify ON TOP of the unchanged global scope. Critically
the GLOBAL scope must NOT gain write (that's what force-invalidated everyone's token)."""
from core.spotify_client import SPOTIFY_OAUTH_SCOPE, SPOTIFY_EXPORT_SCOPE
assert 'playlist-modify' not in SPOTIFY_OAUTH_SCOPE # global stays read-only
assert 'playlist-modify-public' in SPOTIFY_EXPORT_SCOPE
assert 'playlist-modify-private' in SPOTIFY_EXPORT_SCOPE
# export scope is a strict superset of the global read scope
assert set(SPOTIFY_OAUTH_SCOPE.split()).issubset(set(SPOTIFY_EXPORT_SCOPE.split()))
class _CacheHandler:
def __init__(self, token):
self._token = token
def get_cached_token(self):
return self._token
def _client_with_token(token):
import types
c = _SpotifyClient.__new__(_SpotifyClient)
c.sp = types.SimpleNamespace(auth_manager=types.SimpleNamespace(cache_handler=_CacheHandler(token)))
return c
def test_has_write_scope_true_when_token_carries_playlist_modify():
tok = {'scope': 'user-library-read playlist-modify-public playlist-read-private'}
assert _client_with_token(tok).has_write_scope() is True
def test_has_write_scope_false_for_readonly_token_or_missing():
assert _client_with_token({'scope': 'user-library-read playlist-read-private'}).has_write_scope() is False
assert _client_with_token(None).has_write_scope() is False # no cached token
assert _client_with_token({}).has_write_scope() is False # token w/o scope field
def test_has_write_scope_false_when_no_client():
c = _SpotifyClient.__new__(_SpotifyClient)
c.sp = None
assert c.has_write_scope() is False

View file

@ -1,46 +0,0 @@
"""Regression: post-boot, is_authenticated() must NOT refresh a still-valid Tidal token.
#949 moved the "token still valid -> return True" short-circuit into the boot-phase branch
only, so every post-boot call fell through to the silent refresh a constant-refresh loop
(wolf's logs: "access token expired -> refresh -> success" every few seconds)."""
import time
import core.boot_phase as boot_phase
from core.tidal_client import TidalClient
def _client(expires_at):
c = TidalClient.__new__(TidalClient)
c.access_token = "tok"
c.refresh_token = "refresh"
c.token_expires_at = expires_at
c._refresh_calls = 0
def _fake_refresh():
c._refresh_calls += 1
return True
c._refresh_access_token = _fake_refresh
return c
def test_valid_token_does_not_refresh_post_boot(monkeypatch):
monkeypatch.setattr(boot_phase, "_boot_active", False) # post-boot
c = _client(time.time() + 3600) # valid for an hour
assert c.is_authenticated() is True
assert c._refresh_calls == 0 # MUST NOT refresh a valid token
def test_expired_token_still_refreshes_post_boot(monkeypatch):
monkeypatch.setattr(boot_phase, "_boot_active", False)
c = _client(time.time() - 10) # expired
assert c.is_authenticated() is True
assert c._refresh_calls == 1 # expired -> one refresh
def test_valid_token_returns_true_during_boot(monkeypatch):
monkeypatch.setattr(boot_phase, "_boot_active", True) # boot phase
c = _client(time.time() + 3600)
assert c.is_authenticated() is True
assert c._refresh_calls == 0 # boot never probes/refreshes

View file

@ -1,56 +0,0 @@
"""Pure UI-appearance default rules (core/ui_appearance.py).
Pins the worker-orbs default contract: explicit saved choice ALWAYS wins; when unset,
default OFF on Firefox (the blurred orb canvas is the main remaining Firefox lag
source) and ON elsewhere."""
from core.ui_appearance import is_firefox_user_agent, resolve_worker_orbs_default
_FIREFOX_UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:128.0) "
"Gecko/20100101 Firefox/128.0")
_CHROME_UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
_SAFARI_UA = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 "
"(KHTML, like Gecko) Version/17.0 Safari/605.1.15")
_EDGE_UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0")
# ── is_firefox_user_agent ──
def test_detects_firefox():
assert is_firefox_user_agent(_FIREFOX_UA) is True
def test_non_firefox_browsers_are_false():
for ua in (_CHROME_UA, _SAFARI_UA, _EDGE_UA):
assert is_firefox_user_agent(ua) is False
def test_empty_or_none_ua_is_not_firefox():
assert is_firefox_user_agent('') is False
assert is_firefox_user_agent(None) is False
# ── resolve_worker_orbs_default: explicit ALWAYS wins ──
def test_unset_defaults_off_on_firefox():
assert resolve_worker_orbs_default(None, is_firefox=True) is False
def test_unset_defaults_on_elsewhere():
assert resolve_worker_orbs_default(None, is_firefox=False) is True
def test_explicit_true_wins_even_on_firefox():
# A Firefox user who explicitly enabled orbs keeps them — default never overrides.
assert resolve_worker_orbs_default(True, is_firefox=True) is True
def test_explicit_false_wins_even_off_firefox():
assert resolve_worker_orbs_default(False, is_firefox=False) is False
def test_explicit_values_ignore_browser():
assert resolve_worker_orbs_default(True, is_firefox=False) is True
assert resolve_worker_orbs_default(False, is_firefox=True) is False

View file

@ -40,7 +40,7 @@ logger = setup_logging(_log_level, _log_path)
# App version — single source of truth for backup metadata, system-info, update check, etc.
# Semver: MAJOR.MINOR.PATCH. Bump at each dev→main release.
_SOULSYNC_BASE_VERSION = "2.8.2"
_SOULSYNC_BASE_VERSION = "2.8.0"
def _build_version_string():
"""Append short commit hash to version when available (e.g. 2.35+abc1234)."""
@ -82,9 +82,8 @@ if not pp_logger.handlers:
_pp_handler.setFormatter(_logging.Formatter("%(asctime)s - %(message)s", datefmt="%Y-%m-%d %H:%M:%S"))
pp_logger.addHandler(_pp_handler)
pp_logger.propagate = False
from core.spotify_client import SpotifyClient, Playlist as SpotifyPlaylist, Track as SpotifyTrack, _is_globally_rate_limited as _spotify_rate_limited, SPOTIFY_OAUTH_SCOPE
from core.spotify_client import SpotifyClient, Playlist as SpotifyPlaylist, Track as SpotifyTrack, _is_globally_rate_limited as _spotify_rate_limited
from core.plex_client import PlexClient
from core.ui_appearance import is_firefox_user_agent, resolve_worker_orbs_default
from plexapi.myplex import MyPlexAccount, MyPlexPinLogin
from core.jellyfin_client import JellyfinClient
from core.navidrome_client import NavidromeClient
@ -177,7 +176,6 @@ from core.imports.routes import singles_process as _import_singles_process
from core.imports.routes import staging_files as _import_staging_files
from core.imports.routes import staging_groups as _import_staging_groups
from core.imports.routes import staging_hints as _import_staging_hints
from core.imports.routes import staging_scan_status as _import_staging_scan_status
from core.imports.routes import staging_suggestions as _import_staging_suggestions
from core.imports.paths import build_final_path_for_track as _build_final_path_for_track
from core.imports.pipeline import build_import_pipeline_runtime as _build_import_pipeline_runtime
@ -305,105 +303,6 @@ app.config['SEND_FILE_MAX_AGE_DEFAULT'] = 0 if DEV_STATIC_NO_CACHE else 31536000
import time as _cache_bust_time
_STATIC_CACHE_BUST = str(int(_cache_bust_time.time()))
def _valid_hex_color(value, fallback='#1db954'):
value = str(value or '').strip()
return value if re.fullmatch(r'#[0-9a-fA-F]{6}', value) else fallback
def _hex_to_rgb(hex_color):
color = _valid_hex_color(hex_color)
return tuple(int(color[i:i + 2], 16) for i in (1, 3, 5))
def _rgb_to_hsl(r, g, b):
rn, gn, bn = r / 255, g / 255, b / 255
max_c = max(rn, gn, bn)
min_c = min(rn, gn, bn)
lightness = (max_c + min_c) / 2
if max_c == min_c:
return 0, 0, lightness
delta = max_c - min_c
saturation = delta / (2 - max_c - min_c) if lightness > 0.5 else delta / (max_c + min_c)
if max_c == rn:
hue = ((gn - bn) / delta + (6 if gn < bn else 0)) / 6
elif max_c == gn:
hue = ((bn - rn) / delta + 2) / 6
else:
hue = ((rn - gn) / delta + 4) / 6
return hue, saturation, lightness
def _hsl_to_rgb(hue, saturation, lightness):
if saturation == 0:
value = round(lightness * 255)
return value, value, value
def hue_to_rgb(p, q, t):
if t < 0:
t += 1
if t > 1:
t -= 1
if t < 1 / 6:
return p + (q - p) * 6 * t
if t < 1 / 2:
return q
if t < 2 / 3:
return p + (q - p) * (2 / 3 - t) * 6
return p
q = lightness * (1 + saturation) if lightness < 0.5 else lightness + saturation - lightness * saturation
p = 2 * lightness - q
return (
round(hue_to_rgb(p, q, hue + 1 / 3) * 255),
round(hue_to_rgb(p, q, hue) * 255),
round(hue_to_rgb(p, q, hue - 1 / 3) * 255),
)
def _request_is_firefox() -> bool:
"""Whether the current request's browser is Firefox (UA-based). Used ONLY to pick a
performance-friendly default; an explicit saved setting always wins. Safe outside a
request context (returns False)."""
try:
from flask import has_request_context, request
if not has_request_context():
return False
return is_firefox_user_agent(request.headers.get('User-Agent'))
except Exception:
return False
def _initial_appearance_context():
preset = config_manager.get('ui_appearance.accent_preset', '#1db954')
custom = config_manager.get('ui_appearance.accent_color', '#1db954')
accent = _valid_hex_color(custom if preset == 'custom' else preset)
particles_enabled = config_manager.get('ui_appearance.particles_enabled', False) is True
# Worker orbs: explicit choice wins; unset → OFF on Firefox (the blurred orb canvas
# is the main remaining Firefox lag source), ON elsewhere. config default None so we
# can tell "unset" from an explicit False. (#kettui — single source: the server
# decides, the client consumes the injected value below.)
worker_orbs_enabled = resolve_worker_orbs_default(
config_manager.get('ui_appearance.worker_orbs_enabled', None),
_request_is_firefox(),
)
reduce_effects = config_manager.get('ui_appearance.reduce_effects', False) is True
max_performance = config_manager.get('ui_appearance.max_performance', False) is True
r, g, b = _hex_to_rgb(accent)
hue, saturation, lightness = _rgb_to_hsl(r, g, b)
light = _hsl_to_rgb(hue, saturation, min(lightness + 0.16, 0.95))
neon = _hsl_to_rgb(hue, min(saturation + 0.1, 1.0), min(lightness + 0.30, 0.95))
return {
'initial_accent_color': accent,
'initial_accent_rgb': f'{r}, {g}, {b}',
'initial_accent_light_rgb': f'{light[0]}, {light[1]}, {light[2]}',
'initial_accent_neon_rgb': f'{neon[0]}, {neon[1]}, {neon[2]}',
'initial_particles_enabled': particles_enabled,
'initial_worker_orbs_enabled': worker_orbs_enabled,
'initial_reduce_effects': reduce_effects,
'initial_max_performance': max_performance,
}
@app.context_processor
def _inject_static_cache_bust():
@ -420,7 +319,7 @@ def _inject_static_cache_bust():
static_v = str(max(mtimes))
except Exception:
static_v = _STATIC_CACHE_BUST
return {'static_v': static_v, **_initial_appearance_context()}
return {'static_v': static_v}
@app.context_processor
@ -4793,20 +4692,18 @@ def _profile_spotify_oauth(profile_id_int):
chooser so a user can't silently inherit whatever Spotify session is active
in their browser (e.g. the admin's). Returns None if no app creds exist."""
from spotipy.oauth2 import SpotifyOAuth
from core.spotify_client import normalize_spotify_oauth_config
creds = (get_database().get_profile_spotify(profile_id_int) or {})
cfg = normalize_spotify_oauth_config(config_manager.get_spotify_config())
profile_creds = normalize_spotify_oauth_config(creds)
client_id = profile_creds.get('client_id') or cfg.get('client_id')
client_secret = profile_creds.get('client_secret') or cfg.get('client_secret')
redirect_uri = profile_creds.get('redirect_uri') or cfg.get('redirect_uri', 'http://127.0.0.1:8888/callback')
cfg = config_manager.get_spotify_config()
client_id = creds.get('client_id') or cfg.get('client_id')
client_secret = creds.get('client_secret') or cfg.get('client_secret')
redirect_uri = creds.get('redirect_uri') or cfg.get('redirect_uri', 'http://127.0.0.1:8888/callback')
if not client_id or not client_secret:
return None
return SpotifyOAuth(
client_id=client_id,
client_secret=client_secret,
redirect_uri=redirect_uri,
scope=SPOTIFY_OAUTH_SCOPE,
scope="user-library-read user-read-private playlist-read-private playlist-read-collaborative user-read-email user-follow-read",
cache_path=f'config/.spotify_cache_profile_{profile_id_int}',
state=f'profile_{profile_id_int}',
show_dialog=True,
@ -4943,38 +4840,6 @@ def auth_spotify():
logger.error(f"Error starting Spotify auth: {e}")
return f"<h1>Spotify Authentication Error</h1><p>{str(e)}</p>", 500
@app.route('/auth/spotify/export')
def auth_spotify_export():
"""On-demand authorization for Spotify playlist EXPORT (#945).
Requests the export scope (the normal read scope + playlist-modify) so the user can write
a playlist to their Spotify WITHOUT changing the global login scope, so no existing
token is invalidated. Spotify returns a superset token; the normal /callback exchanges and
stores it unchanged (read read+write keeps the standard auth check happy). show_dialog
forces the consent screen so the new write permission is actually granted."""
try:
from spotipy.oauth2 import SpotifyOAuth
from core.spotify_client import normalize_spotify_oauth_config, SPOTIFY_EXPORT_SCOPE
from core.spotify_token_cache import DatabaseTokenCache
cfg = normalize_spotify_oauth_config(config_manager.get_spotify_config())
if not cfg.get('client_id') or not cfg.get('client_secret'):
return "<h1>Spotify not configured</h1><p>Add your Spotify app credentials in Settings first.</p>", 400
auth_manager = SpotifyOAuth(
client_id=cfg['client_id'],
client_secret=cfg['client_secret'],
redirect_uri=cfg.get('redirect_uri', 'http://127.0.0.1:8888/callback'),
scope=SPOTIFY_EXPORT_SCOPE,
cache_handler=DatabaseTokenCache(config_manager),
show_dialog=True,
)
add_activity_item("", "Spotify Export Auth", "Requesting permission to create playlists", "Now")
return redirect(auth_manager.get_authorize_url())
except Exception as e:
logger.error(f"Error starting Spotify export auth: {e}")
return f"<h1>Spotify Export Authorization Error</h1><p>{str(e)}</p>", 500
@app.route('/auth/tidal')
def auth_tidal():
"""
@ -5230,7 +5095,7 @@ def spotify_callback():
pass
try:
from core.spotify_client import SpotifyClient, normalize_spotify_oauth_config
from core.spotify_client import SpotifyClient
from spotipy.oauth2 import SpotifyOAuth
from config.settings import config_manager
@ -5260,20 +5125,16 @@ def spotify_callback():
raise Exception("Failed to exchange authorization code for access token")
# Global callback (admin)
config = normalize_spotify_oauth_config(config_manager.get_spotify_config())
config = config_manager.get_spotify_config()
configured_uri = config.get('redirect_uri', "http://127.0.0.1:8888/callback")
logger.info(f"Using redirect_uri for token exchange: {configured_uri}")
# Write the freshly-exchanged token to the SAME store the client reads
# (DatabaseTokenCache), not the legacy file — otherwise a re-auth never reaches the
# client and "validation failed" even though the exchange succeeded.
from core.spotify_token_cache import DatabaseTokenCache
auth_manager = SpotifyOAuth(
client_id=config['client_id'],
client_secret=config['client_secret'],
redirect_uri=configured_uri,
scope=SPOTIFY_OAUTH_SCOPE,
cache_handler=DatabaseTokenCache(config_manager)
scope="user-library-read user-read-private playlist-read-private playlist-read-collaborative user-read-email user-follow-read",
cache_path='config/.spotify_cache'
)
token_info = auth_manager.get_access_token(auth_code)
@ -7645,7 +7506,6 @@ def manual_search_for_task(task_id):
link = parse_download_track_link(query)
link_source = None
link_track_id = None
linked_result = None # the EXACT track fetched by id, injected into results
# A pasted SoundCloud link can't be turned into an "artist title" query
# and searched — unlisted/private tracks aren't searchable. Instead force
# the SoundCloud source and keep the URL as the query; the SoundCloud
@ -7675,18 +7535,6 @@ def manual_search_for_task(task_id):
query = clean_q
source = _src
link_source, link_track_id = _src, _tid
# Fetch the EXACT linked track as a downloadable result to inject —
# a text search for an obscure track's name often doesn't surface it
# at all, so we can't rely on it being in the search results (#932).
# Defensive: only sources that expose get_track_result (Qobuz today);
# others fall back to the bubble path below — never worse than before.
_link_client = download_orchestrator.client(_src) if download_orchestrator else None
if _link_client is not None and hasattr(_link_client, 'get_track_result'):
try:
linked_result = _link_client.get_track_result(_tid)
except Exception as _lr_err:
logger.debug("[Manual Search] get_track_result failed for %s %s: %s",
_src, _tid, _lr_err)
if source != 'all':
if source not in valid_source_ids:
@ -7747,15 +7595,13 @@ def manual_search_for_task(task_id):
"error": error,
}) + "\n"
continue
# Pasted-link exact match (#932): the linked source's search may
# not surface an obscure linked track at all, so INJECT the exact
# track (fetched by id) at the top and drop any search duplicate of
# it. If we couldn't fetch it (source has no get_track_result, or it
# failed), fall back to bubbling a matching search result — never
# worse than before.
if src_name == link_source and link_track_id:
from core.downloads.track_link import inject_linked_track_first
tracks = inject_linked_track_first(tracks, linked_result, link_track_id)
# Pasted-link exact match: bubble the track whose source id
# matches the link to the top so the user sees the exact version
# first. Reads _source_metadata['track_id'] (TrackResult has no
# top-level id) — the old getattr(t,'id') always missed (#932).
if src_name == link_source and link_track_id and tracks:
from core.downloads.track_link import bubble_linked_track_first
tracks = bubble_linked_track_first(tracks, link_track_id)
serialized = []
for t in tracks:
s = _serialize_candidate(t, source_override=src_name)
@ -11806,9 +11652,6 @@ def reorganize_album_files(album_id):
artist_name=meta['artist_name'],
source=chosen_source,
metadata_source=metadata_source,
# Rename-only (#875): just move files to the current naming scheme — skip
# the copy + post-processing (re-tag / quality / AcoustID) of the full flow.
rename_only=bool(data.get('rename_only')),
)
return jsonify({"success": True, **result})
except Exception as e:
@ -11925,9 +11768,6 @@ try:
get_transfer_path=lambda: docker_resolve_path(
config_manager.get('soulseek.transfer_path', './Transfer')
),
# Rename-only mode (#875) computes destinations via the same path builder the
# preview uses, so apply matches exactly what the user saw.
build_final_path_fn=lambda *a, **kw: _build_final_path_for_track(*a, **kw),
))
except Exception as _runner_init_err:
logger.error(f"Failed to register reorganize queue runner: {_runner_init_err}")
@ -21651,13 +21491,17 @@ def search_spotify_tracks():
legacy_query = request.args.get('query', '').strip()
limit = int(request.args.get('limit', 20))
# Plain combined query — NOT field-scoped (`track:X artist:Y`). That Spotify
# syntax leaks to non-Spotify sources when search falls back (Deezer aborted
# the connection on it); the iTunes/Deezer endpoints already dropped it for the
# same reason, and the rerank below recovers precision. (Pool-fix "no results".)
from core.metadata.relevance import build_combined_search_query
query = build_combined_search_query(track_q, artist_q, legacy_query)
if not query:
# Build Spotify field-filtered query
if track_q or artist_q:
parts = []
if track_q:
parts.append(f'track:{track_q}')
if artist_q:
parts.append(f'artist:{artist_q}')
query = ' '.join(parts)
elif legacy_query:
query = legacy_query
else:
return jsonify({"error": "Query parameter is required"}), 400
if use_hydrabase:
@ -27780,98 +27624,9 @@ _playlist_export_jobs = {}
_playlist_export_jobs_lock = threading.Lock()
def _build_service_search_id_fn(service):
"""Bind a confident-search ID resolver to the service's metadata search client, for the
opt-in export backfill (#945). Returns None when the search client isn't available — the
backfill then simply finds nothing for that track rather than crashing the export."""
from core.exports.export_sources import search_service_track_id
try:
if service == 'spotify':
client = get_spotify_client()
if not client:
return None
# allow_fallback=False is CRITICAL: Spotify's search falls back to iTunes/Deezer
# when rate-limited/free, returning tracks whose .id is NOT a Spotify id — backfill
# would then push wrong ids into the Spotify playlist. Disable it: real Spotify hits
# or nothing.
search_fn = lambda q: client.search_tracks(q, limit=8, allow_fallback=False) # noqa: E731
else:
from core.metadata.registry import get_deezer_client
client = get_deezer_client()
if not client:
return None
# Deezer's search stays within Deezer (query-only fallback), so its .id is always a
# Deezer id — no cross-service guard needed.
search_fn = lambda q: client.search_tracks(q, limit=8) # noqa: E731
return lambda artist, title: search_service_track_id(artist, title, search_fn=search_fn)
except Exception:
return None
def _run_service_export(job, db, playlist_id, title, service, client, resolve_ids_fn=None):
"""Export a mirrored playlist back to a streaming service (Spotify/Deezer, #945).
Resolves each track to a target-service ID via the discovery cache (the track's
extra_data free + already matched) the library's stored id → (when job['backfill']
is set) a confident live-search match, pushes the matched set via the service write
client, and stores the returned playlist id so a re-export updates in place (idempotent,
like the LB #903 fix). Deps injected so the orchestration is unit-testable without a DB
or live service.
"""
from core.exports.export_sources import resolve_service_track_ids
resolve_ids_fn = resolve_ids_fn or resolve_service_track_ids
tracks = db.get_mirrored_playlist_tracks(int(playlist_id))
job['total'] = len(tracks)
job['phase'] = 'resolving'
def on_progress(done, total, stats):
job['done'] = done
job['stats'] = dict(stats)
# Opt-in backfill: confident live-search for tracks the cache + library couldn't resolve.
search_id_fn = _build_service_search_id_fn(service) if job.get('backfill') else None
out = resolve_ids_fn(tracks, service, search_id_fn=search_id_fn, on_progress=on_progress)
job['stats'] = out['stats']
ids = [r['service_track_id'] for r in out['resolved'] if r.get('service_track_id')]
if not ids:
job['phase'] = 'error'
job['error'] = f"No tracks matched a {service.title()} ID — nothing to export"
return
if client is None:
job['phase'] = 'error'
job['error'] = f"{service.title()} is not connected"
return
job['phase'] = 'pushing'
# Re-export updates the same service playlist in place (idempotent), mirroring the
# ListenBrainz #903 fix — the target ID is stored per (playlist, service).
existing = db.get_playlist_export_target(int(playlist_id), service)
res = client.create_or_update_playlist(title, ids, existing_id=existing)
job['push'] = res
if res.get('success'):
if res.get('playlist_id'):
db.set_playlist_export_target(int(playlist_id), service, str(res['playlist_id']))
job['phase'] = 'done'
else:
job['phase'] = 'error'
job['error'] = res.get('error') or f"{service.title()} push failed"
def _run_playlist_export(job_id, playlist_id, title, mode):
job = _playlist_export_jobs[job_id]
try:
# Service export (#945) — resolve to Spotify/Deezer track IDs and push.
if mode in ('spotify', 'deezer'):
db = get_database()
if mode == 'spotify':
client = get_spotify_client()
else:
from core.deezer_download_client import DeezerDownloadClient
client = DeezerDownloadClient()
_run_service_export(job, db, playlist_id, title, mode, client)
return
from core.exports.export_sources import build_resolve_fn
from core.exports.playlist_export import resolve_playlist_tracks
from core.exports.jspf_export import build_jspf
@ -27944,49 +27699,6 @@ def start_playlist_export_listenbrainz(playlist_id):
return jsonify({"success": False, "error": str(e)}), 500
@app.route('/api/playlists/<playlist_id>/export/service/<service>', methods=['POST'])
def start_playlist_export_service(playlist_id, service):
"""Export a mirrored playlist back to a streaming service (#945).
``service`` {spotify, deezer}. Creates a service-owned playlist (or updates the
one a prior export created) from the library tracks' stored service IDs. Returns
{job_id} to poll via the shared export-status endpoint."""
try:
service = (service or '').lower()
if service not in ('spotify', 'deezer'):
return jsonify({"success": False, "error": f"Unsupported export target: {service}"}), 400
# Spotify export needs the write scope, which the normal login doesn't request. If the
# current token doesn't carry it, tell the UI to send the user through the one-time
# on-demand export-auth (instead of starting a job that would 403). #945.
if service == 'spotify' and not (spotify_client and spotify_client.has_write_scope()):
return jsonify({
"success": False, "needs_auth": True,
"auth_url": "/auth/spotify/export",
"error": "Spotify needs permission to create playlists",
}), 200
body = request.get_json(silent=True) or {}
backfill = bool(body.get('backfill'))
db = get_database()
meta = db.get_mirrored_playlist(int(playlist_id)) or {}
title = (meta.get('name') or meta.get('title') or 'SoulSync Export').strip() or 'SoulSync Export'
import uuid
job_id = uuid.uuid4().hex
with _playlist_export_jobs_lock:
_playlist_export_jobs[job_id] = {
'job_id': job_id, 'playlist_id': str(playlist_id), 'title': title,
'mode': service, 'backfill': backfill, 'phase': 'starting', 'done': 0,
'total': 0, 'stats': {}, 'error': None,
}
t = threading.Thread(target=_run_playlist_export,
args=(job_id, playlist_id, title, service), daemon=True)
t.start()
return jsonify({"success": True, "job_id": job_id})
except Exception as e:
logger.error(f"Service playlist export start failed ({service}): {e}")
return jsonify({"success": False, "error": str(e)}), 500
@app.route('/api/playlists/export/status/<job_id>', methods=['GET'])
def playlist_export_status(job_id):
"""Live status for an export job (polled by the mirrored-playlist card)."""
@ -36152,24 +35864,19 @@ def start_oauth_callback_servers():
try:
from spotipy.oauth2 import SpotifyOAuth
from config.settings import config_manager
from core.spotify_client import normalize_spotify_oauth_config
# Get Spotify config
config = normalize_spotify_oauth_config(config_manager.get_spotify_config())
config = config_manager.get_spotify_config()
configured_uri = config.get('redirect_uri', "http://127.0.0.1:8888/callback")
_oauth_logger.info(f"Using redirect_uri for token exchange: {configured_uri}")
# Create auth manager and exchange code for token. Use the SAME store
# the client reads (DatabaseTokenCache), not the legacy file — a re-auth
# written to the file never reaches the DB-backed client, so it would
# report "validation failed" despite a successful exchange.
from core.spotify_token_cache import DatabaseTokenCache
# Create auth manager and exchange code for token
auth_manager = SpotifyOAuth(
client_id=config['client_id'],
client_secret=config['client_secret'],
redirect_uri=configured_uri,
scope=SPOTIFY_OAUTH_SCOPE,
cache_handler=DatabaseTokenCache(config_manager)
scope="user-library-read user-read-private playlist-read-private playlist-read-collaborative user-read-email user-follow-read",
cache_path='config/.spotify_cache'
)
# Extract the authorization code and exchange it for tokens
@ -36535,13 +36242,11 @@ except Exception as e:
# they explicitly want background Spotify enrichment.
spotify_enrichment_worker = None
try:
from core.metadata_service import get_configured_primary_source
from core.metadata_service import get_primary_source as _get_primary_source
from database.music_database import MusicDatabase
spotify_enrichment_db = MusicDatabase()
spotify_enrichment_worker = SpotifyWorker(database=spotify_enrichment_db)
# Use configured source only — get_primary_source() probes Spotify auth and can
# block gunicorn worker boot indefinitely when the API is unreachable.
_primary = get_configured_primary_source()
_primary = _get_primary_source()
_user_paused = config_manager.get('spotify_enrichment_paused', False)
if _user_paused or _primary != 'spotify':
spotify_enrichment_worker.paused = True # Set BEFORE start() to prevent race condition
@ -37703,12 +37408,6 @@ def import_staging_groups():
return jsonify(payload), status
@app.route('/api/import/staging/scan-status', methods=['GET'])
def import_staging_scan_status():
payload, status = _import_staging_scan_status(_build_import_route_runtime())
return jsonify(payload), status
@app.route('/api/import/staging/hints', methods=['GET'])
def import_staging_hints():
payload, status = _import_staging_hints(_build_import_route_runtime())
@ -39027,11 +38726,6 @@ def start_runtime_services():
_runtime_started = True
# Module import is complete — provider clients may now perform network probes.
from core.boot_phase import mark_boot_complete
mark_boot_complete()
# Direct execution: python web_server.py (dev/Windows fallback)
# Production should use: gunicorn -c gunicorn.conf.py wsgi:application
if _DIRECT_RUN:

File diff suppressed because it is too large Load diff

441
webui/package-lock.json generated
View file

@ -96,13 +96,13 @@
"license": "MIT"
},
"node_modules/@babel/code-frame": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
"integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
"integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-validator-identifier": "^7.29.7",
"@babel/helper-validator-identifier": "^7.28.5",
"js-tokens": "^4.0.0",
"picocolors": "^1.1.1"
},
@ -111,9 +111,9 @@
}
},
"node_modules/@babel/compat-data": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz",
"integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==",
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz",
"integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==",
"dev": true,
"license": "MIT",
"engines": {
@ -121,21 +121,21 @@
}
},
"node_modules/@babel/core": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.29.7",
"@babel/generator": "^7.29.7",
"@babel/helper-compilation-targets": "^7.29.7",
"@babel/helper-module-transforms": "^7.29.7",
"@babel/helpers": "^7.29.7",
"@babel/parser": "^7.29.7",
"@babel/template": "^7.29.7",
"@babel/traverse": "^7.29.7",
"@babel/types": "^7.29.7",
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
"@babel/helper-compilation-targets": "^7.28.6",
"@babel/helper-module-transforms": "^7.28.6",
"@babel/helpers": "^7.28.6",
"@babel/parser": "^7.29.0",
"@babel/template": "^7.28.6",
"@babel/traverse": "^7.29.0",
"@babel/types": "^7.29.0",
"@jridgewell/remapping": "^2.3.5",
"convert-source-map": "^2.0.0",
"debug": "^4.1.0",
@ -152,14 +152,14 @@
}
},
"node_modules/@babel/generator": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz",
"integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==",
"version": "7.29.1",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz",
"integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.29.7",
"@babel/types": "^7.29.7",
"@babel/parser": "^7.29.0",
"@babel/types": "^7.29.0",
"@jridgewell/gen-mapping": "^0.3.12",
"@jridgewell/trace-mapping": "^0.3.28",
"jsesc": "^3.0.2"
@ -169,14 +169,14 @@
}
},
"node_modules/@babel/helper-compilation-targets": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz",
"integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==",
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz",
"integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/compat-data": "^7.29.7",
"@babel/helper-validator-option": "^7.29.7",
"@babel/compat-data": "^7.28.6",
"@babel/helper-validator-option": "^7.27.1",
"browserslist": "^4.24.0",
"lru-cache": "^5.1.1",
"semver": "^6.3.1"
@ -196,9 +196,9 @@
}
},
"node_modules/@babel/helper-globals": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz",
"integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==",
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
"integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==",
"dev": true,
"license": "MIT",
"engines": {
@ -206,29 +206,29 @@
}
},
"node_modules/@babel/helper-module-imports": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz",
"integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==",
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz",
"integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/traverse": "^7.29.7",
"@babel/types": "^7.29.7"
"@babel/traverse": "^7.28.6",
"@babel/types": "^7.28.6"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-module-transforms": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz",
"integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==",
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz",
"integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-module-imports": "^7.29.7",
"@babel/helper-validator-identifier": "^7.29.7",
"@babel/traverse": "^7.29.7"
"@babel/helper-module-imports": "^7.28.6",
"@babel/helper-validator-identifier": "^7.28.5",
"@babel/traverse": "^7.28.6"
},
"engines": {
"node": ">=6.9.0"
@ -248,9 +248,9 @@
}
},
"node_modules/@babel/helper-string-parser": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
"integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
"dev": true,
"license": "MIT",
"engines": {
@ -258,9 +258,9 @@
}
},
"node_modules/@babel/helper-validator-identifier": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
"integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
"version": "7.28.5",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
"integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
"dev": true,
"license": "MIT",
"engines": {
@ -268,9 +268,9 @@
}
},
"node_modules/@babel/helper-validator-option": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz",
"integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==",
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz",
"integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==",
"dev": true,
"license": "MIT",
"engines": {
@ -278,27 +278,27 @@
}
},
"node_modules/@babel/helpers": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz",
"integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==",
"version": "7.29.2",
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz",
"integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/template": "^7.29.7",
"@babel/types": "^7.29.7"
"@babel/template": "^7.28.6",
"@babel/types": "^7.29.0"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/parser": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz",
"integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==",
"version": "7.29.2",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz",
"integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/types": "^7.29.7"
"@babel/types": "^7.29.0"
},
"bin": {
"parser": "bin/babel-parser.js"
@ -349,33 +349,33 @@
}
},
"node_modules/@babel/template": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
"integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==",
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
"integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.29.7",
"@babel/parser": "^7.29.7",
"@babel/types": "^7.29.7"
"@babel/code-frame": "^7.28.6",
"@babel/parser": "^7.28.6",
"@babel/types": "^7.28.6"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/traverse": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz",
"integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==",
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz",
"integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.29.7",
"@babel/generator": "^7.29.7",
"@babel/helper-globals": "^7.29.7",
"@babel/parser": "^7.29.7",
"@babel/template": "^7.29.7",
"@babel/types": "^7.29.7",
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
"@babel/helper-globals": "^7.28.0",
"@babel/parser": "^7.29.0",
"@babel/template": "^7.28.6",
"@babel/types": "^7.29.0",
"debug": "^4.3.1"
},
"engines": {
@ -383,14 +383,14 @@
}
},
"node_modules/@babel/types": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz",
"integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==",
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
"integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-string-parser": "^7.29.7",
"@babel/helper-validator-identifier": "^7.29.7"
"@babel/helper-string-parser": "^7.27.1",
"@babel/helper-validator-identifier": "^7.28.5"
},
"engines": {
"node": ">=6.9.0"
@ -610,21 +610,21 @@
}
},
"node_modules/@emnapi/core": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
"integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==",
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
"integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.2",
"@emnapi/wasi-threads": "1.2.1",
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
"integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
"dev": true,
"license": "MIT",
"optional": true,
@ -633,9 +633,9 @@
}
},
"node_modules/@emnapi/wasi-threads": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
"integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==",
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
"integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
"dev": true,
"license": "MIT",
"optional": true,
@ -862,14 +862,14 @@
"license": "MIT"
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz",
"integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==",
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz",
"integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@tybys/wasm-util": "^0.10.3"
"@tybys/wasm-util": "^0.10.1"
},
"funding": {
"type": "github",
@ -905,16 +905,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/@oxc-project/types": {
"version": "0.137.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.137.0.tgz",
"integrity": "sha512-WT+Gb24i8hmvo85AIv2oEYouEXkRlKAlT9WaCa3TfLgNCN+GhrJOGZuIlMouAh38Qe4QOx26eUOVsq70qXrywA==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/Boshen"
}
},
"node_modules/@oxfmt/binding-android-arm-eabi": {
"version": "0.47.0",
"resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm-eabi/-/binding-android-arm-eabi-0.47.0.tgz",
@ -1746,9 +1736,9 @@
}
},
"node_modules/@rolldown/binding-android-arm64": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.3.tgz",
"integrity": "sha512-DT6Z3PhvioeHMvxo+xHc3KtqggrI7CCTXCmC2h/5zUlp5jVitv7XEy+9q5/7v8IolhlioawpMo8Kg0EEBy7J0g==",
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.17.tgz",
"integrity": "sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==",
"cpu": [
"arm64"
],
@ -1763,9 +1753,9 @@
}
},
"node_modules/@rolldown/binding-darwin-arm64": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.3.tgz",
"integrity": "sha512-0NwgwsjM7LrsuVnXMK3koTpagBNOhloc/BNjKqZjv4V5zI5r13qx69uVhRx+o5Z0yy4Hzq+lpy7TAgUG/ocvrw==",
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.17.tgz",
"integrity": "sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw==",
"cpu": [
"arm64"
],
@ -1780,9 +1770,9 @@
}
},
"node_modules/@rolldown/binding-darwin-x64": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.3.tgz",
"integrity": "sha512-YtiBp4disu6V560loT6PjMdiRaWmVvDNrUunAalbiFx2ggeJwxdAsgZMcoGP17uyAsTwAj5V1niksxlHnVQ1Sw==",
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.17.tgz",
"integrity": "sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw==",
"cpu": [
"x64"
],
@ -1797,9 +1787,9 @@
}
},
"node_modules/@rolldown/binding-freebsd-x64": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.3.tgz",
"integrity": "sha512-yD3EkEdXk2LypPxnf/kSZHirarsI8gcPzc62SukhR9VJTyvV+F9Q/GxWNuCojc7sXyuVC4DxRGhdDK4X8VSsbw==",
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.17.tgz",
"integrity": "sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw==",
"cpu": [
"x64"
],
@ -1814,9 +1804,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.3.tgz",
"integrity": "sha512-c+8vieQbsD7HNAHKIA34w0GJ9FedFFuJGD+7E6vz7Q3uqAIugL5p45fhlsj4UaAsHpcmlqugBWMhA0/j7o0sIg==",
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.17.tgz",
"integrity": "sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ==",
"cpu": [
"arm"
],
@ -1831,9 +1821,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm64-gnu": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.3.tgz",
"integrity": "sha512-50jD0uUwLvur7Zz9LHz17kaAdTPjn5wN93hEgjvmYFRZwiR7ZJYovTd5ipyWJDAnXKvZ+wgc+/Ika6dwSF5OcA==",
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.17.tgz",
"integrity": "sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q==",
"cpu": [
"arm64"
],
@ -1851,9 +1841,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm64-musl": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.3.tgz",
"integrity": "sha512-BO9+oPL8K9poZJBfYPsXNtYjPE5uM3qeehT3aFcW4LITOl+iSqhp0abzjR2nWBUNjIZeKXjAEWBZ64WjNoHd6w==",
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.17.tgz",
"integrity": "sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==",
"cpu": [
"arm64"
],
@ -1871,9 +1861,9 @@
}
},
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.3.tgz",
"integrity": "sha512-f3VpLB1vQ0Eo6ecr/6cekLnvYMFF4YBFoVGkfkvPLq1bAkbAwHYQPZKoAmG6OJyTcxxoC+AvezGx/S1obNC0Mw==",
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.17.tgz",
"integrity": "sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==",
"cpu": [
"ppc64"
],
@ -1891,9 +1881,9 @@
}
},
"node_modules/@rolldown/binding-linux-s390x-gnu": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.3.tgz",
"integrity": "sha512-AmurZ26Pqx/RI9N1gzEOCklkKXl927yjfXWUUS0O7Puh8ARM/Ob8qfrD3qnWksScdw6cSrW5PSHE9DyLu7+PtA==",
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.17.tgz",
"integrity": "sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==",
"cpu": [
"s390x"
],
@ -1911,9 +1901,9 @@
}
},
"node_modules/@rolldown/binding-linux-x64-gnu": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.3.tgz",
"integrity": "sha512-JJpqs8bRGITDOdbkNKnlojzBabbOHrqjSvDr0IVsZObE1lBcPjxItUEY9eWIDbxaJ3cGrXPWGfGkIxFijg/URg==",
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.17.tgz",
"integrity": "sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==",
"cpu": [
"x64"
],
@ -1931,9 +1921,9 @@
}
},
"node_modules/@rolldown/binding-linux-x64-musl": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.3.tgz",
"integrity": "sha512-rSJcdjPxzA/by/6/rYs+v+bXU7UjvnbUWz8MJb6kh6+knqB1dCrtHg0uu7C/4haqJvqdkYHQ5IGn+tCH9GLW/g==",
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.17.tgz",
"integrity": "sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==",
"cpu": [
"x64"
],
@ -1951,9 +1941,9 @@
}
},
"node_modules/@rolldown/binding-openharmony-arm64": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.3.tgz",
"integrity": "sha512-hQ3/PYkDJICgevvyNcVrihVeqq7k1Pp3VZ9lY+dauAYUJKO+auqApvANhvR1An9BhmqYKvW2Mu1F9u4DXSMLxQ==",
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.17.tgz",
"integrity": "sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==",
"cpu": [
"arm64"
],
@ -1968,9 +1958,9 @@
}
},
"node_modules/@rolldown/binding-wasm32-wasi": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.3.tgz",
"integrity": "sha512-Elcv/BtML9lXrV6JuKITc/grN2kYV9gjsQpW8Jfw4ioK0TOkjBjye0nnyqQNy9STNaI20lXNaQBRrD5gSgR0Yg==",
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.17.tgz",
"integrity": "sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA==",
"cpu": [
"wasm32"
],
@ -1978,18 +1968,18 @@
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "1.11.1",
"@emnapi/runtime": "1.11.1",
"@napi-rs/wasm-runtime": "^1.1.6"
"@emnapi/core": "1.10.0",
"@emnapi/runtime": "1.10.0",
"@napi-rs/wasm-runtime": "^1.1.4"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-win32-arm64-msvc": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.3.tgz",
"integrity": "sha512-2DrEfhluH9yhiaFApmsjsjwrSYbNcY1oFTzYSP1a535jDbV98zCFanA/96TBUd0iDFcxGmw9QRExwGCXz3U+/g==",
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.17.tgz",
"integrity": "sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA==",
"cpu": [
"arm64"
],
@ -2004,9 +1994,9 @@
}
},
"node_modules/@rolldown/binding-win32-x64-msvc": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.3.tgz",
"integrity": "sha512-OL4OMk7UPXOeVGGd3qo5zJyPIljf4AFgk5QAkPPS+OoLuOOozhuaQGC18MxVTnw/06q93gShAJzlwnSCY9YtqA==",
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.17.tgz",
"integrity": "sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg==",
"cpu": [
"x64"
],
@ -2436,9 +2426,9 @@
}
},
"node_modules/@tybys/wasm-util": {
"version": "0.10.3",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
"integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==",
"version": "0.10.1",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
"integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==",
"dev": true,
"license": "MIT",
"optional": true,
@ -2829,9 +2819,9 @@
}
},
"node_modules/baseline-browser-mapping": {
"version": "2.10.40",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.40.tgz",
"integrity": "sha512-BSSLZ9/Cjjv7Gtj5B68ZzXcXUg8iOf3fme+FCuh8rC/Go+Kmh8cox7M3A8dolou16s64QjLPOSdngh7GxXvkSw==",
"version": "2.10.14",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.14.tgz",
"integrity": "sha512-fOVLPAsFTsQfuCkvahZkzq6nf8KvGWanlYoTh0SVA0A/PIUxQGU2AOZAoD95n2gFLVDW/jP6sbGLny95nmEuHA==",
"dev": true,
"license": "Apache-2.0",
"bin": {
@ -2878,9 +2868,9 @@
}
},
"node_modules/browserslist": {
"version": "4.28.4",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.4.tgz",
"integrity": "sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw==",
"version": "4.28.2",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz",
"integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==",
"dev": true,
"funding": [
{
@ -2898,10 +2888,10 @@
],
"license": "MIT",
"dependencies": {
"baseline-browser-mapping": "^2.10.38",
"caniuse-lite": "^1.0.30001799",
"electron-to-chromium": "^1.5.376",
"node-releases": "^2.0.48",
"baseline-browser-mapping": "^2.10.12",
"caniuse-lite": "^1.0.30001782",
"electron-to-chromium": "^1.5.328",
"node-releases": "^2.0.36",
"update-browserslist-db": "^1.2.3"
},
"bin": {
@ -2912,9 +2902,9 @@
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001799",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz",
"integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==",
"version": "1.0.30001785",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001785.tgz",
"integrity": "sha512-blhOL/WNR+Km1RI/LCVAvA73xplXA7ZbjzI4YkMK9pa6T/P3F2GxjNpEkyw5repTw9IvkyrjyHpwjnhZ5FOvYQ==",
"dev": true,
"funding": [
{
@ -3281,9 +3271,9 @@
"peer": true
},
"node_modules/electron-to-chromium": {
"version": "1.5.380",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.380.tgz",
"integrity": "sha512-W6d5AbuEoRayO447cqrg6lKJIlscgRnnxOZl/08kfV71BQDoEBC7Wwis68z87LjyK6f4kWyTaubuDbhHKrZkbA==",
"version": "1.5.331",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.331.tgz",
"integrity": "sha512-IbxXrsTlD3hRodkLnbxAPP4OuJYdWCeM3IOdT+CpcMoIwIoDfCmRpEtSPfwBXxVkg9xmBeY7Lz2Eo2TDn/HC3Q==",
"dev": true,
"license": "ISC"
},
@ -4069,9 +4059,9 @@
}
},
"node_modules/nanoid": {
"version": "3.3.15",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz",
"integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==",
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
"dev": true,
"funding": [
{
@ -4088,14 +4078,11 @@
}
},
"node_modules/node-releases": {
"version": "2.0.50",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.50.tgz",
"integrity": "sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==",
"version": "2.0.37",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz",
"integrity": "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
"license": "MIT"
},
"node_modules/normalize-path": {
"version": "3.0.0",
@ -4308,9 +4295,9 @@
}
},
"node_modules/postcss": {
"version": "8.5.16",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz",
"integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==",
"version": "8.5.10",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz",
"integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==",
"dev": true,
"funding": [
{
@ -4328,7 +4315,7 @@
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.12",
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
@ -4548,14 +4535,14 @@
"license": "MIT"
},
"node_modules/rolldown": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.3.tgz",
"integrity": "sha512-1F1eEtUBtFvcGm1HQ9TiUIUHPQG7mSAODrhIzjxoUEFuo8OcbrGLiVLkevNgj84TE4lnHvnumwFjhJO5Eu135g==",
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.17.tgz",
"integrity": "sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@oxc-project/types": "=0.137.0",
"@rolldown/pluginutils": "^1.0.0"
"@oxc-project/types": "=0.127.0",
"@rolldown/pluginutils": "1.0.0-rc.17"
},
"bin": {
"rolldown": "bin/cli.mjs"
@ -4564,27 +4551,37 @@
"node": "^20.19.0 || >=22.12.0"
},
"optionalDependencies": {
"@rolldown/binding-android-arm64": "1.1.3",
"@rolldown/binding-darwin-arm64": "1.1.3",
"@rolldown/binding-darwin-x64": "1.1.3",
"@rolldown/binding-freebsd-x64": "1.1.3",
"@rolldown/binding-linux-arm-gnueabihf": "1.1.3",
"@rolldown/binding-linux-arm64-gnu": "1.1.3",
"@rolldown/binding-linux-arm64-musl": "1.1.3",
"@rolldown/binding-linux-ppc64-gnu": "1.1.3",
"@rolldown/binding-linux-s390x-gnu": "1.1.3",
"@rolldown/binding-linux-x64-gnu": "1.1.3",
"@rolldown/binding-linux-x64-musl": "1.1.3",
"@rolldown/binding-openharmony-arm64": "1.1.3",
"@rolldown/binding-wasm32-wasi": "1.1.3",
"@rolldown/binding-win32-arm64-msvc": "1.1.3",
"@rolldown/binding-win32-x64-msvc": "1.1.3"
"@rolldown/binding-android-arm64": "1.0.0-rc.17",
"@rolldown/binding-darwin-arm64": "1.0.0-rc.17",
"@rolldown/binding-darwin-x64": "1.0.0-rc.17",
"@rolldown/binding-freebsd-x64": "1.0.0-rc.17",
"@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.17",
"@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.17",
"@rolldown/binding-linux-arm64-musl": "1.0.0-rc.17",
"@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.17",
"@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.17",
"@rolldown/binding-linux-x64-gnu": "1.0.0-rc.17",
"@rolldown/binding-linux-x64-musl": "1.0.0-rc.17",
"@rolldown/binding-openharmony-arm64": "1.0.0-rc.17",
"@rolldown/binding-wasm32-wasi": "1.0.0-rc.17",
"@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.17",
"@rolldown/binding-win32-x64-msvc": "1.0.0-rc.17"
}
},
"node_modules/rolldown/node_modules/@oxc-project/types": {
"version": "0.127.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.127.0.tgz",
"integrity": "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/Boshen"
}
},
"node_modules/rolldown/node_modules/@rolldown/pluginutils": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
"integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.17.tgz",
"integrity": "sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==",
"dev": true,
"license": "MIT"
},
@ -4791,9 +4788,9 @@
}
},
"node_modules/tinyglobby": {
"version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
"integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
"version": "0.2.16",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
"integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -4925,9 +4922,9 @@
}
},
"node_modules/undici": {
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz",
"integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==",
"version": "7.24.7",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.24.7.tgz",
"integrity": "sha512-H/nlJ/h0ggGC+uRL3ovD+G0i4bqhvsDOpbDv7At5eFLlj2b41L8QliGbnl2H7SnDiYhENphh1tQFJZf+MyfLsQ==",
"dev": true,
"license": "MIT",
"engines": {
@ -5029,17 +5026,17 @@
}
},
"node_modules/vite": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.0.tgz",
"integrity": "sha512-BuJcQK/56NQTWDGn4ABea3q4SSBdNPWwNZKTkkUpcMPnLoquSYH8llRtSUIgoL1KSCpHt5eghLShn50mH36y7Q==",
"version": "8.0.10",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz",
"integrity": "sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==",
"dev": true,
"license": "MIT",
"dependencies": {
"lightningcss": "^1.32.0",
"picomatch": "^4.0.4",
"postcss": "^8.5.15",
"rolldown": "~1.1.2",
"tinyglobby": "^0.2.17"
"postcss": "^8.5.10",
"rolldown": "1.0.0-rc.17",
"tinyglobby": "^0.2.16"
},
"bin": {
"vite": "bin/vite.js"
@ -5055,7 +5052,7 @@
},
"peerDependencies": {
"@types/node": "^20.19.0 || >=22.12.0",
"@vitejs/devtools": "^0.3.0",
"@vitejs/devtools": "^0.1.0",
"esbuild": "^0.27.0 || ^0.28.0",
"jiti": ">=1.21.0",
"less": "^4.0.0",

View file

@ -23,21 +23,11 @@ export interface ImportStagingFile {
manual_match?: ImportTrackResult;
}
/** While a large staging folder is still being scanned in the background (#947), the
* staging endpoints return `scanning: true` + progress instead of files/groups; the query
* polls until the scan completes and real data arrives. */
export interface ImportScanProgress {
scanned: number;
total: number;
}
export interface ImportStagingFilesPayload {
success: boolean;
files?: ImportStagingFile[];
staging_path?: string;
error?: string;
scanning?: boolean;
progress?: ImportScanProgress;
}
export interface ImportStagingGroup {
@ -57,8 +47,6 @@ export interface ImportStagingGroupsPayload {
success: boolean;
groups?: ImportStagingGroup[];
error?: string;
scanning?: boolean;
progress?: ImportScanProgress;
}
export interface ImportAlbumResult {

View file

@ -250,19 +250,6 @@ describe('import route', () => {
expect(await screen.findByText('Import folder: error')).toBeInTheDocument();
});
it('shows scan progress while a large staging folder is still scanning (#947)', async () => {
server.use(
http.get('/api/import/staging/files', () =>
HttpResponse.json({ success: true, scanning: true, progress: { scanned: 5, total: 20 } }),
),
);
renderImportRoute();
expect(await screen.findByTestId('import-page')).toBeInTheDocument();
expect(await screen.findByText(/Scanning 5 of 20 files/)).toBeInTheDocument();
});
it('stores the active tab in nested route paths', async () => {
const { history } = renderImportRoute();

View file

@ -21,24 +21,17 @@ import { fallbackImage, RefreshIcon, useImportStaging } from './import-shared';
export function ImportPage() {
useReactPageShell('import');
const { refreshStaging, scanning, scanProgress, stagingFiles, stagingPath, stagingQuery } =
useImportStaging();
const { refreshStaging, stagingFiles, stagingPath, stagingQuery } = useImportStaging();
const isRefreshing = stagingQuery.isRefetching;
const lastRefreshedAt =
stagingQuery.dataUpdatedAt > 0 ? formatShortTime(stagingQuery.dataUpdatedAt) : null;
// While a large staging folder is still scanning (#947), show progress instead of a count.
const fileCountText = scanning
? scanProgress && scanProgress.total > 0
? `Scanning ${scanProgress.scanned} of ${scanProgress.total} files…`
: 'Scanning staging folder…'
: getStagingStatsText(stagingFiles);
return (
<div id="import-page" data-testid="import-page">
<div className={styles.importPageContainer}>
<ImportHeader
error={stagingQuery.error}
fileCountText={fileCountText}
fileCountText={getStagingStatsText(stagingFiles)}
loading={stagingQuery.isLoading}
stagingPath={stagingPath}
refreshing={isRefreshing}
@ -109,9 +102,9 @@ function ImportOptions() {
/>
<span
id="import-quality-filter-label"
title="Checks imported files against your Quality Profile only. Turning this off imports files regardless of format/bitrate/bit depth/sample rate; AcoustID verification is separate and is not skipped."
title="Only import tracks that meet your quality profile; otherwise import everything regardless of quality."
>
Quality profile check on import
Quality check on import
</span>
</div>
<div className={styles.importOption}>

View file

@ -1,5 +1,4 @@
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect } from 'react';
import type { ImportQueueJob, ImportStagingFile } from '../-import.types';
@ -21,22 +20,6 @@ export function useImportStaging() {
...importStagingFilesQueryOptions(),
});
// A large staging folder (whole-library migration, #947) is scanned in the background; the
// endpoints return `scanning: true` until it's done. While scanning, poll so the page fills
// in automatically once the scan completes. Invalidate ALL staging queries (files, groups,
// suggestions) — not just files — so the album tab's separate groups query refetches too,
// otherwise it would stay stuck on its initial {scanning} response. A plain setInterval (NOT
// react-query's refetchInterval) that only runs while scanning leaves normal/error states
// untouched; only currently-mounted queries actually refetch.
const scanning = stagingQuery.data?.scanning === true;
useEffect(() => {
if (!scanning) return undefined;
const id = window.setInterval(() => {
void invalidateImportStagingQueries(queryClient);
}, 1500);
return () => window.clearInterval(id);
}, [scanning, queryClient]);
return {
refreshStaging: async () => {
clearFinishedJobs();
@ -45,8 +28,6 @@ export function useImportStaging() {
// Keep the empty fallback stable so staging-driven effects do not loop while loading.
stagingFiles: stagingQuery.data?.files ?? EMPTY_STAGING_FILES,
stagingPath: stagingQuery.data?.staging_path || 'Not configured',
scanning,
scanProgress: stagingQuery.data?.progress ?? null,
stagingQuery,
};
}

View file

@ -50,10 +50,7 @@ export function SinglesImportTab() {
setOpenSingleSearch(fileKey);
const defaultQuery =
// "artist - title" (the placeholder hints this, and source search needs the
// dash to disambiguate — "Sub Focus Last Jungle" finds junk, "Sub Focus - Last
// Jungle" finds the track). filter(Boolean) keeps a lone title dash-free.
[file?.artist, file?.title].filter(Boolean).join(' - ') ||
[file?.artist, file?.title].filter(Boolean).join(' ') ||
(file?.filename || '').replace(/\.[^.]+$/, '');
ensureSingleSearch(fileKey, defaultQuery);
if (defaultQuery && !singleSearches[fileKey]?.results.length) {

View file

@ -386,8 +386,8 @@ function _renderEqualizerBars(grid, data) {
// Call embers: tiny accent sparks rise off the fill tip, spawned per
// socket update in proportion to REAL traffic — motion strictly means
// API calls are happening right now. Suppressed during cooldown and
// under reduced-effects / max-performance.
if (!window._reduceEffectsActive && !window._maxPerfActive && !cooling && realPct > 0.03) {
// under reduced-effects.
if (!window._reduceEffectsActive && !cooling && realPct > 0.03) {
_spawnEmbers(bar, pct, realPct > 0.6 ? 3 : realPct > 0.25 ? 2 : 1);
}

View file

@ -3404,15 +3404,20 @@ function closeHelperSearch() {
const WHATS_NEW = {
// Convention: keep only the CURRENT release here, plus a single brief
// "Earlier versions" summary entry. Don't accumulate old per-version blocks.
'2.8.2': [
{ date: 'June 2026 — 2.8.2 release' },
{ title: 'Spotify Docker boot hang fixed (#949)', desc: 'with Spotify as your primary metadata source, an unreachable Spotify API could block the gunicorn worker at startup — the container bound port 8008 but never served the Web UI. provider auth probes are deferred during boot (and capped with a timeout) so startup can\'t hang on a slow Spotify; same guard for Qobuz/Deezer/Tidal. (thanks HellRa1SeR.)', page: 'settings' },
{ title: '"Re-auth didn\'t stick" fixed', desc: 'the OAuth callback wrote your Spotify token to one cache while the app read another, so re-authenticating could silently fail validation. unified on one token store — re-auth takes effect now.', page: 'settings' },
{ title: 'Sync to Spotify works', desc: 'exporting a mirrored playlist to Spotify now asks for playlist-write permission once, on-demand, the first time you use it. your normal Spotify login is untouched, so upgrading never forces a re-auth.', page: 'playlists' },
{ title: 'The "slow after update" fix (#948)', desc: 'the real cause of post-update lag was browser password managers (Bitwarden/1Password/etc.) rebuilding their autofill overlay on every DOM change — and soulsync mutates the DOM constantly. non-credential fields are now marked so managers skip them (login fields left alone). ~110x less main-thread blocking in the reporter\'s benchmark. (thanks @nick2000713.)', page: 'dashboard' },
{ title: 'Max Performance mode', desc: 'Settings → Appearance. one switch kills the worker orbs, particles, all blur/shadows and every animation, and greys out the individual effect toggles. for software-rendered / no-GPU setups (Docker, remote desktop) where even simple animations cost real CPU.', page: 'settings' },
{ title: 'Large-library imports no longer time out (#947)', desc: 'dropping a whole library into staging used to make the import page scan every file synchronously and hit the request timeout — so it never loaded. the scan now runs in the background with a live "Scanning N of M…" progress, and the page fills in automatically when done. (thanks @ramonskie.)', page: 'downloads' },
{ title: 'Earlier versions', desc: '2.8.1 added playlist export to Spotify & Deezer (#945), a Rename-only Library Reorganize (#875), broader lossless + DSD handling, and a refined reduce-visual-effects pass. 2.8.0 brought the Unverified-queue cleanup (#934) + dashboard performance work; 2.7.9 added best-quality downloads + the Wing It Pool; 2.7.0 made multi-user real.' },
'2.8.0': [
{ date: 'June 2026 — 2.8.0 release' },
{ title: 'Preview Clip Cleanup (new Tools job)', desc: 'the HiFi source sometimes returns a ~30s preview clip instead of the full song, landing as a normal track. the new job scans your short tracks, checks how long each should be from its metadata source, and flags the previews. approve a finding and it deletes the clip, drops it from the library, and re-wishlists the full version. each finding has a ▶ Play button + a file-length-vs-real-length readout so you can confirm before approving.', page: 'downloads' },
{ title: 'Unverified queue stops inflating + self-heals (#934)', desc: 'the AcoustID scan was creating a fresh history row every run and leaving already-verified files stuck "unverified". now scans don\'t duplicate rows, a one-time reconcile on startup clears the existing backlog from your library truth (no re-scan), and a 🧹 Clean orphaned button removes dead rows whose file is gone. unverified review rows also got the Quarantine-style cards. (thanks @nick2000713 for #938.)', page: 'downloads' },
{ title: 'Album Completeness handles split albums (#936)', desc: 'an album split across multiple library rows used to show every fragment as falsely "incomplete". it now groups the validated fragments into one logical album and emits one correct finding — grouping by shared id AND validating at the track level, so unrelated rows never fuse. also recognizes MusicBrainz as a readable source. (thanks @ragnarlotus.)', page: 'library' },
{ title: 'Clear Completed is back on Downloads', desc: 'completed downloads now persist across restart, which had hidden the Clear Completed button for them. it\'s back, and clears the live list AND the persisted history so the page actually empties and stays empty (your files are untouched).', page: 'downloads' },
{ title: 'Pasted YouTube cookies fixed (#Docker)', desc: 'pasting cookies threw `unsupported browser: "custom"` — the client passed the "Paste cookies.txt" mode through as a browser name instead of loading the file. now it uses the pasted cookies.txt, the only auth path that works on a headless/Docker box. (thanks HellRa1SeR.)', page: 'settings' },
{ title: 'Longer remasters no longer quarantined (#937)', desc: 'the duration check was symmetric, so a remaster running a few seconds LONGER than the metadata got rejected like a truncated download. it\'s asymmetric now — short files stay strict, longer versions get room. (thanks @diegocade1.)', page: 'downloads' },
{ title: '"Add to Wishlist" from a discography is instant now', desc: 'it was ~1530s PER TRACK on a large library — the per-track ownership check fell through to a full-table fuzzy scan. it now matches in-memory against the artist\'s tracks once.', page: 'library' },
{ title: 'Wishlist art renders for re-downloads', desc: 'library-sourced wishlist items (re-downloads, preview re-fetches) stored a relative media-server path that doesn\'t render in a browser. they\'re normalized on read now, so album + artist art show up — fixes already-saved items too.', page: 'downloads' },
{ title: 'More fixes', desc: 'watchlist now records automatic scans (#933) and no longer fuses different editions of an album; a pasted Qobuz/Tidal track floats to the top of manual search (#932); Popular Picks no longer comes up empty on Deezer.', page: 'search' },
{ title: 'Dashboard performance, esp. Firefox/Zen (#935)', desc: 'frosted-glass blur, cursor-glow blobs and the worker-orb animation were repainting every frame. trimmed the worst offenders, kept the orb framerate steady on Firefox (was dropping to ~1fps), and set Background Particles OFF by default. the system-memory tile now also shows SoulSync\'s own RAM.', page: 'dashboard' },
{ title: 'Bounded memory growth (#802)', desc: 'browsing every page used to climb RSS into the GBs (plexapi XML trees deferring garbage collection) and could lock the app up. a lightweight sweeper now collects + hands memory back to the OS as it grows, so it sawtooths and settles instead of climbing.', page: 'dashboard' },
{ title: 'Earlier versions', desc: '2.7.9 brought best-quality downloads + a ranked quality profile, the quarantine-into-Downloads consolidation, Discover "Based On Your Listening" + Listening Mix, the Wing It Pool, the Auto-Sync redesign, and the multi-disc fix (#927). 2.7.8 added Align Playlists; 2.7.3 the Quality Upgrade Finder; 2.7.0 made multi-user real.' },
],
};
@ -3443,45 +3448,50 @@ const WHATS_NEW = {
// usage_note?: 'optional hint shown at the bottom' }
const VERSION_MODAL_SECTIONS = [
{
title: "Spotify, reliably",
description: "the Docker boot hang and the \"logged out / re-auth won't stick\" issues are fixed.",
title: "The Unverified queue, finally under control",
description: "the headline cleanup — review-queue rows stop piling up and the existing backlog clears itself.",
features: [
"#949 — with Spotify as your primary source, an unreachable Spotify API could block the gunicorn worker at startup (the container bound :8008 but never served the UI). auth probes are deferred during boot + capped with a timeout so startup can't hang; same guard for Qobuz/Deezer/Tidal. thanks HellRa1SeR",
"\"re-auth didn't stick\" — the OAuth callback wrote your token to one cache while the app read another; unified on a single token store, so re-authenticating actually takes effect",
"Sync to Spotify works — exporting a mirrored playlist to Spotify asks for write permission once, on-demand, the first time you use it; your normal login is untouched, so upgrading never forces a re-auth",
"#934 — the AcoustID scan was creating a fresh history row every run and leaving already-verified files stuck \"unverified\" (a frozen import-time path that stopped matching once the file moved)",
"scans no longer duplicate rows + heal on the spot; a one-time reconcile on startup clears the existing backlog from your library's truth — no re-scan, including human-verified files a scan skips",
"a 🧹 Clean orphaned button removes dead rows whose file is genuinely gone, with a safety gate that refuses to run if your library looks offline",
"Unverified review rows got the nicer Quarantine-style cards (artwork + inline details). thanks @nick2000713 for #938",
],
},
{
title: "The \"slow after update\" fix",
description: "the post-update lag wasn't us — it was your password manager.",
title: "Preview Clip Cleanup (new Tools job)",
description: "find the ~30s preview clips the HiFi source sometimes hands back instead of the full song.",
features: [
"#948 — browser password managers (Bitwarden/1Password/etc.) rebuild their autofill overlay on every DOM change, and soulsync mutates the DOM constantly. non-credential fields are now marked so managers skip them (login fields untouched) — ~110x less main-thread blocking, ~20 → ~96 FPS in the reporter's benchmark. thanks @nick2000713",
"new Max Performance mode (Settings → Appearance) — one switch kills orbs, particles, all blur/shadows and every animation, for software-rendered / no-GPU setups (Docker, remote desktop)",
"scans your short tracks, checks how long each should be from its metadata source, and flags the previews — conservative, so genuine short tracks and anything it can't verify are left alone",
"approve a finding and it deletes the clip, drops it from the library, and re-wishlists the full version",
"each finding has a ▶ Play button + a file-length-vs-real-length readout so you can confirm it's busted before approving",
],
},
{
title: "Large-library imports no longer time out",
description: "migrate a whole library into staging without the page choking.",
title: "Album Completeness handles split albums",
description: "a physical album split across multiple library rows no longer reports false \"incomplete\".",
features: [
"#947 — dropping thousands of files into staging used to scan every file synchronously and hit the request timeout, so the import page never loaded (and every reload re-timed-out)",
"the scan now runs in the background with a live \"Scanning N of M…\" progress, and the page fills in automatically when it's done. auto-import remains the hands-off path for matching. thanks @ramonskie",
"#936 — fragments are grouped into one logical album and emit a single correct finding, instead of each row looking incomplete on its own",
"safe by design: it groups by a shared id AND validates at the track level, so a stale id can never fuse two unrelated albums; also recognizes MusicBrainz as a readable album source. thanks @ragnarlotus",
],
},
{
title: "Earlier in 2.8.1",
description: "2.8.1 was a features + reliability release.",
title: "Recent fixes",
description: "reported bugs squashed this cycle.",
features: [
"playlist export to Spotify & Deezer (#945) — send a mirrored playlist back to your streaming account, resolving IDs from the discovery cache + your library",
"Rename-only Library Reorganize (#875), broader lossless + DSD handling (#941/#939), a pile of download/search fixes, and a refined reduce-visual-effects pass",
"pasted YouTube cookies threw `unsupported browser: \"custom\"` on Docker — now it loads the pasted cookies.txt (the only auth path on a headless box). thanks HellRa1SeR",
"#937 — a remaster running a few seconds LONGER than the metadata got quarantined like a truncated download; the duration check is asymmetric now. thanks @diegocade1",
"\"Add to Wishlist\" from a discography went from ~1530s PER TRACK to instant (the ownership check was doing a full-table fuzzy scan per track)",
"wishlist art now renders for re-downloads/preview re-fetches (relative media-server paths are normalized on read); Clear Completed is back on the Downloads page and clears the persisted history too",
"watchlist records automatic scans (#933) + stops fusing different editions; pasted Qobuz/Tidal floats to the top of manual search (#932); Popular Picks no longer empty on Deezer",
],
},
{
title: "Earlier in 2.8.0",
description: "2.8.0 was a quality + reliability release.",
title: "Performance — dashboard + memory",
description: "lower idle cost and no more runaway RAM.",
features: [
"the Unverified review queue stopped inflating and self-heals — the AcoustID scan no longer duplicates rows, a startup reconcile clears the backlog, and a 🧹 Clean orphaned button sweeps dead rows (#934, thanks @nick2000713 for #938)",
"Preview Clip Cleanup (a Tools job that finds ~30s preview clips and re-wishlists the real version); Album Completeness handles split albums (#936, thanks @ragnarlotus)",
"dashboard performance + bounded memory growth that could lock up big libraries (#935 / #802)",
"#935 — frosted-glass blur, cursor-glow blobs and the worker-orb animation were repainting every frame, hammering the GPU (worst on Firefox/Zen). trimmed them, kept the orb framerate steady on Firefox, and set Background Particles OFF by default",
"#802 — browsing every page used to climb RSS into the GBs (plexapi XML trees deferring GC) and could lock up; a lightweight sweeper now collects + returns memory to the OS as it grows, so it settles instead of climbing",
"the dashboard system-memory tile now also shows SoulSync's own RAM",
],
},
{

View file

@ -179,14 +179,6 @@ function initAccentColorListeners() {
applyReduceEffects(reduceEffectsCheckbox.checked);
});
}
// Max Performance toggle — apply immediately on change
const maxPerfCheckbox = document.getElementById('max-performance-enabled');
if (maxPerfCheckbox) {
maxPerfCheckbox.addEventListener('change', () => {
applyMaxPerformance(maxPerfCheckbox.checked);
});
}
}
function applyReduceEffects(enabled) {
@ -219,67 +211,6 @@ function applyReduceEffects(enabled) {
}
}
// Max Performance overrides Worker Orbs / Particles / Reduce Effects, so while it's
// on we lock those checkboxes (greyed + visually off) and restore them when it's
// off. We never fire their change handlers, so the user's real saved prefs
// (window._workerOrbsEnabled / _particlesEnabled / the reduce-effects localStorage)
// stay intact — saving reads those, not these forced-off boxes.
function _syncMaxPerfDependentToggles(maxPerfOn) {
const ids = ['worker-orbs-enabled', 'particles-enabled', 'reduce-effects-enabled'];
ids.forEach(id => {
const cb = document.getElementById(id);
if (!cb) return;
const group = cb.closest('.form-group');
if (maxPerfOn) {
cb.disabled = true;
cb.checked = false;
if (group) group.classList.add('setting-overridden');
} else {
cb.disabled = false;
if (group) group.classList.remove('setting-overridden');
// Restore each box to the user's real per-device preference.
if (id === 'worker-orbs-enabled') cb.checked = window._workerOrbsEnabled !== false;
else if (id === 'particles-enabled') cb.checked = window._particlesEnabled === true;
else if (id === 'reduce-effects-enabled') cb.checked = localStorage.getItem('soulsync-reduce-effects') === '1';
}
});
}
// Max Performance — the nuclear low-power switch for software-rendered / no-GPU
// setups (e.g. Docker). Superset of Reduce Visual Effects: body.max-performance CSS
// kills the expensive GPU properties AND all animation/transitions, while here we
// halt every JS canvas loop (particles + worker orbs; cursor-glow + API sparks gate
// on window._maxPerfActive themselves).
function applyMaxPerformance(enabled) {
if (enabled) {
document.body.classList.add('max-performance');
} else {
document.body.classList.remove('max-performance');
}
window._maxPerfActive = enabled;
localStorage.setItem('soulsync-max-performance', enabled ? '1' : '0');
const pcanvas = document.getElementById('page-particles-canvas');
if (enabled) {
if (window.pageParticles) window.pageParticles.stop();
if (pcanvas) pcanvas.style.display = 'none';
if (window.workerOrbs) window.workerOrbs.setPage('_disabled');
} else {
// Restore whatever the user's own toggles (and reduce-effects) still allow.
const reduce = window._reduceEffectsActive === true;
const activePage = document.querySelector('.page.active');
const activeId = activePage ? activePage.id.replace('-page', '') : null;
if (!reduce && window._particlesEnabled !== false) {
if (pcanvas) pcanvas.style.display = '';
if (window.pageParticles && activeId) window.pageParticles.setPage(activeId);
}
if (window._workerOrbsEnabled !== false && window.workerOrbs && activeId) {
window.workerOrbs.setPage(activeId);
}
}
_syncMaxPerfDependentToggles(enabled);
}
// Bootstrap accent and reduce-effects from localStorage instantly (prevents flash)
(function () {
// Auto performance mode on likely-weak hardware. Only acts when this device has
@ -323,41 +254,16 @@ function applyMaxPerformance(enabled) {
}
}
const reduceEffectsSaved = localStorage.getItem('soulsync-reduce-effects');
if (reduceEffectsSaved === '1') {
if (localStorage.getItem('soulsync-reduce-effects') === '1') {
document.body.classList.add('reduce-effects');
window._reduceEffectsActive = true;
} else if (reduceEffectsSaved === '0') {
document.body.classList.remove('reduce-effects');
window._reduceEffectsActive = false;
} else if (window._reduceEffectsActive) {
document.body.classList.add('reduce-effects');
}
// Max Performance — device-scoped (localStorage wins over the server default,
// same as reduce-effects). The window flag is seeded server-side in index.html
// for a flash-free first paint; localStorage reconciles it here.
const maxPerfSaved = localStorage.getItem('soulsync-max-performance');
if (maxPerfSaved === '1') {
document.body.classList.add('max-performance');
window._maxPerfActive = true;
} else if (maxPerfSaved === '0') {
document.body.classList.remove('max-performance');
window._maxPerfActive = false;
} else if (window._maxPerfActive) {
document.body.classList.add('max-performance');
}
const saved = localStorage.getItem('soulsync-accent');
if (saved) applyAccentColor(saved);
// Bootstrap particles setting from localStorage — OFF by default (continuous
// full-page canvas = real GPU cost); only on when the user explicitly enabled it.
const particlesSaved = localStorage.getItem('soulsync-particles');
if (particlesSaved === 'true') {
window._particlesEnabled = true;
} else if (particlesSaved === 'false') {
window._particlesEnabled = false;
} else if (typeof window._particlesEnabled !== 'boolean') {
window._particlesEnabled = false;
}
window._particlesEnabled = (particlesSaved === 'true');
if (!window._particlesEnabled) {
const canvas = document.getElementById('page-particles-canvas');
if (canvas) canvas.style.display = 'none';
@ -366,125 +272,9 @@ function applyMaxPerformance(enabled) {
const workerOrbsSaved = localStorage.getItem('soulsync-worker-orbs');
if (workerOrbsSaved === 'false') {
window._workerOrbsEnabled = false;
} else if (workerOrbsSaved === 'true') {
window._workerOrbsEnabled = true;
} else if (typeof window._workerOrbsEnabled !== 'boolean') {
window._workerOrbsEnabled = true;
}
})();
async function bootstrapServerAppearanceSettings() {
try {
const response = await fetch('/api/settings', { credentials: 'same-origin' });
const settings = await response.json();
if (!response.ok || !settings || typeof settings !== 'object' || settings.error) return;
const appearance = settings.ui_appearance || {};
const preset = appearance.accent_preset || '#1db954';
const custom = appearance.accent_color || '#1db954';
const accent = preset === 'custom' ? custom : preset;
applyAccentColor(accent);
if (Object.prototype.hasOwnProperty.call(appearance, 'particles_enabled')) {
applyParticlesSetting(appearance.particles_enabled !== false);
}
if (Object.prototype.hasOwnProperty.call(appearance, 'worker_orbs_enabled')) {
applyWorkerOrbsSetting(appearance.worker_orbs_enabled !== false);
}
if (localStorage.getItem('soulsync-reduce-effects') === null) {
applyReduceEffects(appearance.reduce_effects === true);
}
} catch (error) {
console.warn('Could not bootstrap appearance settings:', error);
}
}
bootstrapServerAppearanceSettings();
// ── Password-manager autofill suppression ──────────────────────────────
// Bitwarden / 1Password / LastPass etc. attach an inline autofill overlay to
// every <input>/<select>/<textarea> and REBUILD it on every DOM mutation. This
// app mutates the DOM continuously (live service status, download/automation
// progress bars, the per-second "next run" countdown, innerHTML hub rebuilds),
// so the managers' whole-document MutationObserver storms the main thread. A
// captured DevTools trace (2026-06-29) showed Bitwarden's
// bootstrap-autofill-overlay.js (setupOverlayOnField / setupOverlayListeners)
// using ~6× the CPU of the entire SoulSync app — almost the whole freeze.
//
// None of these fields are credentials (they're search boxes, filters, config),
// so we mark them ignored and the managers skip them: once a field carries the
// ignore hint, the overlay is never (re)attached, so the mutation→re-setup storm
// stops. Real sign-in fields (password type + the auth overlays) are left alone
// so the user can still autofill the login / PIN screen. Purely additive data-*
// attributes — no functional effect on the app, and a no-op for any manager that
// doesn't honour them.
(function suppressPasswordManagerAutofill() {
const SKIP_CONTAINERS = ['#login-overlay', '#launch-pin-overlay', '#profile-pin-dialog'];
const isCredentialField = (el) => {
if (el.type === 'password') return true;
return SKIP_CONTAINERS.some(sel => typeof el.closest === 'function' && el.closest(sel));
};
const IGNORE_ATTRS = ['data-bwignore', 'data-1p-ignore', 'data-lpignore', 'data-form-type'];
const tag = (el) => {
if (el.dataset.pmTagged) return; // tagged once — never touch again
if (isCredentialField(el)) return; // leave real login fields for the manager
el.dataset.pmTagged = '1';
el.setAttribute('data-bwignore', 'true'); // Bitwarden
el.setAttribute('data-1p-ignore', ''); // 1Password
el.setAttribute('data-lpignore', 'true'); // LastPass
el.setAttribute('data-form-type', 'other'); // Dashlane
if (!el.hasAttribute('autocomplete')) el.setAttribute('autocomplete', 'off');
};
const sweep = () => {
document.querySelectorAll(
'input:not([data-pm-tagged]),textarea:not([data-pm-tagged]),select:not([data-pm-tagged])'
).forEach(tag);
};
// Debounce: a burst of DOM mutations triggers at most one sweep per idle slot.
// The `:not([data-pm-tagged])` selector makes the steady-state sweep a no-op
// (it only ever processes freshly-added inputs), and our own attribute writes
// don't re-arm the observer (it watches childList, not attributes).
let pending = false, observer = null, disabled = false;
const scheduleSweep = () => {
if (disabled || pending) return;
pending = true;
const run = () => { pending = false; if (!disabled) sweep(); };
if (typeof requestIdleCallback === 'function') requestIdleCallback(run, { timeout: 400 });
else setTimeout(run, 300);
};
const startObserving = () => {
if (observer) return;
observer = new MutationObserver(scheduleSweep);
observer.observe(document.body, { childList: true, subtree: true });
};
const start = () => { sweep(); startObserving(); };
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', start);
else start();
// Benchmark hook (not used by the app): toggle the suppression at runtime so a
// before/after can be measured without rebuilding. disable() strips the ignore
// hints + stops the observer, so password managers re-attach their autofill
// overlay — i.e. the pre-fix "before" behaviour. enable() re-tags + resumes.
window.__pmSuppress = {
disable() {
disabled = true;
if (observer) { observer.disconnect(); observer = null; }
document.querySelectorAll('[data-pm-tagged]').forEach((el) => {
IGNORE_ATTRS.forEach((a) => el.removeAttribute(a));
delete el.dataset.pmTagged;
});
},
enable() {
disabled = false;
sweep();
startObserving();
},
get isActive() { return !disabled; },
};
})();
// ── Profile System ─────────────────────────────────────────────
let currentProfile = null;
const PROFILE_CONTEXT_CHANGED_EVENT = 'ss:webui-profile-context-changed';
@ -3161,8 +2951,7 @@ async function loadPageData(pageId) {
const RECENTER_DELAY_MS = 1500;
let recenterTimer = 0;
const isReduced = () => document.body.classList.contains('reduce-effects')
|| document.body.classList.contains('max-performance');
const isReduced = () => document.body.classList.contains('reduce-effects');
const gridCenter = () => {
const r = grid.getBoundingClientRect();

View file

@ -7460,16 +7460,6 @@ async function showReorganizeModal(albumId) {
html += '</select>';
html += '</div>';
// Action: full pipeline vs rename-only (#875).
html += '<div class="reorganize-source-section">';
html += '<label class="reorganize-label">Action</label>';
html += '<div class="reorganize-template-hint">"Full reorganize" re-tags and re-checks every track through the import pipeline — thorough, but slow and it re-touches every file. "Rename only" just moves files to your current naming scheme: no re-tagging, no quality/AcoustID checks, and only files whose name actually changes are touched. Tip: renaming can reset play counts / date-added on your media server.</div>';
html += '<select id="reorganize-action-select" class="reorganize-template-input">';
html += '<option value="full">Full reorganize (default)</option>';
html += '<option value="rename">Rename only (skip post-processing)</option>';
html += '</select>';
html += '</div>';
// Preview area
html += '<div class="reorganize-preview-section">';
html += '<div class="reorganize-preview-header">';
@ -7654,11 +7644,10 @@ async function executeReorganize() {
try {
const chosenSource = document.getElementById('reorganize-source-select')?.value || '';
const chosenMode = document.getElementById('reorganize-mode-select')?.value || 'api';
const renameOnly = document.getElementById('reorganize-action-select')?.value === 'rename';
const response = await fetch(`/api/library/album/${_reorganizeAlbumId}/reorganize`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ source: chosenSource, mode: chosenMode, rename_only: renameOnly })
body: JSON.stringify({ source: chosenSource, mode: chosenMode })
});
const result = await response.json();
if (!result.success) throw new Error(result.error);

View file

@ -2352,8 +2352,8 @@
// Listen for page changes from script.js
window.pageParticles = {
setPage(pageId) {
// Reduce Visual Effects / Max Performance modes halt the loop entirely.
if (window._reduceEffectsActive || window._maxPerfActive) { stop(); return; }
// Reduce Visual Effects performance mode halts the loop entirely.
if (window._reduceEffectsActive) { stop(); return; }
const presetName = PAGE_PRESETS[pageId] || 'none';
setPreset(presetName);
},
@ -2362,7 +2362,7 @@
// Auto-start for initial page (respect particles toggle)
requestAnimationFrame(() => {
if (window._particlesEnabled === false || window._reduceEffectsActive || window._maxPerfActive) return;
if (window._particlesEnabled === false || window._reduceEffectsActive) return;
const activePage = document.querySelector('.page.active');
if (activePage) {
const pageId = activePage.id.replace('-page', '');

View file

@ -40,31 +40,15 @@ async function copyAddress(address, cryptoName) {
let settingsAutoSaveTimer = null;
// The "Only allow AcoustID-verified tracks" toggle lives in Audio Verification
// and is always shown (its help notes it needs AcoustID enabled), so users can
// always find it. Kept as a no-op for any existing callers.
// The "Only import AcoustID-verified tracks" toggle (under Quality Profile) is
// meaningless when AcoustID itself is off — only show it when verification is on.
function syncAcoustidRequireVerifiedVisibility() {
const group = document.getElementById('acoustid-require-verified-group');
if (group) group.style.display = '';
const enabled = document.getElementById('acoustid-enabled');
if (group) group.style.display = (enabled && enabled.checked) ? '' : 'none';
}
window.syncAcoustidRequireVerifiedVisibility = syncAcoustidRequireVerifiedVisibility;
// Retry Logic: the two numeric rows are only meaningful when their parent toggle
// is on — hide them otherwise. "Retries per query" needs Exhaustive retry;
// "Minimum matching mismatches" needs the version-mismatch fallback.
function syncRetryConditionalRows() {
const pairs = [
['retry-exhaustive', 'retries-per-query-row'],
['accept-version-mismatch-fallback', 'version-mismatch-min-count-row'],
];
for (const [toggleId, rowId] of pairs) {
const toggle = document.getElementById(toggleId);
const row = document.getElementById(rowId);
if (row) row.style.display = (toggle && toggle.checked) ? '' : 'none';
}
}
window.syncRetryConditionalRows = syncRetryConditionalRows;
function debouncedAutoSaveSettings() {
// Ignore changes made while the page is programmatically populating its
// fields on load — those aren't user edits and must not trigger a full
@ -433,11 +417,6 @@ function switchSettingsTab(tab) {
if (tab === 'advanced' && typeof loadDbMaintenanceInfo === 'function') {
try { loadDbMaintenanceInfo(); } catch (e) { }
}
// First time the Downloads tab is shown, auto-probe source status so the
// dots reflect real connection state without a manual "Test all sources".
if (tab === 'downloads' && typeof autoTestSourcesOnce === 'function') {
autoTestSourcesOnce();
}
// Initialize live log viewer when switching to Logs tab
if (tab === 'logs') {
_logViewerInit();
@ -680,126 +659,6 @@ const ALBUM_LEVEL_HYBRID_SOURCES = new Set(['soulseek', 'torrent', 'usenet']);
let _hybridSourceOrder = ['soulseek', 'youtube'];
let _hybridSourceEnabled = { soulseek: true, youtube: true, tidal: false, qobuz: false, hifi: false, deezer_dl: false, amazon: false, lidarr: false, soundcloud: false, torrent: false, usenet: false };
let _hybridVisualOrder = null; // Full visual order including disabled sources
// In hybrid mode, only one source's config panel is shown at a time (clicked
// open from its row), so the long per-source config blocks don't all stack up.
let _expandedHybridSource = null;
function toggleHybridSourceConfig(srcId) {
_expandedHybridSource = (_expandedHybridSource === srcId) ? null : srcId;
buildHybridSourceList();
updateDownloadSourceUI();
// Bring the freshly opened config panel into view.
if (_expandedHybridSource) {
const map = {
soulseek: 'soulseek-settings-container', youtube: 'youtube-settings-container',
tidal: 'tidal-download-settings-container', qobuz: 'qobuz-settings-container',
hifi: 'hifi-download-settings-container', deezer_dl: 'deezer-download-settings-container',
amazon: 'amazon-download-settings-container', lidarr: 'lidarr-download-settings-container',
soundcloud: 'soundcloud-download-settings-container', torrent: 'prowlarr-source-redirect',
usenet: 'prowlarr-source-redirect',
};
const el = document.getElementById(map[_expandedHybridSource]);
if (el) setTimeout(() => el.scrollIntoView({ behavior: 'smooth', block: 'nearest' }), 60);
}
}
// ── Per-source live connection status (shown as a dot in the hybrid list and
// driven by the "Test all sources" button). srcId -> 'unknown'|'testing'|'ok'|'fail'|'na'
let _hybridSourceStatus = {};
async function _ssJson(url, opts) {
const r = await fetch(url, opts);
return await r.json();
}
function _ssTestConn(service) {
return _ssJson(API.testConnection || '/api/test-connection', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ service })
}).then(j => !!j.success);
}
// Each probe returns a boolean (connected/ok). Endpoints mirror the per-source
// "Test Connection" buttons so the results match what those buttons would show.
const HYBRID_SOURCE_PROBE = {
soulseek: () => _ssTestConn('soulseek'),
tidal: () => _ssTestConn('tidal'),
qobuz: () => _ssJson('/api/qobuz/auth/status').then(j => j.authenticated === true),
hifi: () => _ssJson('/api/hifi/status').then(j => j.available === true),
deezer_dl: () => _ssJson('/api/deezer-download/test').then(j => j.success === true),
amazon: () => _ssJson('/api/amazon/test-connection').then(j => j.connected === true),
lidarr: () => _ssTestConn('lidarr'),
soundcloud: () => _ssJson('/api/soundcloud/status').then(j => j.available === true && j.reachable === true),
torrent: () => _ssTestConn('torrent_client'),
usenet: () => _ssTestConn('usenet_client'),
youtube: () => Promise.resolve(true), // no auth required
};
// Configured metadata / server connections that support a generic test.
const CONNECTION_TEST_SERVICES = ['spotify', 'server', 'tidal', 'qobuz', 'lastfm', 'genius', 'listenbrainz', 'acoustid', 'discogs'];
async function testAllSources(opts = {}) {
const silent = opts.silent === true; // no toast / no connection sweep (used for auto-run on load)
const btn = document.getElementById('test-all-sources-btn');
if (btn && !silent) { btn.disabled = true; btn.dataset._label = btn.textContent; btn.textContent = 'Testing…'; }
// Which download sources to test: the enabled hybrid sources, or the single
// selected source in non-hybrid mode.
const mode = document.getElementById('download-source-mode')?.value;
const sources = new Set();
if (mode === 'hybrid') {
(typeof getHybridOrder === 'function' ? getHybridOrder() : []).forEach(s => sources.add(s));
} else if (mode) {
sources.add(mode);
}
if (sources.size === 0) sources.add('soulseek');
// Torrent/Usenet downloads go through Prowlarr — its connection must be
// established first or those source tests fail. Probe Prowlarr up front.
if (sources.has('torrent') || sources.has('usenet')) {
try { await _ssTestConn('prowlarr'); } catch (e) { /* surfaced via the per-source test below */ }
}
for (const id of sources) _hybridSourceStatus[id] = 'testing';
buildHybridSourceList();
let ok = 0, fail = 0;
for (const id of sources) {
const probe = HYBRID_SOURCE_PROBE[id];
if (!probe) { _hybridSourceStatus[id] = 'na'; continue; }
try { const good = await probe(); _hybridSourceStatus[id] = good ? 'ok' : 'fail'; good ? ok++ : fail++; }
catch (e) { _hybridSourceStatus[id] = 'fail'; fail++; }
buildHybridSourceList();
}
// Also test the metadata / server connections the user has configured
// (skipped on the silent auto-run to keep page load light).
let connOk = 0, connFail = 0;
if (!silent) {
try {
const cfg = await _ssJson('/api/settings/config-status');
for (const svc of CONNECTION_TEST_SERVICES) {
const configured = svc === 'server' ? true : (cfg && cfg[svc] && cfg[svc].configured);
if (!configured) continue;
try { const good = await _ssTestConn(svc); good ? connOk++ : connFail++; } catch (e) { connFail++; }
}
} catch (e) { /* config-status unavailable — skip connection sweep */ }
}
if (btn && !silent) { btn.disabled = false; btn.textContent = btn.dataset._label || 'Test all sources'; }
if (!silent) {
const parts = [`sources ${ok}${fail ? ' / ' + fail + '✗' : ''}`];
if (connOk || connFail) parts.push(`connections ${connOk}${connFail ? ' / ' + connFail + '✗' : ''}`);
showToast('Tested ' + parts.join(', '), (fail || connFail) ? 'error' : 'success');
}
}
window.testAllSources = testAllSources;
// Auto-populate the source status dots once after the page settles, so they
// reflect real state after a restart without the user having to click Test.
let _sourcesAutoTested = false;
function autoTestSourcesOnce() {
if (_sourcesAutoTested) return;
_sourcesAutoTested = true;
setTimeout(() => { try { testAllSources({ silent: true }); } catch (e) { } }, 1200);
}
function buildHybridSourceList() {
const container = document.getElementById('hybrid-source-list');
@ -830,16 +689,11 @@ function buildHybridSourceList() {
const sourceLevelBadge = `<span class="hybrid-source-badge hybrid-source-badge-${sourceLevelClass}" title="${sourceLevelTitle}">${sourceLevel}</span>`;
const item = document.createElement('div');
const isExpanded = enabled && _expandedHybridSource === srcId;
item.className = `hybrid-source-item${enabled ? '' : ' disabled'}${isExpanded ? ' config-open' : ''}`;
item.className = `hybrid-source-item${enabled ? '' : ' disabled'}`;
item.draggable = true;
item.dataset.sourceId = srcId;
// The name + a config chevron open this source's settings panel inline
// (only one at a time), so the long config blocks don't all stack up.
const clickConfig = enabled ? `onclick="toggleHybridSourceConfig('${srcId}')"` : '';
item.innerHTML = `
<span class="hybrid-source-handle" title="Drag to reorder"></span>
<span class="hybrid-source-arrows">
<button class="hybrid-arrow-btn" onclick="moveHybridSource('${srcId}', -1)" title="Move up"></button>
<button class="hybrid-arrow-btn" onclick="moveHybridSource('${srcId}', 1)" title="Move down"></button>
@ -848,11 +702,9 @@ function buildHybridSourceList() {
? `<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" ${clickConfig} style="${enabled ? 'cursor:pointer;' : ''}">${src.name}</span>
<span class="hybrid-source-name">${src.name}</span>
${sourceLevelBadge}
<span class="hybrid-source-priority">${priorityNum}</span>
<span class="hybrid-source-status hss-${_hybridSourceStatus[srcId] || 'unknown'}" title="${({ unknown: 'Not tested yet', testing: 'Testing…', ok: 'Connected', fail: 'Connection failed', na: 'No connection test for this source' })[_hybridSourceStatus[srcId] || 'unknown']}"></span>
${enabled ? `<button class="hybrid-source-config-btn" ${clickConfig} title="Configure ${src.name}">⚙</button>` : ''}
<label class="hybrid-source-toggle">
<input type="checkbox" ${enabled ? 'checked' : ''} onchange="toggleHybridSource('${srcId}', this.checked)">
<span class="toggle-track"></span>
@ -866,15 +718,10 @@ function buildHybridSourceList() {
e.dataTransfer.setData('text/plain', srcId);
item.classList.add('dragging');
});
item.addEventListener('dragend', () => {
item.classList.remove('dragging');
container.querySelectorAll('.hybrid-source-item').forEach(el => el.classList.remove('drag-over'));
});
item.addEventListener('dragover', (e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; item.classList.add('drag-over'); });
item.addEventListener('dragleave', () => item.classList.remove('drag-over'));
item.addEventListener('dragend', () => item.classList.remove('dragging'));
item.addEventListener('dragover', (e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; });
item.addEventListener('drop', (e) => {
e.preventDefault();
item.classList.remove('drag-over');
const draggedId = e.dataTransfer.getData('text/plain');
if (draggedId && draggedId !== srcId) _reorderHybridSource(draggedId, srcId);
});
@ -921,8 +768,6 @@ function _reorderHybridSource(draggedId, targetId) {
function toggleHybridSource(srcId, enabled) {
_hybridSourceEnabled[srcId] = enabled;
// If the source we just disabled had its config panel open, close it.
if (!enabled && _expandedHybridSource === srcId) _expandedHybridSource = null;
// Rebuild enabled order from visual order so priority matches position
if (_hybridVisualOrder) {
_hybridSourceOrder = _hybridVisualOrder.filter(id => _hybridSourceEnabled[id] !== false);
@ -1424,7 +1269,6 @@ async function loadSettingsData() {
document.getElementById('retries-per-query').value = settings.post_processing?.retries_per_query ?? 5;
document.getElementById('accept-version-mismatch-fallback').checked = settings.post_processing?.accept_version_mismatch_fallback === true;
document.getElementById('version-mismatch-min-count').value = settings.post_processing?.version_mismatch_min_count ?? 2;
if (typeof syncRetryConditionalRows === 'function') syncRetryConditionalRows();
// Load service master toggles
document.getElementById('embed-spotify').checked = settings.spotify?.embed_tags !== false;
document.getElementById('embed-itunes').checked = settings.itunes?.embed_tags !== false;
@ -1548,14 +1392,8 @@ async function loadSettingsData() {
if (particlesCheckbox) particlesCheckbox.checked = particlesEnabled;
applyParticlesSetting(particlesEnabled);
// Worker orbs toggle. When the user hasn't saved a preference, reflect the
// server-decided browser-aware default (window._workerOrbsEnabled — OFF on
// Firefox for perf) so saving settings doesn't silently flip a first-time
// Firefox user's orbs back on. An explicit saved config value always wins.
const _orbsCfg = settings.ui_appearance?.worker_orbs_enabled;
const workerOrbsEnabled = (_orbsCfg === undefined || _orbsCfg === null)
? (window._workerOrbsEnabled !== false)
: (_orbsCfg !== false);
// Worker orbs toggle
const workerOrbsEnabled = settings.ui_appearance?.worker_orbs_enabled !== false; // default true
const workerOrbsCheckbox = document.getElementById('worker-orbs-enabled');
if (workerOrbsCheckbox) workerOrbsCheckbox.checked = workerOrbsEnabled;
applyWorkerOrbsSetting(workerOrbsEnabled);
@ -1572,16 +1410,6 @@ async function loadSettingsData() {
if (reduceCheckbox) reduceCheckbox.checked = reduceEffects;
applyReduceEffects(reduceEffects);
// Max Performance — same device-scoped resolution as reduce-effects:
// localStorage is the per-device truth, server value the cross-device default.
// Applied last so it can lock/override the dependent toggles above when on.
const serverMaxPerf = settings.ui_appearance?.max_performance === true; // default false
const localMaxPerf = localStorage.getItem('soulsync-max-performance'); // '1' | '0' | null
const maxPerf = localMaxPerf !== null ? (localMaxPerf === '1') : serverMaxPerf;
const maxPerfCheckbox = document.getElementById('max-performance-enabled');
if (maxPerfCheckbox) maxPerfCheckbox.checked = maxPerf;
applyMaxPerformance(maxPerf);
// Populate Logging information
const logLevelSelect = document.getElementById('log-level-select');
if (logLevelSelect) logLevelSelect.value = settings.logging?.level || 'INFO';
@ -2033,43 +1861,21 @@ function updateDownloadSourceUI() {
activeSources.add(mode);
}
// In single-source mode the one config block is shown directly. In hybrid
// mode there can be many active sources, so we only reveal the one the user
// clicked open in the priority list (accordion-style) — no endless stack.
const isHybrid = mode === 'hybrid';
const showCfg = (src) => activeSources.has(src) && (!isHybrid || _expandedHybridSource === src);
soulseekContainer.style.display = showCfg('soulseek') ? 'block' : 'none';
tidalContainer.style.display = showCfg('tidal') ? 'block' : 'none';
qobuzContainer.style.display = showCfg('qobuz') ? 'block' : 'none';
youtubeContainer.style.display = showCfg('youtube') ? 'block' : 'none';
hifiContainer.style.display = showCfg('hifi') ? 'block' : 'none';
if (deezerDlContainer) deezerDlContainer.style.display = showCfg('deezer_dl') ? 'block' : 'none';
if (amazonContainer) amazonContainer.style.display = showCfg('amazon') ? 'block' : 'none';
if (lidarrContainer) lidarrContainer.style.display = showCfg('lidarr') ? 'block' : 'none';
if (soundcloudContainer) soundcloudContainer.style.display = showCfg('soundcloud') ? 'block' : 'none';
soulseekContainer.style.display = activeSources.has('soulseek') ? 'block' : 'none';
tidalContainer.style.display = activeSources.has('tidal') ? 'block' : 'none';
qobuzContainer.style.display = activeSources.has('qobuz') ? 'block' : 'none';
youtubeContainer.style.display = activeSources.has('youtube') ? 'block' : 'none';
hifiContainer.style.display = activeSources.has('hifi') ? 'block' : 'none';
if (deezerDlContainer) deezerDlContainer.style.display = activeSources.has('deezer_dl') ? 'block' : 'none';
if (amazonContainer) amazonContainer.style.display = activeSources.has('amazon') ? 'block' : 'none';
if (lidarrContainer) lidarrContainer.style.display = activeSources.has('lidarr') ? 'block' : 'none';
if (soundcloudContainer) soundcloudContainer.style.display = activeSources.has('soundcloud') ? 'block' : 'none';
const prowlarrRedirect = document.getElementById('prowlarr-source-redirect');
if (prowlarrRedirect) {
const showProwlarr = showCfg('torrent') || showCfg('usenet');
const showProwlarr = activeSources.has('torrent') || activeSources.has('usenet');
prowlarrRedirect.style.display = showProwlarr ? 'block' : 'none';
}
// Indexers & Downloaders section: only relevant when a torrent or usenet
// source is actually selected. Hide the whole intro + Prowlarr/Torrent/Usenet
// tiles otherwise (Soulseek/HiFi-only users never see them). The two client
// tiles are gated individually on their own source. Selection-based (not the
// hybrid expand state) — the tiles are full config sections, not accordion
// panels. Tab-gated so it never leaks onto another tab.
const onDownloadsTab = document.querySelector('.stg-tab.active')?.dataset.tab === 'downloads';
const torrentActive = activeSources.has('torrent');
const usenetActive = activeSources.has('usenet');
const indSection = document.getElementById('indexers-downloaders-section');
if (indSection) indSection.style.display = (onDownloadsTab && (torrentActive || usenetActive)) ? '' : 'none';
const torrentTile = document.getElementById('torrent-tile');
if (torrentTile) torrentTile.style.display = torrentActive ? '' : 'none';
const usenetTile = document.getElementById('usenet-tile');
if (usenetTile) usenetTile.style.display = usenetActive ? '' : 'none';
// Quality profile is now a GLOBAL system — the same ranked-target list
// drives every source (Soulseek, Tidal, Qobuz, HiFi, Deezer, …), so it is
// no longer Soulseek-gated. Show the whole collapsible tile whenever the
@ -2078,25 +1884,23 @@ function updateDownloadSourceUI() {
const qualityProfileTile = document.getElementById('quality-profile-tile');
if (qualityProfileTile) {
const activeTab = document.querySelector('.stg-tab.active');
const onQualityTab = activeTab && activeTab.dataset.tab === 'quality';
qualityProfileTile.style.display = onQualityTab ? '' : 'none';
const onDownloadsTab = activeTab && activeTab.dataset.tab === 'downloads';
qualityProfileTile.style.display = onDownloadsTab ? '' : 'none';
}
// Only auto-probe a source's live status when its config panel is visible
// (always in single-source mode; only the opened one in hybrid mode).
if (showCfg('tidal')) {
if (activeSources.has('tidal')) {
checkTidalDownloadAuthStatus();
}
if (showCfg('qobuz')) {
if (activeSources.has('qobuz')) {
checkQobuzAuthStatus();
}
if (showCfg('hifi')) {
if (activeSources.has('hifi')) {
testHiFiConnection();
}
if (showCfg('amazon')) {
if (activeSources.has('amazon')) {
testAmazonConnection();
}
if (showCfg('soundcloud')) {
if (activeSources.has('soundcloud')) {
testSoundcloudConnection();
}
}
@ -2217,30 +2021,12 @@ function onSearchModeChange() {
// body is the immediate next element or sits after a control (e.g. a <select>),
// and regardless of any wrapping container.
function toggleSettingHelp(iconEl) {
// Locate the help body to toggle. Search order:
// 1) the next .setting-help-body sibling after the icon's row (icon + body
// both inside the same .form-group / .setting-row),
// 2) the element right after the enclosing .form-group (help wall that sits
// as a sibling just below the group — the common always-visible case).
const row = iconEl.closest('.setting-row') || iconEl;
let el = row.nextElementSibling;
while (el && !el.classList.contains('setting-help-body')) {
el = el.nextElementSibling;
}
if (!el) {
const fg = iconEl.closest('.form-group');
let sib = fg ? fg.nextElementSibling : null;
// Only accept an immediately-following help body — never reach across
// into the next setting/group.
if (sib && sib.classList.contains('setting-help-body')) el = sib;
}
if (el) {
el.hidden = !el.hidden;
// Reflect open state on the icon itself (filled badge) so it's clear
// which help panel is currently revealed.
const icon = iconEl.classList.contains('info-icon') ? iconEl : row.querySelector('.info-icon');
if (icon) icon.classList.toggle('open', !el.hidden);
}
if (el) el.hidden = !el.hidden;
}
function renderRankedTargets() {
@ -2308,7 +2094,7 @@ function deleteRankedTarget(i) {
// Lossless formats take bit-depth + sample-rate constraints; lossy take a
// minimum bitrate. Single source of truth for the add-target field toggle.
const RT_LOSSLESS_FORMATS = ['flac', 'alac', 'wav', 'dsf'];
const RT_LOSSLESS_FORMATS = ['flac', 'alac', 'wav'];
const RT_LOSSY_FORMATS = ['mp3', 'aac', 'ogg', 'opus', 'wma'];
// "group:" selections are a UI convenience: picking one + constraints expands
// into individual per-format targets at that slot (the backend still works
@ -3503,13 +3289,9 @@ async function saveSettings(quiet = false) {
accent_preset: document.getElementById('accent-preset')?.value || '#1db954',
accent_color: document.getElementById('accent-custom-color')?.value || '#1db954',
sidebar_visualizer: document.getElementById('sidebar-visualizer-type')?.value || 'bars',
// Read the runtime flags / localStorage, not the checkboxes: while Max
// Performance is on it locks those boxes visually-off, but the user's real
// saved prefs live in the flags — so saving must not clobber them.
particles_enabled: window._particlesEnabled !== false,
worker_orbs_enabled: window._workerOrbsEnabled !== false,
reduce_effects: window._reduceEffectsActive === true,
max_performance: window._maxPerfActive === true
particles_enabled: document.getElementById('particles-enabled')?.checked !== false,
worker_orbs_enabled: document.getElementById('worker-orbs-enabled')?.checked !== false,
reduce_effects: document.getElementById('reduce-effects-enabled')?.checked === true
},
youtube: {
cookies_browser: document.getElementById('youtube-cookies-browser').value,

View file

@ -663,82 +663,34 @@ function exportMirroredPlaylist(playlistId, name) {
<div style="font-weight:600;">Sync to ListenBrainz</div>
<div style="font-size:12px;color:rgba(255,255,255,0.55);">Create the playlist directly on your ListenBrainz account (needs your LB token).</div>
</button>
<button class="pl-export-choice" data-mode="download" style="width:100%;text-align:left;margin-bottom:10px;padding:13px 15px;border-radius:12px;border:1px solid rgba(255,255,255,0.1);background:rgba(255,255,255,0.04);color:#fff;cursor:pointer;">
<button class="pl-export-choice" data-mode="download" style="width:100%;text-align:left;margin-bottom:16px;padding:13px 15px;border-radius:12px;border:1px solid rgba(255,255,255,0.1);background:rgba(255,255,255,0.04);color:#fff;cursor:pointer;">
<div style="font-weight:600;">Download .jspf file</div>
<div style="font-size:12px;color:rgba(255,255,255,0.55);">Save a JSPF playlist you can upload to ListenBrainz manually.</div>
</button>
<button class="pl-export-choice" data-mode="spotify" style="width:100%;text-align:left;margin-bottom:10px;padding:13px 15px;border-radius:12px;border:1px solid rgba(255,255,255,0.1);background:rgba(255,255,255,0.04);color:#fff;cursor:pointer;">
<div style="font-weight:600;">Sync to Spotify</div>
<div style="font-size:12px;color:rgba(255,255,255,0.55);">Create a Spotify playlist in your account (the first time, you'll grant permission to create playlists).</div>
</button>
<button class="pl-export-choice" data-mode="deezer" style="width:100%;text-align:left;margin-bottom:16px;padding:13px 15px;border-radius:12px;border:1px solid rgba(255,255,255,0.1);background:rgba(255,255,255,0.04);color:#fff;cursor:pointer;">
<div style="font-weight:600;">Sync to Deezer</div>
<div style="font-size:12px;color:rgba(255,255,255,0.55);">Create a Deezer playlist from this list (uses your Deezer login).</div>
</button>
<div style="font-size:11.5px;color:rgba(255,255,255,0.4);line-height:1.5;">Tracks are matched by ID (MusicBrainz for ListenBrainz/JSPF; the stored Spotify/Deezer ID for those). Tracks without a match can't be included — you'll see how many made it. Renaming/re-syncing can reset play counts on the destination.</div>
<label style="display:flex;align-items:flex-start;gap:8px;margin-top:12px;font-size:12px;color:rgba(255,255,255,0.6);cursor:pointer;">
<input type="checkbox" id="pl-export-backfill" style="margin-top:2px;flex-shrink:0;accent-color:rgb(var(--accent-rgb));">
<span><b style="color:rgba(255,255,255,0.75);">Match missing tracks</b> (Spotify/Deezer) search the service for tracks with no known ID. Slower, and only confident matches are added.</span>
</label>
<div style="font-size:11.5px;color:rgba(255,255,255,0.4);line-height:1.5;">Each track is matched to its MusicBrainz recording ID (cached library file tag MusicBrainz). Tracks without an ID can't be included — you'll see how many matched.</div>
<div style="text-align:right;margin-top:14px;"><button onclick="document.getElementById('pl-export-modal').remove()" style="background:none;border:none;color:rgba(255,255,255,0.5);cursor:pointer;font-size:13px;">Cancel</button></div>
</div>`;
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); });
overlay.querySelectorAll('.pl-export-choice').forEach(btn => {
btn.addEventListener('click', () => {
const mode = btn.dataset.mode;
// Gated service button (not connected) → nudge to Settings instead of a doomed export.
if (btn.dataset.disconnected) {
const dest = mode[0].toUpperCase() + mode.slice(1);
overlay.remove();
_setExportStatus(playlistId, `<span style="color:#f59e0b;">Connect ${dest} in Settings → Connections to export here</span>`, 9000);
return;
}
const bfEl = overlay.querySelector('#pl-export-backfill');
const backfill = !!(bfEl && bfEl.checked);
overlay.remove();
_startPlaylistExport(playlistId, mode, name, backfill);
_startPlaylistExport(playlistId, mode, name);
});
});
document.body.appendChild(overlay);
// Gate Spotify/Deezer on connection (cheap token/ARL check — no live verify). Disconnected
// services grey out + nudge to Settings rather than letting the export fail with "not connected".
fetch('/api/discover/your-albums/sources').then(r => r.json()).then(data => {
const connected = (data && data.connected) || [];
overlay.querySelectorAll('.pl-export-choice[data-mode]').forEach(btn => {
const m = btn.dataset.mode;
if ((m === 'spotify' || m === 'deezer') && !connected.includes(m)) {
btn.dataset.disconnected = '1';
btn.style.opacity = '0.5';
const hint = btn.querySelector('div:last-child');
if (hint) hint.innerHTML = `<span style="color:#f59e0b;">Not connected</span> — set up ${m[0].toUpperCase() + m.slice(1)} in Settings → Connections first.`;
}
});
}).catch(() => {});
}
async function _startPlaylistExport(playlistId, mode, name, backfill) {
async function _startPlaylistExport(playlistId, mode, name) {
_setExportStatus(playlistId, `<span style="color:#a78bfa;">Starting export…</span>`);
try {
// Spotify/Deezer go to the service endpoint; ListenBrainz/JSPF keep the LB one.
const isService = (mode === 'spotify' || mode === 'deezer');
const url = isService
? `/api/playlists/${playlistId}/export/service/${mode}`
: `/api/playlists/${playlistId}/export/listenbrainz`;
const resp = await fetch(url, {
const resp = await fetch(`/api/playlists/${playlistId}/export/listenbrainz`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: isService ? JSON.stringify({ backfill: !!backfill }) : JSON.stringify({ mode }),
body: JSON.stringify({ mode }),
});
const data = await resp.json();
// Spotify export needs a one-time write-permission grant. Surface a clickable link (a
// direct user click avoids popup-blocking; window.open after this await would be blocked)
// and tell the user to retry once they've authorized.
if (data.needs_auth && data.auth_url) {
_setExportStatus(playlistId, `<span style="color:#f59e0b;">Spotify needs permission to create playlists — <a href="${data.auth_url}" target="_blank" rel="noopener" style="color:#38bdf8;text-decoration:underline;">authorize</a>, then click Export again.</span>`, 20000);
return;
}
if (!data.success || !data.job_id) {
_setExportStatus(playlistId, `<span style="color:#ef4444;">${_esc(data.error || 'Export failed to start')}</span>`);
_setExportStatus(playlistId, `<span style="color:#ef4444;">Export failed to start</span>`);
return;
}
_pollPlaylistExport(data.job_id, playlistId, mode, name);
@ -757,19 +709,8 @@ async function _pollPlaylistExport(jobId, playlistId, mode, name) {
const pct = job.total ? Math.round(100 * (job.done || 0) / job.total) : 0;
_setExportStatus(playlistId, `<span style="color:#38bdf8;">Matching ${job.done || 0}/${job.total || 0} (${pct}%)${st.resolved != null ? ` · ${st.resolved} matched` : ''}</span>`);
} else if (job.phase === 'pushing') {
const dest = (mode === 'spotify' || mode === 'deezer') ? (mode[0].toUpperCase() + mode.slice(1)) : 'ListenBrainz';
_setExportStatus(playlistId, `<span style="color:#a78bfa;">Pushing to ${dest}…</span>`);
_setExportStatus(playlistId, `<span style="color:#a78bfa;">Pushing to ListenBrainz…</span>`);
} else if (job.phase === 'done') {
// Spotify/Deezer export: report added + unmatched and link the new playlist.
if (mode === 'spotify' || mode === 'deezer') {
const dest = mode[0].toUpperCase() + mode.slice(1);
const push = job.push || {};
const cov = `${st.resolved || 0} added${st.from_search ? ` (${st.from_search} matched live)` : ''}${st.unmatched ? ` · ${st.unmatched} not on ${dest}` : ''}`;
const link = push.url ? ` <a href="${push.url}" target="_blank" style="color:#38bdf8;">open</a>` : '';
_setExportStatus(playlistId, `<span style="color:#22c55e;">Exported to ${dest} · ${cov}</span>${link}`, 12000);
if (typeof showToast === 'function') showToast(`Playlist exported to ${dest} (${cov})`, 'success');
return;
}
const sum = job.summary || {};
const cov = `${sum.included || 0}/${sum.total || 0} matched${sum.skipped ? ` · ${sum.skipped} unmatched` : ''}`;
if (mode === 'download') {
@ -1919,17 +1860,9 @@ async function searchPoolFix() {
if (artistVal) params.set('artist', artistVal);
params.set('limit', '20');
const res = await fetch(`/api/spotify/search_tracks?${params.toString()}`);
const data = await res.json().catch(() => ({}));
// Surface the real failure instead of masking every error (auth, 500, an
// upstream connection abort) as a bland "No results found".
if (!res.ok || data.error) {
const msg = data.error || res.statusText || `request failed (${res.status})`;
resultsContainer.innerHTML = `<div class="pool-fix-empty">Search error: ${_esc(msg)}</div>`;
return;
}
const data = await res.json();
const tracks = data.tracks || [];
if (tracks.length === 0) {
resultsContainer.innerHTML = '<div class="pool-fix-empty">No results found</div>';
return;

File diff suppressed because it is too large Load diff

View file

@ -605,18 +605,10 @@
if (performance.now() < _scrollPauseUntil) return;
frameCount++;
// Frame-skip throttles. The canvas ticks at 60fps; we drop renders to cut cost.
// frameCount still increments every tick, so `time` below stays real-time and the
// drift speed is unchanged — we just draw it less often.
if (sleepLevel > 0.95) {
// Fully asleep → ~20fps: drift is at crawl speed so it's invisible, and the
// GPU cost drops two-thirds for the hours the dashboard sits idle.
if (frameCount % 3 !== 0) return;
} else if (window._reduceEffectsActive) {
// Reduce-effects on (user asked for performance) → ~30fps: the slow drift and
// sparks look the same, the per-frame cost roughly halves.
if (frameCount % 2 !== 0) return;
}
// Fully asleep: render at ~20fps. The drift is at crawl speed so the
// difference is invisible, and the canvas GPU cost drops by two thirds
// for the hours the dashboard sits idle. Wakes re-run every frame.
if (sleepLevel > 0.95 && frameCount % 3 !== 0) return;
const time = frameCount / 60;
const w = canvas.width;
const h = canvas.height;
@ -1065,13 +1057,7 @@
// ── Page awareness ──
function isEnabled() {
// Reduce-effects does NOT gate the orbs — they have their own toggle, so that
// setting controls them on its own. The orbs ARE killed by Max Performance: in a
// Docker / headless / remote-desktop container there is no GPU, so the per-frame
// radial-gradient canvas fill rasterizes on the CPU and can saturate a core,
// freezing the whole UI. Max Performance is the nuclear low-power switch for
// exactly those software-rendered setups, so the canvas loop must stay off there.
return window._workerOrbsEnabled !== false && !window._maxPerfActive;
return window._workerOrbsEnabled !== false && !window._reduceEffectsActive;
}
// ── Real telemetry → pulses ──