Compare commits
116 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ab9301a18 | ||
|
|
4b8ddad9ff | ||
|
|
68d52a1b3f | ||
|
|
81bd85d639 | ||
|
|
f746fb4fc4 | ||
|
|
8f1ffa7632 | ||
|
|
a8520bc9b2 | ||
|
|
79648a4f5f | ||
|
|
9fc3628062 | ||
|
|
9b18e99419 | ||
|
|
b05b641521 | ||
|
|
efefdd64ff | ||
|
|
f3672c7ab4 | ||
|
|
6f451a34e1 | ||
|
|
633aa82b22 | ||
|
|
bd6db37624 | ||
|
|
8d549eb4fa | ||
|
|
1e68e339ca | ||
|
|
84d6208bc9 | ||
|
|
5d5aac9be3 | ||
|
|
60e7193539 | ||
|
|
9bb73c4cb4 | ||
|
|
050ce79d51 | ||
|
|
4743dfd644 | ||
|
|
91eaaeabb2 | ||
|
|
37c8b06a27 | ||
|
|
6e1a377f37 | ||
|
|
614deba0af | ||
|
|
465a77f01d | ||
|
|
0db72bf48d | ||
|
|
37c2b9b569 | ||
|
|
ede62824ad | ||
|
|
c593a17ac2 | ||
|
|
62aa2bef2d | ||
|
|
5df873655f | ||
|
|
92c101e300 | ||
|
|
ad715dddda | ||
|
|
34adb6fb32 | ||
|
|
79383df6d8 | ||
|
|
a62d2d4310 | ||
|
|
3207310448 | ||
|
|
6a388c43fc | ||
|
|
33fe92a525 | ||
|
|
512ec227be | ||
|
|
b07359cdb5 | ||
|
|
caee0fc3e2 | ||
|
|
969700674c | ||
|
|
8abf470018 | ||
|
|
551df0c3ca | ||
|
|
1382cb6117 | ||
|
|
8149f35fae | ||
|
|
1da677ee2d | ||
|
|
b43e44219a | ||
|
|
0276aa8764 | ||
|
|
60dee1b4d8 | ||
|
|
2fb142dded | ||
|
|
e2317de0a4 | ||
|
|
70dba77711 | ||
|
|
a1b0e787bc | ||
|
|
d4e2dccd73 | ||
|
|
d871899451 | ||
|
|
b72febbf1c | ||
|
|
8aa8fcb94a | ||
|
|
4af7600fd5 | ||
|
|
c96135ee60 | ||
|
|
b62d9b5b08 | ||
|
|
df0b4d3595 | ||
|
|
579617f861 | ||
|
|
bcf99d76d3 | ||
|
|
77e3673c9c | ||
|
|
d62afbb2df | ||
|
|
36986e4668 | ||
|
|
d72f6396f0 | ||
|
|
2b8f6f8611 | ||
|
|
b543db147a | ||
|
|
d30273985f | ||
|
|
4487a9d2dc | ||
|
|
d84fba14ba | ||
|
|
50c17ec9f7 | ||
|
|
88b68b8073 | ||
|
|
80d84c6de3 | ||
|
|
60b0022122 | ||
|
|
b683c7fb50 | ||
|
|
4c53a8f3a2 | ||
|
|
0be1952222 | ||
|
|
27ea2ee735 | ||
|
|
034a19b24f | ||
|
|
bd3c5860f6 | ||
|
|
116edeb477 | ||
|
|
319b6483a2 | ||
|
|
b85e9b40d9 | ||
|
|
b4583f23be | ||
|
|
f383a62c4f | ||
|
|
de9e78cf95 | ||
|
|
5a54ffe14a | ||
|
|
6802399805 | ||
|
|
240dce0c1b | ||
|
|
c6caaaa599 | ||
|
|
9faaf5c50c | ||
|
|
bce6a91aa2 | ||
|
|
28a539a840 | ||
|
|
027cf74d47 | ||
|
|
a42cf3b865 | ||
|
|
3a92571c71 | ||
|
|
866c8a6400 | ||
|
|
eddaea2f93 | ||
|
|
b1f061a2a8 | ||
|
|
7e2d2db08d | ||
|
|
e96d62432f | ||
|
|
3a95fc45a4 | ||
|
|
31637e0096 | ||
|
|
18f390b879 | ||
|
|
85d5904846 | ||
|
|
a6364ac283 | ||
|
|
8a6b02b3fd | ||
|
|
528aedbdfe |
115 changed files with 10626 additions and 1715 deletions
4
.github/workflows/docker-publish.yml
vendored
4
.github/workflows/docker-publish.yml
vendored
|
|
@ -9,9 +9,9 @@ on:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
version_tag:
|
version_tag:
|
||||||
description: 'Version tag (e.g. 2.7.9)'
|
description: 'Version tag (e.g. 2.8.2)'
|
||||||
required: true
|
required: true
|
||||||
default: '2.7.9'
|
default: '2.8.2'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-and-push:
|
build-and-push:
|
||||||
|
|
|
||||||
13
RELEASE_2.8.0_discord.md
Normal file
13
RELEASE_2.8.0_discord.md
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
**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 ~15–30s *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! 🎶
|
||||||
15
RELEASE_2.8.1_discord.md
Normal file
15
RELEASE_2.8.1_discord.md
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
**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! 🎶
|
||||||
9
RELEASE_2.8.2_discord.md
Normal file
9
RELEASE_2.8.2_discord.md
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
**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! 🎶
|
||||||
27
core/boot_phase.py
Normal file
27
core/boot_phase.py
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
"""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
|
||||||
|
|
@ -121,6 +121,7 @@ class DeezerDownloadClient(DownloadSourcePlugin):
|
||||||
self._license_token = None
|
self._license_token = None
|
||||||
self._user_data = None
|
self._user_data = None
|
||||||
self._authenticated = False
|
self._authenticated = False
|
||||||
|
self._pending_arl: Optional[str] = None
|
||||||
|
|
||||||
# Quality preference
|
# Quality preference
|
||||||
self._quality = quality_tier_for_source('deezer', default='flac')
|
self._quality = quality_tier_for_source('deezer', default='flac')
|
||||||
|
|
@ -128,7 +129,12 @@ class DeezerDownloadClient(DownloadSourcePlugin):
|
||||||
# Try to authenticate on init if ARL is configured
|
# Try to authenticate on init if ARL is configured
|
||||||
arl = config_manager.get('deezer_download.arl', '')
|
arl = config_manager.get('deezer_download.arl', '')
|
||||||
if arl:
|
if arl:
|
||||||
self._authenticate(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)
|
||||||
|
|
||||||
logger.info(f"Deezer download client initialized (download path: {self.download_path})")
|
logger.info(f"Deezer download client initialized (download path: {self.download_path})")
|
||||||
|
|
||||||
|
|
@ -227,12 +233,66 @@ class DeezerDownloadClient(DownloadSourcePlugin):
|
||||||
return self._authenticated
|
return self._authenticated
|
||||||
|
|
||||||
def is_authenticated(self) -> bool:
|
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
|
return self._authenticated
|
||||||
|
|
||||||
async def check_connection(self) -> bool:
|
async def check_connection(self) -> bool:
|
||||||
loop = asyncio.get_event_loop()
|
loop = asyncio.get_event_loop()
|
||||||
return await loop.run_in_executor(None, self.is_available)
|
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:
|
def reconnect(self, arl: str = None) -> bool:
|
||||||
"""Re-authenticate with a new or existing ARL."""
|
"""Re-authenticate with a new or existing ARL."""
|
||||||
if arl is None:
|
if arl is None:
|
||||||
|
|
|
||||||
|
|
@ -77,7 +77,16 @@ def _normalize_for_finding(text: str) -> str:
|
||||||
return ""
|
return ""
|
||||||
text = unidecode(text).lower()
|
text = unidecode(text).lower()
|
||||||
text = re.sub(r'[._/]', ' ', text)
|
text = re.sub(r'[._/]', ' ', text)
|
||||||
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'[^a-z0-9\s-]', '', text)
|
text = re.sub(r'[^a-z0-9\s-]', '', text)
|
||||||
return ' '.join(text.split()).strip()
|
return ' '.join(text.split()).strip()
|
||||||
|
|
||||||
|
|
|
||||||
62
core/downloads/history_match.py
Normal file
62
core/downloads/history_match.py
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
"""Match a file back to its download-history row when its path has drifted (#934).
|
||||||
|
|
||||||
|
``library_history.file_path`` is frozen at import time, but the file moves afterward
|
||||||
|
(media-server import, library reorganize) and ``tracks.file_path`` — what the AcoustID
|
||||||
|
scanner reads — no longer equals it. Matching on the exact path alone then fails twice:
|
||||||
|
the verification status never reaches the history row (verified tracks read "unverified"),
|
||||||
|
and a fresh ``acoustid_scan`` row gets inserted every run (thousands of duplicates).
|
||||||
|
|
||||||
|
This module picks the canonical history row by exact path first, then by FILENAME guarded
|
||||||
|
by a title check — so a shared filename ("01 - Intro.flac") can never heal the wrong song.
|
||||||
|
Pure (no DB) so the matching rules are unit-testable; the caller does the SQL.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from typing import Iterable, Optional, Sequence, Tuple
|
||||||
|
|
||||||
|
|
||||||
|
def _norm_title(value) -> str:
|
||||||
|
"""Alphanumeric-only lowercase form, so "Song (Remaster)" vs "song remaster"
|
||||||
|
style drift between the download tag and the media-server tag still agrees."""
|
||||||
|
return ''.join(ch for ch in str(value or '').lower() if ch.isalnum())
|
||||||
|
|
||||||
|
|
||||||
|
def like_filename_filter(basename: str) -> str:
|
||||||
|
"""A ``LIKE ... ESCAPE '\\'`` pattern that coarsely matches rows whose path ends
|
||||||
|
in ``basename``. Escapes the LIKE metacharacters (``%`` ``_`` ``\\``) — filenames
|
||||||
|
routinely contain underscores. Callers MUST still confirm with an exact basename
|
||||||
|
compare (``pick_history_row`` does), since ``'%name'`` also matches ``'xname'``."""
|
||||||
|
esc = basename.replace('\\', '\\\\').replace('%', '\\%').replace('_', '\\_')
|
||||||
|
return '%' + esc
|
||||||
|
|
||||||
|
|
||||||
|
def pick_history_row(candidates: Sequence[Tuple], *, current_paths: Iterable[str],
|
||||||
|
basename: str, title: str) -> Optional[int]:
|
||||||
|
"""Return the id of the history row to update for this file, or None.
|
||||||
|
|
||||||
|
``candidates``: ``(id, file_path, title, download_source)`` rows the DB pre-filtered
|
||||||
|
(exact path or filename LIKE). A row matches when its path equals the current path OR
|
||||||
|
its filename matches AND its title agrees — the title guard prevents a shared filename
|
||||||
|
("01 - Intro.flac") from healing a different song's row. Among matches a REAL download
|
||||||
|
row is preferred over a synthetic ``acoustid_scan`` row, so the scanner heals the
|
||||||
|
genuine record and the caller can delete the synthetic duplicate. None when nothing
|
||||||
|
matches safely (caller then inserts a fresh row — the "file SoulSync never downloaded"
|
||||||
|
intent)."""
|
||||||
|
paths = {p for p in current_paths if p}
|
||||||
|
want = _norm_title(title)
|
||||||
|
matches: list = [] # (id, is_exact, is_real)
|
||||||
|
for cid, cpath, ctitle, csource in candidates:
|
||||||
|
is_real = csource != 'acoustid_scan'
|
||||||
|
if cpath and cpath in paths:
|
||||||
|
matches.append((cid, True, is_real))
|
||||||
|
elif (basename and cpath and os.path.basename(cpath) == basename
|
||||||
|
and (not want or not _norm_title(ctitle) or _norm_title(ctitle) == want)):
|
||||||
|
matches.append((cid, False, is_real))
|
||||||
|
if not matches:
|
||||||
|
return None
|
||||||
|
# Prefer a REAL download row over a synthetic acoustid_scan row; within that, prefer an
|
||||||
|
# exact-path match over a filename match. Stable, so ties keep DB order (first/oldest id).
|
||||||
|
matches.sort(key=lambda m: (m[2], m[1]), reverse=True)
|
||||||
|
return matches[0][0]
|
||||||
55
core/downloads/orphan_history.py
Normal file
55
core/downloads/orphan_history.py
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
"""Identify dead review-queue history rows whose file is gone (#934 follow-up).
|
||||||
|
|
||||||
|
The Unverified/Quarantine review queue is fed from ``library_history`` — an
|
||||||
|
append-only log that is never pruned. When a file is deleted, replaced, or
|
||||||
|
re-downloaded elsewhere, its old ``unverified`` row lingers forever and can
|
||||||
|
never be healed (there's no file left to confirm). Those are *orphans*.
|
||||||
|
|
||||||
|
This decides which rows are orphans, given a ``resolve(row) -> path | None``
|
||||||
|
the caller wires to the real filesystem lookup. Pure (no DB, no filesystem) so
|
||||||
|
the rules — including the safety gate — are unit-testable.
|
||||||
|
|
||||||
|
Safety gate: a filesystem check mass-false-positives when the library mount is
|
||||||
|
down (every file looks missing). So if EVERY reviewed file is unreachable and
|
||||||
|
there are enough rows to judge, we flag it ``suspicious`` and the caller refuses
|
||||||
|
to delete — better to clean nothing than to wipe a healthy log during an outage.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Callable, Sequence
|
||||||
|
|
||||||
|
|
||||||
|
def find_orphan_history_ids(
|
||||||
|
rows: Sequence[dict],
|
||||||
|
resolve: Callable[[dict], Any],
|
||||||
|
*,
|
||||||
|
min_for_safety: int = 5,
|
||||||
|
deletable: Callable[[dict], bool] | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""Return ``{'orphan_ids', 'checked', 'suspicious'}``.
|
||||||
|
|
||||||
|
A row is an orphan when it has a non-empty ``file_path`` but ``resolve`` can
|
||||||
|
find no file for it. ``suspicious`` is True when every checked row is
|
||||||
|
missing and there are at least ``min_for_safety`` of them — the mount-down
|
||||||
|
signature; the caller should refuse to delete in that case.
|
||||||
|
|
||||||
|
``deletable`` (optional) protects rows from removal WITHOUT weakening the
|
||||||
|
safety gate: a protected row still counts toward ``checked`` and the
|
||||||
|
all-missing signal (so e.g. a few unverified orphans can't be swept during a
|
||||||
|
mount outage just because protected rows were filtered out first), but it
|
||||||
|
never appears in ``orphan_ids``. Default: every missing row is deletable.
|
||||||
|
"""
|
||||||
|
orphan_ids = []
|
||||||
|
checked = 0
|
||||||
|
missing = 0
|
||||||
|
for row in rows:
|
||||||
|
if not str((row.get('file_path') or '')).strip():
|
||||||
|
continue
|
||||||
|
checked += 1
|
||||||
|
if resolve(row) is None:
|
||||||
|
missing += 1
|
||||||
|
if deletable is None or deletable(row):
|
||||||
|
orphan_ids.append(row.get('id'))
|
||||||
|
suspicious = checked >= min_for_safety and missing == checked
|
||||||
|
return {'orphan_ids': orphan_ids, 'checked': checked, 'suspicious': suspicious}
|
||||||
|
|
@ -453,7 +453,19 @@ 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")
|
logger.error(f"[Post-Processing] Task {task_id} was completed by stream processor - not marking as failed")
|
||||||
return
|
return
|
||||||
download_tasks[task_id]['status'] = 'failed'
|
download_tasks[task_id]['status'] = 'failed'
|
||||||
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)}'
|
# 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."
|
||||||
|
)
|
||||||
deps.on_download_completed(batch_id, task_id, False)
|
deps.on_download_completed(batch_id, task_id, False)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,9 +13,48 @@ Pure + import-safe: parsing only, no network.
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import re
|
import re
|
||||||
from typing import Any, Optional, Tuple
|
from typing import Any, List, Optional, Tuple
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
|
||||||
|
def linked_track_id(track: Any) -> str:
|
||||||
|
"""The source track id stamped on a search result, read from
|
||||||
|
``_source_metadata['track_id']`` — the field every ID-downloadable source
|
||||||
|
(Tidal, Qobuz) records. Empty string when absent. ``TrackResult`` has no
|
||||||
|
top-level ``id``, so callers must NOT use ``getattr(t, 'id')`` (that always
|
||||||
|
missed and left the pasted-link bubble a silent no-op — #932)."""
|
||||||
|
meta = getattr(track, '_source_metadata', None)
|
||||||
|
if not isinstance(meta, dict):
|
||||||
|
return ''
|
||||||
|
return str(meta.get('track_id') or '')
|
||||||
|
|
||||||
|
|
||||||
|
def bubble_linked_track_first(tracks: List[Any], link_track_id: str) -> List[Any]:
|
||||||
|
"""Float the result whose source id matches a pasted link to the top so the
|
||||||
|
user sees the EXACT track they linked, not a fuzzy text-search lookalike
|
||||||
|
(#813/#932). Stable + a graceful no-op when no result carries the id."""
|
||||||
|
if not link_track_id or not tracks:
|
||||||
|
return tracks
|
||||||
|
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.
|
# host substring → download source id. Only ID-downloadable streaming sources.
|
||||||
_HOSTS = (
|
_HOSTS = (
|
||||||
('tidal.com', 'tidal'),
|
('tidal.com', 'tidal'),
|
||||||
|
|
|
||||||
|
|
@ -16,8 +16,9 @@ writes a fresh non-cache hit back to the cache so the next export of the same so
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
import threading
|
import threading
|
||||||
from typing import Callable, Optional, Tuple
|
from typing import Any, Callable, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
from utils.logging_config import get_logger
|
from utils.logging_config import get_logger
|
||||||
|
|
||||||
|
|
@ -72,6 +73,185 @@ def db_recording_mbid(artist: str, title: str) -> Optional[str]:
|
||||||
return _db_match(artist, title)[0]
|
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]:
|
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)."""
|
"""Recording MBID read from the matched track's file tag (set on import post-processing)."""
|
||||||
_mbid, fpath = _db_match(artist, title)
|
_mbid, fpath = _db_match(artist, title)
|
||||||
|
|
|
||||||
|
|
@ -36,16 +36,22 @@ def resolve_playlist_tracks(
|
||||||
resolve_fn: ResolveFn,
|
resolve_fn: ResolveFn,
|
||||||
*,
|
*,
|
||||||
on_progress: Optional[ProgressFn] = None,
|
on_progress: Optional[ProgressFn] = None,
|
||||||
|
id_key: str = "recording_mbid",
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Resolve every track to a recording MBID and build the export pseudo-playlist.
|
"""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.
|
||||||
|
|
||||||
``tracks`` items may use ``artist``/``artist_name`` and ``title``/``track_name`` and
|
``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).
|
``album``/``album_name`` (both the mirrored-playlist and LB-cache shapes are accepted).
|
||||||
|
|
||||||
Returns ``{"resolved": [...], "stats": {...}}`` where each resolved entry is
|
Returns ``{"resolved": [...], "stats": {...}}`` where each resolved entry is
|
||||||
``{artist, title, album, recording_mbid}`` (recording_mbid is None when unmatched),
|
``{artist, title, album, <id_key>}`` (the ID is None when unmatched), in original
|
||||||
in original order, and stats carries ``total, resolved, unmatched, deduped,
|
order, and stats carries ``total, resolved, unmatched, deduped, by_source``.
|
||||||
by_source`` for the live display.
|
|
||||||
"""
|
"""
|
||||||
total = len(tracks or [])
|
total = len(tracks or [])
|
||||||
memo: Dict[str, Tuple[Optional[str], Optional[str]]] = {}
|
memo: Dict[str, Tuple[Optional[str], Optional[str]]] = {}
|
||||||
|
|
@ -71,7 +77,7 @@ def resolve_playlist_tracks(
|
||||||
memo[key] = (mbid, source)
|
memo[key] = (mbid, source)
|
||||||
fresh = True
|
fresh = True
|
||||||
|
|
||||||
resolved.append({"artist": artist, "title": title, "album": album, "recording_mbid": mbid})
|
resolved.append({"artist": artist, "title": title, "album": album, id_key: mbid})
|
||||||
|
|
||||||
if mbid:
|
if mbid:
|
||||||
stats["resolved"] += 1
|
stats["resolved"] += 1
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,14 @@ _DEFAULT_LENGTH_TOLERANCE_S = 3.0
|
||||||
_LENGTH_TOLERANCE_LONG_TRACK_S = 5.0
|
_LENGTH_TOLERANCE_LONG_TRACK_S = 5.0
|
||||||
_LONG_TRACK_THRESHOLD_S = 600.0 # 10 minutes
|
_LONG_TRACK_THRESHOLD_S = 600.0 # 10 minutes
|
||||||
|
|
||||||
|
# A file that runs LONGER than the expected metadata is the opposite of a truncated
|
||||||
|
# download — it's almost always a different master/version (a remaster with a longer
|
||||||
|
# outro, an extended fade, an album cut vs the radio edit). The duration check exists to
|
||||||
|
# catch TRUNCATION (short files) and wildly-wrong matches, so on the auto default we allow
|
||||||
|
# more drift in the longer direction and keep the tight bound for short files. A wrong-song
|
||||||
|
# match still trips this — it's usually off by far more than 15s. (#937)
|
||||||
|
_LONGER_VERSION_TOLERANCE_S = 15.0
|
||||||
|
|
||||||
# Upper bound for the user-configurable override. Anything past 60s
|
# Upper bound for the user-configurable override. Anything past 60s
|
||||||
# means the check is effectively off — cap defends against accidental
|
# means the check is effectively off — cap defends against accidental
|
||||||
# nonsense like 9999 making logs misleading. Users who genuinely want
|
# nonsense like 9999 making logs misleading. Users who genuinely want
|
||||||
|
|
@ -242,18 +250,32 @@ def check_audio_integrity(
|
||||||
if expected_length_s > _LONG_TRACK_THRESHOLD_S
|
if expected_length_s > _LONG_TRACK_THRESHOLD_S
|
||||||
else _DEFAULT_LENGTH_TOLERANCE_S
|
else _DEFAULT_LENGTH_TOLERANCE_S
|
||||||
)
|
)
|
||||||
|
user_pinned_tolerance = False
|
||||||
|
else:
|
||||||
|
user_pinned_tolerance = True
|
||||||
checks["length_tolerance_s"] = length_tolerance_s
|
checks["length_tolerance_s"] = length_tolerance_s
|
||||||
|
|
||||||
drift_s = abs(actual_length_s - expected_length_s)
|
# Positive drift = the file runs LONGER than expected (not truncation). On the auto
|
||||||
|
# default, give the longer direction more room so legit longer masters/versions aren't
|
||||||
|
# quarantined (#937); a user-pinned tolerance is honoured symmetrically.
|
||||||
|
signed_drift_s = actual_length_s - expected_length_s
|
||||||
|
drift_s = abs(signed_drift_s)
|
||||||
checks["length_drift_s"] = drift_s
|
checks["length_drift_s"] = drift_s
|
||||||
|
effective_tolerance_s = length_tolerance_s
|
||||||
|
if signed_drift_s > 0 and not user_pinned_tolerance:
|
||||||
|
effective_tolerance_s = max(length_tolerance_s, _LONGER_VERSION_TOLERANCE_S)
|
||||||
|
checks["effective_tolerance_s"] = effective_tolerance_s
|
||||||
|
|
||||||
if drift_s > length_tolerance_s:
|
if drift_s > effective_tolerance_s:
|
||||||
|
runs_long = signed_drift_s > 0
|
||||||
return IntegrityResult(
|
return IntegrityResult(
|
||||||
ok=False,
|
ok=False,
|
||||||
reason=f"Duration mismatch: file is {actual_length_s:.1f}s, "
|
reason=f"Duration mismatch: file is {actual_length_s:.1f}s, "
|
||||||
f"expected {expected_length_s:.1f}s "
|
f"expected {expected_length_s:.1f}s "
|
||||||
f"(drift {drift_s:.1f}s > tolerance {length_tolerance_s:.1f}s) — "
|
f"(drift {drift_s:.1f}s > tolerance {effective_tolerance_s:.1f}s) — "
|
||||||
"likely truncated download or wrong file matched",
|
+ ("runs longer than expected — likely a different version/master or wrong file"
|
||||||
|
if runs_long
|
||||||
|
else "likely truncated download or wrong file matched"),
|
||||||
checks=checks,
|
checks=checks,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -360,6 +360,22 @@ def probe_audio_quality(file_path: str):
|
||||||
sample_rate=getattr(audio.info, 'sample_rate', None),
|
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
|
return None
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug("probe_audio_quality failed for %s: %s", file_path, e)
|
logger.debug("probe_audio_quality failed for %s: %s", file_path, e)
|
||||||
|
|
@ -508,14 +524,29 @@ def downsample_hires_flac(final_path, context):
|
||||||
return None
|
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):
|
def create_lossy_copy(final_path):
|
||||||
"""Convert a FLAC file to a lossy copy using the configured codec."""
|
"""Convert a lossless file (FLAC / ALAC / WAV / AIFF / DSD) to a lossy copy
|
||||||
from mutagen.flac import FLAC
|
using the configured codec. Non-lossless inputs are skipped (#941)."""
|
||||||
|
from core.quality.lossless import (
|
||||||
|
is_lossless_audio_path,
|
||||||
|
lossy_output_would_overwrite_source,
|
||||||
|
)
|
||||||
|
|
||||||
if not config_manager.get("lossy_copy.enabled", False):
|
if not config_manager.get("lossy_copy.enabled", False):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
if os.path.splitext(final_path)[1].lower() != ".flac":
|
# 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):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
codec = config_manager.get("lossy_copy.codec", "mp3").lower()
|
codec = config_manager.get("lossy_copy.codec", "mp3").lower()
|
||||||
|
|
@ -544,6 +575,16 @@ def create_lossy_copy(final_path):
|
||||||
out_basename = out_basename.replace(original_quality, quality_label)
|
out_basename = out_basename.replace(original_quality, quality_label)
|
||||||
out_path = os.path.join(os.path.dirname(out_path), out_basename)
|
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")
|
ffmpeg_bin = shutil.which("ffmpeg")
|
||||||
if not ffmpeg_bin:
|
if not ffmpeg_bin:
|
||||||
local = os.path.join(os.path.dirname(__file__), "tools", "ffmpeg")
|
local = os.path.join(os.path.dirname(__file__), "tools", "ffmpeg")
|
||||||
|
|
|
||||||
|
|
@ -325,11 +325,14 @@ def post_process_matched_download(context_key, context, file_path, runtime, meta
|
||||||
_mark_task_quarantined(context, quarantine_path)
|
_mark_task_quarantined(context, quarantine_path)
|
||||||
logger.error(f"File quarantined due to integrity failure: {quarantine_path}")
|
logger.error(f"File quarantined due to integrity failure: {quarantine_path}")
|
||||||
except Exception as quarantine_error:
|
except Exception as quarantine_error:
|
||||||
logger.error(f"Quarantine failed ({quarantine_error}), deleting broken file: {file_path}")
|
# Quarantine MOVE failed (e.g. cross-device / permission on a NAS).
|
||||||
try:
|
# Do NOT delete — destroying a download we couldn't even quarantine is
|
||||||
os.remove(file_path)
|
# data loss and forces a re-download. Leave it in place so it can be
|
||||||
except Exception as del_error:
|
# retried; the task is still marked failed below either way (#kettui).
|
||||||
logger.error(f"Could not delete broken file either: {del_error}")
|
logger.error(
|
||||||
|
f"Quarantine failed ({quarantine_error}) — leaving file in place "
|
||||||
|
f"for retry (not deleting): {file_path}"
|
||||||
|
)
|
||||||
|
|
||||||
with matched_context_lock:
|
with matched_context_lock:
|
||||||
if context_key in matched_downloads_context:
|
if context_key in matched_downloads_context:
|
||||||
|
|
@ -383,11 +386,12 @@ def post_process_matched_download(context_key, context, file_path, runtime, meta
|
||||||
_mark_task_quarantined(context, quarantine_path)
|
_mark_task_quarantined(context, quarantine_path)
|
||||||
logger.warning("File quarantined — incomplete/silent audio: %s", quarantine_path)
|
logger.warning("File quarantined — incomplete/silent audio: %s", quarantine_path)
|
||||||
except Exception as quarantine_error:
|
except Exception as quarantine_error:
|
||||||
logger.error(f"Quarantine failed ({quarantine_error}), deleting file: {file_path}")
|
# Don't delete a file we couldn't quarantine — leave it for retry
|
||||||
try:
|
# instead of forcing a re-download (data loss). See integrity block.
|
||||||
os.remove(file_path)
|
logger.error(
|
||||||
except Exception as del_error:
|
f"Quarantine failed ({quarantine_error}) — leaving file in place "
|
||||||
logger.debug("delete broken file fallback: %s", del_error)
|
f"for retry (not deleting): {file_path}"
|
||||||
|
)
|
||||||
|
|
||||||
with matched_context_lock:
|
with matched_context_lock:
|
||||||
matched_downloads_context.pop(context_key, None)
|
matched_downloads_context.pop(context_key, None)
|
||||||
|
|
@ -437,11 +441,12 @@ def post_process_matched_download(context_key, context, file_path, runtime, meta
|
||||||
_mark_task_quarantined(context, quarantine_path)
|
_mark_task_quarantined(context, quarantine_path)
|
||||||
logger.info(f"File quarantined due to quality mismatch: {quarantine_path}")
|
logger.info(f"File quarantined due to quality mismatch: {quarantine_path}")
|
||||||
except Exception as quarantine_error:
|
except Exception as quarantine_error:
|
||||||
logger.error(f"Quarantine failed ({quarantine_error}), deleting file: {file_path}")
|
# Don't delete a file we couldn't quarantine — leave it for retry
|
||||||
try:
|
# instead of forcing a re-download (data loss). See integrity block.
|
||||||
os.remove(file_path)
|
logger.error(
|
||||||
except Exception as e:
|
f"Quarantine failed ({quarantine_error}) — leaving file in place "
|
||||||
logger.debug("delete quarantine fallback: %s", e)
|
f"for retry (not deleting): {file_path}"
|
||||||
|
)
|
||||||
|
|
||||||
context['_bitdepth_rejected'] = True
|
context['_bitdepth_rejected'] = True
|
||||||
task_id = context.get('task_id')
|
task_id = context.get('task_id')
|
||||||
|
|
@ -535,12 +540,13 @@ def post_process_matched_download(context_key, context, file_path, runtime, meta
|
||||||
_mark_task_quarantined(context, quarantine_path)
|
_mark_task_quarantined(context, quarantine_path)
|
||||||
logger.error(f"File quarantined due to verification failure: {quarantine_path}")
|
logger.error(f"File quarantined due to verification failure: {quarantine_path}")
|
||||||
except Exception as quarantine_error:
|
except Exception as quarantine_error:
|
||||||
logger.error(f"Quarantine failed ({quarantine_error}), deleting wrong file: {file_path}")
|
# Don't delete a file we couldn't quarantine — leave it for
|
||||||
logger.error(f"Quarantine failed, deleting wrong file: {file_path}")
|
# retry instead of forcing a re-download (data loss). The
|
||||||
try:
|
# task is still marked failed / requeued below. See integrity.
|
||||||
os.remove(file_path)
|
logger.error(
|
||||||
except Exception as del_error:
|
f"Quarantine failed ({quarantine_error}) — leaving file "
|
||||||
logger.error(f"Could not delete wrong file either: {del_error}")
|
f"in place for retry (not deleting): {file_path}"
|
||||||
|
)
|
||||||
|
|
||||||
context['_acoustid_quarantined'] = True
|
context['_acoustid_quarantined'] = True
|
||||||
context['_acoustid_failure_msg'] = verification_msg
|
context['_acoustid_failure_msg'] = verification_msg
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,12 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
from concurrent.futures import as_completed
|
from concurrent.futures import as_completed
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any, Callable, Dict
|
from typing import Any, Callable, Dict, Optional
|
||||||
|
|
||||||
from core.imports.album import build_album_import_context, build_album_import_match_payload, resolve_album_artist_context
|
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
|
from core.imports.context import get_import_context_artist, get_import_track_info, normalize_import_context
|
||||||
|
|
@ -71,36 +73,219 @@ class ImportRouteRuntime:
|
||||||
logger: Any = module_logger
|
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]:
|
def staging_files(runtime: ImportRouteRuntime) -> tuple[Dict[str, Any], int]:
|
||||||
"""Scan the staging folder and return audio files with tag metadata."""
|
"""Scan the staging folder and return audio files with tag metadata."""
|
||||||
try:
|
try:
|
||||||
staging_path = runtime.get_staging_path()
|
staging_path = runtime.get_staging_path()
|
||||||
os.makedirs(staging_path, exist_ok=True)
|
os.makedirs(staging_path, exist_ok=True)
|
||||||
|
|
||||||
files = []
|
records, scanning = _records_or_scanning_payload(runtime, staging_path)
|
||||||
for root, _dirs, filenames in os.walk(staging_path):
|
if scanning is not None:
|
||||||
for fname in filenames:
|
return scanning, 200
|
||||||
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)
|
|
||||||
|
|
||||||
meta = runtime.read_staging_file_metadata(full_path, rel_path)
|
files = [
|
||||||
|
{
|
||||||
files.append(
|
"filename": r["filename"],
|
||||||
{
|
"rel_path": r["rel_path"],
|
||||||
"filename": fname,
|
"full_path": r["full_path"],
|
||||||
"rel_path": rel_path,
|
"title": r["title"],
|
||||||
"full_path": full_path,
|
"artist": r["albumartist"] or r["artist"] or "Unknown Artist",
|
||||||
"title": meta["title"],
|
"album": r["album"],
|
||||||
"artist": meta["albumartist"] or meta["artist"] or "Unknown Artist",
|
"track_number": r["track_number"],
|
||||||
"album": meta["album"],
|
"disc_number": r["disc_number"],
|
||||||
"track_number": meta["track_number"],
|
"extension": r["extension"],
|
||||||
"disc_number": meta["disc_number"],
|
}
|
||||||
"extension": ext,
|
for r in records
|
||||||
}
|
]
|
||||||
)
|
|
||||||
|
|
||||||
files.sort(key=lambda f: f["filename"].lower())
|
files.sort(key=lambda f: f["filename"].lower())
|
||||||
return {"success": True, "files": files, "staging_path": staging_path}, 200
|
return {"success": True, "files": files, "staging_path": staging_path}, 200
|
||||||
|
|
@ -116,32 +301,28 @@ def staging_groups(runtime: ImportRouteRuntime) -> tuple[Dict[str, Any], int]:
|
||||||
if not os.path.isdir(staging_path):
|
if not os.path.isdir(staging_path):
|
||||||
return {"success": True, "groups": []}, 200
|
return {"success": True, "groups": []}, 200
|
||||||
|
|
||||||
|
records, scanning = _records_or_scanning_payload(runtime, staging_path)
|
||||||
|
if scanning is not None:
|
||||||
|
return scanning, 200
|
||||||
|
|
||||||
album_groups = {}
|
album_groups = {}
|
||||||
for root, _dirs, filenames in os.walk(staging_path):
|
for r in records:
|
||||||
for fname in filenames:
|
album = r["album"]
|
||||||
ext = os.path.splitext(fname)[1].lower()
|
artist = r["albumartist"] or r["artist"]
|
||||||
if ext not in AUDIO_EXTENSIONS:
|
if not album or not artist:
|
||||||
continue
|
continue
|
||||||
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)
|
key = (album.lower().strip(), artist.lower().strip())
|
||||||
album = meta["album"]
|
if key not in album_groups:
|
||||||
artist = meta["albumartist"] or meta["artist"]
|
album_groups[key] = {"album": album.strip(), "artist": artist.strip(), "files": []}
|
||||||
if not album or not artist:
|
album_groups[key]["files"].append(
|
||||||
continue
|
{
|
||||||
|
"filename": r["filename"],
|
||||||
key = (album.lower().strip(), artist.lower().strip())
|
"full_path": r["full_path"],
|
||||||
if key not in album_groups:
|
"title": r["title"],
|
||||||
album_groups[key] = {"album": album.strip(), "artist": artist.strip(), "files": []}
|
"track_number": r["track_number"],
|
||||||
album_groups[key]["files"].append(
|
}
|
||||||
{
|
)
|
||||||
"filename": fname,
|
|
||||||
"full_path": full_path,
|
|
||||||
"title": meta["title"],
|
|
||||||
"track_number": meta["track_number"],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
groups = []
|
groups = []
|
||||||
for group in album_groups.values():
|
for group in album_groups.values():
|
||||||
|
|
@ -171,30 +352,21 @@ def staging_hints(runtime: ImportRouteRuntime) -> tuple[Dict[str, Any], int]:
|
||||||
if not os.path.isdir(staging_path):
|
if not os.path.isdir(staging_path):
|
||||||
return {"success": True, "hints": []}, 200
|
return {"success": True, "hints": []}, 200
|
||||||
|
|
||||||
|
records, scanning = _records_or_scanning_payload(runtime, staging_path)
|
||||||
|
if scanning is not None:
|
||||||
|
return scanning, 200
|
||||||
|
|
||||||
tag_albums = {}
|
tag_albums = {}
|
||||||
folder_hints = {}
|
folder_hints = {}
|
||||||
for root, _dirs, filenames in os.walk(staging_path):
|
for r in records:
|
||||||
audio_files = [f for f in filenames if os.path.splitext(f)[1].lower() in AUDIO_EXTENSIONS]
|
if r["top_folder"]:
|
||||||
if not audio_files:
|
folder_hints[r["top_folder"]] = folder_hints.get(r["top_folder"], 0) + 1
|
||||||
continue
|
|
||||||
|
|
||||||
rel_dir = os.path.relpath(root, staging_path)
|
album = r["album"]
|
||||||
if rel_dir != ".":
|
artist = r["artist"] or r["albumartist"]
|
||||||
top_folder = rel_dir.split(os.sep)[0]
|
if album:
|
||||||
folder_hints[top_folder] = folder_hints.get(top_folder, 0) + len(audio_files)
|
key = (album.strip(), (artist or "").strip())
|
||||||
|
tag_albums[key] = tag_albums.get(key, 0) + 1
|
||||||
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 = []
|
queries = []
|
||||||
seen_queries_lower = set()
|
seen_queries_lower = set()
|
||||||
|
|
@ -371,6 +543,11 @@ def album_process(runtime: ImportRouteRuntime, data: Dict[str, Any]) -> tuple[Di
|
||||||
)
|
)
|
||||||
runtime.refresh_import_suggestions_cache()
|
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
|
return {"success": True, "processed": processed, "total": len(matches), "errors": errors}, 200
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
runtime.logger.error("Error processing album import: %s", exc)
|
runtime.logger.error("Error processing album import: %s", exc)
|
||||||
|
|
@ -506,6 +683,10 @@ def singles_process(runtime: ImportRouteRuntime, files: list[Dict[str, Any]]) ->
|
||||||
)
|
)
|
||||||
runtime.refresh_import_suggestions_cache()
|
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
|
return {"success": True, "processed": processed, "total": len(files), "errors": errors}, 200
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
runtime.logger.error("Error processing singles import: %s", exc)
|
runtime.logger.error("Error processing singles import: %s", exc)
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ run, so a tooling problem never blocks a legitimate import.
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
import re
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
@ -157,6 +158,14 @@ def measured_duration_from_astats(astats_stderr: str, sample_rate: int) -> Optio
|
||||||
return int(m.group(1)) / float(sample_rate)
|
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(
|
def incomplete_audio_reason(
|
||||||
measured_s: Optional[float],
|
measured_s: Optional[float],
|
||||||
container_s: Optional[float],
|
container_s: Optional[float],
|
||||||
|
|
@ -267,11 +276,14 @@ def detect_broken_audio(
|
||||||
|
|
||||||
stderr = proc.stderr.decode("utf-8", errors="replace") if proc.stderr else ""
|
stderr = proc.stderr.decode("utf-8", errors="replace") if proc.stderr else ""
|
||||||
|
|
||||||
# Truncation check first (real audio far shorter than the container).
|
# Truncation check first (real audio far shorter than the container) — but
|
||||||
measured_s = measured_duration_from_astats(stderr, sample_rate)
|
# NOT for DSD: the astats sample-count ÷ DSD-rate math is invalid there and
|
||||||
reason = incomplete_audio_reason(measured_s, container_s, min_ratio=min_ratio)
|
# would always false-positive (#939). Silence detection below still applies.
|
||||||
if reason:
|
if not is_dsd_path(file_path):
|
||||||
return reason
|
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).
|
# Then silence-padding (mostly-silent file).
|
||||||
return is_mostly_silent_reason(stderr, container_s, threshold=threshold)
|
return is_mostly_silent_reason(stderr, container_s, threshold=threshold)
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ from datetime import datetime
|
||||||
import json
|
import json
|
||||||
from utils.logging_config import get_logger
|
from utils.logging_config import get_logger
|
||||||
from config.settings import config_manager
|
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
|
# Shared dataclasses live in the neutral media_server package — every
|
||||||
# server client used to define a near-identical XTrackInfo /
|
# server client used to define a near-identical XTrackInfo /
|
||||||
|
|
@ -512,12 +513,8 @@ class JellyfinClient(MediaServerClient):
|
||||||
try:
|
try:
|
||||||
# SIMPLIFIED APPROACH: Fetch all tracks, then all albums separately (robust and fast)
|
# SIMPLIFIED APPROACH: Fetch all tracks, then all albums separately (robust and fast)
|
||||||
logger.info("Fetching all tracks in bulk...")
|
logger.info("Fetching all tracks in bulk...")
|
||||||
all_tracks = []
|
|
||||||
start_index = 0
|
def _fetch_tracks_page(start_index, limit):
|
||||||
limit = 10000
|
|
||||||
consecutive_failures = 0
|
|
||||||
|
|
||||||
while True:
|
|
||||||
params = {
|
params = {
|
||||||
'ParentId': self.music_library_id,
|
'ParentId': self.music_library_id,
|
||||||
'IncludeItemTypes': 'Audio',
|
'IncludeItemTypes': 'Audio',
|
||||||
|
|
@ -528,41 +525,19 @@ class JellyfinClient(MediaServerClient):
|
||||||
'StartIndex': start_index,
|
'StartIndex': start_index,
|
||||||
'Limit': limit
|
'Limit': limit
|
||||||
}
|
}
|
||||||
|
|
||||||
response = self._make_request(f'/Users/{self.user_id}/Items', params)
|
response = self._make_request(f'/Users/{self.user_id}/Items', params)
|
||||||
|
return response.get('Items', []) if response else None # None = failed page
|
||||||
if not response:
|
|
||||||
consecutive_failures += 1
|
# Page in modest chunks so progress is reported every page — a single
|
||||||
# Wait before retrying — the server may still be processing the timed-out request
|
# huge silent request used to trip the 300s no-progress watchdog on
|
||||||
time.sleep(5)
|
# slow servers even though it was alive (see bulk_paginate docstring).
|
||||||
if limit > 1000:
|
all_tracks = paginate_all_items(
|
||||||
limit = limit // 2
|
_fetch_tracks_page,
|
||||||
consecutive_failures = 0 # Reset — give the smaller batch a fair chance
|
report_progress=self._progress_callback,
|
||||||
logger.warning(f"Track fetch failed - reducing batch size to {limit}")
|
label="tracks",
|
||||||
continue
|
on_retry_wait=lambda: time.sleep(5),
|
||||||
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
|
# Group tracks by album ID for instant lookup
|
||||||
self._track_cache = {}
|
self._track_cache = {}
|
||||||
for track_data in all_tracks:
|
for track_data in all_tracks:
|
||||||
|
|
@ -578,12 +553,8 @@ class JellyfinClient(MediaServerClient):
|
||||||
|
|
||||||
# STEP 2: Fetch all albums in bulk (same proven pattern)
|
# STEP 2: Fetch all albums in bulk (same proven pattern)
|
||||||
logger.info("Fetching all albums in bulk...")
|
logger.info("Fetching all albums in bulk...")
|
||||||
all_albums = []
|
|
||||||
start_index = 0
|
def _fetch_albums_page(start_index, limit):
|
||||||
limit = 10000
|
|
||||||
consecutive_failures = 0
|
|
||||||
|
|
||||||
while True:
|
|
||||||
params = {
|
params = {
|
||||||
'ParentId': self.music_library_id,
|
'ParentId': self.music_library_id,
|
||||||
'IncludeItemTypes': 'MusicAlbum',
|
'IncludeItemTypes': 'MusicAlbum',
|
||||||
|
|
@ -594,41 +565,16 @@ class JellyfinClient(MediaServerClient):
|
||||||
'StartIndex': start_index,
|
'StartIndex': start_index,
|
||||||
'Limit': limit
|
'Limit': limit
|
||||||
}
|
}
|
||||||
|
|
||||||
response = self._make_request(f'/Users/{self.user_id}/Items', params)
|
response = self._make_request(f'/Users/{self.user_id}/Items', params)
|
||||||
|
return response.get('Items', []) if response else None # None = failed page
|
||||||
if not response:
|
|
||||||
consecutive_failures += 1
|
all_albums = paginate_all_items(
|
||||||
# Wait before retrying — the server may still be processing the timed-out request
|
_fetch_albums_page,
|
||||||
time.sleep(5)
|
report_progress=self._progress_callback,
|
||||||
if limit > 1000:
|
label="albums",
|
||||||
limit = limit // 2
|
on_retry_wait=lambda: time.sleep(5),
|
||||||
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
|
# Group albums by artist ID for instant lookup
|
||||||
self._album_cache = {}
|
self._album_cache = {}
|
||||||
for album_data in all_albums:
|
for album_data in all_albums:
|
||||||
|
|
|
||||||
93
core/library/bulk_paginate.py
Normal file
93
core/library/bulk_paginate.py
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
"""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"]
|
||||||
|
|
@ -26,6 +26,7 @@ without a source ID are reported back to the caller and skipped
|
||||||
entirely.
|
entirely.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import errno
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
|
|
@ -34,7 +35,7 @@ import time
|
||||||
import uuid
|
import uuid
|
||||||
from concurrent.futures import FIRST_COMPLETED, ThreadPoolExecutor, wait
|
from concurrent.futures import FIRST_COMPLETED, ThreadPoolExecutor, wait
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Any, Callable, Dict, List, Optional, Set
|
from typing import Any, Callable, Dict, List, Optional, Set, Tuple
|
||||||
|
|
||||||
# Per-album track concurrency. Matches the download workers' per-batch
|
# Per-album track concurrency. Matches the download workers' per-batch
|
||||||
# concurrency (3) so reorganize feels comparable to a fresh download.
|
# concurrency (3) so reorganize feels comparable to a fresh download.
|
||||||
|
|
@ -100,6 +101,7 @@ _ALBUM_ID_COLUMNS = {
|
||||||
'deezer': 'deezer_id',
|
'deezer': 'deezer_id',
|
||||||
'discogs': 'discogs_id',
|
'discogs': 'discogs_id',
|
||||||
'hydrabase': 'soul_id',
|
'hydrabase': 'soul_id',
|
||||||
|
'musicbrainz': 'musicbrainz_release_id',
|
||||||
}
|
}
|
||||||
|
|
||||||
# Human-facing label for each source.
|
# Human-facing label for each source.
|
||||||
|
|
@ -1100,6 +1102,12 @@ def preview_album_reorganize(
|
||||||
'track_number': track.get('track_number', 0),
|
'track_number': track.get('track_number', 0),
|
||||||
'current_path': _trim_to_transfer(db_path, resolved, transfer_dir),
|
'current_path': _trim_to_transfer(db_path, resolved, transfer_dir),
|
||||||
'new_path': '',
|
'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,
|
'file_exists': resolved is not None,
|
||||||
'unchanged': False,
|
'unchanged': False,
|
||||||
'collision': False,
|
'collision': False,
|
||||||
|
|
@ -1147,6 +1155,7 @@ def preview_album_reorganize(
|
||||||
new_full, _ok = build_final_path_fn(
|
new_full, _ok = build_final_path_fn(
|
||||||
context, spotify_artist, album_info, file_ext, create_dirs=False
|
context, spotify_artist, album_info, file_ext, create_dirs=False
|
||||||
)
|
)
|
||||||
|
item['new_path_abs'] = new_full or ''
|
||||||
item['new_path'] = (
|
item['new_path'] = (
|
||||||
os.path.relpath(new_full, transfer_dir)
|
os.path.relpath(new_full, transfer_dir)
|
||||||
if transfer_dir and new_full and new_full.startswith(transfer_dir)
|
if transfer_dir and new_full and new_full.startswith(transfer_dir)
|
||||||
|
|
@ -1806,6 +1815,150 @@ def reorganize_album(
|
||||||
return summary
|
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]:
|
def _find_artist_dir(dest_path: str, transfer_dir: str) -> Optional[str]:
|
||||||
"""Walk up from ``dest_path`` until the parent equals ``transfer_dir``;
|
"""Walk up from ``dest_path`` until the parent equals ``transfer_dir``;
|
||||||
the directory at that point is the artist folder. Returns None if
|
the directory at that point is the artist folder. Returns None if
|
||||||
|
|
|
||||||
|
|
@ -291,6 +291,13 @@ class ListeningStatsWorker:
|
||||||
if not (top_artists or top_albums or top_tracks):
|
if not (top_artists or top_albums or top_tracks):
|
||||||
return
|
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
|
conn = None
|
||||||
try:
|
try:
|
||||||
conn = self.db._get_connection()
|
conn = self.db._get_connection()
|
||||||
|
|
@ -324,7 +331,7 @@ class ListeningStatsWorker:
|
||||||
key = (artist.get('name') or '').lower()
|
key = (artist.get('name') or '').lower()
|
||||||
r = artist_rows.get(key)
|
r = artist_rows.get(key)
|
||||||
if r:
|
if r:
|
||||||
artist['image_url'] = r[1] or None
|
artist['image_url'] = _fix_image(r[1]) or None
|
||||||
artist['id'] = r[2]
|
artist['id'] = r[2]
|
||||||
artist['global_listeners'] = r[3]
|
artist['global_listeners'] = r[3]
|
||||||
artist['global_playcount'] = r[4]
|
artist['global_playcount'] = r[4]
|
||||||
|
|
@ -356,7 +363,7 @@ class ListeningStatsWorker:
|
||||||
key = (album.get('name') or '').lower()
|
key = (album.get('name') or '').lower()
|
||||||
r = album_rows.get(key)
|
r = album_rows.get(key)
|
||||||
if r:
|
if r:
|
||||||
album['image_url'] = r[1] or None
|
album['image_url'] = _fix_image(r[1]) or None
|
||||||
album['id'] = r[2]
|
album['id'] = r[2]
|
||||||
album['artist_id'] = r[3]
|
album['artist_id'] = r[3]
|
||||||
|
|
||||||
|
|
@ -395,7 +402,7 @@ class ListeningStatsWorker:
|
||||||
(track.get('artist') or '').lower())
|
(track.get('artist') or '').lower())
|
||||||
r = track_rows.get(key)
|
r = track_rows.get(key)
|
||||||
if r:
|
if r:
|
||||||
track['image_url'] = r[2] or None
|
track['image_url'] = _fix_image(r[2]) or None
|
||||||
track['id'] = r[3]
|
track['id'] = r[3]
|
||||||
track['artist_id'] = r[4]
|
track['artist_id'] = r[4]
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
|
||||||
|
|
@ -211,7 +211,7 @@ def pick_canonical_release(
|
||||||
# core.library_reorganize._ALBUM_ID_COLUMNS — a test pins them in sync). A manual
|
# core.library_reorganize._ALBUM_ID_COLUMNS — a test pins them in sync). A manual
|
||||||
# match on any of these should pin/lock the canonical version (#758); a match on
|
# match on any of these should pin/lock the canonical version (#758); a match on
|
||||||
# a source the canonical tools don't read (e.g. lastfm) has no version to pin.
|
# a source the canonical tools don't read (e.g. lastfm) has no version to pin.
|
||||||
CANONICAL_ALBUM_SOURCES = frozenset({'spotify', 'itunes', 'deezer', 'discogs', 'hydrabase'})
|
CANONICAL_ALBUM_SOURCES = frozenset({'spotify', 'itunes', 'deezer', 'discogs', 'hydrabase', 'musicbrainz'})
|
||||||
|
|
||||||
|
|
||||||
def should_pin_manual_canonical(entity_type: str, source: str) -> bool:
|
def should_pin_manual_canonical(entity_type: str, source: str) -> bool:
|
||||||
|
|
|
||||||
|
|
@ -167,6 +167,7 @@ def track_already_owned(
|
||||||
album_name: str,
|
album_name: str,
|
||||||
server_source: Optional[str],
|
server_source: Optional[str],
|
||||||
confidence_threshold: float = 0.7,
|
confidence_threshold: float = 0.7,
|
||||||
|
candidate_tracks: Optional[List[Any]] = None,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Return True if the track is already in the user's library.
|
"""Return True if the track is already in the user's library.
|
||||||
|
|
||||||
|
|
@ -186,6 +187,15 @@ def track_already_owned(
|
||||||
original deleted) matches just fine — track_name + artist + album
|
original deleted) matches just fine — track_name + artist + album
|
||||||
don't change with format.
|
don't change with format.
|
||||||
|
|
||||||
|
``candidate_tracks`` (when not None) is the artist's library tracks,
|
||||||
|
pre-fetched ONCE by the caller, so the check scores in-memory instead
|
||||||
|
of firing per-track fuzzy SQL scans against the whole library. Pass an
|
||||||
|
empty list for an artist the user owns nothing of — it still routes
|
||||||
|
through the fast in-memory path (scores against zero candidates →
|
||||||
|
instant "not owned") rather than the slow per-track search. None
|
||||||
|
preserves the original per-track-SQL behaviour for callers that don't
|
||||||
|
pre-fetch.
|
||||||
|
|
||||||
Returns False on any exception so a transient DB hiccup doesn't
|
Returns False on any exception so a transient DB hiccup doesn't
|
||||||
silently nuke a discography fetch — a redundant wishlist add is
|
silently nuke a discography fetch — a redundant wishlist add is
|
||||||
much cheaper to recover from than a missed track.
|
much cheaper to recover from than a missed track.
|
||||||
|
|
@ -198,6 +208,7 @@ def track_already_owned(
|
||||||
confidence_threshold=confidence_threshold,
|
confidence_threshold=confidence_threshold,
|
||||||
server_source=server_source,
|
server_source=server_source,
|
||||||
album=album_name or None,
|
album=album_name or None,
|
||||||
|
candidate_tracks=candidate_tracks,
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
return False
|
return False
|
||||||
|
|
|
||||||
|
|
@ -230,15 +230,20 @@ def get_spotify_client_for_profile(profile_id: Optional[int] = None):
|
||||||
return client
|
return client
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from core.spotify_client import SpotifyClient
|
from core.spotify_client import SpotifyClient, normalize_spotify_oauth_config, SPOTIFY_OAUTH_SCOPE
|
||||||
from spotipy.oauth2 import SpotifyOAuth
|
from spotipy.oauth2 import SpotifyOAuth
|
||||||
import spotipy
|
import spotipy
|
||||||
|
|
||||||
|
normalized_creds = normalize_spotify_oauth_config({
|
||||||
|
"client_id": client_id,
|
||||||
|
"client_secret": client_secret,
|
||||||
|
"redirect_uri": redirect_uri,
|
||||||
|
})
|
||||||
auth_manager = SpotifyOAuth(
|
auth_manager = SpotifyOAuth(
|
||||||
client_id=client_id,
|
client_id=normalized_creds.get("client_id", client_id),
|
||||||
client_secret=client_secret,
|
client_secret=normalized_creds.get("client_secret", client_secret),
|
||||||
redirect_uri=redirect_uri,
|
redirect_uri=normalized_creds.get("redirect_uri", redirect_uri),
|
||||||
scope="user-library-read user-read-private playlist-read-private playlist-read-collaborative user-read-email user-follow-read",
|
scope=SPOTIFY_OAUTH_SCOPE,
|
||||||
cache_path=cache_path,
|
cache_path=cache_path,
|
||||||
state=f"profile_{profile_id}",
|
state=f"profile_{profile_id}",
|
||||||
)
|
)
|
||||||
|
|
@ -352,10 +357,26 @@ def get_hydrabase_client(allow_fallback: bool = True, require_enabled: bool = Tr
|
||||||
return None
|
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:
|
def get_primary_source(spotify_client_factory: Optional[MetadataClientFactory] = None) -> str:
|
||||||
"""Return configured primary metadata source."""
|
"""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]
|
_default = METADATA_SOURCE_PRIORITY[0]
|
||||||
source = _get_config_value("metadata.fallback_source", _default) or _default
|
source = get_configured_primary_source()
|
||||||
|
|
||||||
if source == "spotify":
|
if source == "spotify":
|
||||||
try:
|
try:
|
||||||
|
|
@ -442,7 +463,19 @@ def get_primary_source_status(
|
||||||
musicbrainz_client_factory: Optional[MetadataClientFactory] = None,
|
musicbrainz_client_factory: Optional[MetadataClientFactory] = None,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Return a generic status snapshot for the active primary metadata source."""
|
"""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"
|
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()
|
started = time.time()
|
||||||
connected = False
|
connected = False
|
||||||
|
|
||||||
|
|
@ -510,9 +543,13 @@ def get_client_for_source(
|
||||||
musicbrainz_client_factory: Optional[MetadataClientFactory] = None,
|
musicbrainz_client_factory: Optional[MetadataClientFactory] = None,
|
||||||
):
|
):
|
||||||
"""Return exact client for a source, or None if unavailable."""
|
"""Return exact client for a source, or None if unavailable."""
|
||||||
|
from core.boot_phase import is_boot_phase
|
||||||
|
|
||||||
if source == "spotify":
|
if source == "spotify":
|
||||||
try:
|
try:
|
||||||
client = get_spotify_client(client_factory=spotify_client_factory)
|
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():
|
if client and client.is_spotify_authenticated():
|
||||||
return client
|
return client
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,27 @@ from typing import List, Optional, Sequence
|
||||||
from core.metadata.types import Track
|
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
|
# Pattern tables — public so tests can introspect, callers can extend
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,7 @@ from core.metadata.registry import (
|
||||||
get_discogs_client,
|
get_discogs_client,
|
||||||
get_hydrabase_client,
|
get_hydrabase_client,
|
||||||
get_itunes_client,
|
get_itunes_client,
|
||||||
|
get_configured_primary_source,
|
||||||
get_primary_client,
|
get_primary_client,
|
||||||
get_primary_source,
|
get_primary_source,
|
||||||
get_primary_source_label,
|
get_primary_source_label,
|
||||||
|
|
@ -117,6 +118,7 @@ __all__ = [
|
||||||
"get_metadata_service",
|
"get_metadata_service",
|
||||||
"get_musicmap_similar_artists",
|
"get_musicmap_similar_artists",
|
||||||
"get_primary_client",
|
"get_primary_client",
|
||||||
|
"get_configured_primary_source",
|
||||||
"get_primary_source",
|
"get_primary_source",
|
||||||
"get_primary_source_label",
|
"get_primary_source_label",
|
||||||
"get_spotify_client_for_profile",
|
"get_spotify_client_for_profile",
|
||||||
|
|
|
||||||
|
|
@ -164,6 +164,8 @@ class QobuzClient(DownloadSourcePlugin):
|
||||||
|
|
||||||
def _restore_session(self):
|
def _restore_session(self):
|
||||||
"""Try to restore saved session from config."""
|
"""Try to restore saved session from config."""
|
||||||
|
from core.boot_phase import is_boot_phase
|
||||||
|
|
||||||
saved = config_manager.get('qobuz.session', {})
|
saved = config_manager.get('qobuz.session', {})
|
||||||
app_id = saved.get('app_id', '')
|
app_id = saved.get('app_id', '')
|
||||||
app_secret = saved.get('app_secret', '')
|
app_secret = saved.get('app_secret', '')
|
||||||
|
|
@ -178,6 +180,10 @@ class QobuzClient(DownloadSourcePlugin):
|
||||||
'X-User-Auth-Token': self.user_auth_token,
|
'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
|
# Verify the token is still valid
|
||||||
try:
|
try:
|
||||||
resp = self.session.get(
|
resp = self.session.get(
|
||||||
|
|
@ -715,6 +721,26 @@ class QobuzClient(DownloadSourcePlugin):
|
||||||
logger.error(f"Error getting Qobuz track {track_id}: {e}")
|
logger.error(f"Error getting Qobuz track {track_id}: {e}")
|
||||||
return None
|
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 =====================
|
# ===================== Playlists & Favorites =====================
|
||||||
#
|
#
|
||||||
# Qobuz playlist sync surface — mirrors the Tidal client contract
|
# Qobuz playlist sync surface — mirrors the Tidal client contract
|
||||||
|
|
@ -1012,14 +1038,20 @@ class QobuzClient(DownloadSourcePlugin):
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
return ([], [])
|
return ([], [])
|
||||||
|
|
||||||
def _qobuz_to_track_result(self, track: Dict, quality_info: dict) -> Optional[TrackResult]:
|
def _qobuz_to_track_result(self, track: Dict, quality_info: dict,
|
||||||
"""Convert Qobuz track dict to TrackResult (Soulseek-compatible format)."""
|
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)."""
|
||||||
track_id = track.get('id')
|
track_id = track.get('id')
|
||||||
if not track_id:
|
if not track_id:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Check if track is streamable
|
# Check if track is streamable (skipped for explicit by-id link fetches).
|
||||||
if not track.get('streamable', False):
|
if require_streamable and not track.get('streamable', False):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
performer = track.get('performer', {})
|
performer = track.get('performer', {})
|
||||||
|
|
@ -1074,6 +1106,10 @@ class QobuzClient(DownloadSourcePlugin):
|
||||||
title=title,
|
title=title,
|
||||||
album=album_name,
|
album=album_name,
|
||||||
track_number=track.get('track_number'),
|
track_number=track.get('track_number'),
|
||||||
|
# Stamp the Qobuz track id so a pasted-link manual search can float the
|
||||||
|
# exact track to the top (#932). Without this the bubble never matched
|
||||||
|
# and the linked track stayed buried among fuzzy lookalikes.
|
||||||
|
_source_metadata={'source': 'qobuz', 'track_id': str(track.get('id') or '')},
|
||||||
)
|
)
|
||||||
|
|
||||||
# Stamp real API quality so the global ranker sees actual
|
# Stamp real API quality so the global ranker sees actual
|
||||||
|
|
|
||||||
103
core/quality/lossless.py
Normal file
103
core/quality/lossless.py
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
"""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',
|
||||||
|
]
|
||||||
|
|
@ -39,6 +39,7 @@ class AudioQuality:
|
||||||
# matched target. Cross-format PRIORITY is decided solely by the user's
|
# matched target. Cross-format PRIORITY is decided solely by the user's
|
||||||
# ranked-target list (target index), never by these numbers.
|
# ranked-target list (target index), never by these numbers.
|
||||||
format_base: dict[str, float] = {
|
format_base: dict[str, float] = {
|
||||||
|
'dsf': 102.0, # DSD — 1-bit hi-res lossless, ranks at/above FLAC (#939)
|
||||||
'flac': 100.0,
|
'flac': 100.0,
|
||||||
'alac': 98.0, # lossless (Apple)
|
'alac': 98.0, # lossless (Apple)
|
||||||
'wav': 95.0,
|
'wav': 95.0,
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,9 @@ _EXTENSION_FORMAT_MAP = {
|
||||||
'ogg': 'ogg', 'oga': 'ogg',
|
'ogg': 'ogg', 'oga': 'ogg',
|
||||||
'opus': 'opus',
|
'opus': 'opus',
|
||||||
'wma': 'wma',
|
'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
|
# Audio extensions worth probing/classifying at all — derived from the map so
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,9 @@ class QueueItem:
|
||||||
# 'tags' = read each file's embedded tags as the source
|
# 'tags' = read each file's embedded tags as the source
|
||||||
# of truth (issue #592). Zero API calls.
|
# of truth (issue #592). Zero API calls.
|
||||||
metadata_source: str = 'api'
|
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
|
status: str = 'queued' # queued | running | done | failed | cancelled
|
||||||
started_at: Optional[float] = None
|
started_at: Optional[float] = None
|
||||||
finished_at: Optional[float] = None
|
finished_at: Optional[float] = None
|
||||||
|
|
@ -96,6 +99,7 @@ class QueueItem:
|
||||||
'artist_name': self.artist_name,
|
'artist_name': self.artist_name,
|
||||||
'source': self.source,
|
'source': self.source,
|
||||||
'metadata_source': self.metadata_source,
|
'metadata_source': self.metadata_source,
|
||||||
|
'rename_only': self.rename_only,
|
||||||
'enqueued_at': self.enqueued_at,
|
'enqueued_at': self.enqueued_at,
|
||||||
'started_at': self.started_at,
|
'started_at': self.started_at,
|
||||||
'finished_at': self.finished_at,
|
'finished_at': self.finished_at,
|
||||||
|
|
@ -161,6 +165,7 @@ class ReorganizeQueue:
|
||||||
artist_name: str,
|
artist_name: str,
|
||||||
source: Optional[str] = None,
|
source: Optional[str] = None,
|
||||||
metadata_source: str = 'api',
|
metadata_source: str = 'api',
|
||||||
|
rename_only: bool = False,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Add an album to the queue. Returns a result dict:
|
"""Add an album to the queue. Returns a result dict:
|
||||||
|
|
||||||
|
|
@ -190,6 +195,7 @@ class ReorganizeQueue:
|
||||||
source=source,
|
source=source,
|
||||||
enqueued_at=time.time(),
|
enqueued_at=time.time(),
|
||||||
metadata_source=metadata_source or 'api',
|
metadata_source=metadata_source or 'api',
|
||||||
|
rename_only=bool(rename_only),
|
||||||
)
|
)
|
||||||
self._items.append(item)
|
self._items.append(item)
|
||||||
position = sum(1 for i in self._items if i.status == 'queued')
|
position = sum(1 for i in self._items if i.status == 'queued')
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,7 @@ def build_runner(
|
||||||
is_shutting_down_fn: Callable[[], bool],
|
is_shutting_down_fn: Callable[[], bool],
|
||||||
get_download_path: Callable[[], str],
|
get_download_path: Callable[[], str],
|
||||||
get_transfer_path: Callable[[], str],
|
get_transfer_path: Callable[[], str],
|
||||||
|
build_final_path_fn: Optional[Callable] = None,
|
||||||
) -> Callable[[object], dict]:
|
) -> Callable[[object], dict]:
|
||||||
"""Return the closure the queue worker invokes per item.
|
"""Return the closure the queue worker invokes per item.
|
||||||
|
|
||||||
|
|
@ -59,7 +60,7 @@ def build_runner(
|
||||||
A callable ``runner(item)`` suitable for
|
A callable ``runner(item)`` suitable for
|
||||||
:meth:`core.reorganize_queue.ReorganizeQueue.set_runner`.
|
:meth:`core.reorganize_queue.ReorganizeQueue.set_runner`.
|
||||||
"""
|
"""
|
||||||
from core.library_reorganize import reorganize_album
|
from core.library_reorganize import reorganize_album, reorganize_album_rename_only
|
||||||
from core.reorganize_queue import get_queue
|
from core.reorganize_queue import get_queue
|
||||||
|
|
||||||
def _update_track_path(track_id, new_path):
|
def _update_track_path(track_id, new_path):
|
||||||
|
|
@ -80,17 +81,6 @@ def build_runner(
|
||||||
# server restart.
|
# server restart.
|
||||||
download_dir = get_download_path()
|
download_dir = get_download_path()
|
||||||
transfer_dir = get_transfer_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):
|
def _cleanup_empty(src_dir):
|
||||||
try:
|
try:
|
||||||
|
|
@ -105,6 +95,42 @@ def build_runner(
|
||||||
# Progress fan-out failures must never break a run.
|
# Progress fan-out failures must never break a run.
|
||||||
logger.debug("reorganize progress fan-out: %s", e)
|
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(
|
return reorganize_album(
|
||||||
album_id=item.album_id,
|
album_id=item.album_id,
|
||||||
db=get_database(),
|
db=get_database(),
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,7 @@ _JOB_MODULES = [
|
||||||
'core.repair_jobs.canonical_version_resolve',
|
'core.repair_jobs.canonical_version_resolve',
|
||||||
'core.repair_jobs.library_retag',
|
'core.repair_jobs.library_retag',
|
||||||
'core.repair_jobs.quality_upgrade',
|
'core.repair_jobs.quality_upgrade',
|
||||||
|
'core.repair_jobs.short_preview_track',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -389,14 +389,43 @@ class AcoustIDScannerJob(RepairJob):
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"UPDATE tracks SET verification_status = ? WHERE id = ?",
|
"UPDATE tracks SET verification_status = ? WHERE id = ?",
|
||||||
(status, track_id))
|
(status, track_id))
|
||||||
matched = 0
|
exp = expected or {}
|
||||||
|
# Find the canonical history row for this file. The stored path is frozen at
|
||||||
|
# import time while the file has since moved (media-server import / reorganize),
|
||||||
|
# so an exact-path match alone misses it — then the status never lands and a
|
||||||
|
# duplicate row gets inserted every scan (#934). Match exact path first, then
|
||||||
|
# filename guarded by title, and HEAL the row's path so future scans match cleanly.
|
||||||
|
from core.downloads.history_match import pick_history_row, like_filename_filter
|
||||||
|
current = fpath or db_path
|
||||||
|
basename = os.path.basename(current) if current else ''
|
||||||
|
clauses, params = [], []
|
||||||
for p in {p for p in (fpath, db_path) if p}:
|
for p in {p for p in (fpath, db_path) if p}:
|
||||||
|
clauses.append("file_path = ?")
|
||||||
|
params.append(p)
|
||||||
|
if basename:
|
||||||
|
clauses.append("file_path LIKE ? ESCAPE '\\'")
|
||||||
|
params.append(like_filename_filter(basename))
|
||||||
|
row_id = None
|
||||||
|
if clauses:
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"UPDATE library_history SET verification_status = ? WHERE file_path = ?",
|
"SELECT id, file_path, title, download_source FROM library_history WHERE "
|
||||||
(status, p))
|
+ " OR ".join(clauses),
|
||||||
matched += max(getattr(cur, 'rowcount', 0) or 0, 0)
|
params)
|
||||||
if status == 'unverified' and matched == 0:
|
row_id = pick_history_row(
|
||||||
exp = expected or {}
|
cur.fetchall(),
|
||||||
|
current_paths=(fpath, db_path),
|
||||||
|
basename=basename, title=exp.get('title') or '')
|
||||||
|
if row_id is not None:
|
||||||
|
cur.execute(
|
||||||
|
"UPDATE library_history SET verification_status = ?, file_path = ? WHERE id = ?",
|
||||||
|
(status, current, row_id))
|
||||||
|
# Drop synthetic scan-created duplicates for this exact file (the #934
|
||||||
|
# leftovers). Exact path → collision-free; never touches a real download row.
|
||||||
|
cur.execute(
|
||||||
|
"DELETE FROM library_history WHERE id != ? AND download_source = 'acoustid_scan' "
|
||||||
|
"AND file_path = ?",
|
||||||
|
(row_id, current))
|
||||||
|
elif status == 'unverified':
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""INSERT INTO library_history
|
"""INSERT INTO library_history
|
||||||
(event_type, title, artist_name, album_name, file_path,
|
(event_type, title, artist_name, album_name, file_path,
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,13 +1,19 @@
|
||||||
"""Lossy Converter Job — finds FLAC files that don't have a lossy copy.
|
"""Lossy Converter Job — finds lossless files that don't have a lossy copy.
|
||||||
|
|
||||||
Scans the library for FLAC files without a corresponding lossy copy alongside
|
Scans the library for lossless files without a corresponding lossy copy alongside
|
||||||
them, and creates a finding for each. The fix action converts the file using
|
them, and creates a finding for each. The fix action converts the file using
|
||||||
ffmpeg with the user's configured codec/bitrate settings.
|
ffmpeg with the user's configured codec/bitrate settings.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
|
||||||
|
from core.imports.file_ops import m4a_codec
|
||||||
from core.library.path_resolver import resolve_library_file_path
|
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 import register_job
|
||||||
from core.repair_jobs.base import JobContext, JobResult, RepairJob
|
from core.repair_jobs.base import JobContext, JobResult, RepairJob
|
||||||
from utils.logging_config import get_logger
|
from utils.logging_config import get_logger
|
||||||
|
|
@ -21,6 +27,16 @@ 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):
|
def _resolve_file_path(file_path, transfer_folder, download_folder=None, config_manager=None):
|
||||||
"""Backwards-compat wrapper. Use ``resolve_library_file_path`` directly."""
|
"""Backwards-compat wrapper. Use ``resolve_library_file_path`` directly."""
|
||||||
return resolve_library_file_path(
|
return resolve_library_file_path(
|
||||||
|
|
@ -35,15 +51,15 @@ def _resolve_file_path(file_path, transfer_folder, download_folder=None, config_
|
||||||
class LossyConverterJob(RepairJob):
|
class LossyConverterJob(RepairJob):
|
||||||
job_id = 'lossy_converter'
|
job_id = 'lossy_converter'
|
||||||
display_name = 'Lossy Converter'
|
display_name = 'Lossy Converter'
|
||||||
description = 'Finds FLAC files without a lossy copy'
|
description = 'Finds lossless files without a lossy copy'
|
||||||
help_text = (
|
help_text = (
|
||||||
'Scans your library for FLAC files that don\'t already have a lossy copy '
|
'Scans your library for lossless files (FLAC/ALAC/WAV/AIFF/DSD) that don\'t already have a lossy copy '
|
||||||
'(MP3, Opus, or AAC) alongside them.\n\n'
|
'(MP3, Opus, or AAC) alongside them.\n\n'
|
||||||
'Uses the codec setting from your Lossy Copy configuration on the Settings '
|
'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 '
|
'page. Enable Lossy Copy in Settings first, then run this job to find FLAC '
|
||||||
'files missing a lossy copy.\n\n'
|
'files missing a lossy copy.\n\n'
|
||||||
'Each finding can be fixed individually or in bulk — the fix action converts '
|
'Each finding can be fixed individually or in bulk — the fix action converts '
|
||||||
'the FLAC file using ffmpeg at your configured bitrate.\n\n'
|
'the lossless file using ffmpeg at your configured bitrate.\n\n'
|
||||||
'Requires ffmpeg to be installed.'
|
'Requires ffmpeg to be installed.'
|
||||||
)
|
)
|
||||||
icon = 'repair-icon-lossy'
|
icon = 'repair-icon-lossy'
|
||||||
|
|
@ -81,14 +97,14 @@ class LossyConverterJob(RepairJob):
|
||||||
try:
|
try:
|
||||||
conn = context.db._get_connection()
|
conn = context.db._get_connection()
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
cursor.execute("""
|
cursor.execute(f"""
|
||||||
SELECT t.id, t.title, ar.name, al.title, t.file_path,
|
SELECT t.id, t.title, ar.name, al.title, t.file_path,
|
||||||
al.thumb_url, ar.thumb_url
|
al.thumb_url, ar.thumb_url
|
||||||
FROM tracks t
|
FROM tracks t
|
||||||
LEFT JOIN artists ar ON ar.id = t.artist_id
|
LEFT JOIN artists ar ON ar.id = t.artist_id
|
||||||
LEFT JOIN albums al ON al.id = t.album_id
|
LEFT JOIN albums al ON al.id = t.album_id
|
||||||
WHERE t.file_path IS NOT NULL AND t.file_path != ''
|
WHERE t.file_path IS NOT NULL AND t.file_path != ''
|
||||||
AND LOWER(t.file_path) LIKE '%.flac'
|
AND {_lossless_ext_where('t.file_path')}
|
||||||
""")
|
""")
|
||||||
tracks = cursor.fetchall()
|
tracks = cursor.fetchall()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -104,7 +120,7 @@ class LossyConverterJob(RepairJob):
|
||||||
context.update_progress(0, total)
|
context.update_progress(0, total)
|
||||||
if context.report_progress:
|
if context.report_progress:
|
||||||
context.report_progress(
|
context.report_progress(
|
||||||
phase=f'Scanning {total} FLAC files for missing {quality_label} copies...',
|
phase=f'Scanning {total} lossless files for missing {quality_label} copies...',
|
||||||
total=total
|
total=total
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -135,8 +151,17 @@ class LossyConverterJob(RepairJob):
|
||||||
if not resolved or not os.path.exists(resolved):
|
if not resolved or not os.path.exists(resolved):
|
||||||
continue
|
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
|
# Check if lossy copy already exists
|
||||||
out_path = os.path.splitext(resolved)[0] + out_ext
|
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):
|
if os.path.exists(out_path):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|
@ -159,7 +184,7 @@ class LossyConverterJob(RepairJob):
|
||||||
file_path=file_path,
|
file_path=file_path,
|
||||||
title=f'No {quality_label} copy: {title or "Unknown"}',
|
title=f'No {quality_label} copy: {title or "Unknown"}',
|
||||||
description=(
|
description=(
|
||||||
f'FLAC file "{title}" by {artist_name or "Unknown"} does not have '
|
f'Lossless file "{title}" by {artist_name or "Unknown"} does not have '
|
||||||
f'a {quality_label} copy alongside it'
|
f'a {quality_label} copy alongside it'
|
||||||
),
|
),
|
||||||
details={
|
details={
|
||||||
|
|
@ -195,7 +220,7 @@ class LossyConverterJob(RepairJob):
|
||||||
context.report_progress(
|
context.report_progress(
|
||||||
scanned=total, total=total,
|
scanned=total, total=total,
|
||||||
phase='Complete',
|
phase='Complete',
|
||||||
log_line=f'Found {result.findings_created} FLAC files without {quality_label} copies',
|
log_line=f'Found {result.findings_created} lossless files without {quality_label} copies',
|
||||||
log_type='success' if result.findings_created == 0 else 'info'
|
log_type='success' if result.findings_created == 0 else 'info'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -208,10 +233,10 @@ class LossyConverterJob(RepairJob):
|
||||||
try:
|
try:
|
||||||
conn = context.db._get_connection()
|
conn = context.db._get_connection()
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
cursor.execute("""
|
cursor.execute(f"""
|
||||||
SELECT COUNT(*) FROM tracks
|
SELECT COUNT(*) FROM tracks
|
||||||
WHERE file_path IS NOT NULL AND file_path != ''
|
WHERE file_path IS NOT NULL AND file_path != ''
|
||||||
AND LOWER(file_path) LIKE '%.flac'
|
AND {_lossless_ext_where('file_path')}
|
||||||
""")
|
""")
|
||||||
row = cursor.fetchone()
|
row = cursor.fetchone()
|
||||||
return row[0] if row else 0
|
return row[0] if row else 0
|
||||||
|
|
|
||||||
267
core/repair_jobs/short_preview_track.py
Normal file
267
core/repair_jobs/short_preview_track.py
Normal file
|
|
@ -0,0 +1,267 @@
|
||||||
|
"""Repair job: detect ~30s PREVIEW clips and re-fetch the full track.
|
||||||
|
|
||||||
|
The HiFi endpoint (and occasionally others) sometimes deliver a ~30-second preview/sample
|
||||||
|
instead of the full song. Those land in the library looking like real tracks. This job
|
||||||
|
finds short tracks, looks up the EXPECTED length from the track's metadata source, and —
|
||||||
|
when the source says the real track is meaningfully longer than the file — flags it as a
|
||||||
|
preview clip.
|
||||||
|
|
||||||
|
Approving the finding (in repair_worker._fix_short_preview_track) deletes the preview file,
|
||||||
|
drops the DB row so the track goes missing again, and re-adds it to the wishlist with a full
|
||||||
|
payload so the real version gets downloaded. The scan itself ONLY creates findings — nothing
|
||||||
|
is deleted, removed, or wishlisted without the user approving, exactly like the other tools.
|
||||||
|
|
||||||
|
Conservative by design (it deletes a file): a track is only flagged when the source confirms
|
||||||
|
it should be much longer. Genuine short tracks (intros, skits, interludes — where the source
|
||||||
|
agrees the track is short) are left alone, and tracks whose length can't be verified from a
|
||||||
|
source are skipped, never flagged.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
|
from core.repair_jobs import register_job
|
||||||
|
from core.repair_jobs.base import JobContext, JobResult, RepairJob
|
||||||
|
from utils.logging_config import get_logger
|
||||||
|
|
||||||
|
logger = get_logger("repair_jobs.short_preview")
|
||||||
|
|
||||||
|
|
||||||
|
def _art_from_details(details: Dict[str, Any]) -> Optional[str]:
|
||||||
|
"""Pull a renderable album-art URL out of a get_track_details() response. The cleaned dict
|
||||||
|
doesn't carry images, but raw_data does: Spotify → raw_data.album.images[0].url, iTunes →
|
||||||
|
raw_data.artworkUrl100 (upscaled). Returns None if neither is present."""
|
||||||
|
raw = (details or {}).get("raw_data") or {}
|
||||||
|
album = raw.get("album")
|
||||||
|
if isinstance(album, dict):
|
||||||
|
images = album.get("images")
|
||||||
|
if isinstance(images, list) and images and isinstance(images[0], dict) and images[0].get("url"):
|
||||||
|
return images[0]["url"]
|
||||||
|
art = raw.get("artworkUrl100") or raw.get("artworkUrl60") or raw.get("artworkUrl30")
|
||||||
|
if art:
|
||||||
|
# iTunes serves tiny thumbnails by default; bump to a usable size.
|
||||||
|
for small in ("100x100bb", "60x60bb", "30x30bb"):
|
||||||
|
art = art.replace(small, "600x600bb")
|
||||||
|
return art
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@register_job
|
||||||
|
class ShortPreviewTrackJob(RepairJob):
|
||||||
|
job_id = "short_preview_track"
|
||||||
|
display_name = "Preview Clip Cleanup"
|
||||||
|
description = (
|
||||||
|
"Finds ~30s preview clips that slipped in instead of the full song (common from the "
|
||||||
|
"HiFi endpoint) and re-fetches the real track."
|
||||||
|
)
|
||||||
|
help_text = (
|
||||||
|
"Some downloads — especially via the HiFi source — deliver a ~30-second preview clip "
|
||||||
|
"instead of the full song. They look like normal tracks in your library. This job scans "
|
||||||
|
"for short tracks, checks how long the track ACTUALLY is from its metadata source "
|
||||||
|
"(Spotify / iTunes / MusicBrainz), and flags any whose real length is much greater than "
|
||||||
|
"the file — i.e. a preview.\n\n"
|
||||||
|
"Approving a finding deletes the preview file, removes the track from the database (so it "
|
||||||
|
"shows as missing), and re-adds it to your Wishlist so the full version downloads.\n\n"
|
||||||
|
"It's conservative: genuine short tracks (intros, skits) where the source agrees the track "
|
||||||
|
"is short are left alone, and tracks whose length can't be verified are skipped.\n\n"
|
||||||
|
"Settings:\n"
|
||||||
|
" - max_duration_seconds: only tracks at or below this length are considered (default 30).\n"
|
||||||
|
" - min_expected_drift_seconds: the source must say the real track is at least this many "
|
||||||
|
"seconds longer than the file before it's flagged (default 30)."
|
||||||
|
)
|
||||||
|
icon = "scissors"
|
||||||
|
default_enabled = False
|
||||||
|
default_interval_hours = 168 # weekly
|
||||||
|
default_settings = {
|
||||||
|
"max_duration_seconds": 30,
|
||||||
|
"min_expected_drift_seconds": 30,
|
||||||
|
}
|
||||||
|
setting_options: Dict[str, list] = {}
|
||||||
|
auto_fix = False
|
||||||
|
|
||||||
|
def _setting_int(self, context: JobContext, key: str, default: int) -> int:
|
||||||
|
cm = getattr(context, "config_manager", None)
|
||||||
|
if cm is None:
|
||||||
|
return default
|
||||||
|
try:
|
||||||
|
return int(cm.get(self.get_config_key(key), default) or default)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return default
|
||||||
|
|
||||||
|
def scan(self, context: JobContext) -> JobResult:
|
||||||
|
result = JobResult()
|
||||||
|
max_dur_s = self._setting_int(context, "max_duration_seconds", 30)
|
||||||
|
min_drift_s = self._setting_int(context, "min_expected_drift_seconds", 30)
|
||||||
|
max_dur_ms = max_dur_s * 1000
|
||||||
|
|
||||||
|
conn = context.db._get_connection()
|
||||||
|
try:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT t.id, t.title, t.duration, t.file_path,
|
||||||
|
t.spotify_track_id, t.itunes_track_id, t.musicbrainz_recording_id,
|
||||||
|
ar.name AS artist_name, ar.thumb_url AS artist_thumb,
|
||||||
|
al.title AS album_title, al.thumb_url AS album_thumb
|
||||||
|
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.duration IS NOT NULL AND t.duration > 0 AND t.duration <= ?
|
||||||
|
AND t.file_path IS NOT NULL AND t.file_path != ''
|
||||||
|
""",
|
||||||
|
(max_dur_ms,),
|
||||||
|
)
|
||||||
|
rows = [dict(r) for r in cursor.fetchall()]
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
total = len(rows)
|
||||||
|
if context.report_progress:
|
||||||
|
try:
|
||||||
|
context.report_progress(phase=f"Checking {total} short tracks for previews…", total=total)
|
||||||
|
except Exception: # noqa: S110 — progress is best-effort
|
||||||
|
pass
|
||||||
|
|
||||||
|
for i, row in enumerate(rows):
|
||||||
|
if context.check_stop() or context.wait_if_paused():
|
||||||
|
break
|
||||||
|
result.scanned += 1
|
||||||
|
|
||||||
|
title = row["title"] or "Unknown"
|
||||||
|
artist = row["artist_name"] or "Unknown"
|
||||||
|
# Live progress EVERY track — the source lookup below is a network call, so without
|
||||||
|
# per-track reporting the UI looks frozen at "Starting…" (the #937-follow-up report).
|
||||||
|
if context.update_progress:
|
||||||
|
try:
|
||||||
|
context.update_progress(i + 1, total)
|
||||||
|
except Exception: # noqa: S110 — best-effort
|
||||||
|
pass
|
||||||
|
if context.report_progress:
|
||||||
|
try:
|
||||||
|
context.report_progress(
|
||||||
|
phase=f"Checking {i + 1}/{total} short tracks for previews…",
|
||||||
|
log_line=f"{artist} — {title}", scanned=i + 1, total=total,
|
||||||
|
)
|
||||||
|
except Exception: # noqa: S110 — best-effort
|
||||||
|
pass
|
||||||
|
|
||||||
|
file_dur_s = (row["duration"] or 0) / 1000.0
|
||||||
|
source = self._lookup_source(context, row)
|
||||||
|
|
||||||
|
# Can't verify the real length → never flag (a delete must be backed by evidence).
|
||||||
|
if source is None:
|
||||||
|
result.skipped += 1
|
||||||
|
continue
|
||||||
|
expected_dur_s = source["duration_s"]
|
||||||
|
# Prefer the source's album art (a renderable CDN url) over the library thumb, which
|
||||||
|
# is often empty/non-renderable for un-enriched HiFi previews → art-less wishlist orb.
|
||||||
|
album_image = source.get("album_image") or row["album_thumb"]
|
||||||
|
|
||||||
|
# Source agrees the track is short (genuine intro/skit) → leave it alone. Only a
|
||||||
|
# source that says the real track is MUCH longer than the file marks a preview.
|
||||||
|
if (expected_dur_s - file_dur_s) < min_drift_s:
|
||||||
|
result.skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
if context.create_finding:
|
||||||
|
title = row["title"] or "Unknown"
|
||||||
|
artist = row["artist_name"] or "Unknown"
|
||||||
|
try:
|
||||||
|
inserted = context.create_finding(
|
||||||
|
job_id=self.job_id,
|
||||||
|
finding_type="short_preview_track",
|
||||||
|
severity="warning",
|
||||||
|
entity_type="track",
|
||||||
|
entity_id=str(row["id"]),
|
||||||
|
file_path=row["file_path"],
|
||||||
|
title=f"Preview clip: {artist} - {title}",
|
||||||
|
description=(
|
||||||
|
f'File is {file_dur_s:.0f}s but "{title}" by {artist} is '
|
||||||
|
f"{expected_dur_s:.0f}s at the source — looks like a preview clip. "
|
||||||
|
"Approve to delete it and re-download the full version."
|
||||||
|
),
|
||||||
|
details={
|
||||||
|
"track_id": row["id"],
|
||||||
|
"title": row["title"],
|
||||||
|
"artist": row["artist_name"],
|
||||||
|
"album": row["album_title"],
|
||||||
|
"album_thumb_url": album_image,
|
||||||
|
"artist_thumb_url": row["artist_thumb"],
|
||||||
|
"file_duration_s": round(file_dur_s, 1),
|
||||||
|
"expected_duration_s": round(expected_dur_s, 1),
|
||||||
|
"original_path": row["file_path"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if inserted:
|
||||||
|
result.findings_created += 1
|
||||||
|
else:
|
||||||
|
result.findings_skipped_dedup += 1
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("create_finding failed for track %s: %s", row["id"], exc)
|
||||||
|
result.errors += 1
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _lookup_source(self, context: JobContext, row: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Look the track up at its metadata source: returns {'duration_s', 'album_image'} or
|
||||||
|
None when no source id is usable / the lookup fails. The SAME lookup that confirms the
|
||||||
|
real length also carries the album art (in raw_data), which we capture so the re-wishlist
|
||||||
|
isn't art-less when the library album thumb is missing (the #937-follow-up: HiFi previews
|
||||||
|
on un-enriched albums). Every metadata client exposes get_track_details(id)."""
|
||||||
|
|
||||||
|
def _build(details) -> Optional[Dict[str, Any]]:
|
||||||
|
ms = (details or {}).get("duration_ms")
|
||||||
|
if not ms or ms <= 0:
|
||||||
|
return None
|
||||||
|
return {"duration_s": ms / 1000.0, "album_image": _art_from_details(details)}
|
||||||
|
|
||||||
|
# Spotify — pass allow_fallback=False. The default fallback scrapes the configured
|
||||||
|
# metadata source, which is slow and can BLOCK a scan loop indefinitely when the
|
||||||
|
# official API isn't authed (the #937-follow-up hang). Official-only is fast and
|
||||||
|
# returns None cleanly when unavailable, so we just move to the next source.
|
||||||
|
sp_id = row.get("spotify_track_id")
|
||||||
|
if sp_id and context.spotify_client and not context.is_spotify_rate_limited():
|
||||||
|
try:
|
||||||
|
r = _build(context.spotify_client.get_track_details(str(sp_id), allow_fallback=False))
|
||||||
|
if r:
|
||||||
|
return r
|
||||||
|
except TypeError:
|
||||||
|
pass # older client without the flag — skip, don't risk the slow path
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("spotify lookup failed for %s: %s", sp_id, exc)
|
||||||
|
|
||||||
|
# iTunes (public API, no auth, fast) then MusicBrainz.
|
||||||
|
for source_id, client in (
|
||||||
|
(row.get("itunes_track_id"), context.itunes_client),
|
||||||
|
(row.get("musicbrainz_recording_id"), context.mb_client),
|
||||||
|
):
|
||||||
|
if not source_id or client is None:
|
||||||
|
continue
|
||||||
|
getter = getattr(client, "get_track_details", None)
|
||||||
|
if getter is None:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
r = _build(getter(str(source_id)))
|
||||||
|
if r:
|
||||||
|
return r
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("lookup failed for %s: %s", source_id, exc)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def estimate_scope(self, context: JobContext) -> int:
|
||||||
|
try:
|
||||||
|
max_dur_ms = self._setting_int(context, "max_duration_seconds", 30) * 1000
|
||||||
|
conn = context.db._get_connection()
|
||||||
|
try:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute(
|
||||||
|
"SELECT COUNT(*) FROM tracks WHERE duration > 0 AND duration <= ? "
|
||||||
|
"AND file_path IS NOT NULL AND file_path != ''",
|
||||||
|
(max_dur_ms,),
|
||||||
|
)
|
||||||
|
return (cursor.fetchone() or [0])[0]
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
except Exception:
|
||||||
|
return 0
|
||||||
|
|
@ -997,6 +997,7 @@ class RepairWorker:
|
||||||
'quality_upgrade': self._fix_quality_upgrade,
|
'quality_upgrade': self._fix_quality_upgrade,
|
||||||
'missing_discography_track': self._fix_discography_backfill,
|
'missing_discography_track': self._fix_discography_backfill,
|
||||||
'library_retag': self._fix_library_retag,
|
'library_retag': self._fix_library_retag,
|
||||||
|
'short_preview_track': self._fix_short_preview_track,
|
||||||
}
|
}
|
||||||
handler = handlers.get(finding_type)
|
handler = handlers.get(finding_type)
|
||||||
if not handler:
|
if not handler:
|
||||||
|
|
@ -1211,6 +1212,118 @@ class RepairWorker:
|
||||||
if conn:
|
if conn:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
def _fix_short_preview_track(self, entity_type, entity_id, file_path, details):
|
||||||
|
"""Approve a preview-clip finding: delete the ~30s preview file, drop its DB row, and
|
||||||
|
re-add the track to the wishlist (full payload) so the real version downloads. Mirrors
|
||||||
|
the dead-file 'redownload' payload + the acoustid-mismatch file delete. (Tools #937-adj)
|
||||||
|
"""
|
||||||
|
if not entity_id:
|
||||||
|
return {'success': False, 'error': 'No track ID associated with this finding'}
|
||||||
|
conn = None
|
||||||
|
try:
|
||||||
|
conn = self.db._get_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT t.id, t.title, t.track_number, t.duration, t.bitrate,
|
||||||
|
t.spotify_track_id, t.itunes_track_id, t.deezer_id, t.isrc,
|
||||||
|
ar.name AS artist_name, ar.spotify_artist_id,
|
||||||
|
al.title AS album_title, al.spotify_album_id,
|
||||||
|
al.record_type, al.track_count, al.year, al.thumb_url AS album_thumb
|
||||||
|
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.id = ?
|
||||||
|
""", (entity_id,))
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if not row:
|
||||||
|
return {'success': False, 'error': 'Track not found in database'}
|
||||||
|
|
||||||
|
track_name = row['title'] or details.get('title', 'Unknown')
|
||||||
|
artist_name = row['artist_name'] or details.get('artist', 'Unknown Artist')
|
||||||
|
album_title = row['album_title'] or details.get('album', '')
|
||||||
|
|
||||||
|
wishlist_id = (row['spotify_track_id']
|
||||||
|
or row['itunes_track_id']
|
||||||
|
or row['deezer_id']
|
||||||
|
or f"preview_redl_{entity_id}")
|
||||||
|
|
||||||
|
# Prefer the finding's stored art (the scan captures the metadata source's CDN image)
|
||||||
|
# over the library album thumb, which is often empty for un-enriched HiFi previews.
|
||||||
|
album_images = []
|
||||||
|
album_thumb = details.get('album_thumb_url') or row['album_thumb']
|
||||||
|
if album_thumb:
|
||||||
|
album_images = [{'url': album_thumb}]
|
||||||
|
|
||||||
|
spotify_track_data = {
|
||||||
|
'id': wishlist_id,
|
||||||
|
'name': track_name,
|
||||||
|
'artists': [{'name': artist_name}],
|
||||||
|
'album': {
|
||||||
|
'name': album_title or track_name,
|
||||||
|
'id': row['spotify_album_id'] or '',
|
||||||
|
'release_date': str(row['year']) if row['year'] else '',
|
||||||
|
'images': album_images,
|
||||||
|
'album_type': row['record_type'] or 'album',
|
||||||
|
'total_tracks': row['track_count'] or 0,
|
||||||
|
'artists': [{'name': artist_name}],
|
||||||
|
},
|
||||||
|
'duration_ms': int((details.get('expected_duration_s') or 0) * 1000) or (row['duration'] or 0),
|
||||||
|
'track_number': row['track_number'] or 1,
|
||||||
|
'disc_number': 1,
|
||||||
|
'explicit': False,
|
||||||
|
'external_urls': {},
|
||||||
|
'popularity': 0,
|
||||||
|
'preview_url': None,
|
||||||
|
'uri': f"spotify:track:{row['spotify_track_id']}" if row['spotify_track_id'] else '',
|
||||||
|
'is_local': False,
|
||||||
|
}
|
||||||
|
|
||||||
|
source_info = {
|
||||||
|
'original_path': file_path or details.get('original_path', ''),
|
||||||
|
'album_title': album_title,
|
||||||
|
'artist': artist_name,
|
||||||
|
'reason': 'preview_clip_redownload',
|
||||||
|
}
|
||||||
|
|
||||||
|
added = self.db.add_to_wishlist(
|
||||||
|
spotify_track_data,
|
||||||
|
failure_reason='Preview clip — re-downloading full track',
|
||||||
|
source_type='redownload',
|
||||||
|
source_info=source_info,
|
||||||
|
)
|
||||||
|
if not added:
|
||||||
|
return {'success': False, 'error': 'Failed to add to wishlist (may already exist or be blocklisted)'}
|
||||||
|
|
||||||
|
# Delete the preview file (path resolved like the other delete tools).
|
||||||
|
deleted_file = False
|
||||||
|
target_path = file_path or details.get('original_path')
|
||||||
|
if target_path:
|
||||||
|
download_folder = self._config_manager.get('soulseek.download_path', '') if self._config_manager else None
|
||||||
|
resolved = _resolve_file_path(target_path, self.transfer_folder,
|
||||||
|
download_folder=download_folder,
|
||||||
|
config_manager=self._config_manager)
|
||||||
|
if resolved and os.path.exists(resolved):
|
||||||
|
try:
|
||||||
|
os.remove(resolved)
|
||||||
|
deleted_file = True
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Could not delete preview file %s: %s", resolved, e)
|
||||||
|
|
||||||
|
# Drop the DB row so the track shows as missing.
|
||||||
|
cursor.execute("DELETE FROM tracks WHERE id = ?", (entity_id,))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
return {'success': True, 'action': 'added_to_wishlist',
|
||||||
|
'message': (f'Deleted preview clip and re-wishlisted "{track_name}" for full download'
|
||||||
|
if deleted_file else
|
||||||
|
f'Re-wishlisted "{track_name}" (preview file already gone)')}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Preview-clip fix failed for track %s: %s", entity_id, e)
|
||||||
|
return {'success': False, 'error': str(e)}
|
||||||
|
finally:
|
||||||
|
if conn:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
def _fix_orphan_file(self, entity_type, entity_id, file_path, details):
|
def _fix_orphan_file(self, entity_type, entity_id, file_path, details):
|
||||||
"""Handle an orphan file — move to staging or delete based on user choice.
|
"""Handle an orphan file — move to staging or delete based on user choice.
|
||||||
|
|
||||||
|
|
@ -3260,6 +3373,14 @@ class RepairWorker:
|
||||||
return {'success': False, 'error': f'Source file not found: {file_path}'}
|
return {'success': False, 'error': f'Source file not found: {file_path}'}
|
||||||
|
|
||||||
out_path = os.path.splitext(resolved)[0] + out_ext
|
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):
|
if os.path.exists(out_path):
|
||||||
return {'success': True, 'action': 'already_exists',
|
return {'success': True, 'action': 'already_exists',
|
||||||
'message': f'{quality_label} copy already exists'}
|
'message': f'{quality_label} copy already exists'}
|
||||||
|
|
@ -3419,7 +3540,7 @@ class RepairWorker:
|
||||||
'incomplete_album', 'path_mismatch',
|
'incomplete_album', 'path_mismatch',
|
||||||
'missing_lossy_copy', 'missing_replaygain', 'empty_folder',
|
'missing_lossy_copy', 'missing_replaygain', 'empty_folder',
|
||||||
'missing_discography_track', 'acoustid_mismatch',
|
'missing_discography_track', 'acoustid_mismatch',
|
||||||
'quality_upgrade')
|
'quality_upgrade', 'short_preview_track')
|
||||||
placeholders = ','.join(['?'] * len(fixable_types))
|
placeholders = ','.join(['?'] * len(fixable_types))
|
||||||
where_parts = [f"finding_type IN ({placeholders})", "status = 'pending'"]
|
where_parts = [f"finding_type IN ({placeholders})", "status = 'pending'"]
|
||||||
params = list(fixable_types)
|
params = list(fixable_types)
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,61 @@ from core.metadata.cache import get_metadata_cache
|
||||||
|
|
||||||
logger = get_logger("spotify_client")
|
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:
|
def _upgrade_spotify_image_url(url: str) -> str:
|
||||||
"""Upgrade a Spotify CDN image URL to the highest available resolution.
|
"""Upgrade a Spotify CDN image URL to the highest available resolution.
|
||||||
|
|
||||||
|
|
@ -697,7 +752,7 @@ class SpotifyClient:
|
||||||
self._setup_client()
|
self._setup_client()
|
||||||
|
|
||||||
def _setup_client(self):
|
def _setup_client(self):
|
||||||
config = config_manager.get_spotify_config()
|
config = normalize_spotify_oauth_config(config_manager.get_spotify_config())
|
||||||
|
|
||||||
if not config.get('client_id') or not config.get('client_secret'):
|
if not config.get('client_id') or not config.get('client_secret'):
|
||||||
logger.warning("Spotify credentials not configured")
|
logger.warning("Spotify credentials not configured")
|
||||||
|
|
@ -716,7 +771,7 @@ class SpotifyClient:
|
||||||
client_id=config['client_id'],
|
client_id=config['client_id'],
|
||||||
client_secret=config['client_secret'],
|
client_secret=config['client_secret'],
|
||||||
redirect_uri=config.get('redirect_uri', "http://127.0.0.1:8888/callback"),
|
redirect_uri=config.get('redirect_uri', "http://127.0.0.1:8888/callback"),
|
||||||
scope="user-library-read user-read-private playlist-read-private playlist-read-collaborative user-read-email user-follow-read",
|
scope=SPOTIFY_OAUTH_SCOPE,
|
||||||
cache_handler=DatabaseTokenCache(config_manager)
|
cache_handler=DatabaseTokenCache(config_manager)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -751,6 +806,16 @@ class SpotifyClient:
|
||||||
self._auth_cached_result = None
|
self._auth_cached_result = None
|
||||||
self._auth_cache_time = 0
|
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:
|
def is_spotify_authenticated(self) -> bool:
|
||||||
"""Check if Spotify client is specifically authenticated (not just iTunes fallback).
|
"""Check if Spotify client is specifically authenticated (not just iTunes fallback).
|
||||||
Results are cached for 60 seconds to avoid excessive API calls.
|
Results are cached for 60 seconds to avoid excessive API calls.
|
||||||
|
|
@ -826,6 +891,26 @@ class SpotifyClient:
|
||||||
logger.debug("publish_spotify_status cache hit: %s", e)
|
logger.debug("publish_spotify_status cache hit: %s", e)
|
||||||
return self._auth_cached_result
|
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.
|
# Cache miss — make API call outside the lock.
|
||||||
# Safety: if there's no cached token, return False immediately.
|
# Safety: if there's no cached token, return False immediately.
|
||||||
# Without this guard, spotipy's auth_manager will try to start an interactive
|
# Without this guard, spotipy's auth_manager will try to start an interactive
|
||||||
|
|
@ -857,7 +942,8 @@ class SpotifyClient:
|
||||||
# Use a dedicated probe client (retries=0) so a 429 here propagates
|
# Use a dedicated probe client (retries=0) so a 429 here propagates
|
||||||
# immediately and we can detect long Retry-After bans.
|
# immediately and we can detect long Retry-After bans.
|
||||||
try:
|
try:
|
||||||
probe = spotipy.Spotify(auth_manager=self.sp.auth_manager, retries=0)
|
probe = spotipy.Spotify(
|
||||||
|
auth_manager=self.sp.auth_manager, retries=0, requests_timeout=15)
|
||||||
probe.current_user()
|
probe.current_user()
|
||||||
result = True
|
result = True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -1108,6 +1194,69 @@ class SpotifyClient:
|
||||||
return playlists
|
return playlists
|
||||||
return []
|
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
|
@rate_limited
|
||||||
def get_saved_tracks_count(self) -> int:
|
def get_saved_tracks_count(self) -> int:
|
||||||
"""Get the total count of user's saved/liked songs without fetching all tracks"""
|
"""Get the total count of user's saved/liked songs without fetching all tracks"""
|
||||||
|
|
|
||||||
|
|
@ -520,8 +520,18 @@ class TidalClient:
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def is_authenticated(self):
|
def is_authenticated(self) -> bool:
|
||||||
"""Check if client is authenticated, refreshing expired tokens if possible"""
|
"""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:
|
if self.access_token and time.time() < self.token_expires_at:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -134,6 +134,7 @@ class TidalDownloadClient(DownloadSourcePlugin):
|
||||||
|
|
||||||
self._device_auth_future = None
|
self._device_auth_future = None
|
||||||
self._device_auth_link = None
|
self._device_auth_link = None
|
||||||
|
self._boot_session_tokens: Optional[dict] = None
|
||||||
|
|
||||||
# Engine reference is populated by set_engine() at registration
|
# Engine reference is populated by set_engine() at registration
|
||||||
# time. Until then dispatch returns None — orchestrator wires
|
# time. Until then dispatch returns None — orchestrator wires
|
||||||
|
|
@ -163,6 +164,14 @@ class TidalDownloadClient(DownloadSourcePlugin):
|
||||||
expiry_time = saved.get('expiry_time', 0)
|
expiry_time = saved.get('expiry_time', 0)
|
||||||
|
|
||||||
if token_type and access_token:
|
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:
|
try:
|
||||||
expiry_dt = datetime.fromtimestamp(expiry_time, tz=timezone.utc) if expiry_time else None
|
expiry_dt = datetime.fromtimestamp(expiry_time, tz=timezone.utc) if expiry_time else None
|
||||||
|
|
||||||
|
|
@ -181,6 +190,39 @@ class TidalDownloadClient(DownloadSourcePlugin):
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Could not restore Tidal session: {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):
|
def _save_session(self):
|
||||||
if not self.session:
|
if not self.session:
|
||||||
return
|
return
|
||||||
|
|
@ -192,6 +234,14 @@ class TidalDownloadClient(DownloadSourcePlugin):
|
||||||
})
|
})
|
||||||
|
|
||||||
def is_authenticated(self) -> bool:
|
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:
|
if not self.session:
|
||||||
return False
|
return False
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
40
core/ui_appearance.py
Normal file
40
core/ui_appearance.py
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
"""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']
|
||||||
|
|
@ -185,7 +185,11 @@ def process_watchlist_scan_automatically(automation_id=None, profile_id=None, de
|
||||||
'results': [],
|
'results': [],
|
||||||
'summary': {},
|
'summary': {},
|
||||||
'error': None,
|
'error': None,
|
||||||
'cancel_requested': False
|
'cancel_requested': False,
|
||||||
|
# #933: stamp these so this scan lands in the History modal too —
|
||||||
|
# the scanner fills scan_track_events; persist_scan_run reads both.
|
||||||
|
'scan_run_id': datetime.now().strftime('%Y%m%d-%H%M%S'),
|
||||||
|
'scan_track_events': [],
|
||||||
}
|
}
|
||||||
|
|
||||||
scan_results = []
|
scan_results = []
|
||||||
|
|
@ -294,6 +298,17 @@ def process_watchlist_scan_automatically(automation_id=None, profile_id=None, de
|
||||||
total_added_to_wishlist = deps.watchlist_scan_state.get('summary', {}).get('tracks_added_to_wishlist', 0)
|
total_added_to_wishlist = deps.watchlist_scan_state.get('summary', {}).get('tracks_added_to_wishlist', 0)
|
||||||
logger.warning("Automatic watchlist scan cancelled — skipping post-scan steps")
|
logger.warning("Automatic watchlist scan cancelled — skipping post-scan steps")
|
||||||
|
|
||||||
|
# #933: record this run in the History ledger — same helper the manual
|
||||||
|
# scan uses, so scheduled scans show up alongside manual ones.
|
||||||
|
try:
|
||||||
|
from core.watchlist.scan_history import persist_scan_run
|
||||||
|
persist_scan_run(
|
||||||
|
database, deps.watchlist_scan_state,
|
||||||
|
profile_id=profile_id, was_cancelled=was_cancelled,
|
||||||
|
)
|
||||||
|
except Exception as _hist_err:
|
||||||
|
logger.error(f"Failed to persist watchlist scan run: {_hist_err}")
|
||||||
|
|
||||||
# Post-scan steps — skip if cancelled
|
# Post-scan steps — skip if cancelled
|
||||||
if not was_cancelled:
|
if not was_cancelled:
|
||||||
# Populate discovery pool from similar artists (per-profile)
|
# Populate discovery pool from similar artists (per-profile)
|
||||||
|
|
|
||||||
51
core/watchlist/scan_history.py
Normal file
51
core/watchlist/scan_history.py
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
"""Persist a finished watchlist scan to the History ledger (#831 / #933).
|
||||||
|
|
||||||
|
Both the manual scan (``web_server.start_watchlist_scan``) and the automatic
|
||||||
|
scan (``core.watchlist.auto_scan.process_watchlist_scan_automatically``) finish
|
||||||
|
with the same ``watchlist_scan_state`` shape, but only the manual path used to
|
||||||
|
record a history row — so scheduled/nightly scans never showed up in the
|
||||||
|
History modal (#933). This single helper is the shared seam: both paths call it,
|
||||||
|
so they can't drift apart again.
|
||||||
|
|
||||||
|
Pure except for the one ``database.save_watchlist_scan_run`` call — the field
|
||||||
|
extraction is unit-testable with a fake database.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
|
|
||||||
|
def _iso(value: Any) -> Optional[str]:
|
||||||
|
"""ISO-format a datetime; pass through an already-stringified timestamp."""
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
return value.isoformat() if hasattr(value, 'isoformat') else str(value)
|
||||||
|
|
||||||
|
|
||||||
|
def persist_scan_run(database: Any, state: Dict[str, Any], *,
|
||||||
|
profile_id: Any, was_cancelled: bool) -> bool:
|
||||||
|
"""Record one watchlist scan run + its track ledger from ``state``.
|
||||||
|
|
||||||
|
Reads the counts/timestamps/ledger off the live ``watchlist_scan_state`` the
|
||||||
|
scanner just finished writing, and writes a single history row. ``run_id``
|
||||||
|
comes from ``state['scan_run_id']`` (both paths stamp it); a timestamp
|
||||||
|
fallback keeps it from ever colliding if that's somehow missing. Returns the
|
||||||
|
DB call's truthiness; callers wrap in their own try/except so a history-write
|
||||||
|
failure never breaks the scan.
|
||||||
|
"""
|
||||||
|
summary = state.get('summary') or {}
|
||||||
|
run_id = state.get('scan_run_id') or datetime.now().strftime('%Y%m%d-%H%M%S')
|
||||||
|
return database.save_watchlist_scan_run(
|
||||||
|
run_id=run_id,
|
||||||
|
profile_id=profile_id if profile_id else 1,
|
||||||
|
status='cancelled' if was_cancelled else 'completed',
|
||||||
|
started_at=_iso(state.get('started_at')),
|
||||||
|
completed_at=_iso(state.get('completed_at')) or datetime.now().isoformat(),
|
||||||
|
total_artists=summary.get('total_artists', state.get('total_artists', 0)),
|
||||||
|
artists_scanned=summary.get('successful_scans', 0),
|
||||||
|
tracks_found=state.get('tracks_found_this_scan', 0),
|
||||||
|
tracks_added=state.get('tracks_added_this_scan', 0),
|
||||||
|
track_events=state.get('scan_track_events') or [],
|
||||||
|
)
|
||||||
|
|
@ -328,6 +328,22 @@ _ALBUM_QUALIFIER_RE = re.compile(
|
||||||
re.IGNORECASE,
|
re.IGNORECASE,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# A trailing "- ..." clause is stripped ONLY when EVERY token in it is an edition/format
|
||||||
|
# qualifier (+ connectors / a year-ordinal). So "- Single", "- Acoustic Version", "- 2011
|
||||||
|
# Remaster" collapse to the base name, but a real distinguishing subtitle ("- Nos vies en
|
||||||
|
# Lumière", "- Live in Berlin") is kept — the bug was a blanket "- anything$" strip that
|
||||||
|
# erased subtitles and fused different editions (Sokhi: Expedition 33 OST vs Bonus Edition).
|
||||||
|
_DASH_QUALIFIER_WORD = (
|
||||||
|
r'live|acoustic|electric|instrumental|unplugged|mono|stereo|demos?|reissue|'
|
||||||
|
r'remix(?:es)?|edit(?:ed)?|radio|single|ep|lp|version|mix(?:es)?|sessions?|bootleg|'
|
||||||
|
r'covers?|original|redux|deluxe|expanded|remaster(?:ed)?|anniversary|special|'
|
||||||
|
r'edition|bonus|extended|explicit|clean|soundtrack|ost|score'
|
||||||
|
)
|
||||||
|
_TRAILING_DASH_QUALIFIER_RE = re.compile(
|
||||||
|
r'\s*-\s*(?:(?:' + _DASH_QUALIFIER_WORD + r'|the|a|and|&|\+|\d+(?:st|nd|rd|th)?)\b[\s\-]*)+$',
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _normalize_album_for_match(name: str) -> str:
|
def _normalize_album_for_match(name: str) -> str:
|
||||||
"""Return a canonical form of an album name suitable for fuzzy comparison.
|
"""Return a canonical form of an album name suitable for fuzzy comparison.
|
||||||
|
|
@ -347,8 +363,9 @@ def _normalize_album_for_match(name: str) -> str:
|
||||||
# they're almost always edition or commentary noise, not part of the
|
# they're almost always edition or commentary noise, not part of the
|
||||||
# album's identifying name.
|
# album's identifying name.
|
||||||
cleaned = re.sub(r'\s*[\(\[][^\)\]]*[\)\]]\s*', ' ', cleaned)
|
cleaned = re.sub(r'\s*[\(\[][^\)\]]*[\)\]]\s*', ' ', cleaned)
|
||||||
# Trailing dash-clauses ("Album - Remastered", "Album - Live")
|
# Trailing dash-clause, but ONLY when it's entirely edition/format qualifiers — a real
|
||||||
cleaned = re.sub(r'\s*-\s*[^-]+$', '', cleaned)
|
# subtitle is preserved (see _TRAILING_DASH_QUALIFIER_RE).
|
||||||
|
cleaned = _TRAILING_DASH_QUALIFIER_RE.sub(' ', cleaned)
|
||||||
cleaned = re.sub(r'[^a-z0-9 ]+', ' ', cleaned.lower())
|
cleaned = re.sub(r'[^a-z0-9 ]+', ' ', cleaned.lower())
|
||||||
cleaned = re.sub(r'\s+', ' ', cleaned).strip()
|
cleaned = re.sub(r'\s+', ' ', cleaned).strip()
|
||||||
return cleaned
|
return cleaned
|
||||||
|
|
@ -382,7 +399,7 @@ def _extract_volume_marker(normalized_name: str):
|
||||||
return last.group(1) or last.group(2)
|
return last.group(1) or last.group(2)
|
||||||
|
|
||||||
|
|
||||||
def _albums_likely_match(spotify_album: str, lib_album: str, threshold: float = 0.6) -> bool:
|
def _albums_likely_match(spotify_album: str, lib_album: str, threshold: float = 0.85) -> bool:
|
||||||
"""Return True when two album names plausibly identify the same release.
|
"""Return True when two album names plausibly identify the same release.
|
||||||
|
|
||||||
Designed to swallow naming drift between metadata sources and the
|
Designed to swallow naming drift between metadata sources and the
|
||||||
|
|
@ -406,11 +423,11 @@ def _albums_likely_match(spotify_album: str, lib_album: str, threshold: float =
|
||||||
return False
|
return False
|
||||||
if norm_a == norm_b:
|
if norm_a == norm_b:
|
||||||
return True
|
return True
|
||||||
# After normalization the shorter name often becomes a prefix /
|
# No loose substring shortcut: after qualifier-stripping, a short name being a
|
||||||
# substring of the longer one ("napoleon dynamite" ⊂ "napoleon
|
# prefix of a longer one is usually a DIFFERENT edition carrying a real subtitle
|
||||||
# dynamite music from the motion picture" before stripping).
|
# ("clair obscur expedition 33" ⊂ "clair obscur expedition 33 nos vies en lumiere"),
|
||||||
if norm_a in norm_b or norm_b in norm_a:
|
# not naming drift. Genuine drift collapses to an EXACT match above; everything else
|
||||||
return True
|
# must clear a high overall-similarity bar.
|
||||||
return SequenceMatcher(None, norm_a, norm_b).ratio() >= threshold
|
return SequenceMatcher(None, norm_a, norm_b).ratio() >= threshold
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,8 @@ import threading
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any, Callable, Dict
|
from typing import Any, Callable, Dict
|
||||||
|
|
||||||
|
from core.metadata import normalize_image_url
|
||||||
|
from core.metadata.artwork import is_internal_image_host
|
||||||
from core.wishlist.reporting import build_wishlist_stats_payload
|
from core.wishlist.reporting import build_wishlist_stats_payload
|
||||||
from core.wishlist.selection import prepare_wishlist_tracks_for_display
|
from core.wishlist.selection import prepare_wishlist_tracks_for_display
|
||||||
from core.wishlist.service import get_wishlist_service
|
from core.wishlist.service import get_wishlist_service
|
||||||
|
|
@ -210,6 +212,74 @@ def set_wishlist_cycle(runtime: WishlistRouteRuntime, cycle: str) -> tuple[Dict[
|
||||||
return {"error": str(exc)}, 500
|
return {"error": str(exc)}, 500
|
||||||
|
|
||||||
|
|
||||||
|
def _needs_image_fix(url: str | None) -> bool:
|
||||||
|
"""True when an image URL won't render in the browser as-is — a media-server RELATIVE
|
||||||
|
path (/library/.., /Items/.., /rest/..) or an internal/localhost host. Spotify/iTunes CDN
|
||||||
|
URLs render directly and are left untouched, so already-working items never change."""
|
||||||
|
if not url or not isinstance(url, str):
|
||||||
|
return False
|
||||||
|
if url.startswith('/') and not url.startswith('//'):
|
||||||
|
return True
|
||||||
|
if url.startswith('http://') or url.startswith('https://'):
|
||||||
|
return is_internal_image_host(url)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _enrich_wishlist_images(tracks: list[dict[str, Any]], db: Any) -> dict[str, str]:
|
||||||
|
"""Make wishlist art browser-renderable using the library data we already have.
|
||||||
|
|
||||||
|
The library stores album/artist art as media-server RELATIVE paths (e.g. Plex
|
||||||
|
/library/metadata/..) which don't render in a browser <img>. Normal wishlist items carry
|
||||||
|
Spotify CDN URLs (fine), but library-sourced items — re-downloads and preview-clip
|
||||||
|
re-fetches — carry the relative path, so their art comes up blank. We fix two things here,
|
||||||
|
on read, so it also repairs items already sitting in the wishlist:
|
||||||
|
|
||||||
|
1. Normalize each track's album.images[*].url that needs it (relative/internal only —
|
||||||
|
CDN URLs are left as-is to avoid regressing items that already render).
|
||||||
|
2. Build an artist-name -> normalized library photo map so the nebula can show artist
|
||||||
|
photos for non-watchlist artists (it otherwise only has watchlisted-artist photos).
|
||||||
|
"""
|
||||||
|
artist_names: set[str] = set()
|
||||||
|
for track in tracks:
|
||||||
|
sd = track.get('spotify_data')
|
||||||
|
if isinstance(sd, dict):
|
||||||
|
album = sd.get('album')
|
||||||
|
if isinstance(album, dict):
|
||||||
|
images = album.get('images')
|
||||||
|
if isinstance(images, list):
|
||||||
|
for img in images:
|
||||||
|
if isinstance(img, dict) and _needs_image_fix(img.get('url')):
|
||||||
|
fixed = normalize_image_url(img['url'])
|
||||||
|
if fixed:
|
||||||
|
img['url'] = fixed
|
||||||
|
name = track.get('artist_name')
|
||||||
|
if name and name != 'Unknown Artist':
|
||||||
|
artist_names.add(name)
|
||||||
|
|
||||||
|
artist_images: dict[str, str] = {}
|
||||||
|
if not artist_names:
|
||||||
|
return artist_images
|
||||||
|
try:
|
||||||
|
conn = db._get_connection()
|
||||||
|
try:
|
||||||
|
placeholders = ','.join('?' * len(artist_names))
|
||||||
|
rows = conn.execute(
|
||||||
|
f"SELECT name, thumb_url FROM artists "
|
||||||
|
f"WHERE name IN ({placeholders}) AND thumb_url IS NOT NULL AND thumb_url != ''",
|
||||||
|
list(artist_names),
|
||||||
|
).fetchall()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
for row in rows:
|
||||||
|
name, thumb = row[0], row[1]
|
||||||
|
fixed = normalize_image_url(thumb) if _needs_image_fix(thumb) else thumb
|
||||||
|
if name and fixed:
|
||||||
|
artist_images[name.lower()] = fixed
|
||||||
|
except Exception as exc: # noqa: BLE001 — art is cosmetic, never fail the tracks endpoint
|
||||||
|
logger.debug("Could not build wishlist artist-image map: %s", exc)
|
||||||
|
return artist_images
|
||||||
|
|
||||||
|
|
||||||
def get_wishlist_tracks(
|
def get_wishlist_tracks(
|
||||||
runtime: WishlistRouteRuntime,
|
runtime: WishlistRouteRuntime,
|
||||||
*,
|
*,
|
||||||
|
|
@ -242,6 +312,9 @@ def get_wishlist_tracks(
|
||||||
prepared["duplicates_found"],
|
prepared["duplicates_found"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Make library-sourced art renderable + supply artist photos (see _enrich_wishlist_images).
|
||||||
|
artist_images = _enrich_wishlist_images(prepared["tracks"], db)
|
||||||
|
|
||||||
if category:
|
if category:
|
||||||
runtime.logger.info(
|
runtime.logger.info(
|
||||||
"Wishlist filter: %s/%s tracks in '%s' category (limit: %s)",
|
"Wishlist filter: %s/%s tracks in '%s' category (limit: %s)",
|
||||||
|
|
@ -250,9 +323,18 @@ def get_wishlist_tracks(
|
||||||
category,
|
category,
|
||||||
limit or "none",
|
limit or "none",
|
||||||
)
|
)
|
||||||
return {"tracks": prepared["tracks"], "category": category, "total": prepared["total"]}, 200
|
return {
|
||||||
|
"tracks": prepared["tracks"],
|
||||||
|
"category": category,
|
||||||
|
"total": prepared["total"],
|
||||||
|
"artist_images": artist_images,
|
||||||
|
}, 200
|
||||||
|
|
||||||
return {"tracks": prepared["tracks"], "total": prepared["total"]}, 200
|
return {
|
||||||
|
"tracks": prepared["tracks"],
|
||||||
|
"total": prepared["total"],
|
||||||
|
"artist_images": artist_images,
|
||||||
|
}, 200
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
runtime.logger.error("Error getting wishlist tracks: %s", exc)
|
runtime.logger.error("Error getting wishlist tracks: %s", exc)
|
||||||
return {"error": str(exc)}, 500
|
return {"error": str(exc)}, 500
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,20 @@ from core.download_plugins.types import SearchResult, TrackResult, AlbumResult,
|
||||||
logger = get_logger("youtube_client")
|
logger = get_logger("youtube_client")
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_cookie_opts() -> dict:
|
||||||
|
"""yt-dlp cookie options from Settings → YouTube: either a browser store OR a
|
||||||
|
pasted cookies.txt. The 'Paste cookies.txt' dropdown value is the sentinel
|
||||||
|
'custom' — which must become a yt-dlp ``cookiefile`` pointing at the saved file,
|
||||||
|
NOT be passed through as a browser name (yt-dlp rejects: 'unsupported browser:
|
||||||
|
custom'). Delegates to the shared, tested precedence in core.youtube_cookies."""
|
||||||
|
from config.settings import config_manager
|
||||||
|
from core.youtube_cookies import build_youtube_cookie_opts
|
||||||
|
mode = config_manager.get('youtube.cookies_browser', '')
|
||||||
|
cookiefile = config_manager.get('youtube.cookies_file', '')
|
||||||
|
exists = bool(cookiefile) and os.path.exists(cookiefile)
|
||||||
|
return build_youtube_cookie_opts(mode, cookiefile, cookiefile_exists=exists)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class YouTubeSearchResult:
|
class YouTubeSearchResult:
|
||||||
"""YouTube search result with metadata parsing"""
|
"""YouTube search result with metadata parsing"""
|
||||||
|
|
@ -220,11 +234,9 @@ class YouTubeClient(DownloadSourcePlugin):
|
||||||
'age_limit': None, # Don't skip age-restricted
|
'age_limit': None, # Don't skip age-restricted
|
||||||
}
|
}
|
||||||
|
|
||||||
# Cookie support — use browser cookies for YouTube auth
|
# Cookie support — a logged-in browser store OR a pasted cookies.txt
|
||||||
from config.settings import config_manager
|
# (the 'custom' paste mode resolves to a cookiefile, not a browser name).
|
||||||
cookies_browser = config_manager.get('youtube.cookies_browser', '')
|
self.download_opts.update(_resolve_cookie_opts())
|
||||||
if cookies_browser:
|
|
||||||
self.download_opts['cookiesfrombrowser'] = (cookies_browser,)
|
|
||||||
|
|
||||||
# Track current download progress (mirrors Soulseek transfer tracking)
|
# Track current download progress (mirrors Soulseek transfer tracking)
|
||||||
self.current_download_id: Optional[str] = None
|
self.current_download_id: Optional[str] = None
|
||||||
|
|
@ -309,11 +321,12 @@ class YouTubeClient(DownloadSourcePlugin):
|
||||||
"""Reload YouTube settings from config (called when settings are saved)."""
|
"""Reload YouTube settings from config (called when settings are saved)."""
|
||||||
from config.settings import config_manager
|
from config.settings import config_manager
|
||||||
self._download_delay = config_manager.get('youtube.download_delay', 3)
|
self._download_delay = config_manager.get('youtube.download_delay', 3)
|
||||||
cookies_browser = config_manager.get('youtube.cookies_browser', '')
|
# Clear both cookie sources, then re-apply from current settings (browser
|
||||||
if cookies_browser:
|
# store or pasted cookies.txt) so a mode switch doesn't leave a stale arg.
|
||||||
self.download_opts['cookiesfrombrowser'] = (cookies_browser,)
|
self.download_opts.pop('cookiesfrombrowser', None)
|
||||||
elif 'cookiesfrombrowser' in self.download_opts:
|
self.download_opts.pop('cookiefile', None)
|
||||||
del self.download_opts['cookiesfrombrowser']
|
_cookie_opts = _resolve_cookie_opts()
|
||||||
|
self.download_opts.update(_cookie_opts)
|
||||||
|
|
||||||
# Reload download path
|
# Reload download path
|
||||||
new_path = Path(config_manager.get('soulseek.download_path', './downloads'))
|
new_path = Path(config_manager.get('soulseek.download_path', './downloads'))
|
||||||
|
|
@ -323,7 +336,7 @@ class YouTubeClient(DownloadSourcePlugin):
|
||||||
self.download_opts['outtmpl'] = str(self.download_path / '%(title)s.%(ext)s')
|
self.download_opts['outtmpl'] = str(self.download_path / '%(title)s.%(ext)s')
|
||||||
logger.info(f"YouTube download path updated to: {self.download_path}")
|
logger.info(f"YouTube download path updated to: {self.download_path}")
|
||||||
|
|
||||||
logger.info(f"YouTube settings reloaded (delay={self._download_delay}s, cookies={'enabled' if cookies_browser else 'disabled'})")
|
logger.info(f"YouTube settings reloaded (delay={self._download_delay}s, cookies={'enabled' if _cookie_opts else 'disabled'})")
|
||||||
|
|
||||||
async def check_connection(self) -> bool:
|
async def check_connection(self) -> bool:
|
||||||
"""
|
"""
|
||||||
|
|
@ -728,7 +741,6 @@ class YouTubeClient(DownloadSourcePlugin):
|
||||||
loop = asyncio.get_event_loop()
|
loop = asyncio.get_event_loop()
|
||||||
|
|
||||||
def _search():
|
def _search():
|
||||||
from config.settings import config_manager
|
|
||||||
ydl_opts = {
|
ydl_opts = {
|
||||||
'quiet': True,
|
'quiet': True,
|
||||||
'no_warnings': True,
|
'no_warnings': True,
|
||||||
|
|
@ -736,9 +748,7 @@ class YouTubeClient(DownloadSourcePlugin):
|
||||||
'default_search': 'ytsearch',
|
'default_search': 'ytsearch',
|
||||||
'user_agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
'user_agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
||||||
}
|
}
|
||||||
cookies_browser = config_manager.get('youtube.cookies_browser', '')
|
ydl_opts.update(_resolve_cookie_opts())
|
||||||
if cookies_browser:
|
|
||||||
ydl_opts['cookiesfrombrowser'] = (cookies_browser,)
|
|
||||||
|
|
||||||
search_query = self._escape_ytsearch_query(query)
|
search_query = self._escape_ytsearch_query(query)
|
||||||
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||||||
|
|
@ -806,7 +816,6 @@ class YouTubeClient(DownloadSourcePlugin):
|
||||||
loop = asyncio.get_event_loop()
|
loop = asyncio.get_event_loop()
|
||||||
|
|
||||||
def _search():
|
def _search():
|
||||||
from config.settings import config_manager
|
|
||||||
ydl_opts = {
|
ydl_opts = {
|
||||||
'quiet': True,
|
'quiet': True,
|
||||||
'no_warnings': True,
|
'no_warnings': True,
|
||||||
|
|
@ -816,9 +825,7 @@ class YouTubeClient(DownloadSourcePlugin):
|
||||||
}
|
}
|
||||||
|
|
||||||
# Add cookie support for search (avoids bot detection)
|
# Add cookie support for search (avoids bot detection)
|
||||||
cookies_browser = config_manager.get('youtube.cookies_browser', '')
|
ydl_opts.update(_resolve_cookie_opts())
|
||||||
if cookies_browser:
|
|
||||||
ydl_opts['cookiesfrombrowser'] = (cookies_browser,)
|
|
||||||
|
|
||||||
search_query = self._escape_ytsearch_query(query)
|
search_query = self._escape_ytsearch_query(query)
|
||||||
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||||||
|
|
@ -1087,10 +1094,12 @@ class YouTubeClient(DownloadSourcePlugin):
|
||||||
|
|
||||||
# On retry, try different strategies
|
# On retry, try different strategies
|
||||||
if attempt == 1:
|
if attempt == 1:
|
||||||
# Drop browser cookies — authenticated sessions sometimes get restricted formats
|
# Drop cookies — authenticated sessions (browser store OR a
|
||||||
if 'cookiesfrombrowser' in download_opts:
|
# pasted cookies.txt) sometimes get restricted formats.
|
||||||
logger.info(f"Retry {attempt + 1}/{max_retries} without browser cookies")
|
if 'cookiesfrombrowser' in download_opts or 'cookiefile' in download_opts:
|
||||||
|
logger.info(f"Retry {attempt + 1}/{max_retries} without cookies")
|
||||||
download_opts.pop('cookiesfrombrowser', None)
|
download_opts.pop('cookiesfrombrowser', None)
|
||||||
|
download_opts.pop('cookiefile', None)
|
||||||
else:
|
else:
|
||||||
logger.info(f"Retry {attempt + 1}/{max_retries} with web_creator client")
|
logger.info(f"Retry {attempt + 1}/{max_retries} with web_creator client")
|
||||||
download_opts['extractor_args'] = {
|
download_opts['extractor_args'] = {
|
||||||
|
|
@ -1100,6 +1109,7 @@ class YouTubeClient(DownloadSourcePlugin):
|
||||||
logger.info(f"Retry {attempt + 1}/{max_retries} with 'best' format (video fallback)")
|
logger.info(f"Retry {attempt + 1}/{max_retries} with 'best' format (video fallback)")
|
||||||
download_opts['format'] = 'best'
|
download_opts['format'] = 'best'
|
||||||
download_opts.pop('cookiesfrombrowser', None)
|
download_opts.pop('cookiesfrombrowser', None)
|
||||||
|
download_opts.pop('cookiefile', None)
|
||||||
download_opts.pop('extractor_args', None)
|
download_opts.pop('extractor_args', None)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1160,8 +1170,6 @@ class YouTubeClient(DownloadSourcePlugin):
|
||||||
Final file path if successful, None otherwise
|
Final file path if successful, None otherwise
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
from config.settings import config_manager
|
|
||||||
|
|
||||||
def _progress_hook(d):
|
def _progress_hook(d):
|
||||||
if progress_callback and d.get('status') == 'downloading':
|
if progress_callback and d.get('status') == 'downloading':
|
||||||
total = d.get('total_bytes') or d.get('total_bytes_estimate') or 0
|
total = d.get('total_bytes') or d.get('total_bytes_estimate') or 0
|
||||||
|
|
@ -1180,9 +1188,7 @@ class YouTubeClient(DownloadSourcePlugin):
|
||||||
'user_agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
'user_agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
||||||
}
|
}
|
||||||
|
|
||||||
cookies_browser = config_manager.get('youtube.cookies_browser', '')
|
download_opts.update(_resolve_cookie_opts())
|
||||||
if cookies_browser:
|
|
||||||
download_opts['cookiesfrombrowser'] = (cookies_browser,)
|
|
||||||
|
|
||||||
with yt_dlp.YoutubeDL(download_opts) as ydl:
|
with yt_dlp.YoutubeDL(download_opts) as ydl:
|
||||||
info = ydl.extract_info(video_url, download=True)
|
info = ydl.extract_info(video_url, download=True)
|
||||||
|
|
|
||||||
|
|
@ -1000,6 +1000,10 @@ class MusicDatabase:
|
||||||
|
|
||||||
self._init_manual_library_match_table()
|
self._init_manual_library_match_table()
|
||||||
self._backfill_mirrored_track_source_ids()
|
self._backfill_mirrored_track_source_ids()
|
||||||
|
# Self-heal the Unverified review queue: lift history rows stuck at
|
||||||
|
# 'unverified' whose file has since been verified (issue #934). Cheap,
|
||||||
|
# idempotent (only touches rows that need it), so it's safe every boot.
|
||||||
|
self.reconcile_unverified_history_from_tracks()
|
||||||
|
|
||||||
def _backfill_mirrored_track_source_ids(self) -> int:
|
def _backfill_mirrored_track_source_ids(self) -> int:
|
||||||
"""One-time, idempotent: assign a stable source_track_id to mirrored tracks
|
"""One-time, idempotent: assign a stable source_track_id to mirrored tracks
|
||||||
|
|
@ -13795,6 +13799,28 @@ class MusicDatabase:
|
||||||
logger.debug(f"Error deleting history rows: {e}")
|
logger.debug(f"Error deleting history rows: {e}")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
def clear_completed_download_history(self) -> int:
|
||||||
|
"""Delete the persisted completed-download history shown on the Downloads
|
||||||
|
page (every event_type='download' row). This also clears the verification
|
||||||
|
review queue, since those unverified/force_imported rows ARE download-history
|
||||||
|
rows — that's intended: 'Clear Completed' empties the list. It only removes
|
||||||
|
HISTORY rows; the actual files and their `tracks` entries are untouched, so
|
||||||
|
nothing in the library is lost — only the 'needs verification' review flags.
|
||||||
|
Returns the number of rows removed."""
|
||||||
|
conn = None
|
||||||
|
try:
|
||||||
|
conn = self._get_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("DELETE FROM library_history WHERE event_type = 'download'")
|
||||||
|
conn.commit()
|
||||||
|
return cursor.rowcount
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Error clearing completed download history: %s", e)
|
||||||
|
return 0
|
||||||
|
finally:
|
||||||
|
if conn:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
def delete_track_by_file_path(self, file_path):
|
def delete_track_by_file_path(self, file_path):
|
||||||
"""Delete a library track row whose stored path matches. Returns count."""
|
"""Delete a library track row whose stored path matches. Returns count."""
|
||||||
if not file_path:
|
if not file_path:
|
||||||
|
|
@ -13857,6 +13883,107 @@ class MusicDatabase:
|
||||||
logger.error("Error querying unverified library history: %s", e)
|
logger.error("Error querying unverified library history: %s", e)
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
def reconcile_unverified_history_from_tracks(self) -> int:
|
||||||
|
"""Heal library_history rows stuck at 'unverified' whose underlying file
|
||||||
|
has since been confirmed in the tracks table (AcoustID scan PASS or a
|
||||||
|
human decision). Matches by exact path AND basename — the same physical
|
||||||
|
file keeps its filename across path-form differences (relative vs
|
||||||
|
absolute, library moved/reorganized, different mount), which is why an
|
||||||
|
exact-path-only heal left thousands of already-verified files showing as
|
||||||
|
Unverified (issue #934).
|
||||||
|
|
||||||
|
A basename match is title-guarded: a shared track-number filename
|
||||||
|
("01 - Intro.flac") must NOT heal a different song. When both the history
|
||||||
|
row and the candidate track carry a title they have to agree
|
||||||
|
(alphanumeric-lowercase) — the same guard the AcoustID matcher uses. When
|
||||||
|
a title is missing on either side we can't tell which file the basename
|
||||||
|
refers to, so we only heal if that basename is unambiguous (a single
|
||||||
|
verified candidate). An exact-path match needs no guard.
|
||||||
|
|
||||||
|
Upgrade-only and non-destructive: it only lifts 'unverified' rows to the
|
||||||
|
confirmed status, never downgrades and never deletes. Returns the number
|
||||||
|
of rows healed. Genuinely-unverified rows and orphans (no matching
|
||||||
|
track) are left untouched.
|
||||||
|
"""
|
||||||
|
healed = 0
|
||||||
|
conn = None
|
||||||
|
try:
|
||||||
|
conn = self._get_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
def _norm(value):
|
||||||
|
return ''.join(ch for ch in str(value or '').lower() if ch.isalnum())
|
||||||
|
|
||||||
|
# Load the stuck rows first. Cheap early-out when nothing is stuck —
|
||||||
|
# and their paths/basenames scope the tracks scan below, so the
|
||||||
|
# lookup dicts stay proportional to the (small) review queue instead
|
||||||
|
# of the whole library.
|
||||||
|
cursor.execute(
|
||||||
|
"SELECT id, file_path, title FROM library_history "
|
||||||
|
"WHERE verification_status = 'unverified' "
|
||||||
|
"AND file_path IS NOT NULL AND file_path != ''")
|
||||||
|
stuck_rows = cursor.fetchall()
|
||||||
|
if not stuck_rows:
|
||||||
|
return 0
|
||||||
|
needed_paths = {fp for _, fp, _ in stuck_rows if fp}
|
||||||
|
needed_bases = {os.path.basename(fp) for _, fp, _ in stuck_rows if fp}
|
||||||
|
|
||||||
|
rank = {'verified': 1, 'human_verified': 2}
|
||||||
|
by_path = {} # exact path -> status (unambiguous; no title guard)
|
||||||
|
by_base = {} # basename -> list of (norm_title, status)
|
||||||
|
cursor.execute(
|
||||||
|
"SELECT file_path, verification_status, title FROM tracks "
|
||||||
|
"WHERE verification_status IN ('verified', 'human_verified') "
|
||||||
|
"AND file_path IS NOT NULL AND file_path != ''")
|
||||||
|
for fp, st, ttitle in cursor.fetchall():
|
||||||
|
if not fp:
|
||||||
|
continue
|
||||||
|
base = os.path.basename(fp)
|
||||||
|
# Skip verified tracks that can't possibly match a queued row.
|
||||||
|
if fp not in needed_paths and base not in needed_bases:
|
||||||
|
continue
|
||||||
|
if rank.get(st, 0) >= rank.get(by_path.get(fp), 0):
|
||||||
|
by_path[fp] = st
|
||||||
|
if base:
|
||||||
|
by_base.setdefault(base, []).append((_norm(ttitle), st))
|
||||||
|
|
||||||
|
updates = []
|
||||||
|
for rid, fp, rtitle in stuck_rows:
|
||||||
|
target = by_path.get(fp)
|
||||||
|
if not target:
|
||||||
|
want = _norm(rtitle)
|
||||||
|
candidates = by_base.get(os.path.basename(fp or ''), ())
|
||||||
|
best = 0
|
||||||
|
for ttitle, st in candidates:
|
||||||
|
if want and ttitle:
|
||||||
|
# Both titled: must agree.
|
||||||
|
if want != ttitle:
|
||||||
|
continue
|
||||||
|
elif len(candidates) > 1:
|
||||||
|
# Title missing on a side AND the basename collides
|
||||||
|
# across verified files — can't tell which one this
|
||||||
|
# row is, so don't risk healing the wrong song.
|
||||||
|
continue
|
||||||
|
if rank.get(st, 0) >= best:
|
||||||
|
best = rank.get(st, 0)
|
||||||
|
target = st
|
||||||
|
if target:
|
||||||
|
updates.append((target, rid))
|
||||||
|
for status, rid in updates:
|
||||||
|
cursor.execute(
|
||||||
|
"UPDATE library_history SET verification_status = ? WHERE id = ?",
|
||||||
|
(status, rid))
|
||||||
|
healed += 1
|
||||||
|
if healed:
|
||||||
|
conn.commit()
|
||||||
|
logger.info("Reconciled %d unverified history rows from tracks truth", healed)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Error reconciling unverified history: %s", e)
|
||||||
|
finally:
|
||||||
|
if conn:
|
||||||
|
conn.close()
|
||||||
|
return healed
|
||||||
|
|
||||||
def get_library_history_stats(self):
|
def get_library_history_stats(self):
|
||||||
"""Return counts per event_type and per download_source."""
|
"""Return counts per event_type and per download_source."""
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
|
|
@ -1,49 +1,21 @@
|
||||||
# soulsync 2.7.9 — `dev` → `main`
|
# soulsync 2.8.2 — `dev` → `main`
|
||||||
|
|
||||||
a big one. the headline is the new **best-quality download system + a real quality profile**, plus a much smarter **Discover** page, a new **Wing It Pool**, a redesigned **Auto-Sync** board, and a pile of reported fixes (multi-disc albums, playlist sync labels, the import-vs-quarantine race).
|
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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## what's new
|
## what's new
|
||||||
|
|
||||||
### best-quality downloads + a real quality profile
|
### 🎧 Spotify reliability
|
||||||
downloads are now driven by a **ranked-target quality profile** instead of a fixed preference. you order the formats you want (drag to reorder — FLAC 24/192 down to mp3, every format controllable, with "all lossless / all lossy" group shortcuts), and:
|
- **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.
|
||||||
- **best-quality search mode** pools candidates across *every* source per query and grabs the highest-quality copy that meets your profile — not just the first/fastest match. priority mode is still there, now with an opt-in **"rank-based download order"** toggle if you want quality-first ordering there too.
|
- **"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.
|
||||||
- each streaming source's download tier is derived from the one global profile, so you set it once.
|
- **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.
|
||||||
- AAC is an opt-in tier; old per-source Hi-Res preferences migrate into the profile automatically.
|
|
||||||
|
|
||||||
### quarantine, cleaned up + safer
|
### ⚡ Performance — the "slow after update" fix
|
||||||
- the quarantine view is **consolidated into the Downloads page** as a filter (no separate place to check), with real audio quality shown on the rows and approve/retry handled inline.
|
- **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.
|
||||||
- **AcoustID fail-closed mode** (opt-in): only import tracks that actually verify, so a wrong file never lands in your library.
|
- **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.
|
||||||
- **silence + truncated-download guards** catch mostly-silent preview files and downloads that are shorter than their container claims, before they import.
|
|
||||||
- a **library quality check** runs as a repair job and can flag files that are upgradeable to your preferred quality.
|
|
||||||
|
|
||||||
### Discover got a lot smarter
|
### 📥 Large-library imports no longer time out (#947 — thanks @ramonskie)
|
||||||
- **"Based On Your Listening"** — a new artist row, ranked from who you actually *play* the most (consensus + recency weighted), with a "because you listen to X, Y" reason on each card.
|
- 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.)
|
||||||
- **"Your Listening Mix"** — a playable track playlist built from those artists' top tracks. works on **any** metadata source (falls back to Deezer's public API), not just Spotify.
|
|
||||||
- **Fresh Tape** actually fills now — it was starving down to 5–10 tracks because future-dated albums ate the candidate budget.
|
|
||||||
- the **SoulSync Discovery** tab on the Sync page now lists *every* playlist kind (incl. the Listening Mix) so you can mirror + auto-sync them.
|
|
||||||
|
|
||||||
### Wing It Pool
|
enjoy 🎶
|
||||||
a new button next to **Discovery Pool** on the Mirrored Playlists tab. Wing It auto-matches tracks it couldn't match to metadata on a best-effort guess — those were invisible until now. the Wing It Pool opens to a two-card view (**guesses to review** + **resolved**) so you can verify or re-match what it guessed.
|
|
||||||
|
|
||||||
### Auto-Sync Manager redesign
|
|
||||||
the scheduling board no longer scrolls sideways through a wall of columns. intervals (hourly) and days (weekly) are now **horizontal lanes** — empty ones collapse, busy ones grow, and the scroll position holds when you add a playlist.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## fixes
|
|
||||||
|
|
||||||
- **multi-disc albums showed disc-2 tracks as "missing" / under disc 1 (#927)** — the library scan never read the disc number, so every track was stored as disc 1. it now captures the real disc from Jellyfin/Plex/Navidrome at scan time. *(re-scan your library once to backfill existing tracks.)*
|
|
||||||
- **playlists always said "Never Synced" (#925)** — auto-synced/mirrored playlists were only checked against the direct-sync status, never their auto-sync status. fixed (thanks @ramonskie).
|
|
||||||
- **tracks imported while quarantined / shown "completed" (#928)** — a race let both the browser poll and the download monitor post-process the same finished download. an atomic claim now ensures exactly one path handles it (thanks @nick2000713).
|
|
||||||
- **library card badges hijacked the click** — clicking the watchlist eye or a source badge on an artist card also opened the artist detail page (and the badge's own link). badges now do only their own thing.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## under the hood
|
|
||||||
- music automations page no longer shows video-app automations (they live in the shared engine DB).
|
|
||||||
- quality-settings tile tidied up — collapsible ⓘ help instead of walls of text, proper reset button, dropped the redundant per-source "quality is global" notes.
|
|
||||||
- download clients don't crash on init when the download path can't be created.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
|
||||||
|
|
@ -170,7 +170,12 @@ def test_file_not_found_after_retries_marks_failed(monkeypatch):
|
||||||
deps, rec = _build_deps()
|
deps, rec = _build_deps()
|
||||||
pp.run_post_processing_worker('t1', 'b1', deps)
|
pp.run_post_processing_worker('t1', 'b1', deps)
|
||||||
assert download_tasks['t1']['status'] == 'failed'
|
assert download_tasks['t1']['status'] == 'failed'
|
||||||
assert 'File not found on disk' in download_tasks['t1']['error_message']
|
# 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 ('on_complete', ('b1', 't1', False), {}) in rec.calls
|
assert ('on_complete', ('b1', 't1', False), {}) in rec.calls
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -396,3 +396,56 @@ def test_real_soulseek_path_still_basenamed(tmp_path):
|
||||||
str(downloads), r'shared\Billy Ocean\Very Best Of\01 - Suddenly.flac',
|
str(downloads), r'shared\Billy Ocean\Very Best Of\01 - Suddenly.flac',
|
||||||
)
|
)
|
||||||
assert found == str(target)
|
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)
|
||||||
|
|
|
||||||
|
|
@ -78,3 +78,156 @@ def test_payload_non_dict_or_empty():
|
||||||
assert q('tidal', None) is None
|
assert q('tidal', None) is None
|
||||||
assert q('tidal', {}) is None
|
assert q('tidal', {}) is None
|
||||||
assert q('qobuz', 'garbage') is None
|
assert q('qobuz', 'garbage') is None
|
||||||
|
|
||||||
|
|
||||||
|
# ── bubble the pasted-link track to the top (#932) ──
|
||||||
|
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
from core.downloads.track_link import linked_track_id, bubble_linked_track_first
|
||||||
|
|
||||||
|
|
||||||
|
def _result(track_id=None):
|
||||||
|
"""Mimic a TrackResult: NO top-level `id`, the source id lives in
|
||||||
|
_source_metadata['track_id'] (or absent entirely)."""
|
||||||
|
meta = {'source': 'qobuz', 'track_id': track_id} if track_id is not None else None
|
||||||
|
return SimpleNamespace(_source_metadata=meta, title='t')
|
||||||
|
|
||||||
|
|
||||||
|
def test_linked_track_id_reads_source_metadata():
|
||||||
|
assert linked_track_id(_result('296427754')) == '296427754'
|
||||||
|
|
||||||
|
|
||||||
|
def test_linked_track_id_empty_when_absent():
|
||||||
|
# the exact #932 trap: there is no top-level `id`, so getattr(t,'id') would miss.
|
||||||
|
r = _result()
|
||||||
|
assert not hasattr(r, 'id')
|
||||||
|
assert linked_track_id(r) == ''
|
||||||
|
|
||||||
|
|
||||||
|
def test_bubble_floats_exact_track_to_top():
|
||||||
|
fuzzy_a, exact, fuzzy_b = _result('111'), _result('296427754'), _result('222')
|
||||||
|
out = bubble_linked_track_first([fuzzy_a, exact, fuzzy_b], '296427754')
|
||||||
|
assert out[0] is exact # exact track surfaced first
|
||||||
|
assert out[1:] == [fuzzy_a, fuzzy_b] # stable order for the rest
|
||||||
|
|
||||||
|
|
||||||
|
def test_bubble_handles_int_vs_str_id():
|
||||||
|
out = bubble_linked_track_first([_result('999'), _result('296427754')], 296427754)
|
||||||
|
assert linked_track_id(out[0]) == '296427754'
|
||||||
|
|
||||||
|
|
||||||
|
def test_bubble_noop_when_nothing_matches_or_empty():
|
||||||
|
a, b = _result('1'), _result('2')
|
||||||
|
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'
|
||||||
|
|
|
||||||
|
|
@ -57,3 +57,182 @@ def test_all_miss_returns_none_and_no_write():
|
||||||
fn, recorded = _wire()
|
fn, recorded = _wire()
|
||||||
assert fn("A", "T") == (None, None)
|
assert fn("A", "T") == (None, None)
|
||||||
assert recorded == {}
|
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
|
||||||
|
except→None 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
|
||||||
|
|
|
||||||
|
|
@ -72,3 +72,31 @@ def test_empty_playlist():
|
||||||
out = resolve_playlist_tracks([], lambda a, t: (None, None))
|
out = resolve_playlist_tracks([], lambda a, t: (None, None))
|
||||||
assert out["resolved"] == []
|
assert out["resolved"] == []
|
||||||
assert out["stats"]["total"] == 0
|
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
|
||||||
|
|
|
||||||
|
|
@ -201,6 +201,60 @@ def test_rejects_truncated_file(tmp_path: Path) -> None:
|
||||||
assert result.checks["length_drift_s"] > 3.0
|
assert result.checks["length_drift_s"] > 3.0
|
||||||
|
|
||||||
|
|
||||||
|
# ── #937: a file that runs LONGER than expected is a version/master difference, not
|
||||||
|
# truncation — it gets more leeway, while SHORTER files stay tight. ──
|
||||||
|
|
||||||
|
def test_accepts_longer_master_beyond_short_tolerance(tmp_path: Path) -> None:
|
||||||
|
"""The reported case (A-Ha remaster): file runs ~3.5s LONGER than the metadata.
|
||||||
|
Past the 3s short-tolerance but a remaster, not a bad download — must pass."""
|
||||||
|
f = tmp_path / "remaster.wav"
|
||||||
|
_write_minimal_wav(f, duration_s=9.0) # 9.0s file vs 5.5s expected → +3.5s longer
|
||||||
|
|
||||||
|
result = file_integrity.check_audio_integrity(str(f), expected_duration_ms=5500)
|
||||||
|
|
||||||
|
assert result.ok is True
|
||||||
|
assert result.checks["length_check"] == "passed"
|
||||||
|
assert result.checks["effective_tolerance_s"] == pytest.approx(15.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_shorter_file_still_tight_after_longer_loosening(tmp_path: Path) -> None:
|
||||||
|
"""Loosening the LONGER direction must not loosen truncation detection — a file
|
||||||
|
3.5s SHORTER than expected is still rejected at the 3s tolerance."""
|
||||||
|
f = tmp_path / "short.wav"
|
||||||
|
_write_minimal_wav(f, duration_s=5.5) # 5.5s vs 9.0s expected → -3.5s shorter
|
||||||
|
|
||||||
|
result = file_integrity.check_audio_integrity(str(f), expected_duration_ms=9000)
|
||||||
|
|
||||||
|
assert result.ok is False
|
||||||
|
assert "truncated" in result.reason.lower()
|
||||||
|
assert result.checks["effective_tolerance_s"] == pytest.approx(3.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_wildly_longer_file_still_rejected(tmp_path: Path) -> None:
|
||||||
|
"""A different/wrong song that happens to run long is still caught — +25s blows
|
||||||
|
past even the 15s longer-tolerance."""
|
||||||
|
f = tmp_path / "wronglong.wav"
|
||||||
|
_write_minimal_wav(f, duration_s=30.0) # 30s vs 5s expected → +25s
|
||||||
|
|
||||||
|
result = file_integrity.check_audio_integrity(str(f), expected_duration_ms=5000)
|
||||||
|
|
||||||
|
assert result.ok is False
|
||||||
|
assert "longer than expected" in result.reason.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_pinned_tolerance_is_symmetric(tmp_path: Path) -> None:
|
||||||
|
"""An explicit user tolerance is honoured in BOTH directions — the longer-direction
|
||||||
|
loosening only applies to the auto default, not a value the user pinned."""
|
||||||
|
f = tmp_path / "long.wav"
|
||||||
|
_write_minimal_wav(f, duration_s=9.0) # +4s longer
|
||||||
|
|
||||||
|
result = file_integrity.check_audio_integrity(
|
||||||
|
str(f), expected_duration_ms=5000, length_tolerance_s=2.0)
|
||||||
|
|
||||||
|
assert result.ok is False
|
||||||
|
assert result.checks["effective_tolerance_s"] == pytest.approx(2.0)
|
||||||
|
|
||||||
|
|
||||||
def test_rejects_wrong_file_substituted(tmp_path: Path) -> None:
|
def test_rejects_wrong_file_substituted(tmp_path: Path) -> None:
|
||||||
"""A 10-second clip masquerading as a 3-minute album track. slskd
|
"""A 10-second clip masquerading as a 3-minute album track. slskd
|
||||||
matched on a similar filename but the actual content is a snippet."""
|
matched on a similar filename but the actual content is a snippet."""
|
||||||
|
|
|
||||||
|
|
@ -244,3 +244,61 @@ def test_atomic_helper_cleans_temp_and_keeps_source_on_failure(tmp_path, monkeyp
|
||||||
assert src.exists() # source preserved on failure
|
assert src.exists() # source preserved on failure
|
||||||
assert not dst.exists() # no partial final file
|
assert not dst.exists() # no partial final file
|
||||||
assert not list(dstdir.glob(".*ssync-tmp")) # temp cleaned up
|
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
|
||||||
|
|
|
||||||
|
|
@ -711,3 +711,73 @@ def test_exhaustive_single_source_exhausted_fails(monkeypatch):
|
||||||
assert task["status"] == "failed"
|
assert task["status"] == "failed"
|
||||||
assert submitted == []
|
assert submitted == []
|
||||||
assert completion == [("rbatch", "rtask", False)]
|
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)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
import os
|
import os
|
||||||
from concurrent.futures import Future
|
from concurrent.futures import Future
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
import core.imports.routes as import_routes
|
import core.imports.routes as import_routes
|
||||||
from core.imports.routes import (
|
from core.imports.routes import (
|
||||||
ImportRouteRuntime,
|
ImportRouteRuntime,
|
||||||
|
|
@ -17,6 +19,15 @@ 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:
|
class _FakeLogger:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.debug_messages = []
|
self.debug_messages = []
|
||||||
|
|
@ -181,14 +192,21 @@ def test_staging_hints_prefers_tag_queries_then_folder_queries(tmp_path):
|
||||||
_touch(tmp_path / "Folder_Album" / "02.mp3")
|
_touch(tmp_path / "Folder_Album" / "02.mp3")
|
||||||
_touch(tmp_path / "Loose" / "track.flac")
|
_touch(tmp_path / "Loose" / "track.flac")
|
||||||
|
|
||||||
def _read_tags(file_path):
|
def _empty(artist="", album="", track_number=0):
|
||||||
if file_path.endswith("01.mp3") or file_path.endswith("02.mp3"):
|
return {"title": "", "artist": artist, "albumartist": "",
|
||||||
return {"album": ["Tagged Album"], "artist": ["Tagged Artist"]}
|
"album": album, "track_number": track_number, "disc_number": 1}
|
||||||
return {}
|
|
||||||
|
# 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(),
|
||||||
|
}
|
||||||
|
|
||||||
runtime = ImportRouteRuntime(
|
runtime = ImportRouteRuntime(
|
||||||
get_staging_path=lambda: str(tmp_path),
|
get_staging_path=lambda: str(tmp_path),
|
||||||
read_tags=_read_tags,
|
read_staging_file_metadata=_metadata_for(metadata),
|
||||||
logger=_FakeLogger(),
|
logger=_FakeLogger(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -606,3 +624,31 @@ def test_singles_process_requires_files():
|
||||||
|
|
||||||
assert status == 400
|
assert status == 400
|
||||||
assert payload == {"success": False, "error": "No files provided"}
|
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")]
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,10 @@ parsers are tested here; the ffmpeg call is integration.
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
import core.imports.silence as silence_mod
|
||||||
from core.imports.silence import (
|
from core.imports.silence import (
|
||||||
|
detect_broken_audio,
|
||||||
|
is_dsd_path,
|
||||||
silence_ratio_from_output,
|
silence_ratio_from_output,
|
||||||
is_mostly_silent_reason,
|
is_mostly_silent_reason,
|
||||||
measured_duration_from_astats,
|
measured_duration_from_astats,
|
||||||
|
|
@ -102,3 +105,47 @@ def test_no_incomplete_reason_for_full_file():
|
||||||
def test_no_incomplete_reason_when_unmeasurable():
|
def test_no_incomplete_reason_when_unmeasurable():
|
||||||
assert incomplete_audio_reason(None, 188.4, min_ratio=0.85) is None
|
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
|
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
|
||||||
|
|
|
||||||
163
tests/imports/test_staging_scan_async.py
Normal file
163
tests/imports/test_staging_scan_async.py
Normal file
|
|
@ -0,0 +1,163 @@
|
||||||
|
"""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
|
||||||
95
tests/library/test_bulk_paginate.py
Normal file
95
tests/library/test_bulk_paginate.py
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
"""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
|
||||||
77
tests/metadata/test_boot_phase_providers.py
Normal file
77
tests/metadata/test_boot_phase_providers.py
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
"""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
|
||||||
32
tests/metadata/test_configured_primary_source.py
Normal file
32
tests/metadata/test_configured_primary_source.py
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
"""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]
|
||||||
|
|
@ -301,6 +301,37 @@ class TestTrackAlreadyOwned:
|
||||||
track_already_owned(db, 'Track', 'Artist', 'Album X', 'plex')
|
track_already_owned(db, 'Track', 'Artist', 'Album X', 'plex')
|
||||||
assert db.calls[0]['album'] == 'Album X'
|
assert db.calls[0]['album'] == 'Album X'
|
||||||
|
|
||||||
|
def test_candidate_tracks_threaded_to_batched_path(self):
|
||||||
|
"""The discography endpoint pre-fetches the artist's owned tracks once
|
||||||
|
and passes them so check_track_exists scores in-memory instead of firing
|
||||||
|
per-track fuzzy SQL — the fix for ~15-30s/track on a large library."""
|
||||||
|
owned = [SimpleNamespace(title='Owned')]
|
||||||
|
db = _FakeDB((object(), 0.9))
|
||||||
|
track_already_owned(
|
||||||
|
db, 'Owned', 'Artist', 'Album', 'plex', candidate_tracks=owned,
|
||||||
|
)
|
||||||
|
# The pre-fetched candidates must reach check_track_exists verbatim.
|
||||||
|
assert db.calls[0]['candidate_tracks'] is owned
|
||||||
|
|
||||||
|
def test_empty_candidate_list_still_uses_batched_path(self):
|
||||||
|
"""Owns-nothing case: an EMPTY list (not None) must be forwarded so the
|
||||||
|
check takes the fast in-memory path (scores against zero candidates →
|
||||||
|
instant 'not owned') instead of falling back to the slow per-track SQL."""
|
||||||
|
db = _FakeDB((None, 0.0))
|
||||||
|
result = track_already_owned(
|
||||||
|
db, 'Anything', 'Artist', 'Album', 'plex', candidate_tracks=[],
|
||||||
|
)
|
||||||
|
assert result is False
|
||||||
|
# [] is forwarded (not coerced to None) — that's what keeps it fast.
|
||||||
|
assert db.calls[0]['candidate_tracks'] == []
|
||||||
|
|
||||||
|
def test_default_omits_candidate_tracks_for_legacy_callers(self):
|
||||||
|
"""Callers that don't pre-fetch get None → check_track_exists keeps its
|
||||||
|
original per-track-SQL behaviour. No other caller is forced to change."""
|
||||||
|
db = _FakeDB((object(), 0.9))
|
||||||
|
track_already_owned(db, 'Track', 'Artist', 'Album', 'plex')
|
||||||
|
assert db.calls[0]['candidate_tracks'] is None
|
||||||
|
|
||||||
def test_passes_server_source_to_check(self):
|
def test_passes_server_source_to_check(self):
|
||||||
"""Active media server scopes the lookup so the skip check
|
"""Active media server scopes the lookup so the skip check
|
||||||
only fires on tracks the user can actually see in their
|
only fires on tracks the user can actually see in their
|
||||||
|
|
|
||||||
|
|
@ -447,3 +447,36 @@ class TestFilterAndRerank:
|
||||||
# Karaoke pattern reduces score by 0.05x — well below 0.5
|
# Karaoke pattern reduces score by 0.05x — well below 0.5
|
||||||
assert all(t.id != 'karaoke-id' for t in result)
|
assert all(t.id != 'karaoke-id' for t in result)
|
||||||
assert any(t.id == 'real-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(' ', '', ' ') == ''
|
||||||
|
|
|
||||||
86
tests/metadata/test_spotify_oauth_integration.py
Normal file
86
tests/metadata/test_spotify_oauth_integration.py
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
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()
|
||||||
163
tests/quality/test_lossless.py
Normal file
163
tests/quality/test_lossless.py
Normal file
|
|
@ -0,0 +1,163 @@
|
||||||
|
"""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}"
|
||||||
|
)
|
||||||
|
|
@ -128,3 +128,22 @@ def test_v2_to_v3_preserves_order_and_maps_fields():
|
||||||
assert formats == ['flac', 'mp3'] # disabled mp3_192 omitted
|
assert formats == ['flac', 'mp3'] # disabled mp3_192 omitted
|
||||||
assert targets[0]['bit_depth'] == 24
|
assert targets[0]['bit_depth'] == 24
|
||||||
assert targets[1]['min_bitrate'] == 320
|
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()
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ from core.quality.source_map import (
|
||||||
("aiff", "wav"), ("aif", "wav"), # PCM → wav tier
|
("aiff", "wav"), ("aif", "wav"), # PCM → wav tier
|
||||||
("wma", "wma"),
|
("wma", "wma"),
|
||||||
("alac", "alac"),
|
("alac", "alac"),
|
||||||
|
("dsf", "dsf"), (".dsf", "dsf"), ("dff", "dsf"), # DSD → dsf tier (#939)
|
||||||
("xyz", "unknown"), ("", "unknown"), (None, "unknown"),
|
("xyz", "unknown"), ("", "unknown"), (None, "unknown"),
|
||||||
])
|
])
|
||||||
def test_format_from_extension(ext, fmt):
|
def test_format_from_extension(ext, fmt):
|
||||||
|
|
|
||||||
191
tests/repair_jobs/test_short_preview_track.py
Normal file
191
tests/repair_jobs/test_short_preview_track.py
Normal file
|
|
@ -0,0 +1,191 @@
|
||||||
|
"""Preview-clip cleanup job (#937-adjacent): flag ~30s preview clips whose source says the
|
||||||
|
real track is much longer, then on approval delete the file + drop the row + re-wishlist."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from core.repair_jobs.base import JobContext
|
||||||
|
from core.repair_jobs.short_preview_track import ShortPreviewTrackJob
|
||||||
|
from core.repair_worker import RepairWorker
|
||||||
|
from database.music_database import MusicDatabase
|
||||||
|
|
||||||
|
|
||||||
|
def _seed(db: MusicDatabase):
|
||||||
|
conn = db._get_connection()
|
||||||
|
conn.execute("INSERT OR IGNORE INTO artists (id, name) VALUES ('ar1', 'A-ha')")
|
||||||
|
conn.execute("INSERT INTO albums (id, artist_id, title) VALUES ('al1', 'ar1', 'Hunting High and Low')")
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _track(db, tid: int, duration_ms, path, spotify_id=None):
|
||||||
|
# tid is an INTEGER id, exactly like production (tracks.id is INTEGER PRIMARY KEY) — so the
|
||||||
|
# test exercises the real round-trip: the finding stores str(id) and the fix queries WHERE id=?.
|
||||||
|
conn = db._get_connection()
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO tracks (id, artist_id, album_id, title, duration, file_path, spotify_track_id) "
|
||||||
|
"VALUES (?, 'ar1', 'al1', ?, ?, ?, ?)",
|
||||||
|
(tid, f"Track {tid}", duration_ms, path, spotify_id),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeSpotify:
|
||||||
|
"""get_track_details(id) -> {'duration_ms': N}. 'sp_long' is a full song; else short."""
|
||||||
|
def get_track_details(self, track_id, **_):
|
||||||
|
return {'duration_ms': 200_000} if track_id == 'sp_long' else {'duration_ms': 28_000}
|
||||||
|
|
||||||
|
|
||||||
|
def _ctx(db, findings, spotify=None):
|
||||||
|
return JobContext(
|
||||||
|
db=db, transfer_folder='/tmp', config_manager=None,
|
||||||
|
spotify_client=spotify,
|
||||||
|
create_finding=lambda **kw: findings.append(kw) or True,
|
||||||
|
should_stop=lambda: False, is_paused=lambda: False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── scan ──
|
||||||
|
|
||||||
|
def test_scan_flags_preview_skips_genuine_short_and_unverifiable(tmp_path: Path):
|
||||||
|
db = MusicDatabase(str(tmp_path / 'm.db'))
|
||||||
|
_seed(db)
|
||||||
|
_track(db, 1, 28_000, '/m/p.flac', spotify_id='sp_long') # id 1: 28s file, source 200s → FLAG
|
||||||
|
_track(db, 2, 28_000, '/m/i.flac', spotify_id='sp_short') # id 2: 28s file, source 28s → skip (genuine)
|
||||||
|
_track(db, 3, 28_000, '/m/m.flac', spotify_id=None) # id 3: 28s, no source id → skip (unverifiable)
|
||||||
|
_track(db, 4, 200_000, '/m/l.flac', spotify_id='sp_long') # id 4: 200s → not scanned (>30s)
|
||||||
|
|
||||||
|
findings = []
|
||||||
|
result = ShortPreviewTrackJob().scan(_ctx(db, findings, _FakeSpotify()))
|
||||||
|
|
||||||
|
assert len(findings) == 1
|
||||||
|
f = findings[0]
|
||||||
|
assert f['finding_type'] == 'short_preview_track'
|
||||||
|
assert f['entity_id'] == '1' # str(int id), as create_finding stores it
|
||||||
|
assert f['entity_type'] == 'track'
|
||||||
|
assert f['details']['expected_duration_s'] == pytest.approx(200.0)
|
||||||
|
assert result.findings_created == 1
|
||||||
|
assert result.scanned == 3 # the 200s track is excluded by the query, not scanned
|
||||||
|
assert result.skipped == 2 # skit + noid
|
||||||
|
|
||||||
|
|
||||||
|
def test_scan_creates_no_finding_when_source_agrees_short(tmp_path: Path):
|
||||||
|
db = MusicDatabase(str(tmp_path / 'm.db'))
|
||||||
|
_seed(db)
|
||||||
|
_track(db, 1, 28_000, '/m/i.flac', spotify_id='sp_short') # source also says 28s
|
||||||
|
findings = []
|
||||||
|
ShortPreviewTrackJob().scan(_ctx(db, findings, _FakeSpotify()))
|
||||||
|
assert findings == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_estimate_scope_counts_short_tracks(tmp_path: Path):
|
||||||
|
db = MusicDatabase(str(tmp_path / 'm.db'))
|
||||||
|
_seed(db)
|
||||||
|
_track(db, 1, 28_000, '/m/a.flac', spotify_id='sp_long')
|
||||||
|
_track(db, 2, 10_000, '/m/b.flac', spotify_id='sp_short')
|
||||||
|
_track(db, 3, 200_000, '/m/c.flac', spotify_id='sp_long') # >30s, excluded
|
||||||
|
assert ShortPreviewTrackJob().estimate_scope(_ctx(db, [], _FakeSpotify())) == 2
|
||||||
|
|
||||||
|
|
||||||
|
# ── fix (approval) ──
|
||||||
|
|
||||||
|
def test_fix_deletes_file_removes_row_and_wishlists(tmp_path: Path):
|
||||||
|
db = MusicDatabase(str(tmp_path / 'm.db'))
|
||||||
|
_seed(db)
|
||||||
|
preview = tmp_path / 'preview.flac'
|
||||||
|
preview.write_bytes(b'fake audio bytes')
|
||||||
|
_track(db, 1, 28_000, str(preview), spotify_id='sp1')
|
||||||
|
|
||||||
|
captured = {}
|
||||||
|
db.add_to_wishlist = lambda spotify_track_data, **kw: captured.update(
|
||||||
|
{'data': spotify_track_data, 'kw': kw}) or True
|
||||||
|
|
||||||
|
w = RepairWorker.__new__(RepairWorker)
|
||||||
|
w.db = db
|
||||||
|
w.transfer_folder = str(tmp_path)
|
||||||
|
w._config_manager = None
|
||||||
|
|
||||||
|
res = w._fix_short_preview_track(
|
||||||
|
'track', '1', str(preview), # entity_id is the string the finding stored
|
||||||
|
{'expected_duration_s': 225.0, 'original_path': str(preview)})
|
||||||
|
|
||||||
|
assert res['success'] is True
|
||||||
|
assert not preview.exists() # preview file deleted
|
||||||
|
assert captured['data']['name'] == 'Track 1' # re-wishlisted with payload
|
||||||
|
assert captured['data']['duration_ms'] == 225_000 # uses the real (expected) length
|
||||||
|
assert captured['kw'].get('source_type') == 'redownload'
|
||||||
|
conn = db._get_connection()
|
||||||
|
remaining = conn.execute("SELECT COUNT(*) FROM tracks WHERE id=1").fetchone()[0]
|
||||||
|
conn.close()
|
||||||
|
assert remaining == 0 # DB row dropped → track missing again
|
||||||
|
|
||||||
|
|
||||||
|
# ── album art capture (so the re-wishlisted item isn't art-less) ──
|
||||||
|
|
||||||
|
def test_scan_captures_source_album_art_into_finding(tmp_path: Path):
|
||||||
|
"""The duration lookup's raw_data carries the source CDN album art — capture it so the
|
||||||
|
re-wishlist isn't art-less when the library thumb is empty (the reported bug)."""
|
||||||
|
db = MusicDatabase(str(tmp_path / 'm.db'))
|
||||||
|
_seed(db)
|
||||||
|
_track(db, 1, 28_000, '/m/p.flac', spotify_id='sp_long')
|
||||||
|
|
||||||
|
class _SpWithArt:
|
||||||
|
def get_track_details(self, track_id, **_):
|
||||||
|
return {'duration_ms': 200_000,
|
||||||
|
'raw_data': {'album': {'images': [{'url': 'https://cdn/cover.jpg'}]}}}
|
||||||
|
|
||||||
|
findings = []
|
||||||
|
ShortPreviewTrackJob().scan(_ctx(db, findings, _SpWithArt()))
|
||||||
|
assert len(findings) == 1
|
||||||
|
assert findings[0]['details']['album_thumb_url'] == 'https://cdn/cover.jpg'
|
||||||
|
|
||||||
|
|
||||||
|
def test_art_from_itunes_artwork_is_upscaled():
|
||||||
|
from core.repair_jobs.short_preview_track import _art_from_details
|
||||||
|
d = {'raw_data': {'artworkUrl100': 'https://is1.mzstatic.com/a/100x100bb.jpg'}}
|
||||||
|
assert _art_from_details(d) == 'https://is1.mzstatic.com/a/600x600bb.jpg'
|
||||||
|
|
||||||
|
|
||||||
|
def test_fix_uses_finding_art_for_wishlist_payload(tmp_path: Path):
|
||||||
|
db = MusicDatabase(str(tmp_path / 'm.db'))
|
||||||
|
_seed(db)
|
||||||
|
preview = tmp_path / 'preview.flac'
|
||||||
|
preview.write_bytes(b'fake audio')
|
||||||
|
_track(db, 1, 28_000, str(preview), spotify_id='sp1')
|
||||||
|
|
||||||
|
captured = {}
|
||||||
|
db.add_to_wishlist = lambda spotify_track_data, **kw: captured.update(
|
||||||
|
{'data': spotify_track_data}) or True
|
||||||
|
|
||||||
|
w = RepairWorker.__new__(RepairWorker)
|
||||||
|
w.db = db
|
||||||
|
w.transfer_folder = str(tmp_path)
|
||||||
|
w._config_manager = None
|
||||||
|
|
||||||
|
w._fix_short_preview_track('track', '1', str(preview),
|
||||||
|
{'expected_duration_s': 200.0,
|
||||||
|
'album_thumb_url': 'https://cdn/art.jpg'})
|
||||||
|
assert captured['data']['album']['images'] == [{'url': 'https://cdn/art.jpg'}]
|
||||||
|
|
||||||
|
|
||||||
|
def test_fix_missing_file_still_wishlists_and_drops_row(tmp_path: Path):
|
||||||
|
"""If the preview file is already gone, still re-wishlist + drop the row (idempotent-ish)."""
|
||||||
|
db = MusicDatabase(str(tmp_path / 'm.db'))
|
||||||
|
_seed(db)
|
||||||
|
_track(db, 1, 28_000, str(tmp_path / 'gone.flac'), spotify_id='sp2')
|
||||||
|
db.add_to_wishlist = lambda spotify_track_data, **kw: True
|
||||||
|
|
||||||
|
w = RepairWorker.__new__(RepairWorker)
|
||||||
|
w.db = db
|
||||||
|
w.transfer_folder = str(tmp_path)
|
||||||
|
w._config_manager = None
|
||||||
|
|
||||||
|
res = w._fix_short_preview_track('track', '1', str(tmp_path / 'gone.flac'), {})
|
||||||
|
assert res['success'] is True
|
||||||
|
conn = db._get_connection()
|
||||||
|
assert conn.execute("SELECT COUNT(*) FROM tracks WHERE id=1").fetchone()[0] == 0
|
||||||
|
conn.close()
|
||||||
95
tests/test_acoustid_history_heal.py
Normal file
95
tests/test_acoustid_history_heal.py
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
"""#934: the AcoustID scanner heals the download-history row when the file moved,
|
||||||
|
instead of leaving it stuck 'unverified' and inserting duplicate scan rows.
|
||||||
|
|
||||||
|
Seeds the exact bug shape (a real download row at the OLD import path + a synthetic
|
||||||
|
'acoustid_scan' duplicate at the NEW library path) and drives the real _persist_status,
|
||||||
|
asserting it collapses to one correct, verified row.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import types
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from core.repair_jobs.acoustid_scanner import AcoustIDScannerJob
|
||||||
|
from database.music_database import MusicDatabase
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def db(tmp_path):
|
||||||
|
return MusicDatabase(str(tmp_path / 'm.db'))
|
||||||
|
|
||||||
|
|
||||||
|
def _scanner():
|
||||||
|
# _persist_status uses only `context`, never instance state — bypass __init__.
|
||||||
|
return AcoustIDScannerJob.__new__(AcoustIDScannerJob)
|
||||||
|
|
||||||
|
|
||||||
|
def _rows(db):
|
||||||
|
with db._get_connection() as conn:
|
||||||
|
return [dict(r) for r in conn.execute(
|
||||||
|
"SELECT file_path, download_source, verification_status FROM library_history")]
|
||||||
|
|
||||||
|
|
||||||
|
OLD = '/downloads/transfer/Artist/01 - Song.flac' # frozen import path in history
|
||||||
|
NEW = '/music/Artist/Album/01 - Song.flac' # where the file lives now (tracks path)
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_heals_drifted_row_and_drops_synthetic_dup(db):
|
||||||
|
# the bug's leftover state: a stuck real row + a synthetic scan dup for the same song.
|
||||||
|
db.add_library_history_entry('download', 'Song', file_path=OLD,
|
||||||
|
download_source='soulseek', verification_status='unverified')
|
||||||
|
db.add_library_history_entry('download', 'Song', file_path=NEW,
|
||||||
|
download_source='acoustid_scan', verification_status='unverified')
|
||||||
|
|
||||||
|
_scanner()._persist_status(
|
||||||
|
types.SimpleNamespace(db=db), track_id='t1', fpath=NEW, db_path=NEW,
|
||||||
|
status='verified', write_tag=False, expected={'title': 'Song'})
|
||||||
|
|
||||||
|
rows = _rows(db)
|
||||||
|
assert len(rows) == 1 # synthetic dup deleted, no new insert
|
||||||
|
assert rows[0]['download_source'] == 'soulseek' # the REAL row survived
|
||||||
|
assert rows[0]['verification_status'] == 'verified'
|
||||||
|
assert rows[0]['file_path'] == NEW # path healed to current location
|
||||||
|
|
||||||
|
|
||||||
|
def test_idempotent_no_growth_on_rescan(db):
|
||||||
|
db.add_library_history_entry('download', 'Song', file_path=OLD,
|
||||||
|
download_source='soulseek', verification_status='unverified')
|
||||||
|
ctx = types.SimpleNamespace(db=db)
|
||||||
|
for _ in range(3):
|
||||||
|
_scanner()._persist_status(ctx, track_id='t1', fpath=NEW, db_path=NEW,
|
||||||
|
status='verified', write_tag=False, expected={'title': 'Song'})
|
||||||
|
rows = _rows(db)
|
||||||
|
assert len(rows) == 1 and rows[0]['verification_status'] == 'verified'
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_file_inserts_one_row_then_dedups(db):
|
||||||
|
# a file SoulSync never downloaded → first scan inserts one review-queue row...
|
||||||
|
ctx = types.SimpleNamespace(db=db)
|
||||||
|
_scanner()._persist_status(ctx, track_id='t1', fpath=NEW, db_path=NEW,
|
||||||
|
status='unverified', write_tag=False,
|
||||||
|
expected={'title': 'Song', 'artist': 'Artist'})
|
||||||
|
assert len(_rows(db)) == 1
|
||||||
|
# ...and a rescan matches it (no duplicate).
|
||||||
|
_scanner()._persist_status(ctx, track_id='t1', fpath=NEW, db_path=NEW,
|
||||||
|
status='unverified', write_tag=False,
|
||||||
|
expected={'title': 'Song', 'artist': 'Artist'})
|
||||||
|
rows = _rows(db)
|
||||||
|
assert len(rows) == 1 and rows[0]['download_source'] == 'acoustid_scan'
|
||||||
|
|
||||||
|
|
||||||
|
def test_does_not_heal_wrong_song_with_same_filename(db):
|
||||||
|
# different song, same filename, different title → must stay untouched (no false heal).
|
||||||
|
db.add_library_history_entry('download', 'A Different Song', file_path='/other/01 - Song.flac',
|
||||||
|
download_source='soulseek', verification_status='verified')
|
||||||
|
_scanner()._persist_status(
|
||||||
|
types.SimpleNamespace(db=db), track_id='t1', fpath=NEW, db_path=NEW,
|
||||||
|
status='unverified', write_tag=False, expected={'title': 'Song'})
|
||||||
|
rows = _rows(db)
|
||||||
|
# the unrelated row is untouched, and the unknown file got its own new row.
|
||||||
|
paths = {r['file_path'] for r in rows}
|
||||||
|
assert '/other/01 - Song.flac' in paths and NEW in paths
|
||||||
|
other = next(r for r in rows if r['file_path'] == '/other/01 - Song.flac')
|
||||||
|
assert other['verification_status'] == 'verified' # not corrupted
|
||||||
|
|
@ -4,8 +4,9 @@ from core.repair_jobs.acoustid_scanner import AcoustIDScannerJob
|
||||||
|
|
||||||
|
|
||||||
class _FakeCursor:
|
class _FakeCursor:
|
||||||
def __init__(self, rows):
|
def __init__(self, rows, lib_rows=None):
|
||||||
self._rows = rows
|
self._rows = rows
|
||||||
|
self._lib_rows = lib_rows or []
|
||||||
self.executed = []
|
self.executed = []
|
||||||
|
|
||||||
def execute(self, query, params=None):
|
def execute(self, query, params=None):
|
||||||
|
|
@ -13,6 +14,10 @@ class _FakeCursor:
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def fetchall(self):
|
def fetchall(self):
|
||||||
|
# The #934 history-match SELECT gets its own (id, file_path, title, source)
|
||||||
|
# rows; the tracks scan query gets the track rows.
|
||||||
|
if self.executed and 'FROM library_history' in self.executed[-1][0]:
|
||||||
|
return self._lib_rows
|
||||||
return self._rows
|
return self._rows
|
||||||
|
|
||||||
def fetchone(self):
|
def fetchone(self):
|
||||||
|
|
@ -20,8 +25,8 @@ class _FakeCursor:
|
||||||
|
|
||||||
|
|
||||||
class _FakeConnection:
|
class _FakeConnection:
|
||||||
def __init__(self, rows):
|
def __init__(self, rows, lib_rows=None):
|
||||||
self._cursor = _FakeCursor(rows)
|
self._cursor = _FakeCursor(rows, lib_rows)
|
||||||
|
|
||||||
def cursor(self):
|
def cursor(self):
|
||||||
return self._cursor
|
return self._cursor
|
||||||
|
|
@ -107,12 +112,13 @@ def test_scan_handles_mixed_track_id_types(monkeypatch):
|
||||||
# and finds the primary artist at 100%, suppressing the false flag.
|
# and finds the primary artist at 100%, suppressing the false flag.
|
||||||
|
|
||||||
|
|
||||||
def _make_finding_capturing_context(track_row, captured):
|
def _make_finding_capturing_context(track_row, captured, lib_rows=None):
|
||||||
"""Context that captures any create_finding calls into the
|
"""Context that captures any create_finding calls into the
|
||||||
`captured` list. Tests assert against this list to verify whether
|
`captured` list. Tests assert against this list to verify whether
|
||||||
the scanner created a finding (false positive) or correctly
|
the scanner created a finding (false positive) or correctly
|
||||||
skipped (multi-value match resolved)."""
|
skipped (multi-value match resolved). ``lib_rows`` seeds the
|
||||||
conn = _FakeConnection([track_row])
|
library_history match SELECT (#934)."""
|
||||||
|
conn = _FakeConnection([track_row], lib_rows)
|
||||||
config_manager = SimpleNamespace(
|
config_manager = SimpleNamespace(
|
||||||
get=lambda key, default=None: default,
|
get=lambda key, default=None: default,
|
||||||
set=lambda *args, **kwargs: None,
|
set=lambda *args, **kwargs: None,
|
||||||
|
|
@ -887,9 +893,10 @@ def test_human_verified_files_are_never_scanned(monkeypatch):
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def _run_persistence_scan(monkeypatch, *, file_status, aid_artist, expected_artist):
|
def _run_persistence_scan(monkeypatch, *, file_status, aid_artist, expected_artist, lib_rows=None):
|
||||||
"""Drive one _scan_file call and return (status_updates, tag_writes) where
|
"""Drive one _scan_file call and return (status_updates, tag_writes) where
|
||||||
status_updates is the list of (query, params) UPDATEs the scanner ran."""
|
status_updates is the list of (query, params) UPDATEs the scanner ran.
|
||||||
|
``lib_rows`` seeds the library_history match SELECT (#934)."""
|
||||||
import core.repair_jobs.acoustid_scanner as scanner_mod
|
import core.repair_jobs.acoustid_scanner as scanner_mod
|
||||||
monkeypatch.setattr(scanner_mod, "_resolve_expected_artist_aliases",
|
monkeypatch.setattr(scanner_mod, "_resolve_expected_artist_aliases",
|
||||||
lambda name: [], raising=False)
|
lambda name: [], raising=False)
|
||||||
|
|
@ -905,7 +912,7 @@ def _run_persistence_scan(monkeypatch, *, file_status, aid_artist, expected_arti
|
||||||
context = _make_finding_capturing_context(
|
context = _make_finding_capturing_context(
|
||||||
track_row=("9", "Call Your Name", expected_artist,
|
track_row=("9", "Call Your Name", expected_artist,
|
||||||
"/music/cyn.flac", 1, "Album", None, None),
|
"/music/cyn.flac", 1, "Album", None, None),
|
||||||
captured=captured)
|
captured=captured, lib_rows=lib_rows)
|
||||||
fake = SimpleNamespace(fingerprint_and_lookup=lambda f: {
|
fake = SimpleNamespace(fingerprint_and_lookup=lambda f: {
|
||||||
'best_score': 0.97,
|
'best_score': 0.97,
|
||||||
'recordings': [{'title': 'Call Your Name', 'artist': aid_artist}]})
|
'recordings': [{'title': 'Call Your Name', 'artist': aid_artist}]})
|
||||||
|
|
@ -920,29 +927,32 @@ def _run_persistence_scan(monkeypatch, *, file_status, aid_artist, expected_arti
|
||||||
|
|
||||||
|
|
||||||
def test_scan_pass_backfills_verified_status(monkeypatch):
|
def test_scan_pass_backfills_verified_status(monkeypatch):
|
||||||
# Untagged file + clean fingerprint PASS → the scan backfills 'verified'
|
# Clean fingerprint PASS → the scan backfills 'verified' into the tag, the tracks
|
||||||
# into the tag, the tracks row AND library_history (review-queue feed).
|
# row AND the file's library_history row. The history row's path drifted since
|
||||||
|
# download (file moved), so the scan heals it by id (#934) rather than missing it.
|
||||||
updates, tag_writes, captured = _run_persistence_scan(
|
updates, tag_writes, captured = _run_persistence_scan(
|
||||||
monkeypatch, file_status=None,
|
monkeypatch, file_status=None,
|
||||||
aid_artist='Sawano Hiroyuki', expected_artist='Sawano Hiroyuki')
|
aid_artist='Sawano Hiroyuki', expected_artist='Sawano Hiroyuki',
|
||||||
|
lib_rows=[(1, '/downloads/old/cyn.flac', 'Call Your Name', 'soulseek')])
|
||||||
assert captured == []
|
assert captured == []
|
||||||
assert tag_writes == [('/music/cyn.flac', 'verified')]
|
assert tag_writes == [('/music/cyn.flac', 'verified')]
|
||||||
assert any('tracks' in q and p == ('verified', '9') for q, p in updates)
|
assert any('tracks' in q and p == ('verified', '9') for q, p in updates)
|
||||||
assert any('library_history' in q and p == ('verified', '/music/cyn.flac')
|
# healed by id, status set + path refreshed to the file's current location.
|
||||||
|
assert any('library_history' in q and p == ('verified', '/music/cyn.flac', 1)
|
||||||
for q, p in updates)
|
for q, p in updates)
|
||||||
|
|
||||||
|
|
||||||
def test_scan_skip_marks_untagged_file_unverified(monkeypatch):
|
def test_scan_skip_marks_untagged_file_unverified(monkeypatch):
|
||||||
# Title matches but the artist is ambiguous (cover/collab band?) → SKIP.
|
# Title matches but the artist is ambiguous (cover/collab band?) → SKIP.
|
||||||
# An untagged file gets 'unverified' so it surfaces in the Downloads-page
|
# An untagged file SoulSync never downloaded (no history row) gets a fresh
|
||||||
# review queue instead of silently passing or being deleted.
|
# 'unverified' row INSERTed so it surfaces in the Downloads-page review queue.
|
||||||
updates, tag_writes, captured = _run_persistence_scan(
|
updates, tag_writes, captured = _run_persistence_scan(
|
||||||
monkeypatch, file_status=None,
|
monkeypatch, file_status=None,
|
||||||
aid_artist='Mantilla', expected_artist='Metallica')
|
aid_artist='Mantilla', expected_artist='Metallica') # no lib_rows → no existing row
|
||||||
assert captured == []
|
assert captured == []
|
||||||
assert tag_writes == [('/music/cyn.flac', 'unverified')]
|
assert tag_writes == [('/music/cyn.flac', 'unverified')]
|
||||||
assert any('tracks' in q and p == ('unverified', '9') for q, p in updates)
|
assert any('tracks' in q and p == ('unverified', '9') for q, p in updates)
|
||||||
assert any('library_history' in q and p == ('unverified', '/music/cyn.flac')
|
assert any('INSERT INTO library_history' in q and p[-1] == 'unverified'
|
||||||
for q, p in updates)
|
for q, p in updates)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
628
tests/test_album_completeness_fragmented_rows.py
Normal file
628
tests/test_album_completeness_fragmented_rows.py
Normal file
|
|
@ -0,0 +1,628 @@
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
|
||||||
|
class _DummyConfigManager:
|
||||||
|
def get(self, key, default=None):
|
||||||
|
return default
|
||||||
|
|
||||||
|
def get_active_media_server(self):
|
||||||
|
return "plex"
|
||||||
|
|
||||||
|
|
||||||
|
if "spotipy" not in sys.modules:
|
||||||
|
spotipy = types.ModuleType("spotipy")
|
||||||
|
|
||||||
|
class _DummySpotify:
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
pass
|
||||||
|
|
||||||
|
oauth2 = types.ModuleType("spotipy.oauth2")
|
||||||
|
|
||||||
|
class _DummyOAuth:
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
pass
|
||||||
|
|
||||||
|
spotipy.Spotify = _DummySpotify
|
||||||
|
oauth2.SpotifyOAuth = _DummyOAuth
|
||||||
|
oauth2.SpotifyClientCredentials = _DummyOAuth
|
||||||
|
spotipy.oauth2 = oauth2
|
||||||
|
sys.modules["spotipy"] = spotipy
|
||||||
|
sys.modules["spotipy.oauth2"] = oauth2
|
||||||
|
|
||||||
|
if "config.settings" not in sys.modules:
|
||||||
|
config_pkg = types.ModuleType("config")
|
||||||
|
settings_mod = types.ModuleType("config.settings")
|
||||||
|
|
||||||
|
settings_mod.config_manager = _DummyConfigManager()
|
||||||
|
config_pkg.settings = settings_mod
|
||||||
|
sys.modules["config"] = config_pkg
|
||||||
|
sys.modules["config.settings"] = settings_mod
|
||||||
|
|
||||||
|
from core.repair_jobs.album_completeness import AlbumCompletenessJob
|
||||||
|
import core.repair_jobs.album_completeness as album_completeness_module
|
||||||
|
|
||||||
|
|
||||||
|
class _SharedMemoryDB:
|
||||||
|
def __init__(self):
|
||||||
|
self.uri = (
|
||||||
|
f"file:testdb_{uuid.uuid4().hex}"
|
||||||
|
"?mode=memory&cache=shared"
|
||||||
|
)
|
||||||
|
self._keepalive = sqlite3.connect(self.uri, uri=True)
|
||||||
|
self._keepalive.executescript(
|
||||||
|
"""
|
||||||
|
CREATE TABLE artists (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT,
|
||||||
|
thumb_url TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE albums (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
artist_id TEXT,
|
||||||
|
title TEXT,
|
||||||
|
thumb_url TEXT,
|
||||||
|
spotify_album_id TEXT,
|
||||||
|
itunes_album_id TEXT,
|
||||||
|
deezer_id TEXT,
|
||||||
|
discogs_id TEXT,
|
||||||
|
soul_id TEXT,
|
||||||
|
musicbrainz_release_id TEXT,
|
||||||
|
canonical_source TEXT,
|
||||||
|
canonical_album_id TEXT,
|
||||||
|
api_track_count INTEGER
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE tracks (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
album_id TEXT,
|
||||||
|
title TEXT,
|
||||||
|
track_number INTEGER,
|
||||||
|
disc_number INTEGER,
|
||||||
|
duration INTEGER,
|
||||||
|
musicbrainz_recording_id TEXT
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
self._keepalive.commit()
|
||||||
|
|
||||||
|
def _get_connection(self):
|
||||||
|
return sqlite3.connect(self.uri, uri=True)
|
||||||
|
|
||||||
|
def insert_artist(self, artist_id, name):
|
||||||
|
self._keepalive.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO artists (id, name)
|
||||||
|
VALUES (?, ?)
|
||||||
|
""",
|
||||||
|
(artist_id, name),
|
||||||
|
)
|
||||||
|
self._keepalive.commit()
|
||||||
|
|
||||||
|
def insert_album(
|
||||||
|
self,
|
||||||
|
album_id,
|
||||||
|
artist_id,
|
||||||
|
title,
|
||||||
|
*,
|
||||||
|
spotify_id=None,
|
||||||
|
musicbrainz_id=None,
|
||||||
|
canonical_source=None,
|
||||||
|
canonical_album_id=None,
|
||||||
|
api_track_count=None,
|
||||||
|
):
|
||||||
|
self._keepalive.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO albums (
|
||||||
|
id,
|
||||||
|
artist_id,
|
||||||
|
title,
|
||||||
|
spotify_album_id,
|
||||||
|
musicbrainz_release_id,
|
||||||
|
canonical_source,
|
||||||
|
canonical_album_id,
|
||||||
|
api_track_count
|
||||||
|
)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
album_id,
|
||||||
|
artist_id,
|
||||||
|
title,
|
||||||
|
spotify_id,
|
||||||
|
musicbrainz_id,
|
||||||
|
canonical_source,
|
||||||
|
canonical_album_id,
|
||||||
|
api_track_count,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self._keepalive.commit()
|
||||||
|
|
||||||
|
def insert_track(
|
||||||
|
self,
|
||||||
|
album_id,
|
||||||
|
number,
|
||||||
|
title,
|
||||||
|
*,
|
||||||
|
disc=1,
|
||||||
|
duration_ms=180000,
|
||||||
|
mbid=None,
|
||||||
|
):
|
||||||
|
track_id = f"{album_id}-{disc}-{number}-{uuid.uuid4().hex}"
|
||||||
|
self._keepalive.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO tracks (
|
||||||
|
id,
|
||||||
|
album_id,
|
||||||
|
title,
|
||||||
|
track_number,
|
||||||
|
disc_number,
|
||||||
|
duration,
|
||||||
|
musicbrainz_recording_id
|
||||||
|
)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
track_id,
|
||||||
|
album_id,
|
||||||
|
title,
|
||||||
|
number,
|
||||||
|
disc,
|
||||||
|
duration_ms,
|
||||||
|
mbid,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self._keepalive.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _context(db, findings):
|
||||||
|
return types.SimpleNamespace(
|
||||||
|
db=db,
|
||||||
|
transfer_folder='',
|
||||||
|
config_manager=_DummyConfigManager(),
|
||||||
|
spotify_client=None,
|
||||||
|
is_spotify_rate_limited=lambda: False,
|
||||||
|
stop_event=None,
|
||||||
|
create_finding=lambda **kwargs: (
|
||||||
|
findings.append(kwargs) or True
|
||||||
|
),
|
||||||
|
should_stop=None,
|
||||||
|
is_paused=None,
|
||||||
|
update_progress=None,
|
||||||
|
report_progress=None,
|
||||||
|
check_stop=lambda: False,
|
||||||
|
wait_if_paused=lambda: False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _canonical_tracks(count, *, prefix="Canonical"):
|
||||||
|
return {
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"id": f"track-{number}",
|
||||||
|
"name": f"{prefix} Track {number}",
|
||||||
|
"track_number": number,
|
||||||
|
"disc_number": 1,
|
||||||
|
"duration_ms": 180000 + number,
|
||||||
|
"artists": [],
|
||||||
|
}
|
||||||
|
for number in range(1, count + 1)
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_scan_groups_validated_fragmented_rows_into_one_finding(
|
||||||
|
monkeypatch,
|
||||||
|
):
|
||||||
|
db = _SharedMemoryDB()
|
||||||
|
db.insert_artist("artist-1", "Artist")
|
||||||
|
db.insert_album(
|
||||||
|
"anchor",
|
||||||
|
"artist-1",
|
||||||
|
"Album",
|
||||||
|
spotify_id="shared-release",
|
||||||
|
canonical_source="deezer",
|
||||||
|
canonical_album_id="canonical-release",
|
||||||
|
)
|
||||||
|
db.insert_album(
|
||||||
|
"fragment",
|
||||||
|
"artist-1",
|
||||||
|
"ALBUM",
|
||||||
|
spotify_id="shared-release",
|
||||||
|
api_track_count=2,
|
||||||
|
)
|
||||||
|
|
||||||
|
db.insert_track("anchor", 1, "Canonical Track 1")
|
||||||
|
db.insert_track("anchor", 2, "Canonical Track 2")
|
||||||
|
db.insert_track("fragment", 3, "Canonical Track 3")
|
||||||
|
db.insert_track("fragment", 4, "Canonical Track 4")
|
||||||
|
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def get_tracks(source, album_id):
|
||||||
|
calls.append((source, album_id))
|
||||||
|
assert source == "deezer"
|
||||||
|
assert album_id == "canonical-release"
|
||||||
|
return _canonical_tracks(5)
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
album_completeness_module,
|
||||||
|
"get_album_tracks_for_source",
|
||||||
|
get_tracks,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
album_completeness_module,
|
||||||
|
"get_primary_source",
|
||||||
|
lambda: "spotify",
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
album_completeness_module,
|
||||||
|
"get_source_priority",
|
||||||
|
lambda primary: ["spotify", "deezer"],
|
||||||
|
)
|
||||||
|
|
||||||
|
findings = []
|
||||||
|
result = AlbumCompletenessJob().scan(
|
||||||
|
_context(db, findings)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.scanned == 1
|
||||||
|
assert result.findings_created == 1
|
||||||
|
assert calls == [("deezer", "canonical-release")]
|
||||||
|
|
||||||
|
details = findings[0]["details"]
|
||||||
|
assert details["album_id"] == "anchor"
|
||||||
|
assert details["expected_tracks"] == 5
|
||||||
|
assert details["actual_tracks"] == 4
|
||||||
|
assert details["raw_local_tracks"] == 4
|
||||||
|
assert details["related_album_ids"] == [
|
||||||
|
"anchor",
|
||||||
|
"fragment",
|
||||||
|
]
|
||||||
|
assert [
|
||||||
|
track["track_number"]
|
||||||
|
for track in details["missing_tracks"]
|
||||||
|
] == [5]
|
||||||
|
|
||||||
|
|
||||||
|
def test_shared_id_without_track_match_stays_independent(
|
||||||
|
monkeypatch,
|
||||||
|
):
|
||||||
|
db = _SharedMemoryDB()
|
||||||
|
db.insert_artist("artist-1", "Artist")
|
||||||
|
db.insert_album(
|
||||||
|
"anchor",
|
||||||
|
"artist-1",
|
||||||
|
"Album",
|
||||||
|
spotify_id="shared-release",
|
||||||
|
canonical_source="deezer",
|
||||||
|
canonical_album_id="canonical-release",
|
||||||
|
)
|
||||||
|
db.insert_album(
|
||||||
|
"unrelated",
|
||||||
|
"artist-1",
|
||||||
|
"Different Album",
|
||||||
|
spotify_id="shared-release",
|
||||||
|
api_track_count=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
db.insert_track("anchor", 1, "Canonical Track 1")
|
||||||
|
db.insert_track(
|
||||||
|
"unrelated",
|
||||||
|
99,
|
||||||
|
"Completely Unrelated",
|
||||||
|
duration_ms=900000,
|
||||||
|
)
|
||||||
|
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def get_tracks(source, album_id):
|
||||||
|
calls.append((source, album_id))
|
||||||
|
return _canonical_tracks(3)
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
album_completeness_module,
|
||||||
|
"get_album_tracks_for_source",
|
||||||
|
get_tracks,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
album_completeness_module,
|
||||||
|
"get_primary_source",
|
||||||
|
lambda: "spotify",
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
album_completeness_module,
|
||||||
|
"get_source_priority",
|
||||||
|
lambda primary: ["spotify", "deezer"],
|
||||||
|
)
|
||||||
|
|
||||||
|
findings = []
|
||||||
|
result = AlbumCompletenessJob().scan(
|
||||||
|
_context(db, findings)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.scanned == 2
|
||||||
|
assert result.findings_created == 1
|
||||||
|
assert calls == [("deezer", "canonical-release")]
|
||||||
|
assert findings[0]["entity_id"] == "anchor"
|
||||||
|
assert findings[0]["details"]["related_album_ids"] == [
|
||||||
|
"anchor",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_excluded_canonical_sibling_reports_only_its_missing_tracks(
|
||||||
|
monkeypatch,
|
||||||
|
):
|
||||||
|
"""A second row pinned to the SAME canonical edition whose tracks fail the
|
||||||
|
strict fragment match is still evaluated against that edition — but it must
|
||||||
|
report only the tracks it does NOT own, not the whole tracklist. Regression
|
||||||
|
for the excluded-sibling bug (it previously flagged every canonical track as
|
||||||
|
missing, including the ones the row already had)."""
|
||||||
|
db = _SharedMemoryDB()
|
||||||
|
db.insert_artist("artist-1", "Artist")
|
||||||
|
# Anchor: complete, titles match the canonical edition.
|
||||||
|
db.insert_album(
|
||||||
|
"anchor",
|
||||||
|
"artist-1",
|
||||||
|
"Album",
|
||||||
|
canonical_source="deezer",
|
||||||
|
canonical_album_id="canonical-release",
|
||||||
|
)
|
||||||
|
for number in range(1, 6):
|
||||||
|
db.insert_track("anchor", number, f"Canonical Track {number}")
|
||||||
|
|
||||||
|
# Sibling: same canonical pair, owns tracks 1-3 by NUMBER but with blank
|
||||||
|
# titles → fails the strict fragment match → excluded from the anchor group.
|
||||||
|
db.insert_album(
|
||||||
|
"sibling",
|
||||||
|
"artist-1",
|
||||||
|
"Album",
|
||||||
|
canonical_source="deezer",
|
||||||
|
canonical_album_id="canonical-release",
|
||||||
|
)
|
||||||
|
for number in range(1, 4):
|
||||||
|
db.insert_track("sibling", number, "")
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
album_completeness_module,
|
||||||
|
"get_album_tracks_for_source",
|
||||||
|
lambda source, album_id: _canonical_tracks(5),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
album_completeness_module,
|
||||||
|
"get_primary_source",
|
||||||
|
lambda: "deezer",
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
album_completeness_module,
|
||||||
|
"get_source_priority",
|
||||||
|
lambda primary: ["deezer"],
|
||||||
|
)
|
||||||
|
|
||||||
|
findings = []
|
||||||
|
AlbumCompletenessJob().scan(_context(db, findings))
|
||||||
|
|
||||||
|
sibling_finding = next(
|
||||||
|
f for f in findings if f["entity_id"] == "sibling"
|
||||||
|
)
|
||||||
|
details = sibling_finding["details"]
|
||||||
|
# Owns tracks 1-3 (by number) → "3 of 5", and only 2 missing (4 & 5),
|
||||||
|
# NOT the full 5 — and the count stays internally consistent.
|
||||||
|
assert details["actual_tracks"] == 3
|
||||||
|
assert details["expected_tracks"] == 5
|
||||||
|
missing_numbers = sorted(
|
||||||
|
t["track_number"] for t in details["missing_tracks"]
|
||||||
|
)
|
||||||
|
assert missing_numbers == [4, 5]
|
||||||
|
|
||||||
|
|
||||||
|
def test_candidate_matching_two_canonical_groups_stays_independent():
|
||||||
|
"""A candidate whose shared ID resolves to MORE THAN ONE canonical group is
|
||||||
|
ambiguous and must not be fused into either. Locks the `len(matches) == 1`
|
||||||
|
rule the one-pass grouping relies on. (`_build_candidate_groups` is pure, so
|
||||||
|
drive it directly with album dicts.)"""
|
||||||
|
def _album(album_id, order, **extra):
|
||||||
|
base = {
|
||||||
|
'album_id': album_id, 'artist_id': 'artist-1',
|
||||||
|
'album_title': album_id, 'actual_count': 1, '_scan_order': order,
|
||||||
|
'spotify_album_id': '', 'itunes_album_id': '', 'deezer_album_id': '',
|
||||||
|
'discogs_album_id': '', 'hydrabase_album_id': '',
|
||||||
|
'musicbrainz_album_id': '', 'canonical_source': '',
|
||||||
|
'canonical_album_id': '',
|
||||||
|
}
|
||||||
|
base.update(extra)
|
||||||
|
return base
|
||||||
|
|
||||||
|
# Two distinct canonical editions (same artist) that share spotify-X, plus a
|
||||||
|
# candidate that also carries spotify-X → it resolves to BOTH groups.
|
||||||
|
albums = [
|
||||||
|
_album('anchor-a', 0, spotify_album_id='shared-X',
|
||||||
|
canonical_source='deezer', canonical_album_id='canonical-a'),
|
||||||
|
_album('anchor-b', 1, spotify_album_id='shared-X',
|
||||||
|
canonical_source='deezer', canonical_album_id='canonical-b'),
|
||||||
|
_album('candidate', 2, spotify_album_id='shared-X'),
|
||||||
|
]
|
||||||
|
|
||||||
|
groups = AlbumCompletenessJob()._build_candidate_groups(albums)
|
||||||
|
candidate_groups = [
|
||||||
|
g for g in groups
|
||||||
|
if any(m['album_id'] == 'candidate' for m in g['members'])
|
||||||
|
]
|
||||||
|
# Candidate is its OWN singleton group, never fused into A or B.
|
||||||
|
assert len(candidate_groups) == 1
|
||||||
|
assert [m['album_id'] for m in candidate_groups[0]['members']] == [
|
||||||
|
'candidate'
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_unambiguous_candidate_joins_its_single_group():
|
||||||
|
"""The complement: a candidate that resolves to exactly one canonical group
|
||||||
|
joins it (the one-pass path produces the same membership as before)."""
|
||||||
|
def _album(album_id, order, **extra):
|
||||||
|
base = {
|
||||||
|
'album_id': album_id, 'artist_id': 'artist-1',
|
||||||
|
'album_title': album_id, 'actual_count': 1, '_scan_order': order,
|
||||||
|
'spotify_album_id': '', 'itunes_album_id': '', 'deezer_album_id': '',
|
||||||
|
'discogs_album_id': '', 'hydrabase_album_id': '',
|
||||||
|
'musicbrainz_album_id': '', 'canonical_source': '',
|
||||||
|
'canonical_album_id': '',
|
||||||
|
}
|
||||||
|
base.update(extra)
|
||||||
|
return base
|
||||||
|
|
||||||
|
albums = [
|
||||||
|
_album('anchor', 0, spotify_album_id='shared-X',
|
||||||
|
canonical_source='deezer', canonical_album_id='canonical-a'),
|
||||||
|
_album('candidate', 1, spotify_album_id='shared-X'),
|
||||||
|
]
|
||||||
|
groups = AlbumCompletenessJob()._build_candidate_groups(albums)
|
||||||
|
assert len(groups) == 1
|
||||||
|
assert sorted(m['album_id'] for m in groups[0]['members']) == [
|
||||||
|
'anchor', 'candidate'
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_fragment_grouping_never_crosses_artist_boundary(
|
||||||
|
monkeypatch,
|
||||||
|
):
|
||||||
|
db = _SharedMemoryDB()
|
||||||
|
db.insert_artist("artist-1", "Artist One")
|
||||||
|
db.insert_artist("artist-2", "Artist Two")
|
||||||
|
db.insert_album(
|
||||||
|
"anchor",
|
||||||
|
"artist-1",
|
||||||
|
"Album",
|
||||||
|
spotify_id="shared-release",
|
||||||
|
canonical_source="deezer",
|
||||||
|
canonical_album_id="canonical-release",
|
||||||
|
)
|
||||||
|
db.insert_album(
|
||||||
|
"other-artist",
|
||||||
|
"artist-2",
|
||||||
|
"Album",
|
||||||
|
spotify_id="shared-release",
|
||||||
|
api_track_count=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
db.insert_track("anchor", 1, "Canonical Track 1")
|
||||||
|
db.insert_track(
|
||||||
|
"other-artist",
|
||||||
|
2,
|
||||||
|
"Canonical Track 2",
|
||||||
|
)
|
||||||
|
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def get_tracks(source, album_id):
|
||||||
|
calls.append((source, album_id))
|
||||||
|
return _canonical_tracks(3)
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
album_completeness_module,
|
||||||
|
"get_album_tracks_for_source",
|
||||||
|
get_tracks,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
album_completeness_module,
|
||||||
|
"get_primary_source",
|
||||||
|
lambda: "spotify",
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
album_completeness_module,
|
||||||
|
"get_source_priority",
|
||||||
|
lambda primary: ["spotify", "deezer"],
|
||||||
|
)
|
||||||
|
|
||||||
|
findings = []
|
||||||
|
result = AlbumCompletenessJob().scan(
|
||||||
|
_context(db, findings)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.scanned == 2
|
||||||
|
assert result.findings_created == 1
|
||||||
|
assert calls == [("deezer", "canonical-release")]
|
||||||
|
assert findings[0]["details"]["related_album_ids"] == [
|
||||||
|
"anchor",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_musicbrainz_recording_id_validates_fragment(
|
||||||
|
monkeypatch,
|
||||||
|
):
|
||||||
|
db = _SharedMemoryDB()
|
||||||
|
db.insert_artist("artist-1", "Artist")
|
||||||
|
db.insert_album(
|
||||||
|
"anchor",
|
||||||
|
"artist-1",
|
||||||
|
"Album",
|
||||||
|
spotify_id="shared-release",
|
||||||
|
musicbrainz_id="mb-release",
|
||||||
|
canonical_source="musicbrainz",
|
||||||
|
canonical_album_id="mb-release",
|
||||||
|
)
|
||||||
|
db.insert_album(
|
||||||
|
"fragment",
|
||||||
|
"artist-1",
|
||||||
|
"Album Fragment",
|
||||||
|
spotify_id="shared-release",
|
||||||
|
api_track_count=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
db.insert_track(
|
||||||
|
"anchor",
|
||||||
|
1,
|
||||||
|
"Canonical Track 1",
|
||||||
|
mbid="track-1",
|
||||||
|
)
|
||||||
|
db.insert_track(
|
||||||
|
"fragment",
|
||||||
|
99,
|
||||||
|
"Wrong title and position",
|
||||||
|
duration_ms=999999,
|
||||||
|
mbid="track-2",
|
||||||
|
)
|
||||||
|
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def get_tracks(source, album_id):
|
||||||
|
calls.append((source, album_id))
|
||||||
|
return _canonical_tracks(3)
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
album_completeness_module,
|
||||||
|
"get_album_tracks_for_source",
|
||||||
|
get_tracks,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
album_completeness_module,
|
||||||
|
"get_primary_source",
|
||||||
|
lambda: "spotify",
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
album_completeness_module,
|
||||||
|
"get_source_priority",
|
||||||
|
lambda primary: ["spotify", "musicbrainz"],
|
||||||
|
)
|
||||||
|
|
||||||
|
findings = []
|
||||||
|
result = AlbumCompletenessJob().scan(
|
||||||
|
_context(db, findings)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.scanned == 1
|
||||||
|
assert result.findings_created == 1
|
||||||
|
assert calls == [("musicbrainz", "mb-release")]
|
||||||
|
|
||||||
|
details = findings[0]["details"]
|
||||||
|
assert details["actual_tracks"] == 2
|
||||||
|
assert details["related_album_ids"] == [
|
||||||
|
"anchor",
|
||||||
|
"fragment",
|
||||||
|
]
|
||||||
|
assert [
|
||||||
|
track["track_number"]
|
||||||
|
for track in details["missing_tracks"]
|
||||||
|
] == [3]
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import sys
|
|
||||||
import types
|
import types
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -10,35 +9,10 @@ class _DummyConfigManager:
|
||||||
return "plex"
|
return "plex"
|
||||||
|
|
||||||
|
|
||||||
if "spotipy" not in sys.modules:
|
# NOTE: deliberately no sys.modules stubbing of spotipy / config.settings here. Both import
|
||||||
spotipy = types.ModuleType("spotipy")
|
# fine in the test env, and faking them globally (with no teardown) leaked into other files —
|
||||||
|
# it left a config.settings with no ConfigManager, intermittently breaking
|
||||||
class _DummySpotify:
|
# tests/test_config_save_retry depending on collection order.
|
||||||
def __init__(self, *args, **kwargs):
|
|
||||||
pass
|
|
||||||
|
|
||||||
oauth2 = types.ModuleType("spotipy.oauth2")
|
|
||||||
|
|
||||||
class _DummyOAuth:
|
|
||||||
def __init__(self, *args, **kwargs):
|
|
||||||
pass
|
|
||||||
|
|
||||||
spotipy.Spotify = _DummySpotify
|
|
||||||
oauth2.SpotifyOAuth = _DummyOAuth
|
|
||||||
oauth2.SpotifyClientCredentials = _DummyOAuth
|
|
||||||
spotipy.oauth2 = oauth2
|
|
||||||
sys.modules["spotipy"] = spotipy
|
|
||||||
sys.modules["spotipy.oauth2"] = oauth2
|
|
||||||
|
|
||||||
if "config.settings" not in sys.modules:
|
|
||||||
config_pkg = types.ModuleType("config")
|
|
||||||
settings_mod = types.ModuleType("config.settings")
|
|
||||||
|
|
||||||
settings_mod.config_manager = _DummyConfigManager()
|
|
||||||
config_pkg.settings = settings_mod
|
|
||||||
sys.modules["config"] = config_pkg
|
|
||||||
sys.modules["config.settings"] = settings_mod
|
|
||||||
|
|
||||||
from core.repair_jobs.album_completeness import AlbumCompletenessJob
|
from core.repair_jobs.album_completeness import AlbumCompletenessJob
|
||||||
import core.repair_jobs.album_completeness as album_completeness_module
|
import core.repair_jobs.album_completeness as album_completeness_module
|
||||||
|
|
||||||
|
|
@ -420,6 +394,8 @@ class _SharedMemoryDB:
|
||||||
deezer_id TEXT,
|
deezer_id TEXT,
|
||||||
discogs_id TEXT,
|
discogs_id TEXT,
|
||||||
soul_id TEXT,
|
soul_id TEXT,
|
||||||
|
canonical_source TEXT,
|
||||||
|
canonical_album_id TEXT,
|
||||||
track_count INTEGER,
|
track_count INTEGER,
|
||||||
api_track_count INTEGER
|
api_track_count INTEGER
|
||||||
);
|
);
|
||||||
|
|
@ -442,13 +418,41 @@ class _SharedMemoryDB:
|
||||||
)
|
)
|
||||||
self._keepalive.commit()
|
self._keepalive.commit()
|
||||||
|
|
||||||
def insert_album(self, album_id, artist_id, title, *, spotify_id=None,
|
def insert_album(
|
||||||
track_count=None, api_track_count=None):
|
self,
|
||||||
|
album_id,
|
||||||
|
artist_id,
|
||||||
|
title,
|
||||||
|
*,
|
||||||
|
spotify_id=None,
|
||||||
|
canonical_source=None,
|
||||||
|
canonical_album_id=None,
|
||||||
|
track_count=None,
|
||||||
|
api_track_count=None,
|
||||||
|
):
|
||||||
self._keepalive.execute(
|
self._keepalive.execute(
|
||||||
"""INSERT INTO albums
|
"""INSERT INTO albums
|
||||||
(id, artist_id, title, spotify_album_id, track_count, api_track_count)
|
(
|
||||||
VALUES (?, ?, ?, ?, ?, ?)""",
|
id,
|
||||||
(album_id, artist_id, title, spotify_id, track_count, api_track_count),
|
artist_id,
|
||||||
|
title,
|
||||||
|
spotify_album_id,
|
||||||
|
canonical_source,
|
||||||
|
canonical_album_id,
|
||||||
|
track_count,
|
||||||
|
api_track_count
|
||||||
|
)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||||
|
(
|
||||||
|
album_id,
|
||||||
|
artist_id,
|
||||||
|
title,
|
||||||
|
spotify_id,
|
||||||
|
canonical_source,
|
||||||
|
canonical_album_id,
|
||||||
|
track_count,
|
||||||
|
api_track_count,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
self._keepalive.commit()
|
self._keepalive.commit()
|
||||||
|
|
||||||
|
|
@ -543,6 +547,170 @@ def test_scan_uses_cached_api_track_count_without_expected_total_lookup(monkeypa
|
||||||
assert finding['details']['actual_tracks'] == 10
|
assert finding['details']['actual_tracks'] == 10
|
||||||
|
|
||||||
|
|
||||||
|
def test_scan_uses_exact_canonical_edition_for_count_and_missing_tracks(
|
||||||
|
monkeypatch,
|
||||||
|
):
|
||||||
|
db = _SharedMemoryDB()
|
||||||
|
db.insert_artist('a1', 'Test Artist')
|
||||||
|
db.insert_album(
|
||||||
|
'alb-canonical',
|
||||||
|
'a1',
|
||||||
|
'Canonical Album',
|
||||||
|
spotify_id='sp-other-edition',
|
||||||
|
canonical_source='deezer',
|
||||||
|
canonical_album_id='dz-canonical',
|
||||||
|
track_count=2,
|
||||||
|
api_track_count=99,
|
||||||
|
)
|
||||||
|
db.insert_tracks('alb-canonical', 2)
|
||||||
|
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def get_tracks(source, album_id):
|
||||||
|
calls.append((source, album_id))
|
||||||
|
|
||||||
|
if source == 'deezer' and album_id == 'dz-canonical':
|
||||||
|
return {
|
||||||
|
'items': [
|
||||||
|
{
|
||||||
|
'id': f'dz-{number}',
|
||||||
|
'name': f'Canonical Track {number}',
|
||||||
|
'track_number': number,
|
||||||
|
'disc_number': 1,
|
||||||
|
'artists': [],
|
||||||
|
}
|
||||||
|
for number in range(1, 5)
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
if source == 'spotify' and album_id == 'sp-other-edition':
|
||||||
|
return {
|
||||||
|
'items': [
|
||||||
|
{
|
||||||
|
'id': f'sp-{number}',
|
||||||
|
'name': f'Other Edition Track {number}',
|
||||||
|
'track_number': number,
|
||||||
|
'disc_number': 1,
|
||||||
|
'artists': [],
|
||||||
|
}
|
||||||
|
for number in range(1, 13)
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
album_completeness_module,
|
||||||
|
'get_album_tracks_for_source',
|
||||||
|
get_tracks,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
album_completeness_module,
|
||||||
|
'get_primary_source',
|
||||||
|
lambda: 'spotify',
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
album_completeness_module,
|
||||||
|
'get_source_priority',
|
||||||
|
lambda primary: ['spotify', 'deezer'],
|
||||||
|
)
|
||||||
|
|
||||||
|
findings = []
|
||||||
|
context = _make_job_context(
|
||||||
|
db,
|
||||||
|
create_finding=lambda **kwargs: (findings.append(kwargs) or True),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = AlbumCompletenessJob().scan(context)
|
||||||
|
|
||||||
|
assert result.findings_created == 1
|
||||||
|
assert calls == [('deezer', 'dz-canonical')]
|
||||||
|
|
||||||
|
details = findings[0]['details']
|
||||||
|
|
||||||
|
assert details['primary_source'] == 'deezer'
|
||||||
|
assert details['primary_album_id'] == 'dz-canonical'
|
||||||
|
assert details['canonical_source'] == 'deezer'
|
||||||
|
assert details['canonical_album_id'] == 'dz-canonical'
|
||||||
|
assert details['expected_tracks'] == 4
|
||||||
|
assert details['actual_tracks'] == 2
|
||||||
|
assert [
|
||||||
|
track['track_number']
|
||||||
|
for track in details['missing_tracks']
|
||||||
|
] == [3, 4]
|
||||||
|
assert all(
|
||||||
|
track['source'] == 'deezer'
|
||||||
|
for track in details['missing_tracks']
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_scan_does_not_fallback_when_canonical_edition_is_unavailable(
|
||||||
|
monkeypatch,
|
||||||
|
):
|
||||||
|
db = _SharedMemoryDB()
|
||||||
|
db.insert_artist('a1', 'Test Artist')
|
||||||
|
db.insert_album(
|
||||||
|
'alb-unavailable-canonical',
|
||||||
|
'a1',
|
||||||
|
'Unavailable Canonical Album',
|
||||||
|
spotify_id='sp-other-edition',
|
||||||
|
canonical_source='deezer',
|
||||||
|
canonical_album_id='dz-unavailable',
|
||||||
|
track_count=2,
|
||||||
|
api_track_count=99,
|
||||||
|
)
|
||||||
|
db.insert_tracks('alb-unavailable-canonical', 2)
|
||||||
|
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def get_tracks(source, album_id):
|
||||||
|
calls.append((source, album_id))
|
||||||
|
|
||||||
|
if source == 'deezer' and album_id == 'dz-unavailable':
|
||||||
|
return {'items': []}
|
||||||
|
|
||||||
|
return {
|
||||||
|
'items': [
|
||||||
|
{
|
||||||
|
'id': f'sp-{number}',
|
||||||
|
'name': f'Other Edition Track {number}',
|
||||||
|
'track_number': number,
|
||||||
|
'disc_number': 1,
|
||||||
|
'artists': [],
|
||||||
|
}
|
||||||
|
for number in range(1, 11)
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
album_completeness_module,
|
||||||
|
'get_album_tracks_for_source',
|
||||||
|
get_tracks,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
album_completeness_module,
|
||||||
|
'get_primary_source',
|
||||||
|
lambda: 'spotify',
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
album_completeness_module,
|
||||||
|
'get_source_priority',
|
||||||
|
lambda primary: ['spotify', 'deezer'],
|
||||||
|
)
|
||||||
|
|
||||||
|
findings = []
|
||||||
|
context = _make_job_context(
|
||||||
|
db,
|
||||||
|
create_finding=lambda **kwargs: (findings.append(kwargs) or True),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = AlbumCompletenessJob().scan(context)
|
||||||
|
|
||||||
|
assert result.findings_created == 0
|
||||||
|
assert findings == []
|
||||||
|
assert calls == [('deezer', 'dz-unavailable')]
|
||||||
|
|
||||||
|
|
||||||
def test_scan_falls_back_to_api_and_persists_count_on_cache_miss(monkeypatch):
|
def test_scan_falls_back_to_api_and_persists_count_on_cache_miss(monkeypatch):
|
||||||
"""Integration: when api_track_count is NULL, scan calls the API,
|
"""Integration: when api_track_count is NULL, scan calls the API,
|
||||||
gets the expected total, caches it, and creates the finding."""
|
gets the expected total, caches it, and creates the finding."""
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ from database.music_database import MusicDatabase
|
||||||
# should_pin_manual_canonical — pure
|
# should_pin_manual_canonical — pure
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
@pytest.mark.parametrize('source', ['spotify', 'itunes', 'deezer', 'discogs', 'hydrabase'])
|
@pytest.mark.parametrize('source', ['spotify', 'itunes', 'deezer', 'discogs', 'hydrabase', 'musicbrainz'])
|
||||||
def test_pins_album_on_recognised_source(source):
|
def test_pins_album_on_recognised_source(source):
|
||||||
assert should_pin_manual_canonical('album', source) is True
|
assert should_pin_manual_canonical('album', source) is True
|
||||||
|
|
||||||
|
|
@ -34,7 +34,7 @@ def test_does_not_pin_non_album(entity):
|
||||||
assert should_pin_manual_canonical(entity, 'spotify') is False
|
assert should_pin_manual_canonical(entity, 'spotify') is False
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize('source', ['lastfm', 'genius', 'musicbrainz', 'audiodb', 'tidal'])
|
@pytest.mark.parametrize('source', ['lastfm', 'genius', 'audiodb', 'tidal'])
|
||||||
def test_does_not_pin_source_canonical_cant_read(source):
|
def test_does_not_pin_source_canonical_cant_read(source):
|
||||||
# No album-version data the canonical tools read → nothing to pin.
|
# No album-version data the canonical tools read → nothing to pin.
|
||||||
assert should_pin_manual_canonical('album', source) is False
|
assert should_pin_manual_canonical('album', source) is False
|
||||||
|
|
|
||||||
103
tests/test_clear_completed_download_history.py
Normal file
103
tests/test_clear_completed_download_history.py
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
"""'Clear Completed' on the Downloads page deletes ALL persisted completed-download
|
||||||
|
history (every event_type='download' row), including the unverified review-queue rows
|
||||||
|
— the user wants the list emptied, and those unverified rows ARE download-history rows.
|
||||||
|
It only removes HISTORY rows; the actual files / `tracks` entries are untouched, so the
|
||||||
|
library is never affected — only the 'needs verification' flags. (Clear-button restoration.)"""
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
|
|
||||||
|
if "spotipy" not in sys.modules: # match the suite's lightweight stubs
|
||||||
|
spotipy = types.ModuleType("spotipy")
|
||||||
|
spotipy.Spotify = object
|
||||||
|
oauth2 = types.ModuleType("spotipy.oauth2")
|
||||||
|
oauth2.SpotifyOAuth = object
|
||||||
|
oauth2.SpotifyClientCredentials = object
|
||||||
|
spotipy.oauth2 = oauth2
|
||||||
|
sys.modules["spotipy"] = spotipy
|
||||||
|
sys.modules["spotipy.oauth2"] = oauth2
|
||||||
|
|
||||||
|
if "config.settings" not in sys.modules:
|
||||||
|
config_pkg = types.ModuleType("config")
|
||||||
|
settings_mod = types.ModuleType("config.settings")
|
||||||
|
|
||||||
|
class _DummyConfigManager:
|
||||||
|
def get(self, key, default=None):
|
||||||
|
return default
|
||||||
|
|
||||||
|
def get_active_media_server(self):
|
||||||
|
return "primary"
|
||||||
|
|
||||||
|
settings_mod.config_manager = _DummyConfigManager()
|
||||||
|
config_pkg.settings = settings_mod
|
||||||
|
sys.modules["config"] = config_pkg
|
||||||
|
sys.modules["config.settings"] = settings_mod
|
||||||
|
|
||||||
|
from database.music_database import MusicDatabase # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
class _NonClosingConn:
|
||||||
|
def __init__(self, real):
|
||||||
|
self._real = real
|
||||||
|
|
||||||
|
def cursor(self):
|
||||||
|
return self._real.cursor()
|
||||||
|
|
||||||
|
def commit(self):
|
||||||
|
return self._real.commit()
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class _InMemoryDB(MusicDatabase):
|
||||||
|
def __init__(self):
|
||||||
|
self._conn = sqlite3.connect(":memory:")
|
||||||
|
self._conn.row_factory = sqlite3.Row
|
||||||
|
self._conn.execute(
|
||||||
|
"CREATE TABLE library_history ("
|
||||||
|
"id INTEGER PRIMARY KEY AUTOINCREMENT, event_type TEXT NOT NULL, "
|
||||||
|
"title TEXT, file_path TEXT, verification_status TEXT)")
|
||||||
|
|
||||||
|
def _get_connection(self):
|
||||||
|
return _NonClosingConn(self._conn)
|
||||||
|
|
||||||
|
def _add(self, event_type, status, title="Song"):
|
||||||
|
self._conn.execute(
|
||||||
|
"INSERT INTO library_history (event_type, title, verification_status) "
|
||||||
|
"VALUES (?, ?, ?)", (event_type, title, status))
|
||||||
|
self._conn.commit()
|
||||||
|
|
||||||
|
def _statuses(self):
|
||||||
|
return [r[0] for r in self._conn.execute(
|
||||||
|
"SELECT verification_status FROM library_history ORDER BY id").fetchall()]
|
||||||
|
|
||||||
|
|
||||||
|
def test_clears_all_completed_download_rows_including_unverified():
|
||||||
|
db = _InMemoryDB()
|
||||||
|
db._add("download", "verified")
|
||||||
|
db._add("download", "human_verified")
|
||||||
|
db._add("download", None) # legacy / unscored completed
|
||||||
|
db._add("download", "unverified") # review queue — also cleared (user chose clear-all)
|
||||||
|
db._add("download", "force_imported")
|
||||||
|
removed = db.clear_completed_download_history()
|
||||||
|
assert removed == 5
|
||||||
|
assert db._statuses() == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_does_not_touch_non_download_history():
|
||||||
|
"""Only event_type='download' rows are the Downloads-page tail; imports etc. stay."""
|
||||||
|
db = _InMemoryDB()
|
||||||
|
db._add("download", "verified")
|
||||||
|
db._add("import", "verified")
|
||||||
|
removed = db.clear_completed_download_history()
|
||||||
|
assert removed == 1
|
||||||
|
# the import row survives
|
||||||
|
rows = db._conn.execute("SELECT event_type FROM library_history").fetchall()
|
||||||
|
assert [r[0] for r in rows] == ["import"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_history_returns_zero():
|
||||||
|
db = _InMemoryDB()
|
||||||
|
assert db.clear_completed_download_history() == 0
|
||||||
56
tests/test_deezer_playlist_export.py
Normal file
56
tests/test_deezer_playlist_export.py
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
"""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']
|
||||||
63
tests/test_history_match.py
Normal file
63
tests/test_history_match.py
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
"""Pure matcher that re-links a moved file to its download-history row (#934)."""
|
||||||
|
|
||||||
|
from core.downloads.history_match import pick_history_row, like_filename_filter
|
||||||
|
|
||||||
|
|
||||||
|
# (id, file_path, title, download_source)
|
||||||
|
def _row(i, path, title='', source='soulseek'):
|
||||||
|
return (i, path, title, source)
|
||||||
|
|
||||||
|
|
||||||
|
def test_exact_current_path_wins():
|
||||||
|
cands = [_row(1, '/old/song.flac', 'Song'), _row(2, '/lib/song.flac', 'Song')]
|
||||||
|
assert pick_history_row(cands, current_paths=('/lib/song.flac', None),
|
||||||
|
basename='song.flac', title='Song') == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_falls_back_to_filename_when_path_drifted():
|
||||||
|
# history has the OLD import path; scanner only knows the NEW library path.
|
||||||
|
cands = [_row(7, '/downloads/transfer/Artist/01 - Song.flac', 'Song')]
|
||||||
|
assert pick_history_row(cands, current_paths=('/music/Artist/Album/01 - Song.flac', None),
|
||||||
|
basename='01 - Song.flac', title='Song') == 7
|
||||||
|
|
||||||
|
|
||||||
|
def test_title_guard_blocks_shared_filename_collision():
|
||||||
|
# two different songs both named "01 - Intro.flac" — must NOT heal the wrong one.
|
||||||
|
cands = [_row(1, '/a/01 - Intro.flac', 'Album A Intro'),
|
||||||
|
_row(2, '/b/01 - Intro.flac', 'Album B Intro')]
|
||||||
|
got = pick_history_row(cands, current_paths=('/c/01 - Intro.flac', None),
|
||||||
|
basename='01 - Intro.flac', title='Album B Intro')
|
||||||
|
assert got == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_title_drift_still_matches_after_normalization():
|
||||||
|
cands = [_row(5, '/old/track.flac', 'Song (Remastered)')]
|
||||||
|
assert pick_history_row(cands, current_paths=('/new/track.flac', None),
|
||||||
|
basename='track.flac', title='song remastered') == 5
|
||||||
|
|
||||||
|
|
||||||
|
def test_prefers_real_download_row_over_synthetic_scan_row():
|
||||||
|
# the #934 collapse: a real download row (drifted path) + a synthetic scan dup at the
|
||||||
|
# exact current path. The REAL row must win, so the synthetic one can be deleted.
|
||||||
|
cands = [_row(10, '/old/song.flac', 'Song', source='soulseek'),
|
||||||
|
_row(11, '/lib/song.flac', 'Song', source='acoustid_scan')]
|
||||||
|
assert pick_history_row(cands, current_paths=('/lib/song.flac', None),
|
||||||
|
basename='song.flac', title='Song') == 10
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_basename_match_returns_none():
|
||||||
|
cands = [_row(1, '/x/other.flac', 'Other')]
|
||||||
|
assert pick_history_row(cands, current_paths=('/x/wanted.flac', None),
|
||||||
|
basename='wanted.flac', title='Wanted') is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_filename_only_substring_is_not_a_match():
|
||||||
|
# '/x/mysong.flac' must NOT satisfy basename 'song.flac'
|
||||||
|
cands = [_row(1, '/x/mysong.flac', 'My Song')]
|
||||||
|
assert pick_history_row(cands, current_paths=('/x/song.flac', None),
|
||||||
|
basename='song.flac', title='Song') is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_like_filter_escapes_metacharacters():
|
||||||
|
# underscores/percents in filenames must not become LIKE wildcards
|
||||||
|
assert like_filename_filter('a_b%c.flac') == r'%a\_b\%c.flac'
|
||||||
|
|
@ -218,13 +218,44 @@ class TestEnrichStatsItems:
|
||||||
|
|
||||||
album_by_name = {a["name"]: a for a in cache["top_albums"]}
|
album_by_name = {a["name"]: a for a in cache["top_albums"]}
|
||||||
assert album_by_name["First Album"]["id"] == "al1"
|
assert album_by_name["First Album"]["id"] == "al1"
|
||||||
assert album_by_name["First Album"]["image_url"] == "http://img/al1.jpg"
|
# 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"]
|
||||||
|
|
||||||
track_by_name = {t["name"]: t for t in cache["top_tracks"]}
|
track_by_name = {t["name"]: t for t in cache["top_tracks"]}
|
||||||
assert track_by_name["Alpha"]["id"] == "t1"
|
assert track_by_name["Alpha"]["id"] == "t1"
|
||||||
assert track_by_name["Bravo"]["id"] == "t2"
|
assert track_by_name["Bravo"]["id"] == "t2"
|
||||||
assert track_by_name["Alpha"]["artist_id"] == "a1"
|
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):
|
def test_unknown_entries_left_untouched(self, db, worker):
|
||||||
_insert_track(db, "t1", "Real", "a1", "Real Band", "al1", "Real Album")
|
_insert_track(db, "t1", "Real", "a1", "Real Band", "al1", "Real Album")
|
||||||
|
|
||||||
|
|
|
||||||
87
tests/test_orphan_history.py
Normal file
87
tests/test_orphan_history.py
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
"""Pure orphan-detection rules for the review-queue cleanup (#934 follow-up)."""
|
||||||
|
|
||||||
|
from core.downloads.orphan_history import find_orphan_history_ids
|
||||||
|
|
||||||
|
|
||||||
|
def _rows(*specs):
|
||||||
|
# specs: (id, file_path, exists?)
|
||||||
|
return [{'id': i, 'file_path': p, '_exists': e} for i, p, e in specs]
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve(row):
|
||||||
|
# stand-in for _resolve_history_audio_path: returns a path or None
|
||||||
|
return '/on/disk' if row.get('_exists') else None
|
||||||
|
|
||||||
|
|
||||||
|
def test_only_missing_files_are_orphans():
|
||||||
|
rows = _rows((1, '/a.flac', True), (2, '/b.flac', False), (3, '/c.flac', True))
|
||||||
|
out = find_orphan_history_ids(rows, _resolve)
|
||||||
|
assert out['orphan_ids'] == [2]
|
||||||
|
assert out['checked'] == 3
|
||||||
|
assert out['suspicious'] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_rows_without_path_are_skipped():
|
||||||
|
rows = _rows((1, '', False), (2, None, False), (3, '/c.flac', False))
|
||||||
|
out = find_orphan_history_ids(rows, _resolve)
|
||||||
|
assert out['checked'] == 1
|
||||||
|
assert out['orphan_ids'] == [3]
|
||||||
|
|
||||||
|
|
||||||
|
def test_all_missing_with_enough_rows_is_suspicious():
|
||||||
|
rows = _rows(*[(i, f'/x{i}.flac', False) for i in range(6)])
|
||||||
|
out = find_orphan_history_ids(rows, _resolve)
|
||||||
|
assert out['suspicious'] is True # mount-down signature -> caller refuses
|
||||||
|
assert len(out['orphan_ids']) == 6
|
||||||
|
|
||||||
|
|
||||||
|
def test_all_missing_but_few_rows_is_not_suspicious():
|
||||||
|
rows = _rows((1, '/x.flac', False), (2, '/y.flac', False))
|
||||||
|
out = find_orphan_history_ids(rows, _resolve)
|
||||||
|
assert out['suspicious'] is False # too few to suspect an outage
|
||||||
|
assert out['orphan_ids'] == [1, 2]
|
||||||
|
|
||||||
|
|
||||||
|
def test_some_present_is_never_suspicious():
|
||||||
|
rows = _rows(*[(i, f'/x{i}.flac', False) for i in range(8)], (99, '/real.flac', True))
|
||||||
|
out = find_orphan_history_ids(rows, _resolve)
|
||||||
|
assert out['suspicious'] is False # at least one file exists -> library is up
|
||||||
|
assert 99 not in out['orphan_ids']
|
||||||
|
|
||||||
|
|
||||||
|
def _status_rows(*specs):
|
||||||
|
# specs: (id, file_path, exists?, verification_status)
|
||||||
|
return [{'id': i, 'file_path': p, '_exists': e, 'verification_status': s}
|
||||||
|
for i, p, e, s in specs]
|
||||||
|
|
||||||
|
|
||||||
|
def _deletable_unverified(row):
|
||||||
|
return row.get('verification_status') == 'unverified'
|
||||||
|
|
||||||
|
|
||||||
|
def test_deletable_protects_rows_from_orphan_ids():
|
||||||
|
"""force_imported rows must never be swept, even when their file is gone."""
|
||||||
|
rows = _status_rows(
|
||||||
|
(1, '/a.flac', False, 'unverified'),
|
||||||
|
(2, '/b.flac', False, 'force_imported'), # gone, but protected
|
||||||
|
)
|
||||||
|
out = find_orphan_history_ids(rows, _resolve, deletable=_deletable_unverified)
|
||||||
|
assert out['orphan_ids'] == [1] # only the unverified orphan
|
||||||
|
assert 2 not in out['orphan_ids']
|
||||||
|
|
||||||
|
|
||||||
|
def test_protected_rows_still_count_toward_mount_down_gate():
|
||||||
|
"""Regression guard (#938 follow-up): protecting rows must NOT shrink the
|
||||||
|
safety gate. A few unverified + many force_imported, ALL unreachable (mount
|
||||||
|
outage) must still read 'suspicious' so nothing is deleted — even though only
|
||||||
|
the unverified ones would otherwise be deletable."""
|
||||||
|
rows = _status_rows(
|
||||||
|
(1, '/a.flac', False, 'unverified'),
|
||||||
|
(2, '/b.flac', False, 'unverified'),
|
||||||
|
*[(i, f'/x{i}.flac', False, 'force_imported') for i in range(3, 8)],
|
||||||
|
)
|
||||||
|
out = find_orphan_history_ids(rows, _resolve, deletable=_deletable_unverified)
|
||||||
|
assert out['checked'] == 7 # all rows counted
|
||||||
|
assert out['suspicious'] is True # gate fires on all-missing → refuse
|
||||||
|
# (caller refuses on suspicious, so orphan_ids is moot — but it's only the unverified)
|
||||||
|
assert set(out['orphan_ids']) == {1, 2}
|
||||||
39
tests/test_reduce_effects_css.py
Normal file
39
tests/test_reduce_effects_css.py
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
"""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"
|
||||||
|
)
|
||||||
|
|
@ -7,7 +7,11 @@ canonical_source/canonical_album_id, and an explicit user source pick
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
import core.library_reorganize as lr
|
import core.library_reorganize as lr
|
||||||
|
import core.metadata.registry as metadata_registry
|
||||||
|
from core.musicbrainz_search import MusicBrainzSearchClient
|
||||||
|
|
||||||
|
|
||||||
def _patch_fetch(monkeypatch, tracklists):
|
def _patch_fetch(monkeypatch, tracklists):
|
||||||
|
|
@ -73,3 +77,82 @@ def test_no_canonical_unchanged(monkeypatch):
|
||||||
album_data = {"spotify_album_id": "sp1"}
|
album_data = {"spotify_album_id": "sp1"}
|
||||||
source, _, _ = lr._resolve_source(album_data, "spotify")
|
source, _, _ = lr._resolve_source(album_data, "spotify")
|
||||||
assert source == "spotify"
|
assert source == "spotify"
|
||||||
|
|
||||||
|
|
||||||
|
def test_musicbrainz_release_id_is_used_by_priority_walk(monkeypatch):
|
||||||
|
client = MusicBrainzSearchClient()
|
||||||
|
client._client = MagicMock()
|
||||||
|
client._client.get_release_group.return_value = None
|
||||||
|
client._client.get_release.return_value = {
|
||||||
|
"id": "mb-release-1",
|
||||||
|
"title": "Test Album",
|
||||||
|
"date": "2024-01-01",
|
||||||
|
"artist-credit": [{"name": "Test Artist"}],
|
||||||
|
"release-group": {
|
||||||
|
"id": "mb-group-1",
|
||||||
|
"primary-type": "Album",
|
||||||
|
"secondary-types": [],
|
||||||
|
},
|
||||||
|
"media": [
|
||||||
|
{
|
||||||
|
"position": 1,
|
||||||
|
"tracks": [
|
||||||
|
{
|
||||||
|
"id": "track-1",
|
||||||
|
"number": "1",
|
||||||
|
"position": 1,
|
||||||
|
"length": 180000,
|
||||||
|
"recording": {
|
||||||
|
"id": "recording-1",
|
||||||
|
"title": "Test Track",
|
||||||
|
"artist-credit": [{"name": "Test Artist"}],
|
||||||
|
"length": 180000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
metadata_registry,
|
||||||
|
"get_musicbrainz_client",
|
||||||
|
lambda *args, **kwargs: client,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
lr,
|
||||||
|
"get_source_priority",
|
||||||
|
lambda primary: ["musicbrainz", "spotify"],
|
||||||
|
)
|
||||||
|
|
||||||
|
album_data = {
|
||||||
|
"musicbrainz_release_id": "mb-release-1",
|
||||||
|
}
|
||||||
|
|
||||||
|
source, api_album, items = lr._resolve_source(
|
||||||
|
album_data,
|
||||||
|
"musicbrainz",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert source == "musicbrainz"
|
||||||
|
assert api_album["id"] == "mb-release-1"
|
||||||
|
assert api_album["name"] == "Test Album"
|
||||||
|
assert items == api_album["tracks"]
|
||||||
|
assert items == [
|
||||||
|
{
|
||||||
|
"id": "recording-1",
|
||||||
|
"name": "Test Track",
|
||||||
|
"artists": [{"name": "Test Artist"}],
|
||||||
|
"duration_ms": 180000,
|
||||||
|
"track_number": 1,
|
||||||
|
"disc_number": 1,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
client._client.get_release_group.assert_any_call(
|
||||||
|
"mb-release-1",
|
||||||
|
includes=["releases", "artist-credits"],
|
||||||
|
)
|
||||||
|
client._client.get_release.assert_any_call(
|
||||||
|
"mb-release-1",
|
||||||
|
includes=["recordings", "artist-credits", "release-groups"],
|
||||||
|
)
|
||||||
|
|
|
||||||
168
tests/test_reorganize_rename_only.py
Normal file
168
tests/test_reorganize_rename_only.py
Normal file
|
|
@ -0,0 +1,168 @@
|
||||||
|
"""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
|
||||||
|
|
@ -73,6 +73,9 @@ def _make_item(*, queue_id='qid-1', album_id='alb-1', source=None):
|
||||||
item.queue_id = queue_id
|
item.queue_id = queue_id
|
||||||
item.album_id = album_id
|
item.album_id = album_id
|
||||||
item.source = source
|
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
|
return item
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -233,3 +236,57 @@ def test_runner_progress_callback_forwards_to_queue(monkeypatch, tmp_path):
|
||||||
# The progress fan-out happened *while* the item was running. The
|
# The progress fan-out happened *while* the item was running. The
|
||||||
# final snapshot shows the worker-set values — what we're really
|
# final snapshot shows the worker-set values — what we're really
|
||||||
# asserting is that progress callbacks didn't raise.
|
# 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'
|
||||||
|
|
|
||||||
154
tests/test_service_playlist_export.py
Normal file
154
tests/test_service_playlist_export.py
Normal file
|
|
@ -0,0 +1,154 @@
|
||||||
|
"""_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')
|
||||||
240
tests/test_spotify_client.py
Normal file
240
tests/test_spotify_client.py
Normal file
|
|
@ -0,0 +1,240 @@
|
||||||
|
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
|
||||||
46
tests/test_tidal_token_refresh_loop.py
Normal file
46
tests/test_tidal_token_refresh_loop.py
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
"""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
|
||||||
56
tests/test_ui_appearance.py
Normal file
56
tests/test_ui_appearance.py
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
"""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
|
||||||
191
tests/test_unverified_history_reconcile.py
Normal file
191
tests/test_unverified_history_reconcile.py
Normal file
|
|
@ -0,0 +1,191 @@
|
||||||
|
"""Issue #934 — one-time reconcile that clears the existing backlog of
|
||||||
|
``library_history`` rows stuck at 'unverified' even though the file has since
|
||||||
|
been verified (by an AcoustID scan, or human-confirmed). Heals from the
|
||||||
|
``tracks`` truth, matching exact path AND basename (so a reorganized/moved file
|
||||||
|
heals too), upgrade-only. Never deletes anything."""
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
|
|
||||||
|
if "spotipy" not in sys.modules: # match the suite's lightweight stubs
|
||||||
|
spotipy = types.ModuleType("spotipy")
|
||||||
|
spotipy.Spotify = object
|
||||||
|
oauth2 = types.ModuleType("spotipy.oauth2")
|
||||||
|
oauth2.SpotifyOAuth = object
|
||||||
|
oauth2.SpotifyClientCredentials = object
|
||||||
|
spotipy.oauth2 = oauth2
|
||||||
|
sys.modules["spotipy"] = spotipy
|
||||||
|
sys.modules["spotipy.oauth2"] = oauth2
|
||||||
|
|
||||||
|
if "config.settings" not in sys.modules:
|
||||||
|
config_pkg = types.ModuleType("config")
|
||||||
|
settings_mod = types.ModuleType("config.settings")
|
||||||
|
|
||||||
|
class _DummyConfigManager:
|
||||||
|
def get(self, key, default=None):
|
||||||
|
return default
|
||||||
|
|
||||||
|
def get_active_media_server(self):
|
||||||
|
return "primary"
|
||||||
|
|
||||||
|
settings_mod.config_manager = _DummyConfigManager()
|
||||||
|
config_pkg.settings = settings_mod
|
||||||
|
sys.modules["config"] = config_pkg
|
||||||
|
sys.modules["config.settings"] = settings_mod
|
||||||
|
|
||||||
|
from database.music_database import MusicDatabase # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
class _NonClosingConn:
|
||||||
|
def __init__(self, real):
|
||||||
|
self._real = real
|
||||||
|
|
||||||
|
def cursor(self):
|
||||||
|
return self._real.cursor()
|
||||||
|
|
||||||
|
def commit(self):
|
||||||
|
return self._real.commit()
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *args):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class _InMemoryDB(MusicDatabase):
|
||||||
|
def __init__(self):
|
||||||
|
self._conn = sqlite3.connect(":memory:")
|
||||||
|
self._conn.row_factory = sqlite3.Row
|
||||||
|
self._conn.execute(
|
||||||
|
"CREATE TABLE tracks (id INTEGER PRIMARY KEY, file_path TEXT, "
|
||||||
|
"title TEXT, verification_status TEXT)"
|
||||||
|
)
|
||||||
|
self._conn.execute(
|
||||||
|
"CREATE TABLE library_history ("
|
||||||
|
"id INTEGER PRIMARY KEY AUTOINCREMENT, event_type TEXT, title TEXT, "
|
||||||
|
"artist_name TEXT, album_name TEXT, file_path TEXT, "
|
||||||
|
"download_source TEXT, verification_status TEXT, "
|
||||||
|
"created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _get_connection(self):
|
||||||
|
return _NonClosingConn(self._conn)
|
||||||
|
|
||||||
|
def _add_track(self, tid, path, status, title="Song"):
|
||||||
|
self._conn.execute(
|
||||||
|
"INSERT INTO tracks (id, file_path, title, verification_status) VALUES (?,?,?,?)",
|
||||||
|
(tid, path, title, status))
|
||||||
|
self._conn.commit()
|
||||||
|
|
||||||
|
def _add_history(self, path, status, title="Song"):
|
||||||
|
self._conn.execute(
|
||||||
|
"INSERT INTO library_history (event_type, title, file_path, "
|
||||||
|
"verification_status) VALUES ('download', ?, ?, ?)",
|
||||||
|
(title, path, status))
|
||||||
|
self._conn.commit()
|
||||||
|
|
||||||
|
def _status_of(self, hid):
|
||||||
|
return self._conn.execute(
|
||||||
|
"SELECT verification_status FROM library_history WHERE id = ?", (hid,)
|
||||||
|
).fetchone()[0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_reconcile_heals_exact_path_match():
|
||||||
|
db = _InMemoryDB()
|
||||||
|
db._add_track(1, "/lib/A/01 - Song.flac", "verified")
|
||||||
|
db._add_history("/lib/A/01 - Song.flac", "unverified")
|
||||||
|
healed = db.reconcile_unverified_history_from_tracks()
|
||||||
|
assert healed == 1
|
||||||
|
assert db._status_of(1) == "verified"
|
||||||
|
|
||||||
|
|
||||||
|
def test_reconcile_heals_by_basename_when_path_form_differs():
|
||||||
|
db = _InMemoryDB()
|
||||||
|
db._add_track(1, "/library/Artist/Album/01 - Song.flac", "verified")
|
||||||
|
# History stored the transfer-folder path; basename still matches.
|
||||||
|
db._add_history("/transfer/Artist - Album/01 - Song.flac", "unverified")
|
||||||
|
healed = db.reconcile_unverified_history_from_tracks()
|
||||||
|
assert healed == 1
|
||||||
|
assert db._status_of(1) == "verified"
|
||||||
|
|
||||||
|
|
||||||
|
def test_reconcile_propagates_human_verified():
|
||||||
|
db = _InMemoryDB()
|
||||||
|
db._add_track(1, "/lib/01 - Song.flac", "human_verified")
|
||||||
|
db._add_history("/lib/01 - Song.flac", "unverified")
|
||||||
|
db.reconcile_unverified_history_from_tracks()
|
||||||
|
assert db._status_of(1) == "human_verified"
|
||||||
|
|
||||||
|
|
||||||
|
def test_reconcile_leaves_genuinely_unverified_rows():
|
||||||
|
db = _InMemoryDB()
|
||||||
|
db._add_track(1, "/lib/01 - Song.flac", "unverified") # track itself unconfirmed
|
||||||
|
db._add_history("/lib/01 - Song.flac", "unverified")
|
||||||
|
healed = db.reconcile_unverified_history_from_tracks()
|
||||||
|
assert healed == 0
|
||||||
|
assert db._status_of(1) == "unverified"
|
||||||
|
|
||||||
|
|
||||||
|
def test_reconcile_leaves_orphans_untouched():
|
||||||
|
db = _InMemoryDB()
|
||||||
|
# No track references this file at all (deleted / re-downloaded elsewhere).
|
||||||
|
db._add_history("/lib/gone.flac", "unverified")
|
||||||
|
healed = db.reconcile_unverified_history_from_tracks()
|
||||||
|
assert healed == 0
|
||||||
|
assert db._status_of(1) == "unverified"
|
||||||
|
|
||||||
|
|
||||||
|
def test_reconcile_basename_collision_does_not_false_heal():
|
||||||
|
"""Two different songs share the track-number filename '01 - Intro.flac'.
|
||||||
|
Only one is a verified track; the OTHER's stale history row must NOT inherit
|
||||||
|
that verified status just because the filename collides (title guard)."""
|
||||||
|
db = _InMemoryDB()
|
||||||
|
db._add_track(1, "/lib/AlbumA/01 - Intro.flac", "verified", title="Intro A")
|
||||||
|
# A genuinely different, still-unverified song with the same filename.
|
||||||
|
db._add_history("/transfer/AlbumB/01 - Intro.flac", "unverified", title="Intro B")
|
||||||
|
healed = db.reconcile_unverified_history_from_tracks()
|
||||||
|
assert healed == 0
|
||||||
|
assert db._status_of(1) == "unverified"
|
||||||
|
|
||||||
|
|
||||||
|
def test_reconcile_basename_heals_when_titles_agree():
|
||||||
|
"""Same filename, same song (path drifted) — titles agree, so it heals."""
|
||||||
|
db = _InMemoryDB()
|
||||||
|
db._add_track(1, "/lib/AlbumA/01 - Intro.flac", "verified", title="Intro")
|
||||||
|
db._add_history("/transfer/old/01 - Intro.flac", "unverified", title="Intro")
|
||||||
|
healed = db.reconcile_unverified_history_from_tracks()
|
||||||
|
assert healed == 1
|
||||||
|
assert db._status_of(1) == "verified"
|
||||||
|
|
||||||
|
|
||||||
|
def test_reconcile_basename_heals_when_history_title_missing():
|
||||||
|
"""Legacy history rows may have no title — fall back to filename-only match
|
||||||
|
(mirrors the scanner matcher's allowance) so they still heal."""
|
||||||
|
db = _InMemoryDB()
|
||||||
|
db._add_track(1, "/lib/A/01 - Song.flac", "verified", title="Whatever")
|
||||||
|
db._add_history("/transfer/old/01 - Song.flac", "unverified", title="")
|
||||||
|
healed = db.reconcile_unverified_history_from_tracks()
|
||||||
|
assert healed == 1
|
||||||
|
assert db._status_of(1) == "verified"
|
||||||
|
|
||||||
|
|
||||||
|
def test_reconcile_titleless_row_does_not_heal_on_basename_collision():
|
||||||
|
"""Follow-up hardening (#938 review): a title-less history row must NOT heal
|
||||||
|
via filename when that basename collides across MORE THAN ONE verified track —
|
||||||
|
we can't tell which song it is, so healing would risk marking a genuinely
|
||||||
|
unverified import 'verified'. Unique-basename title-less heal still works
|
||||||
|
(see test_reconcile_basename_heals_when_history_title_missing)."""
|
||||||
|
db = _InMemoryDB()
|
||||||
|
# Two DIFFERENT verified songs that happen to share the generic filename.
|
||||||
|
db._add_track(1, "/lib/AlbumA/01 - Intro.flac", "verified", title="Intro A")
|
||||||
|
db._add_track(2, "/lib/AlbumB/01 - Intro.flac", "verified", title="Intro B")
|
||||||
|
# A stale, title-less history row with that same basename.
|
||||||
|
db._add_history("/transfer/X/01 - Intro.flac", "unverified", title="")
|
||||||
|
healed = db.reconcile_unverified_history_from_tracks()
|
||||||
|
assert healed == 0
|
||||||
|
assert db._status_of(1) == "unverified"
|
||||||
|
|
@ -156,6 +156,13 @@ def test_compilation_score_explanation() -> None:
|
||||||
("Inception (Music From The Motion Picture)", "Inception Soundtrack"),
|
("Inception (Music From The Motion Picture)", "Inception Soundtrack"),
|
||||||
# Substring containment
|
# Substring containment
|
||||||
("Random Access Memories", "Random Access Memories (Bonus Edition)"),
|
("Random Access Memories", "Random Access Memories (Bonus Edition)"),
|
||||||
|
# Dash-suffixed qualifiers must still collapse — these are the SAME album, so
|
||||||
|
# treating them as different would re-wishlist/redownload forever (the failure the
|
||||||
|
# original blanket strip guarded against; the narrowed strip must keep covering it).
|
||||||
|
("Album Name", "Album Name - Single"),
|
||||||
|
("Album Name", "Album Name - Acoustic Version"),
|
||||||
|
("Hotel California", "Hotel California - 2013 Remaster"),
|
||||||
|
("Album", "Album - The Remixes"),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_likely_match_positive(spotify_name, lib_name) -> None:
|
def test_likely_match_positive(spotify_name, lib_name) -> None:
|
||||||
|
|
@ -176,12 +183,29 @@ def test_likely_match_positive(spotify_name, lib_name) -> None:
|
||||||
("Abbey Road", "Sgt. Pepper's Lonely Hearts Club Band"),
|
("Abbey Road", "Sgt. Pepper's Lonely Hearts Club Band"),
|
||||||
# Same word in title but different album
|
# Same word in title but different album
|
||||||
("Greatest Hits Volume 1", "Greatest Hits Volume 2"),
|
("Greatest Hits Volume 1", "Greatest Hits Volume 2"),
|
||||||
|
# Sokhi: distinct editions of the same franchise — the OST vs a bonus edition
|
||||||
|
# with a real subtitle. USED to collapse to the same normalized name (the blanket
|
||||||
|
# trailing-dash strip removed "- Nos vies en Lumière"), so the watchlist marked
|
||||||
|
# unowned OST tracks as owned via the bonus edition. Must be DIFFERENT albums.
|
||||||
|
("Clair Obscur: Expedition 33: Original Soundtrack",
|
||||||
|
"Clair Obscur: Expedition 33 - Nos vies en Lumière (Bonus Edition)"),
|
||||||
|
# A real subtitle after a dash must not be stripped down to the base name.
|
||||||
|
("The Album", "The Album - A Whole Different Subtitle"),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_likely_match_negative(spotify_name, lib_name) -> None:
|
def test_likely_match_negative(spotify_name, lib_name) -> None:
|
||||||
assert not _albums_likely_match(spotify_name, lib_name)
|
assert not _albums_likely_match(spotify_name, lib_name)
|
||||||
|
|
||||||
|
|
||||||
|
def test_real_subtitle_after_dash_is_preserved() -> None:
|
||||||
|
# the regression's root cause: a meaningful subtitle must survive normalization,
|
||||||
|
# while a recognized qualifier after a dash ("- Live", "- 2011") still collapses.
|
||||||
|
assert _normalize_album_for_match("Clair Obscur: Expedition 33 - Nos vies en Lumière") \
|
||||||
|
!= _normalize_album_for_match("Clair Obscur: Expedition 33")
|
||||||
|
assert _normalize_album_for_match("Some Album - Live") == _normalize_album_for_match("Some Album")
|
||||||
|
assert _normalize_album_for_match("Some Album - 2011") == _normalize_album_for_match("Some Album")
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# _albums_likely_match — defensive cases
|
# _albums_likely_match — defensive cases
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,62 @@ def test_resave_is_idempotent_on_run_id(db):
|
||||||
assert len(runs) == 1 and runs[0]['tracks_added'] == 11
|
assert len(runs) == 1 and runs[0]['tracks_added'] == 11
|
||||||
|
|
||||||
|
|
||||||
|
# ── persist_scan_run: the shared seam both scan paths use (#933) ──
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from core.watchlist.scan_history import persist_scan_run
|
||||||
|
|
||||||
|
|
||||||
|
def _state(**over):
|
||||||
|
"""A watchlist_scan_state as the scanner leaves it when a scan finishes."""
|
||||||
|
s = {
|
||||||
|
'scan_run_id': 'auto-run-1',
|
||||||
|
'started_at': datetime(2026, 6, 26, 2, 0, 0),
|
||||||
|
'completed_at': datetime(2026, 6, 26, 2, 4, 0),
|
||||||
|
'total_artists': 40,
|
||||||
|
'tracks_found_this_scan': 7,
|
||||||
|
'tracks_added_this_scan': 3,
|
||||||
|
'scan_track_events': _events(n_added=3, n_skipped=1),
|
||||||
|
'summary': {'total_artists': 40, 'successful_scans': 40,
|
||||||
|
'new_tracks_found': 7, 'tracks_added_to_wishlist': 3},
|
||||||
|
}
|
||||||
|
s.update(over)
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
def test_persist_scan_run_records_a_history_row(db):
|
||||||
|
# the #933 fix: an automatic (all-profiles → profile_id=None) scan must land in History.
|
||||||
|
assert persist_scan_run(db, _state(), profile_id=None, was_cancelled=False) is True
|
||||||
|
runs = db.get_watchlist_scan_runs()
|
||||||
|
assert len(runs) == 1
|
||||||
|
r = runs[0]
|
||||||
|
assert (r['run_id'], r['status'], r['tracks_found'], r['tracks_added']) == \
|
||||||
|
('auto-run-1', 'completed', 7, 3)
|
||||||
|
assert r['profile_id'] == 1 # None coerced to a concrete profile, never NULL
|
||||||
|
# the per-run ledger came through too
|
||||||
|
assert [e['status'] for e in db.get_watchlist_scan_run_events('auto-run-1')] == \
|
||||||
|
['added', 'added', 'added', 'skipped']
|
||||||
|
|
||||||
|
|
||||||
|
def test_persist_scan_run_cancelled_status(db):
|
||||||
|
persist_scan_run(db, _state(scan_run_id='c1'), profile_id=2, was_cancelled=True)
|
||||||
|
assert db.get_watchlist_scan_runs()[0]['status'] == 'cancelled'
|
||||||
|
|
||||||
|
|
||||||
|
def test_persist_scan_run_accepts_datetime_or_iso_string(db):
|
||||||
|
# state timestamps may be datetime (auto path) or already-iso strings — both must persist.
|
||||||
|
persist_scan_run(db, _state(scan_run_id='dt', completed_at='2026-06-26T02:09:00'),
|
||||||
|
profile_id=1, was_cancelled=False)
|
||||||
|
assert db.get_watchlist_scan_runs()[0]['run_id'] == 'dt'
|
||||||
|
|
||||||
|
|
||||||
|
def test_persist_scan_run_tolerates_sparse_state(db):
|
||||||
|
# a bare/early-finished state must not raise — history-write must never break a scan.
|
||||||
|
assert persist_scan_run(db, {'scan_run_id': 'sparse'}, profile_id=None, was_cancelled=False)
|
||||||
|
assert db.get_watchlist_scan_runs()[0]['tracks_added'] == 0
|
||||||
|
|
||||||
|
|
||||||
def test_prune_keeps_most_recent(db):
|
def test_prune_keeps_most_recent(db):
|
||||||
for i in range(1, 8):
|
for i in range(1, 8):
|
||||||
db.save_watchlist_scan_run(
|
db.save_watchlist_scan_run(
|
||||||
|
|
|
||||||
|
|
@ -98,3 +98,35 @@ def test_write_refuses_junk_without_clobbering_existing(tmp_path):
|
||||||
before = dest.read_text()
|
before = dest.read_text()
|
||||||
assert write_pasted_cookiefile("", str(dest)) == ""
|
assert write_pasted_cookiefile("", str(dest)) == ""
|
||||||
assert dest.read_text() == before
|
assert dest.read_text() == before
|
||||||
|
|
||||||
|
|
||||||
|
# ── regression: youtube_client must USE the helper, not pass 'custom' as a browser ──
|
||||||
|
# (Docker bug: pasted cookies threw yt-dlp 'unsupported browser: "custom"' because the
|
||||||
|
# client built cookiesfrombrowser=('custom',) instead of a cookiefile.)
|
||||||
|
|
||||||
|
def test_resolve_cookie_opts_routes_custom_to_cookiefile(monkeypatch, tmp_path):
|
||||||
|
import core.youtube_client as yt
|
||||||
|
cookiefile = tmp_path / "youtube_cookies.txt"
|
||||||
|
cookiefile.write_text(".youtube.com\tTRUE\t/\tTRUE\t123\tSID\tv\n")
|
||||||
|
cfg = {'youtube.cookies_browser': 'custom', 'youtube.cookies_file': str(cookiefile)}
|
||||||
|
monkeypatch.setattr('config.settings.config_manager.get',
|
||||||
|
lambda k, d=None: cfg.get(k, d))
|
||||||
|
opts = yt._resolve_cookie_opts()
|
||||||
|
assert opts == {'cookiefile': str(cookiefile)}
|
||||||
|
assert 'cookiesfrombrowser' not in opts # never the bogus browser arg
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_cookie_opts_browser_mode_unchanged(monkeypatch):
|
||||||
|
import core.youtube_client as yt
|
||||||
|
cfg = {'youtube.cookies_browser': 'firefox', 'youtube.cookies_file': ''}
|
||||||
|
monkeypatch.setattr('config.settings.config_manager.get',
|
||||||
|
lambda k, d=None: cfg.get(k, d))
|
||||||
|
assert yt._resolve_cookie_opts() == {'cookiesfrombrowser': ('firefox',)}
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_cookie_opts_custom_missing_file_is_anonymous(monkeypatch):
|
||||||
|
import core.youtube_client as yt
|
||||||
|
cfg = {'youtube.cookies_browser': 'custom', 'youtube.cookies_file': '/nope/gone.txt'}
|
||||||
|
monkeypatch.setattr('config.settings.config_manager.get',
|
||||||
|
lambda k, d=None: cfg.get(k, d))
|
||||||
|
assert yt._resolve_cookie_opts() == {} # not a broken cookiefile arg
|
||||||
|
|
|
||||||
|
|
@ -81,6 +81,11 @@ class _FakeDB:
|
||||||
self._watchlist_artists = watchlist_artists or []
|
self._watchlist_artists = watchlist_artists or []
|
||||||
self._lb_profiles = lb_profiles or []
|
self._lb_profiles = lb_profiles or []
|
||||||
self.database_path = '/tmp/test.db'
|
self.database_path = '/tmp/test.db'
|
||||||
|
self.scan_runs_saved = []
|
||||||
|
|
||||||
|
def save_watchlist_scan_run(self, **kwargs):
|
||||||
|
self.scan_runs_saved.append(kwargs)
|
||||||
|
return True
|
||||||
|
|
||||||
def get_all_profiles(self):
|
def get_all_profiles(self):
|
||||||
return self._profiles
|
return self._profiles
|
||||||
|
|
@ -237,6 +242,39 @@ def test_successful_scan_runs_post_steps(patched_modules):
|
||||||
assert deps._state_ref[0]['summary']['new_tracks_found'] == 2
|
assert deps._state_ref[0]['summary']['new_tracks_found'] == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_successful_scan_records_history_run(patched_modules):
|
||||||
|
"""#933: the automatic scan must persist a History row (it used to skip this,
|
||||||
|
so only manual scans appeared in History)."""
|
||||||
|
scanner, db = patched_modules
|
||||||
|
deps = _build_deps()
|
||||||
|
|
||||||
|
autosc.process_watchlist_scan_automatically(automation_id='a1', deps=deps)
|
||||||
|
|
||||||
|
assert len(db.scan_runs_saved) == 1 # exactly one row, no double-record
|
||||||
|
saved = db.scan_runs_saved[0]
|
||||||
|
assert saved['status'] == 'completed'
|
||||||
|
assert saved['artists_scanned'] == 1 # one successful _ScanResult
|
||||||
|
assert saved['run_id'] # a run id was stamped
|
||||||
|
|
||||||
|
|
||||||
|
def test_cancelled_scan_still_records_history_run(patched_modules, monkeypatch):
|
||||||
|
"""A cancelled scan is recorded too, with status='cancelled'."""
|
||||||
|
scanner, db = patched_modules
|
||||||
|
deps = _build_deps()
|
||||||
|
# Make the scan observe a cancel request mid-run.
|
||||||
|
orig = scanner.scan_watchlist_artists
|
||||||
|
def _cancel_during(artists, *, scan_state, progress_callback, cancel_check):
|
||||||
|
scan_state['cancel_requested'] = True
|
||||||
|
return orig(artists, scan_state=scan_state, progress_callback=progress_callback,
|
||||||
|
cancel_check=cancel_check)
|
||||||
|
monkeypatch.setattr(scanner, 'scan_watchlist_artists', _cancel_during)
|
||||||
|
|
||||||
|
autosc.process_watchlist_scan_automatically(automation_id='a1', deps=deps)
|
||||||
|
|
||||||
|
assert len(db.scan_runs_saved) == 1
|
||||||
|
assert db.scan_runs_saved[0]['status'] == 'cancelled'
|
||||||
|
|
||||||
|
|
||||||
def test_completion_emits_automation_event(patched_modules):
|
def test_completion_emits_automation_event(patched_modules):
|
||||||
"""Successful scan emits 'watchlist_scan_completed' on automation_engine."""
|
"""Successful scan emits 'watchlist_scan_completed' on automation_engine."""
|
||||||
scanner, _ = patched_modules
|
scanner, _ = patched_modules
|
||||||
|
|
|
||||||
91
tests/wishlist/test_image_enrichment.py
Normal file
91
tests/wishlist/test_image_enrichment.py
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
"""Wishlist art enrichment (read-path): library-sourced items (re-downloads, preview-clip
|
||||||
|
re-fetches) store media-server RELATIVE thumb paths that don't render in a browser, and the
|
||||||
|
nebula only has artist photos for watchlisted artists. _enrich_wishlist_images fixes both on
|
||||||
|
read — normalizing relative/internal image URLs (leaving CDN URLs untouched) and building an
|
||||||
|
artist-name -> library-photo map — so even items already sitting in the wishlist get fixed."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from core.wishlist import routes
|
||||||
|
|
||||||
|
|
||||||
|
class _DB:
|
||||||
|
def __init__(self, artists):
|
||||||
|
self._conn = sqlite3.connect(":memory:")
|
||||||
|
self._conn.execute("CREATE TABLE artists (name TEXT, thumb_url TEXT)")
|
||||||
|
self._conn.executemany("INSERT INTO artists VALUES (?, ?)", artists)
|
||||||
|
self._conn.commit()
|
||||||
|
|
||||||
|
def _get_connection(self):
|
||||||
|
return self._conn
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _stub_normalize(monkeypatch):
|
||||||
|
# Deterministic stand-in for the real Plex/Jellyfin URL rebuild.
|
||||||
|
monkeypatch.setattr(routes, "normalize_image_url", lambda u: f"PROXY({u})")
|
||||||
|
|
||||||
|
|
||||||
|
def test_needs_image_fix_predicate():
|
||||||
|
assert routes._needs_image_fix("/library/metadata/1/thumb/2") is True
|
||||||
|
assert routes._needs_image_fix("/Items/x/Images/Primary") is True
|
||||||
|
assert routes._needs_image_fix("http://localhost:32400/library/x") is True
|
||||||
|
assert routes._needs_image_fix("https://i.scdn.co/image/ab") is False
|
||||||
|
assert routes._needs_image_fix("https://is1.mzstatic.com/600x600bb.jpg") is False
|
||||||
|
assert routes._needs_image_fix("") is False
|
||||||
|
assert routes._needs_image_fix(None) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_relative_album_image_is_normalized():
|
||||||
|
tracks = [{"artist_name": "A",
|
||||||
|
"spotify_data": {"album": {"images": [{"url": "/library/metadata/9/thumb/1"}]}}}]
|
||||||
|
routes._enrich_wishlist_images(tracks, _DB([]))
|
||||||
|
assert tracks[0]["spotify_data"]["album"]["images"][0]["url"] == "PROXY(/library/metadata/9/thumb/1)"
|
||||||
|
|
||||||
|
|
||||||
|
def test_cdn_album_image_is_left_untouched():
|
||||||
|
"""Items that already render must not change — guards against regressing normal wishlist art."""
|
||||||
|
url = "https://i.scdn.co/image/ab67616d"
|
||||||
|
tracks = [{"artist_name": "A", "spotify_data": {"album": {"images": [{"url": url}]}}}]
|
||||||
|
routes._enrich_wishlist_images(tracks, _DB([]))
|
||||||
|
assert tracks[0]["spotify_data"]["album"]["images"][0]["url"] == url
|
||||||
|
|
||||||
|
|
||||||
|
def test_builds_artist_image_map_from_library():
|
||||||
|
tracks = [
|
||||||
|
{"artist_name": "Modest Mouse", "spotify_data": {"album": {"images": []}}},
|
||||||
|
{"artist_name": "Unknown Artist", "spotify_data": {}}, # skipped
|
||||||
|
]
|
||||||
|
db = _DB([("Modest Mouse", "/library/metadata/111/thumb/9"),
|
||||||
|
("Other Band", "/library/metadata/222/thumb/9")]) # not in wishlist → not returned
|
||||||
|
amap = routes._enrich_wishlist_images(tracks, db)
|
||||||
|
assert amap == {"modest mouse": "PROXY(/library/metadata/111/thumb/9)"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_artist_with_empty_thumb_is_omitted():
|
||||||
|
tracks = [{"artist_name": "NoArt", "spotify_data": {"album": {"images": []}}}]
|
||||||
|
amap = routes._enrich_wishlist_images(tracks, _DB([("NoArt", "")]))
|
||||||
|
assert amap == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_already_proxied_artist_thumb_not_double_wrapped():
|
||||||
|
"""A library thumb that's already a CDN/proxy URL passes through unchanged (idempotent)."""
|
||||||
|
tracks = [{"artist_name": "B", "spotify_data": {"album": {"images": []}}}]
|
||||||
|
amap = routes._enrich_wishlist_images(tracks, _DB([("B", "https://i.scdn.co/image/cdn")]))
|
||||||
|
assert amap == {"b": "https://i.scdn.co/image/cdn"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_handles_string_or_missing_spotify_data_gracefully():
|
||||||
|
tracks = [
|
||||||
|
{"artist_name": "A", "spotify_data": "not-a-dict"},
|
||||||
|
{"artist_name": "B"},
|
||||||
|
{"spotify_data": {"album": {"images": [{"url": "/library/x"}]}}}, # no artist_name
|
||||||
|
]
|
||||||
|
amap = routes._enrich_wishlist_images(tracks, _DB([("A", "/library/a")]))
|
||||||
|
# third track's relative album image still gets fixed
|
||||||
|
assert tracks[2]["spotify_data"]["album"]["images"][0]["url"] == "PROXY(/library/x)"
|
||||||
|
assert amap == {"a": "PROXY(/library/a)"}
|
||||||
591
web_server.py
591
web_server.py
|
|
@ -40,7 +40,7 @@ logger = setup_logging(_log_level, _log_path)
|
||||||
|
|
||||||
# App version — single source of truth for backup metadata, system-info, update check, etc.
|
# App version — single source of truth for backup metadata, system-info, update check, etc.
|
||||||
# Semver: MAJOR.MINOR.PATCH. Bump at each dev→main release.
|
# Semver: MAJOR.MINOR.PATCH. Bump at each dev→main release.
|
||||||
_SOULSYNC_BASE_VERSION = "2.7.9"
|
_SOULSYNC_BASE_VERSION = "2.8.2"
|
||||||
|
|
||||||
def _build_version_string():
|
def _build_version_string():
|
||||||
"""Append short commit hash to version when available (e.g. 2.35+abc1234)."""
|
"""Append short commit hash to version when available (e.g. 2.35+abc1234)."""
|
||||||
|
|
@ -82,8 +82,9 @@ if not pp_logger.handlers:
|
||||||
_pp_handler.setFormatter(_logging.Formatter("%(asctime)s - %(message)s", datefmt="%Y-%m-%d %H:%M:%S"))
|
_pp_handler.setFormatter(_logging.Formatter("%(asctime)s - %(message)s", datefmt="%Y-%m-%d %H:%M:%S"))
|
||||||
pp_logger.addHandler(_pp_handler)
|
pp_logger.addHandler(_pp_handler)
|
||||||
pp_logger.propagate = False
|
pp_logger.propagate = False
|
||||||
from core.spotify_client import SpotifyClient, Playlist as SpotifyPlaylist, Track as SpotifyTrack, _is_globally_rate_limited as _spotify_rate_limited
|
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.plex_client import PlexClient
|
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 plexapi.myplex import MyPlexAccount, MyPlexPinLogin
|
||||||
from core.jellyfin_client import JellyfinClient
|
from core.jellyfin_client import JellyfinClient
|
||||||
from core.navidrome_client import NavidromeClient
|
from core.navidrome_client import NavidromeClient
|
||||||
|
|
@ -176,6 +177,7 @@ 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_files as _import_staging_files
|
||||||
from core.imports.routes import staging_groups as _import_staging_groups
|
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_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.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.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
|
from core.imports.pipeline import build_import_pipeline_runtime as _build_import_pipeline_runtime
|
||||||
|
|
@ -303,6 +305,105 @@ app.config['SEND_FILE_MAX_AGE_DEFAULT'] = 0 if DEV_STATIC_NO_CACHE else 31536000
|
||||||
import time as _cache_bust_time
|
import time as _cache_bust_time
|
||||||
_STATIC_CACHE_BUST = str(int(_cache_bust_time.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
|
@app.context_processor
|
||||||
def _inject_static_cache_bust():
|
def _inject_static_cache_bust():
|
||||||
|
|
@ -319,7 +420,7 @@ def _inject_static_cache_bust():
|
||||||
static_v = str(max(mtimes))
|
static_v = str(max(mtimes))
|
||||||
except Exception:
|
except Exception:
|
||||||
static_v = _STATIC_CACHE_BUST
|
static_v = _STATIC_CACHE_BUST
|
||||||
return {'static_v': static_v}
|
return {'static_v': static_v, **_initial_appearance_context()}
|
||||||
|
|
||||||
|
|
||||||
@app.context_processor
|
@app.context_processor
|
||||||
|
|
@ -2837,9 +2938,15 @@ def _build_system_stats():
|
||||||
uptime_seconds = time.time() - start_time
|
uptime_seconds = time.time() - start_time
|
||||||
uptime = str(timedelta(seconds=int(uptime_seconds)))
|
uptime = str(timedelta(seconds=int(uptime_seconds)))
|
||||||
|
|
||||||
# Get memory usage
|
# Get memory usage — global system %, plus SoulSync's own resident memory (RSS),
|
||||||
|
# so the dashboard can show "system load" and "how much RAM SoulSync itself uses".
|
||||||
memory = psutil.virtual_memory()
|
memory = psutil.virtual_memory()
|
||||||
memory_usage = f"{memory.percent}%"
|
memory_usage = f"{memory.percent}%"
|
||||||
|
try:
|
||||||
|
_proc_rss_mb = psutil.Process(os.getpid()).memory_info().rss / (1024 * 1024)
|
||||||
|
process_memory = f"{_proc_rss_mb:.0f} MB" if _proc_rss_mb < 1024 else f"{_proc_rss_mb / 1024:.1f} GB"
|
||||||
|
except Exception:
|
||||||
|
process_memory = None
|
||||||
|
|
||||||
# Count active downloads from download_batches (batches that are currently downloading)
|
# Count active downloads from download_batches (batches that are currently downloading)
|
||||||
active_downloads = len([batch_id for batch_id, batch_data in download_batches.items()
|
active_downloads = len([batch_id for batch_id, batch_data in download_batches.items()
|
||||||
|
|
@ -2917,7 +3024,8 @@ def _build_system_stats():
|
||||||
'download_speed': download_speed_str,
|
'download_speed': download_speed_str,
|
||||||
'active_syncs': active_syncs,
|
'active_syncs': active_syncs,
|
||||||
'uptime': uptime,
|
'uptime': uptime,
|
||||||
'memory_usage': memory_usage
|
'memory_usage': memory_usage,
|
||||||
|
'process_memory': process_memory
|
||||||
}
|
}
|
||||||
|
|
||||||
@app.route('/api/system/stats')
|
@app.route('/api/system/stats')
|
||||||
|
|
@ -2978,6 +3086,61 @@ def debug_memory_stop():
|
||||||
return jsonify({'error': str(e)}), 500
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/debug/memory/objects')
|
||||||
|
def debug_memory_objects():
|
||||||
|
"""One-shot memory breakdown by live object type (plain gc — NO tracemalloc, so it
|
||||||
|
won't lock up a loaded app). Hit this once when RSS is high to see which type/cache
|
||||||
|
dominates: a big 'count' reveals accumulation (a cache that never evicts); a big
|
||||||
|
'mb' under bytes/str reveals blob retention. Pinpoints the leak without tracing."""
|
||||||
|
import gc
|
||||||
|
import sys
|
||||||
|
from collections import defaultdict
|
||||||
|
try:
|
||||||
|
gc.collect()
|
||||||
|
objs = gc.get_objects()
|
||||||
|
counts = defaultdict(int)
|
||||||
|
sizes = defaultdict(int)
|
||||||
|
biggest = []
|
||||||
|
for o in objs:
|
||||||
|
tn = type(o).__name__
|
||||||
|
counts[tn] += 1
|
||||||
|
try:
|
||||||
|
sz = sys.getsizeof(o)
|
||||||
|
except Exception:
|
||||||
|
sz = 0
|
||||||
|
sizes[tn] += sz
|
||||||
|
if sz > 1_000_000 and isinstance(o, (dict, list, set, frozenset, bytes, bytearray, str, tuple)):
|
||||||
|
try:
|
||||||
|
biggest.append((sz, tn, len(o)))
|
||||||
|
except Exception: # noqa: S110 — debug stat, len() on an exotic obj is non-fatal
|
||||||
|
pass
|
||||||
|
biggest.sort(reverse=True)
|
||||||
|
rss = None
|
||||||
|
try:
|
||||||
|
import psutil
|
||||||
|
rss = round(psutil.Process(os.getpid()).memory_info().rss / (1024 * 1024), 1)
|
||||||
|
except Exception: # noqa: S110 — psutil optional; rss stays None in the debug payload
|
||||||
|
pass
|
||||||
|
return jsonify({
|
||||||
|
'rss_mb': rss,
|
||||||
|
'total_objects': len(objs),
|
||||||
|
'top_by_size_mb': [
|
||||||
|
{'type': t, 'count': counts[t], 'mb': round(sizes[t] / (1024 * 1024), 1)}
|
||||||
|
for t, _ in sorted(sizes.items(), key=lambda x: x[1], reverse=True)[:20]
|
||||||
|
],
|
||||||
|
'top_by_count': [
|
||||||
|
{'type': t, 'count': c, 'mb': round(sizes[t] / (1024 * 1024), 1)}
|
||||||
|
for t, c in sorted(counts.items(), key=lambda x: x[1], reverse=True)[:20]
|
||||||
|
],
|
||||||
|
'biggest_containers': [
|
||||||
|
{'mb': round(s / (1024 * 1024), 1), 'type': t, 'len': ln}
|
||||||
|
for s, t, ln in biggest[:15]
|
||||||
|
],
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/activity/feed')
|
@app.route('/api/activity/feed')
|
||||||
def get_activity_feed():
|
def get_activity_feed():
|
||||||
"""Get recent activity feed for dashboard"""
|
"""Get recent activity feed for dashboard"""
|
||||||
|
|
@ -4630,18 +4793,20 @@ def _profile_spotify_oauth(profile_id_int):
|
||||||
chooser so a user can't silently inherit whatever Spotify session is active
|
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."""
|
in their browser (e.g. the admin's). Returns None if no app creds exist."""
|
||||||
from spotipy.oauth2 import SpotifyOAuth
|
from spotipy.oauth2 import SpotifyOAuth
|
||||||
|
from core.spotify_client import normalize_spotify_oauth_config
|
||||||
creds = (get_database().get_profile_spotify(profile_id_int) or {})
|
creds = (get_database().get_profile_spotify(profile_id_int) or {})
|
||||||
cfg = config_manager.get_spotify_config()
|
cfg = normalize_spotify_oauth_config(config_manager.get_spotify_config())
|
||||||
client_id = creds.get('client_id') or cfg.get('client_id')
|
profile_creds = normalize_spotify_oauth_config(creds)
|
||||||
client_secret = creds.get('client_secret') or cfg.get('client_secret')
|
client_id = profile_creds.get('client_id') or cfg.get('client_id')
|
||||||
redirect_uri = creds.get('redirect_uri') or cfg.get('redirect_uri', 'http://127.0.0.1:8888/callback')
|
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')
|
||||||
if not client_id or not client_secret:
|
if not client_id or not client_secret:
|
||||||
return None
|
return None
|
||||||
return SpotifyOAuth(
|
return SpotifyOAuth(
|
||||||
client_id=client_id,
|
client_id=client_id,
|
||||||
client_secret=client_secret,
|
client_secret=client_secret,
|
||||||
redirect_uri=redirect_uri,
|
redirect_uri=redirect_uri,
|
||||||
scope="user-library-read user-read-private playlist-read-private playlist-read-collaborative user-read-email user-follow-read",
|
scope=SPOTIFY_OAUTH_SCOPE,
|
||||||
cache_path=f'config/.spotify_cache_profile_{profile_id_int}',
|
cache_path=f'config/.spotify_cache_profile_{profile_id_int}',
|
||||||
state=f'profile_{profile_id_int}',
|
state=f'profile_{profile_id_int}',
|
||||||
show_dialog=True,
|
show_dialog=True,
|
||||||
|
|
@ -4778,6 +4943,38 @@ def auth_spotify():
|
||||||
logger.error(f"Error starting Spotify auth: {e}")
|
logger.error(f"Error starting Spotify auth: {e}")
|
||||||
return f"<h1>Spotify Authentication Error</h1><p>{str(e)}</p>", 500
|
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')
|
@app.route('/auth/tidal')
|
||||||
def auth_tidal():
|
def auth_tidal():
|
||||||
"""
|
"""
|
||||||
|
|
@ -5033,7 +5230,7 @@ def spotify_callback():
|
||||||
pass
|
pass
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from core.spotify_client import SpotifyClient
|
from core.spotify_client import SpotifyClient, normalize_spotify_oauth_config
|
||||||
from spotipy.oauth2 import SpotifyOAuth
|
from spotipy.oauth2 import SpotifyOAuth
|
||||||
from config.settings import config_manager
|
from config.settings import config_manager
|
||||||
|
|
||||||
|
|
@ -5063,16 +5260,20 @@ def spotify_callback():
|
||||||
raise Exception("Failed to exchange authorization code for access token")
|
raise Exception("Failed to exchange authorization code for access token")
|
||||||
|
|
||||||
# Global callback (admin)
|
# Global callback (admin)
|
||||||
config = config_manager.get_spotify_config()
|
config = normalize_spotify_oauth_config(config_manager.get_spotify_config())
|
||||||
configured_uri = config.get('redirect_uri', "http://127.0.0.1:8888/callback")
|
configured_uri = config.get('redirect_uri', "http://127.0.0.1:8888/callback")
|
||||||
logger.info(f"Using redirect_uri for token exchange: {configured_uri}")
|
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(
|
auth_manager = SpotifyOAuth(
|
||||||
client_id=config['client_id'],
|
client_id=config['client_id'],
|
||||||
client_secret=config['client_secret'],
|
client_secret=config['client_secret'],
|
||||||
redirect_uri=configured_uri,
|
redirect_uri=configured_uri,
|
||||||
scope="user-library-read user-read-private playlist-read-private playlist-read-collaborative user-read-email user-follow-read",
|
scope=SPOTIFY_OAUTH_SCOPE,
|
||||||
cache_path='config/.spotify_cache'
|
cache_handler=DatabaseTokenCache(config_manager)
|
||||||
)
|
)
|
||||||
|
|
||||||
token_info = auth_manager.get_access_token(auth_code)
|
token_info = auth_manager.get_access_token(auth_code)
|
||||||
|
|
@ -7444,6 +7645,7 @@ def manual_search_for_task(task_id):
|
||||||
link = parse_download_track_link(query)
|
link = parse_download_track_link(query)
|
||||||
link_source = None
|
link_source = None
|
||||||
link_track_id = 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
|
# A pasted SoundCloud link can't be turned into an "artist title" query
|
||||||
# and searched — unlisted/private tracks aren't searchable. Instead force
|
# and searched — unlisted/private tracks aren't searchable. Instead force
|
||||||
# the SoundCloud source and keep the URL as the query; the SoundCloud
|
# the SoundCloud source and keep the URL as the query; the SoundCloud
|
||||||
|
|
@ -7473,6 +7675,18 @@ def manual_search_for_task(task_id):
|
||||||
query = clean_q
|
query = clean_q
|
||||||
source = _src
|
source = _src
|
||||||
link_source, link_track_id = _src, _tid
|
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 != 'all':
|
||||||
if source not in valid_source_ids:
|
if source not in valid_source_ids:
|
||||||
|
|
@ -7533,13 +7747,15 @@ def manual_search_for_task(task_id):
|
||||||
"error": error,
|
"error": error,
|
||||||
}) + "\n"
|
}) + "\n"
|
||||||
continue
|
continue
|
||||||
# Pasted-link exact match: bubble the track whose id matches
|
# Pasted-link exact match (#932): the linked source's search may
|
||||||
# the link to the top so the user sees the exact version
|
# not surface an obscure linked track at all, so INJECT the exact
|
||||||
# first (graceful no-op if ids don't line up).
|
# track (fetched by id) at the top and drop any search duplicate of
|
||||||
if src_name == link_source and link_track_id and tracks:
|
# it. If we couldn't fetch it (source has no get_track_result, or it
|
||||||
tracks = sorted(
|
# failed), fall back to bubbling a matching search result — never
|
||||||
tracks,
|
# worse than before.
|
||||||
key=lambda t: str(getattr(t, 'id', '')) != str(link_track_id))
|
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)
|
||||||
serialized = []
|
serialized = []
|
||||||
for t in tracks:
|
for t in tracks:
|
||||||
s = _serialize_candidate(t, source_override=src_name)
|
s = _serialize_candidate(t, source_override=src_name)
|
||||||
|
|
@ -8158,6 +8374,46 @@ def delete_verification_item(history_id):
|
||||||
return jsonify({"success": False, "error": str(e)}), 500
|
return jsonify({"success": False, "error": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/verification/clean-orphans', methods=['POST'])
|
||||||
|
@admin_only
|
||||||
|
def clean_orphan_verification_items():
|
||||||
|
"""Remove dead review-queue rows whose file no longer exists anywhere
|
||||||
|
(deleted / replaced / re-downloaded elsewhere). These are append-only
|
||||||
|
library_history rows that can never be healed — there's no file left to
|
||||||
|
confirm — so they linger in the Unverified list forever (#934).
|
||||||
|
|
||||||
|
User-initiated only, never automatic: it does a filesystem check, which
|
||||||
|
would mass-false-positive if the library mount were down. The pure helper
|
||||||
|
flags that signature (every reviewed file unreachable) and we refuse. Only
|
||||||
|
history ROWS are deleted — the files are already gone; this never removes a
|
||||||
|
file. Admin-only: it mutates shared review state."""
|
||||||
|
try:
|
||||||
|
from core.downloads.orphan_history import find_orphan_history_ids
|
||||||
|
db = get_database()
|
||||||
|
# get_library_history_unverified() returns unverified + force_imported rows.
|
||||||
|
# Check ALL of them so the mount-down gate sees the true count, but only
|
||||||
|
# DELETE 'unverified' orphans — 'force_imported' is a deliberate user
|
||||||
|
# decision (accepted a version mismatch) and stays for human approval.
|
||||||
|
rows = db.get_library_history_unverified() or []
|
||||||
|
result = find_orphan_history_ids(
|
||||||
|
rows, _resolve_history_audio_path,
|
||||||
|
deletable=lambda r: r.get('verification_status') == 'unverified')
|
||||||
|
if result['suspicious']:
|
||||||
|
return jsonify({
|
||||||
|
"success": False,
|
||||||
|
"error": "Every reviewed file is unreachable — your library may be "
|
||||||
|
"offline right now. Nothing was removed.",
|
||||||
|
}), 409
|
||||||
|
orphan_ids = result['orphan_ids']
|
||||||
|
removed = db.delete_library_history_rows(orphan_ids) if orphan_ids else 0
|
||||||
|
logger.info("[Verification] Cleaned %d orphaned review rows (checked %d)",
|
||||||
|
removed, result['checked'])
|
||||||
|
return jsonify({"success": True, "removed": removed, "checked": result['checked']})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[Verification] Clean orphans failed: {e}")
|
||||||
|
return jsonify({"success": False, "error": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/quarantine/<entry_id>/recover', methods=['POST'])
|
@app.route('/api/quarantine/<entry_id>/recover', methods=['POST'])
|
||||||
def recover_quarantine_item(entry_id):
|
def recover_quarantine_item(entry_id):
|
||||||
"""Fallback for legacy thin sidecars: move file into Staging so the user
|
"""Fallback for legacy thin sidecars: move file into Staging so the user
|
||||||
|
|
@ -9652,6 +9908,27 @@ def download_discography(artist_id):
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug("active media server lookup failed: %s", e)
|
logger.debug("active media server lookup failed: %s", e)
|
||||||
|
|
||||||
|
# Pre-fetch the artist's owned library tracks ONCE so the per-track
|
||||||
|
# ownership check scores in-memory instead of firing fuzzy SQL scans
|
||||||
|
# against the whole library for every track (which, on a large library
|
||||||
|
# and an artist the user owns nothing of, was ~15-30s PER TRACK — every
|
||||||
|
# title/artist variation fell through to a full-table fuzzy fallback).
|
||||||
|
# Same batched path the discography backfill job + completion-stream use.
|
||||||
|
# Crucially we pass an empty list (not None) when nothing is owned, so the
|
||||||
|
# owns-nothing case still takes the fast in-memory path → instant.
|
||||||
|
owned_candidate_tracks = []
|
||||||
|
try:
|
||||||
|
cand_albums = db.get_candidate_albums_for_artist(
|
||||||
|
artist_name, server_source=active_server
|
||||||
|
)
|
||||||
|
if cand_albums:
|
||||||
|
owned_candidate_tracks = db.get_candidate_tracks_for_albums(
|
||||||
|
[a.id for a in cand_albums]
|
||||||
|
) or []
|
||||||
|
except Exception as _cand_err:
|
||||||
|
logger.debug("Discography: candidate pre-fetch failed for %s: %s", artist_name, _cand_err)
|
||||||
|
owned_candidate_tracks = []
|
||||||
|
|
||||||
total_added = 0
|
total_added = 0
|
||||||
total_skipped = 0
|
total_skipped = 0
|
||||||
total_skipped_artist = 0
|
total_skipped_artist = 0
|
||||||
|
|
@ -9741,7 +10018,8 @@ def download_discography(artist_id):
|
||||||
# Same library-ownership check the discography
|
# Same library-ownership check the discography
|
||||||
# backfill repair job uses. Format-agnostic so
|
# backfill repair job uses. Format-agnostic so
|
||||||
# Blasphemy mode (FLAC→MP3) doesn't false-miss.
|
# Blasphemy mode (FLAC→MP3) doesn't false-miss.
|
||||||
if track_already_owned(db, track_name, hint_artist, album_name, active_server):
|
if track_already_owned(db, track_name, hint_artist, album_name, active_server,
|
||||||
|
candidate_tracks=owned_candidate_tracks):
|
||||||
skipped_owned += 1
|
skipped_owned += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|
@ -11528,6 +11806,9 @@ def reorganize_album_files(album_id):
|
||||||
artist_name=meta['artist_name'],
|
artist_name=meta['artist_name'],
|
||||||
source=chosen_source,
|
source=chosen_source,
|
||||||
metadata_source=metadata_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})
|
return jsonify({"success": True, **result})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -11644,6 +11925,9 @@ try:
|
||||||
get_transfer_path=lambda: docker_resolve_path(
|
get_transfer_path=lambda: docker_resolve_path(
|
||||||
config_manager.get('soulseek.transfer_path', './Transfer')
|
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:
|
except Exception as _runner_init_err:
|
||||||
logger.error(f"Failed to register reorganize queue runner: {_runner_init_err}")
|
logger.error(f"Failed to register reorganize queue runner: {_runner_init_err}")
|
||||||
|
|
@ -18978,10 +19262,17 @@ def get_batch_history():
|
||||||
|
|
||||||
@app.route('/api/downloads/clear-completed', methods=['POST'])
|
@app.route('/api/downloads/clear-completed', methods=['POST'])
|
||||||
def clear_completed_downloads():
|
def clear_completed_downloads():
|
||||||
"""Remove completed/failed/cancelled tasks from the download tracker."""
|
"""Clear completed/failed downloads from the Downloads page: the live
|
||||||
|
session tasks AND the persisted download-history tail (so the list actually
|
||||||
|
empties and stays empty across restart). Rows still awaiting verification
|
||||||
|
(unverified / force_imported) are preserved — they belong to the review
|
||||||
|
queue, not this cleanup."""
|
||||||
try:
|
try:
|
||||||
cleared = _downloads_cancel.clear_completed_local()
|
cleared = _downloads_cancel.clear_completed_local()
|
||||||
return jsonify({'success': True, 'cleared': cleared})
|
history_cleared = get_database().clear_completed_download_history()
|
||||||
|
return jsonify({'success': True, 'cleared': cleared,
|
||||||
|
'history_cleared': history_cleared,
|
||||||
|
'total_cleared': cleared + history_cleared})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error clearing completed downloads: {e}")
|
logger.error(f"Error clearing completed downloads: {e}")
|
||||||
return jsonify({'success': False, 'error': str(e)}), 500
|
return jsonify({'success': False, 'error': str(e)}), 500
|
||||||
|
|
@ -21360,17 +21651,13 @@ def search_spotify_tracks():
|
||||||
legacy_query = request.args.get('query', '').strip()
|
legacy_query = request.args.get('query', '').strip()
|
||||||
limit = int(request.args.get('limit', 20))
|
limit = int(request.args.get('limit', 20))
|
||||||
|
|
||||||
# Build Spotify field-filtered query
|
# Plain combined query — NOT field-scoped (`track:X artist:Y`). That Spotify
|
||||||
if track_q or artist_q:
|
# syntax leaks to non-Spotify sources when search falls back (Deezer aborted
|
||||||
parts = []
|
# the connection on it); the iTunes/Deezer endpoints already dropped it for the
|
||||||
if track_q:
|
# same reason, and the rerank below recovers precision. (Pool-fix "no results".)
|
||||||
parts.append(f'track:{track_q}')
|
from core.metadata.relevance import build_combined_search_query
|
||||||
if artist_q:
|
query = build_combined_search_query(track_q, artist_q, legacy_query)
|
||||||
parts.append(f'artist:{artist_q}')
|
if not query:
|
||||||
query = ' '.join(parts)
|
|
||||||
elif legacy_query:
|
|
||||||
query = legacy_query
|
|
||||||
else:
|
|
||||||
return jsonify({"error": "Query parameter is required"}), 400
|
return jsonify({"error": "Query parameter is required"}), 400
|
||||||
|
|
||||||
if use_hydrabase:
|
if use_hydrabase:
|
||||||
|
|
@ -27493,9 +27780,98 @@ _playlist_export_jobs = {}
|
||||||
_playlist_export_jobs_lock = threading.Lock()
|
_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):
|
def _run_playlist_export(job_id, playlist_id, title, mode):
|
||||||
job = _playlist_export_jobs[job_id]
|
job = _playlist_export_jobs[job_id]
|
||||||
try:
|
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.export_sources import build_resolve_fn
|
||||||
from core.exports.playlist_export import resolve_playlist_tracks
|
from core.exports.playlist_export import resolve_playlist_tracks
|
||||||
from core.exports.jspf_export import build_jspf
|
from core.exports.jspf_export import build_jspf
|
||||||
|
|
@ -27568,6 +27944,49 @@ def start_playlist_export_listenbrainz(playlist_id):
|
||||||
return jsonify({"success": False, "error": str(e)}), 500
|
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'])
|
@app.route('/api/playlists/export/status/<job_id>', methods=['GET'])
|
||||||
def playlist_export_status(job_id):
|
def playlist_export_status(job_id):
|
||||||
"""Live status for an export job (polled by the mirrored-playlist card)."""
|
"""Live status for an export job (polled by the mirrored-playlist card)."""
|
||||||
|
|
@ -28223,22 +28642,10 @@ def start_watchlist_scan():
|
||||||
# #831 round 2: persist this run + its track ledger so the
|
# #831 round 2: persist this run + its track ledger so the
|
||||||
# Watchlist History modal can show what every past scan did.
|
# Watchlist History modal can show what every past scan did.
|
||||||
try:
|
try:
|
||||||
_state = watchlist_scan_state
|
from core.watchlist.scan_history import persist_scan_run
|
||||||
get_database().save_watchlist_scan_run(
|
persist_scan_run(
|
||||||
run_id=_state.get('scan_run_id') or datetime.now().strftime('%Y%m%d-%H%M%S'),
|
get_database(), watchlist_scan_state,
|
||||||
profile_id=scan_profile_id,
|
profile_id=scan_profile_id, was_cancelled=was_cancelled,
|
||||||
status='cancelled' if was_cancelled else 'completed',
|
|
||||||
started_at=(_state.get('started_at').isoformat()
|
|
||||||
if _state.get('started_at') else None),
|
|
||||||
completed_at=(_state.get('completed_at') or datetime.now()).isoformat()
|
|
||||||
if not isinstance(_state.get('completed_at'), str)
|
|
||||||
else _state.get('completed_at'),
|
|
||||||
total_artists=(_state.get('summary') or {}).get('total_artists',
|
|
||||||
_state.get('total_artists', 0)),
|
|
||||||
artists_scanned=(_state.get('summary') or {}).get('successful_scans', 0),
|
|
||||||
tracks_found=_state.get('tracks_found_this_scan', 0),
|
|
||||||
tracks_added=_state.get('tracks_added_this_scan', 0),
|
|
||||||
track_events=_state.get('scan_track_events') or [],
|
|
||||||
)
|
)
|
||||||
except Exception as _hist_err:
|
except Exception as _hist_err:
|
||||||
logger.error(f"Failed to persist watchlist scan run: {_hist_err}")
|
logger.error(f"Failed to persist watchlist scan run: {_hist_err}")
|
||||||
|
|
@ -35745,19 +36152,24 @@ def start_oauth_callback_servers():
|
||||||
try:
|
try:
|
||||||
from spotipy.oauth2 import SpotifyOAuth
|
from spotipy.oauth2 import SpotifyOAuth
|
||||||
from config.settings import config_manager
|
from config.settings import config_manager
|
||||||
|
from core.spotify_client import normalize_spotify_oauth_config
|
||||||
|
|
||||||
# Get Spotify config
|
# Get Spotify config
|
||||||
config = config_manager.get_spotify_config()
|
config = normalize_spotify_oauth_config(config_manager.get_spotify_config())
|
||||||
configured_uri = config.get('redirect_uri', "http://127.0.0.1:8888/callback")
|
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}")
|
_oauth_logger.info(f"Using redirect_uri for token exchange: {configured_uri}")
|
||||||
|
|
||||||
# Create auth manager and exchange code for token
|
# 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
|
||||||
auth_manager = SpotifyOAuth(
|
auth_manager = SpotifyOAuth(
|
||||||
client_id=config['client_id'],
|
client_id=config['client_id'],
|
||||||
client_secret=config['client_secret'],
|
client_secret=config['client_secret'],
|
||||||
redirect_uri=configured_uri,
|
redirect_uri=configured_uri,
|
||||||
scope="user-library-read user-read-private playlist-read-private playlist-read-collaborative user-read-email user-follow-read",
|
scope=SPOTIFY_OAUTH_SCOPE,
|
||||||
cache_path='config/.spotify_cache'
|
cache_handler=DatabaseTokenCache(config_manager)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Extract the authorization code and exchange it for tokens
|
# Extract the authorization code and exchange it for tokens
|
||||||
|
|
@ -36123,11 +36535,13 @@ except Exception as e:
|
||||||
# they explicitly want background Spotify enrichment.
|
# they explicitly want background Spotify enrichment.
|
||||||
spotify_enrichment_worker = None
|
spotify_enrichment_worker = None
|
||||||
try:
|
try:
|
||||||
from core.metadata_service import get_primary_source as _get_primary_source
|
from core.metadata_service import get_configured_primary_source
|
||||||
from database.music_database import MusicDatabase
|
from database.music_database import MusicDatabase
|
||||||
spotify_enrichment_db = MusicDatabase()
|
spotify_enrichment_db = MusicDatabase()
|
||||||
spotify_enrichment_worker = SpotifyWorker(database=spotify_enrichment_db)
|
spotify_enrichment_worker = SpotifyWorker(database=spotify_enrichment_db)
|
||||||
_primary = _get_primary_source()
|
# 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()
|
||||||
_user_paused = config_manager.get('spotify_enrichment_paused', False)
|
_user_paused = config_manager.get('spotify_enrichment_paused', False)
|
||||||
if _user_paused or _primary != 'spotify':
|
if _user_paused or _primary != 'spotify':
|
||||||
spotify_enrichment_worker.paused = True # Set BEFORE start() to prevent race condition
|
spotify_enrichment_worker.paused = True # Set BEFORE start() to prevent race condition
|
||||||
|
|
@ -37289,6 +37703,12 @@ def import_staging_groups():
|
||||||
return jsonify(payload), status
|
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'])
|
@app.route('/api/import/staging/hints', methods=['GET'])
|
||||||
def import_staging_hints():
|
def import_staging_hints():
|
||||||
payload, status = _import_staging_hints(_build_import_route_runtime())
|
payload, status = _import_staging_hints(_build_import_route_runtime())
|
||||||
|
|
@ -38499,6 +38919,58 @@ def start_runtime_services():
|
||||||
# Initialize app start time for uptime tracking
|
# Initialize app start time for uptime tracking
|
||||||
app.start_time = time.time()
|
app.start_time = time.time()
|
||||||
|
|
||||||
|
# Growth-triggered garbage collection (#802 / resource usage). plexapi builds large XML
|
||||||
|
# Element trees whose nodes reference each other in cycles; Python's generational GC
|
||||||
|
# parks those in gen2 and sweeps it rarely, so they accumulate and RSS climbs (measured:
|
||||||
|
# ~300MB fresh -> 1.8-2.2GB after browsing every page, most of it reclaimable cyclic
|
||||||
|
# garbage a full gc.collect() frees instantly). A FIXED timer overshoots — browsing piles
|
||||||
|
# garbage faster than once-a-minute catches it. Instead, poll RSS cheaply and collect as
|
||||||
|
# soon as it has grown GROW_MB since the last sweep, so it kicks in DURING a heavy browse
|
||||||
|
# and caps the peak near (floor + GROW_MB) instead of letting it run to 2GB+. A backstop
|
||||||
|
# interval still collects slow accumulation when idle.
|
||||||
|
def _growth_triggered_gc():
|
||||||
|
import gc
|
||||||
|
CHECK_SECONDS = 8 # cheap RSS poll cadence
|
||||||
|
GROW_MB = 200 # collect once RSS climbs this much since the last sweep
|
||||||
|
BACKSTOP_SECONDS = 120 # ...or at least this often regardless
|
||||||
|
try:
|
||||||
|
import psutil
|
||||||
|
proc = psutil.Process(os.getpid())
|
||||||
|
rss_mb = lambda: proc.memory_info().rss / (1024 * 1024)
|
||||||
|
except Exception:
|
||||||
|
rss_mb = lambda: 0.0
|
||||||
|
# glibc malloc_trim hands freed arenas back to the OS — WITHOUT it gc.collect() frees
|
||||||
|
# the Python objects but glibc hoards the memory, so RSS never drops and the peak still
|
||||||
|
# runs to 2GB+. Best-effort: absent on musl/Alpine or non-Linux, where we just skip it
|
||||||
|
# (gc still helps, RSS just sticks closer to the high-water mark there).
|
||||||
|
_trim = None
|
||||||
|
try:
|
||||||
|
import ctypes
|
||||||
|
import ctypes.util
|
||||||
|
_libc = ctypes.CDLL(ctypes.util.find_library('c') or 'libc.so.6')
|
||||||
|
if hasattr(_libc, 'malloc_trim'):
|
||||||
|
_trim = _libc.malloc_trim
|
||||||
|
except Exception: # noqa: S110 — malloc_trim absent on musl/non-Linux; just skip it
|
||||||
|
pass
|
||||||
|
floor = rss_mb()
|
||||||
|
last = time.time()
|
||||||
|
while True:
|
||||||
|
time.sleep(CHECK_SECONDS)
|
||||||
|
try:
|
||||||
|
if (rss_mb() - floor) >= GROW_MB or (time.time() - last) >= BACKSTOP_SECONDS:
|
||||||
|
gc.collect()
|
||||||
|
if _trim is not None:
|
||||||
|
try:
|
||||||
|
_trim(0)
|
||||||
|
except Exception: # noqa: S110 — best-effort OS reclaim, never fatal
|
||||||
|
pass
|
||||||
|
floor = rss_mb()
|
||||||
|
last = time.time()
|
||||||
|
except Exception: # noqa: S110 — best-effort housekeeping, never crash the app
|
||||||
|
pass
|
||||||
|
threading.Thread(target=_growth_triggered_gc, daemon=True, name='gc-sweeper').start()
|
||||||
|
logger.info("GC sweeper started (collect + malloc_trim on +200MB growth, backstop 120s)")
|
||||||
|
|
||||||
# Register action handlers and start automation engine
|
# Register action handlers and start automation engine
|
||||||
_register_automation_handlers()
|
_register_automation_handlers()
|
||||||
if automation_engine:
|
if automation_engine:
|
||||||
|
|
@ -38555,6 +39027,11 @@ def start_runtime_services():
|
||||||
_runtime_started = True
|
_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)
|
# Direct execution: python web_server.py (dev/Windows fallback)
|
||||||
# Production should use: gunicorn -c gunicorn.conf.py wsgi:application
|
# Production should use: gunicorn -c gunicorn.conf.py wsgi:application
|
||||||
if _DIRECT_RUN:
|
if _DIRECT_RUN:
|
||||||
|
|
|
||||||
1046
webui/index.html
1046
webui/index.html
File diff suppressed because it is too large
Load diff
441
webui/package-lock.json
generated
441
webui/package-lock.json
generated
|
|
@ -96,13 +96,13 @@
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@babel/code-frame": {
|
"node_modules/@babel/code-frame": {
|
||||||
"version": "7.29.0",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
|
||||||
"integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==",
|
"integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/helper-validator-identifier": "^7.28.5",
|
"@babel/helper-validator-identifier": "^7.29.7",
|
||||||
"js-tokens": "^4.0.0",
|
"js-tokens": "^4.0.0",
|
||||||
"picocolors": "^1.1.1"
|
"picocolors": "^1.1.1"
|
||||||
},
|
},
|
||||||
|
|
@ -111,9 +111,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/compat-data": {
|
"node_modules/@babel/compat-data": {
|
||||||
"version": "7.29.0",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz",
|
||||||
"integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==",
|
"integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|
@ -121,21 +121,21 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/core": {
|
"node_modules/@babel/core": {
|
||||||
"version": "7.29.0",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
|
||||||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.29.0",
|
"@babel/code-frame": "^7.29.7",
|
||||||
"@babel/generator": "^7.29.0",
|
"@babel/generator": "^7.29.7",
|
||||||
"@babel/helper-compilation-targets": "^7.28.6",
|
"@babel/helper-compilation-targets": "^7.29.7",
|
||||||
"@babel/helper-module-transforms": "^7.28.6",
|
"@babel/helper-module-transforms": "^7.29.7",
|
||||||
"@babel/helpers": "^7.28.6",
|
"@babel/helpers": "^7.29.7",
|
||||||
"@babel/parser": "^7.29.0",
|
"@babel/parser": "^7.29.7",
|
||||||
"@babel/template": "^7.28.6",
|
"@babel/template": "^7.29.7",
|
||||||
"@babel/traverse": "^7.29.0",
|
"@babel/traverse": "^7.29.7",
|
||||||
"@babel/types": "^7.29.0",
|
"@babel/types": "^7.29.7",
|
||||||
"@jridgewell/remapping": "^2.3.5",
|
"@jridgewell/remapping": "^2.3.5",
|
||||||
"convert-source-map": "^2.0.0",
|
"convert-source-map": "^2.0.0",
|
||||||
"debug": "^4.1.0",
|
"debug": "^4.1.0",
|
||||||
|
|
@ -152,14 +152,14 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/generator": {
|
"node_modules/@babel/generator": {
|
||||||
"version": "7.29.1",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz",
|
||||||
"integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==",
|
"integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/parser": "^7.29.0",
|
"@babel/parser": "^7.29.7",
|
||||||
"@babel/types": "^7.29.0",
|
"@babel/types": "^7.29.7",
|
||||||
"@jridgewell/gen-mapping": "^0.3.12",
|
"@jridgewell/gen-mapping": "^0.3.12",
|
||||||
"@jridgewell/trace-mapping": "^0.3.28",
|
"@jridgewell/trace-mapping": "^0.3.28",
|
||||||
"jsesc": "^3.0.2"
|
"jsesc": "^3.0.2"
|
||||||
|
|
@ -169,14 +169,14 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/helper-compilation-targets": {
|
"node_modules/@babel/helper-compilation-targets": {
|
||||||
"version": "7.28.6",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz",
|
||||||
"integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==",
|
"integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/compat-data": "^7.28.6",
|
"@babel/compat-data": "^7.29.7",
|
||||||
"@babel/helper-validator-option": "^7.27.1",
|
"@babel/helper-validator-option": "^7.29.7",
|
||||||
"browserslist": "^4.24.0",
|
"browserslist": "^4.24.0",
|
||||||
"lru-cache": "^5.1.1",
|
"lru-cache": "^5.1.1",
|
||||||
"semver": "^6.3.1"
|
"semver": "^6.3.1"
|
||||||
|
|
@ -196,9 +196,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/helper-globals": {
|
"node_modules/@babel/helper-globals": {
|
||||||
"version": "7.28.0",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz",
|
||||||
"integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==",
|
"integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|
@ -206,29 +206,29 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/helper-module-imports": {
|
"node_modules/@babel/helper-module-imports": {
|
||||||
"version": "7.28.6",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz",
|
||||||
"integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==",
|
"integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/traverse": "^7.28.6",
|
"@babel/traverse": "^7.29.7",
|
||||||
"@babel/types": "^7.28.6"
|
"@babel/types": "^7.29.7"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/helper-module-transforms": {
|
"node_modules/@babel/helper-module-transforms": {
|
||||||
"version": "7.28.6",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz",
|
||||||
"integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==",
|
"integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/helper-module-imports": "^7.28.6",
|
"@babel/helper-module-imports": "^7.29.7",
|
||||||
"@babel/helper-validator-identifier": "^7.28.5",
|
"@babel/helper-validator-identifier": "^7.29.7",
|
||||||
"@babel/traverse": "^7.28.6"
|
"@babel/traverse": "^7.29.7"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
|
|
@ -248,9 +248,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/helper-string-parser": {
|
"node_modules/@babel/helper-string-parser": {
|
||||||
"version": "7.27.1",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
|
||||||
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
|
"integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|
@ -258,9 +258,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/helper-validator-identifier": {
|
"node_modules/@babel/helper-validator-identifier": {
|
||||||
"version": "7.28.5",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
|
||||||
"integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
|
"integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|
@ -268,9 +268,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/helper-validator-option": {
|
"node_modules/@babel/helper-validator-option": {
|
||||||
"version": "7.27.1",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz",
|
||||||
"integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==",
|
"integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|
@ -278,27 +278,27 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/helpers": {
|
"node_modules/@babel/helpers": {
|
||||||
"version": "7.29.2",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz",
|
||||||
"integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==",
|
"integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/template": "^7.28.6",
|
"@babel/template": "^7.29.7",
|
||||||
"@babel/types": "^7.29.0"
|
"@babel/types": "^7.29.7"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/parser": {
|
"node_modules/@babel/parser": {
|
||||||
"version": "7.29.2",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz",
|
||||||
"integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==",
|
"integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/types": "^7.29.0"
|
"@babel/types": "^7.29.7"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"parser": "bin/babel-parser.js"
|
"parser": "bin/babel-parser.js"
|
||||||
|
|
@ -349,33 +349,33 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/template": {
|
"node_modules/@babel/template": {
|
||||||
"version": "7.28.6",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
|
||||||
"integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==",
|
"integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.28.6",
|
"@babel/code-frame": "^7.29.7",
|
||||||
"@babel/parser": "^7.28.6",
|
"@babel/parser": "^7.29.7",
|
||||||
"@babel/types": "^7.28.6"
|
"@babel/types": "^7.29.7"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/traverse": {
|
"node_modules/@babel/traverse": {
|
||||||
"version": "7.29.0",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz",
|
||||||
"integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==",
|
"integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.29.0",
|
"@babel/code-frame": "^7.29.7",
|
||||||
"@babel/generator": "^7.29.0",
|
"@babel/generator": "^7.29.7",
|
||||||
"@babel/helper-globals": "^7.28.0",
|
"@babel/helper-globals": "^7.29.7",
|
||||||
"@babel/parser": "^7.29.0",
|
"@babel/parser": "^7.29.7",
|
||||||
"@babel/template": "^7.28.6",
|
"@babel/template": "^7.29.7",
|
||||||
"@babel/types": "^7.29.0",
|
"@babel/types": "^7.29.7",
|
||||||
"debug": "^4.3.1"
|
"debug": "^4.3.1"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|
@ -383,14 +383,14 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/types": {
|
"node_modules/@babel/types": {
|
||||||
"version": "7.29.0",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz",
|
||||||
"integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
|
"integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/helper-string-parser": "^7.27.1",
|
"@babel/helper-string-parser": "^7.29.7",
|
||||||
"@babel/helper-validator-identifier": "^7.28.5"
|
"@babel/helper-validator-identifier": "^7.29.7"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
|
|
@ -610,21 +610,21 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@emnapi/core": {
|
"node_modules/@emnapi/core": {
|
||||||
"version": "1.10.0",
|
"version": "1.11.1",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
|
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
|
||||||
"integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
|
"integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emnapi/wasi-threads": "1.2.1",
|
"@emnapi/wasi-threads": "1.2.2",
|
||||||
"tslib": "^2.4.0"
|
"tslib": "^2.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@emnapi/runtime": {
|
"node_modules/@emnapi/runtime": {
|
||||||
"version": "1.10.0",
|
"version": "1.11.1",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
|
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
|
||||||
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
|
"integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
|
|
@ -633,9 +633,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@emnapi/wasi-threads": {
|
"node_modules/@emnapi/wasi-threads": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
|
||||||
"integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
|
"integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
|
|
@ -862,14 +862,14 @@
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@napi-rs/wasm-runtime": {
|
"node_modules/@napi-rs/wasm-runtime": {
|
||||||
"version": "1.1.4",
|
"version": "1.1.6",
|
||||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz",
|
||||||
"integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==",
|
"integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tybys/wasm-util": "^0.10.1"
|
"@tybys/wasm-util": "^0.10.3"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "github",
|
"type": "github",
|
||||||
|
|
@ -905,6 +905,16 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@oxfmt/binding-android-arm-eabi": {
|
||||||
"version": "0.47.0",
|
"version": "0.47.0",
|
||||||
"resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm-eabi/-/binding-android-arm-eabi-0.47.0.tgz",
|
"resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm-eabi/-/binding-android-arm-eabi-0.47.0.tgz",
|
||||||
|
|
@ -1736,9 +1746,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-android-arm64": {
|
"node_modules/@rolldown/binding-android-arm64": {
|
||||||
"version": "1.0.0-rc.17",
|
"version": "1.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.17.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.3.tgz",
|
||||||
"integrity": "sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==",
|
"integrity": "sha512-DT6Z3PhvioeHMvxo+xHc3KtqggrI7CCTXCmC2h/5zUlp5jVitv7XEy+9q5/7v8IolhlioawpMo8Kg0EEBy7J0g==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
|
@ -1753,9 +1763,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-darwin-arm64": {
|
"node_modules/@rolldown/binding-darwin-arm64": {
|
||||||
"version": "1.0.0-rc.17",
|
"version": "1.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.17.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.3.tgz",
|
||||||
"integrity": "sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw==",
|
"integrity": "sha512-0NwgwsjM7LrsuVnXMK3koTpagBNOhloc/BNjKqZjv4V5zI5r13qx69uVhRx+o5Z0yy4Hzq+lpy7TAgUG/ocvrw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
|
@ -1770,9 +1780,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-darwin-x64": {
|
"node_modules/@rolldown/binding-darwin-x64": {
|
||||||
"version": "1.0.0-rc.17",
|
"version": "1.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.17.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.3.tgz",
|
||||||
"integrity": "sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw==",
|
"integrity": "sha512-YtiBp4disu6V560loT6PjMdiRaWmVvDNrUunAalbiFx2ggeJwxdAsgZMcoGP17uyAsTwAj5V1niksxlHnVQ1Sw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
|
@ -1787,9 +1797,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-freebsd-x64": {
|
"node_modules/@rolldown/binding-freebsd-x64": {
|
||||||
"version": "1.0.0-rc.17",
|
"version": "1.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.17.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.3.tgz",
|
||||||
"integrity": "sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw==",
|
"integrity": "sha512-yD3EkEdXk2LypPxnf/kSZHirarsI8gcPzc62SukhR9VJTyvV+F9Q/GxWNuCojc7sXyuVC4DxRGhdDK4X8VSsbw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
|
@ -1804,9 +1814,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
|
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
|
||||||
"version": "1.0.0-rc.17",
|
"version": "1.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.17.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.3.tgz",
|
||||||
"integrity": "sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ==",
|
"integrity": "sha512-c+8vieQbsD7HNAHKIA34w0GJ9FedFFuJGD+7E6vz7Q3uqAIugL5p45fhlsj4UaAsHpcmlqugBWMhA0/j7o0sIg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
|
|
@ -1821,9 +1831,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-linux-arm64-gnu": {
|
"node_modules/@rolldown/binding-linux-arm64-gnu": {
|
||||||
"version": "1.0.0-rc.17",
|
"version": "1.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.17.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.3.tgz",
|
||||||
"integrity": "sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q==",
|
"integrity": "sha512-50jD0uUwLvur7Zz9LHz17kaAdTPjn5wN93hEgjvmYFRZwiR7ZJYovTd5ipyWJDAnXKvZ+wgc+/Ika6dwSF5OcA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
|
@ -1841,9 +1851,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-linux-arm64-musl": {
|
"node_modules/@rolldown/binding-linux-arm64-musl": {
|
||||||
"version": "1.0.0-rc.17",
|
"version": "1.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.17.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.3.tgz",
|
||||||
"integrity": "sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==",
|
"integrity": "sha512-BO9+oPL8K9poZJBfYPsXNtYjPE5uM3qeehT3aFcW4LITOl+iSqhp0abzjR2nWBUNjIZeKXjAEWBZ64WjNoHd6w==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
|
@ -1861,9 +1871,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
|
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
|
||||||
"version": "1.0.0-rc.17",
|
"version": "1.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.17.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.3.tgz",
|
||||||
"integrity": "sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==",
|
"integrity": "sha512-f3VpLB1vQ0Eo6ecr/6cekLnvYMFF4YBFoVGkfkvPLq1bAkbAwHYQPZKoAmG6OJyTcxxoC+AvezGx/S1obNC0Mw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
|
|
@ -1881,9 +1891,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-linux-s390x-gnu": {
|
"node_modules/@rolldown/binding-linux-s390x-gnu": {
|
||||||
"version": "1.0.0-rc.17",
|
"version": "1.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.17.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.3.tgz",
|
||||||
"integrity": "sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==",
|
"integrity": "sha512-AmurZ26Pqx/RI9N1gzEOCklkKXl927yjfXWUUS0O7Puh8ARM/Ob8qfrD3qnWksScdw6cSrW5PSHE9DyLu7+PtA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
|
|
@ -1901,9 +1911,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-linux-x64-gnu": {
|
"node_modules/@rolldown/binding-linux-x64-gnu": {
|
||||||
"version": "1.0.0-rc.17",
|
"version": "1.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.17.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.3.tgz",
|
||||||
"integrity": "sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==",
|
"integrity": "sha512-JJpqs8bRGITDOdbkNKnlojzBabbOHrqjSvDr0IVsZObE1lBcPjxItUEY9eWIDbxaJ3cGrXPWGfGkIxFijg/URg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
|
@ -1921,9 +1931,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-linux-x64-musl": {
|
"node_modules/@rolldown/binding-linux-x64-musl": {
|
||||||
"version": "1.0.0-rc.17",
|
"version": "1.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.17.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.3.tgz",
|
||||||
"integrity": "sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==",
|
"integrity": "sha512-rSJcdjPxzA/by/6/rYs+v+bXU7UjvnbUWz8MJb6kh6+knqB1dCrtHg0uu7C/4haqJvqdkYHQ5IGn+tCH9GLW/g==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
|
@ -1941,9 +1951,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-openharmony-arm64": {
|
"node_modules/@rolldown/binding-openharmony-arm64": {
|
||||||
"version": "1.0.0-rc.17",
|
"version": "1.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.17.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.3.tgz",
|
||||||
"integrity": "sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==",
|
"integrity": "sha512-hQ3/PYkDJICgevvyNcVrihVeqq7k1Pp3VZ9lY+dauAYUJKO+auqApvANhvR1An9BhmqYKvW2Mu1F9u4DXSMLxQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
|
@ -1958,9 +1968,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-wasm32-wasi": {
|
"node_modules/@rolldown/binding-wasm32-wasi": {
|
||||||
"version": "1.0.0-rc.17",
|
"version": "1.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.17.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.3.tgz",
|
||||||
"integrity": "sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA==",
|
"integrity": "sha512-Elcv/BtML9lXrV6JuKITc/grN2kYV9gjsQpW8Jfw4ioK0TOkjBjye0nnyqQNy9STNaI20lXNaQBRrD5gSgR0Yg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"wasm32"
|
"wasm32"
|
||||||
],
|
],
|
||||||
|
|
@ -1968,18 +1978,18 @@
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emnapi/core": "1.10.0",
|
"@emnapi/core": "1.11.1",
|
||||||
"@emnapi/runtime": "1.10.0",
|
"@emnapi/runtime": "1.11.1",
|
||||||
"@napi-rs/wasm-runtime": "^1.1.4"
|
"@napi-rs/wasm-runtime": "^1.1.6"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^20.19.0 || >=22.12.0"
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-win32-arm64-msvc": {
|
"node_modules/@rolldown/binding-win32-arm64-msvc": {
|
||||||
"version": "1.0.0-rc.17",
|
"version": "1.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.17.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.3.tgz",
|
||||||
"integrity": "sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA==",
|
"integrity": "sha512-2DrEfhluH9yhiaFApmsjsjwrSYbNcY1oFTzYSP1a535jDbV98zCFanA/96TBUd0iDFcxGmw9QRExwGCXz3U+/g==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
|
@ -1994,9 +2004,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-win32-x64-msvc": {
|
"node_modules/@rolldown/binding-win32-x64-msvc": {
|
||||||
"version": "1.0.0-rc.17",
|
"version": "1.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.17.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.3.tgz",
|
||||||
"integrity": "sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg==",
|
"integrity": "sha512-OL4OMk7UPXOeVGGd3qo5zJyPIljf4AFgk5QAkPPS+OoLuOOozhuaQGC18MxVTnw/06q93gShAJzlwnSCY9YtqA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
|
@ -2426,9 +2436,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tybys/wasm-util": {
|
"node_modules/@tybys/wasm-util": {
|
||||||
"version": "0.10.1",
|
"version": "0.10.3",
|
||||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
|
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
|
||||||
"integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==",
|
"integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
|
|
@ -2819,9 +2829,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/baseline-browser-mapping": {
|
"node_modules/baseline-browser-mapping": {
|
||||||
"version": "2.10.14",
|
"version": "2.10.40",
|
||||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.14.tgz",
|
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.40.tgz",
|
||||||
"integrity": "sha512-fOVLPAsFTsQfuCkvahZkzq6nf8KvGWanlYoTh0SVA0A/PIUxQGU2AOZAoD95n2gFLVDW/jP6sbGLny95nmEuHA==",
|
"integrity": "sha512-BSSLZ9/Cjjv7Gtj5B68ZzXcXUg8iOf3fme+FCuh8rC/Go+Kmh8cox7M3A8dolou16s64QjLPOSdngh7GxXvkSw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
|
|
@ -2868,9 +2878,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/browserslist": {
|
"node_modules/browserslist": {
|
||||||
"version": "4.28.2",
|
"version": "4.28.4",
|
||||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz",
|
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.4.tgz",
|
||||||
"integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==",
|
"integrity": "sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
|
|
@ -2888,10 +2898,10 @@
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.10.12",
|
"baseline-browser-mapping": "^2.10.38",
|
||||||
"caniuse-lite": "^1.0.30001782",
|
"caniuse-lite": "^1.0.30001799",
|
||||||
"electron-to-chromium": "^1.5.328",
|
"electron-to-chromium": "^1.5.376",
|
||||||
"node-releases": "^2.0.36",
|
"node-releases": "^2.0.48",
|
||||||
"update-browserslist-db": "^1.2.3"
|
"update-browserslist-db": "^1.2.3"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
|
|
@ -2902,9 +2912,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/caniuse-lite": {
|
"node_modules/caniuse-lite": {
|
||||||
"version": "1.0.30001785",
|
"version": "1.0.30001799",
|
||||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001785.tgz",
|
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz",
|
||||||
"integrity": "sha512-blhOL/WNR+Km1RI/LCVAvA73xplXA7ZbjzI4YkMK9pa6T/P3F2GxjNpEkyw5repTw9IvkyrjyHpwjnhZ5FOvYQ==",
|
"integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
|
|
@ -3271,9 +3281,9 @@
|
||||||
"peer": true
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/electron-to-chromium": {
|
"node_modules/electron-to-chromium": {
|
||||||
"version": "1.5.331",
|
"version": "1.5.380",
|
||||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.331.tgz",
|
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.380.tgz",
|
||||||
"integrity": "sha512-IbxXrsTlD3hRodkLnbxAPP4OuJYdWCeM3IOdT+CpcMoIwIoDfCmRpEtSPfwBXxVkg9xmBeY7Lz2Eo2TDn/HC3Q==",
|
"integrity": "sha512-W6d5AbuEoRayO447cqrg6lKJIlscgRnnxOZl/08kfV71BQDoEBC7Wwis68z87LjyK6f4kWyTaubuDbhHKrZkbA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
|
@ -4059,9 +4069,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/nanoid": {
|
"node_modules/nanoid": {
|
||||||
"version": "3.3.11",
|
"version": "3.3.15",
|
||||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz",
|
||||||
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
|
"integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
|
|
@ -4078,11 +4088,14 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/node-releases": {
|
"node_modules/node-releases": {
|
||||||
"version": "2.0.37",
|
"version": "2.0.50",
|
||||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz",
|
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.50.tgz",
|
||||||
"integrity": "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==",
|
"integrity": "sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"node_modules/normalize-path": {
|
"node_modules/normalize-path": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
|
|
@ -4295,9 +4308,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.10",
|
"version": "8.5.16",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz",
|
||||||
"integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==",
|
"integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
|
|
@ -4315,7 +4328,7 @@
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^3.3.11",
|
"nanoid": "^3.3.12",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
"source-map-js": "^1.2.1"
|
"source-map-js": "^1.2.1"
|
||||||
},
|
},
|
||||||
|
|
@ -4535,14 +4548,14 @@
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/rolldown": {
|
"node_modules/rolldown": {
|
||||||
"version": "1.0.0-rc.17",
|
"version": "1.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.17.tgz",
|
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.3.tgz",
|
||||||
"integrity": "sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==",
|
"integrity": "sha512-1F1eEtUBtFvcGm1HQ9TiUIUHPQG7mSAODrhIzjxoUEFuo8OcbrGLiVLkevNgj84TE4lnHvnumwFjhJO5Eu135g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@oxc-project/types": "=0.127.0",
|
"@oxc-project/types": "=0.137.0",
|
||||||
"@rolldown/pluginutils": "1.0.0-rc.17"
|
"@rolldown/pluginutils": "^1.0.0"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"rolldown": "bin/cli.mjs"
|
"rolldown": "bin/cli.mjs"
|
||||||
|
|
@ -4551,37 +4564,27 @@
|
||||||
"node": "^20.19.0 || >=22.12.0"
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@rolldown/binding-android-arm64": "1.0.0-rc.17",
|
"@rolldown/binding-android-arm64": "1.1.3",
|
||||||
"@rolldown/binding-darwin-arm64": "1.0.0-rc.17",
|
"@rolldown/binding-darwin-arm64": "1.1.3",
|
||||||
"@rolldown/binding-darwin-x64": "1.0.0-rc.17",
|
"@rolldown/binding-darwin-x64": "1.1.3",
|
||||||
"@rolldown/binding-freebsd-x64": "1.0.0-rc.17",
|
"@rolldown/binding-freebsd-x64": "1.1.3",
|
||||||
"@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.17",
|
"@rolldown/binding-linux-arm-gnueabihf": "1.1.3",
|
||||||
"@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.17",
|
"@rolldown/binding-linux-arm64-gnu": "1.1.3",
|
||||||
"@rolldown/binding-linux-arm64-musl": "1.0.0-rc.17",
|
"@rolldown/binding-linux-arm64-musl": "1.1.3",
|
||||||
"@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.17",
|
"@rolldown/binding-linux-ppc64-gnu": "1.1.3",
|
||||||
"@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.17",
|
"@rolldown/binding-linux-s390x-gnu": "1.1.3",
|
||||||
"@rolldown/binding-linux-x64-gnu": "1.0.0-rc.17",
|
"@rolldown/binding-linux-x64-gnu": "1.1.3",
|
||||||
"@rolldown/binding-linux-x64-musl": "1.0.0-rc.17",
|
"@rolldown/binding-linux-x64-musl": "1.1.3",
|
||||||
"@rolldown/binding-openharmony-arm64": "1.0.0-rc.17",
|
"@rolldown/binding-openharmony-arm64": "1.1.3",
|
||||||
"@rolldown/binding-wasm32-wasi": "1.0.0-rc.17",
|
"@rolldown/binding-wasm32-wasi": "1.1.3",
|
||||||
"@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.17",
|
"@rolldown/binding-win32-arm64-msvc": "1.1.3",
|
||||||
"@rolldown/binding-win32-x64-msvc": "1.0.0-rc.17"
|
"@rolldown/binding-win32-x64-msvc": "1.1.3"
|
||||||
}
|
|
||||||
},
|
|
||||||
"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": {
|
"node_modules/rolldown/node_modules/@rolldown/pluginutils": {
|
||||||
"version": "1.0.0-rc.17",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.17.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
|
||||||
"integrity": "sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==",
|
"integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
|
@ -4788,9 +4791,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/tinyglobby": {
|
"node_modules/tinyglobby": {
|
||||||
"version": "0.2.16",
|
"version": "0.2.17",
|
||||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
|
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
||||||
"integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==",
|
"integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|
@ -4922,9 +4925,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/undici": {
|
"node_modules/undici": {
|
||||||
"version": "7.24.7",
|
"version": "7.28.0",
|
||||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.24.7.tgz",
|
"resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz",
|
||||||
"integrity": "sha512-H/nlJ/h0ggGC+uRL3ovD+G0i4bqhvsDOpbDv7At5eFLlj2b41L8QliGbnl2H7SnDiYhENphh1tQFJZf+MyfLsQ==",
|
"integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|
@ -5026,17 +5029,17 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "8.0.10",
|
"version": "8.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.0.tgz",
|
||||||
"integrity": "sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==",
|
"integrity": "sha512-BuJcQK/56NQTWDGn4ABea3q4SSBdNPWwNZKTkkUpcMPnLoquSYH8llRtSUIgoL1KSCpHt5eghLShn50mH36y7Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"lightningcss": "^1.32.0",
|
"lightningcss": "^1.32.0",
|
||||||
"picomatch": "^4.0.4",
|
"picomatch": "^4.0.4",
|
||||||
"postcss": "^8.5.10",
|
"postcss": "^8.5.15",
|
||||||
"rolldown": "1.0.0-rc.17",
|
"rolldown": "~1.1.2",
|
||||||
"tinyglobby": "^0.2.16"
|
"tinyglobby": "^0.2.17"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"vite": "bin/vite.js"
|
"vite": "bin/vite.js"
|
||||||
|
|
@ -5052,7 +5055,7 @@
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/node": "^20.19.0 || >=22.12.0",
|
"@types/node": "^20.19.0 || >=22.12.0",
|
||||||
"@vitejs/devtools": "^0.1.0",
|
"@vitejs/devtools": "^0.3.0",
|
||||||
"esbuild": "^0.27.0 || ^0.28.0",
|
"esbuild": "^0.27.0 || ^0.28.0",
|
||||||
"jiti": ">=1.21.0",
|
"jiti": ">=1.21.0",
|
||||||
"less": "^4.0.0",
|
"less": "^4.0.0",
|
||||||
|
|
|
||||||
|
|
@ -23,11 +23,21 @@ export interface ImportStagingFile {
|
||||||
manual_match?: ImportTrackResult;
|
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 {
|
export interface ImportStagingFilesPayload {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
files?: ImportStagingFile[];
|
files?: ImportStagingFile[];
|
||||||
staging_path?: string;
|
staging_path?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
|
scanning?: boolean;
|
||||||
|
progress?: ImportScanProgress;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ImportStagingGroup {
|
export interface ImportStagingGroup {
|
||||||
|
|
@ -47,6 +57,8 @@ export interface ImportStagingGroupsPayload {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
groups?: ImportStagingGroup[];
|
groups?: ImportStagingGroup[];
|
||||||
error?: string;
|
error?: string;
|
||||||
|
scanning?: boolean;
|
||||||
|
progress?: ImportScanProgress;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ImportAlbumResult {
|
export interface ImportAlbumResult {
|
||||||
|
|
|
||||||
|
|
@ -250,6 +250,19 @@ describe('import route', () => {
|
||||||
expect(await screen.findByText('Import folder: error')).toBeInTheDocument();
|
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 () => {
|
it('stores the active tab in nested route paths', async () => {
|
||||||
const { history } = renderImportRoute();
|
const { history } = renderImportRoute();
|
||||||
|
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue