Merge pull request #930 from Nezreka/dev

Dev
This commit is contained in:
BoulderBadgeDad 2026-06-25 17:06:37 -07:00 committed by GitHub
commit c8accf4e28
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
110 changed files with 7868 additions and 1679 deletions

View file

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

View file

@ -0,0 +1,74 @@
# discover page — best in class plan (#913 + full generator audit)
morning notes. did the work overnight. tl;dr at top, details below, all of it `break nothing` + tested.
## what i shipped tonight (done, tested, safe)
### 1. listening recommendations (#913) — went from BROKEN to best-in-class
the feature was silently producing **zero** recs on real data. dug in and found three stacked bugs in the generation:
- **wrong id key (the killer).** `similar_artists.source_artist_id` is a *source* id (spotify/itunes/deezer), but the scanner built its id→name map from `artists.id` (the internal row id). so every edge resolved to nothing → 0 recs. proved it on your live db: internal-id join = 0 rows, spotify-id join = 71,636 rows.
- **consensus could never fire.** it fed the ranker `get_top_similar_artists`, which does `GROUP BY similar_artist_name` + `MAX(source_artist_id)` — collapsing every similar artist down to a *single* seed. the whole point of the ranker is "artist X is similar to 3 of your seeds = strong signal," and that signal was being flattened away before it ever reached the ranker.
- **similarity strength thrown away.** each edge stores a 1-10 closeness rank; it was ignored (everything weighted equally).
the fix (all in the pure, tested core + thin scan wiring):
- build id→name from the **source-id columns**, query the **raw per-seed edges** (consensus preserved), and thread **similarity_rank** into the score so a seed's closest matches count for more.
- **recency-weighted seeds**: `weight = lifetime_plays + 1.5 × recent_30d_plays`. picks now track what you're into *now*, not just all-time totals.
result on your actual library (simulated through the real code path): **40 recommendations, 13 with multi-seed consensus, all 40 with cached art.** top picks: Arcangel (Bad Bunny + Ozuna + J Balvin), Melanie Martinez (Ariana + Billie), Maluma, De La Ghetto — all coherent, all explainable.
### 2. its own row on the discover page
new row **"Based On Your Listening"** — play-weighted, consensus-ranked artist cards with a **"Because you listen to X, Y"** line. sits right above the library-driven "Recommended For You" row. purely additive: new endpoint `/api/discover/listening-recommendations`, new loader, hides itself when empty.
**you need to run one watchlist scan** for the row to populate (the data regenerates during the scan — i did NOT touch your live db). before that scan the row just stays hidden; after it, it fills in.
> note: this is deliberately different from the existing "Recommended For You" row. that one is driven by your *whole library / watchlist*. this one is driven by your *actual listening intensity* — the ~30 artists you really play, not the thousands you happen to own.
### 3. Fresh Tape "only 5-10 tracks" — fixed
root cause: `get_discovery_recent_albums` orders `release_date DESC`, so announced-but-unreleased albums sort to the *top* and ate the 50-album budget. the scanner skipped them *after* the budget was already spent → only a handful of released albums left → 5-10 tracks. fixed by fetching a generous budget (300) **and** excluding next-year albums at the query, so released albums fill the budget. the precise same-year `is_future_release` skip stays as a second guard. downstream caps (6/artist, top 75, take 50) unchanged.
**tests:** 25 pure-core cases (consensus/similarity/recency) + 2 Fresh Tape regression tests, all green. full discovery suite (255) green. nothing else touched.
---
## best-in-class roadmap for listening recs (next phases — your call)
these are the levers to take it further. ordered by value-to-risk. none are required; tonight's work stands on its own.
| phase | what | value | risk | notes |
|---|---|---|---|---|
| **3** | **playable track row** ✅ DONE | high | low-med | shipped: "🎧 Your Listening Mix" row — a track playlist (play/queue/download/sync) right under the artist row. stored as full render-ready dicts (not pool-hydrated, so it can't shrink on pool rotation like Fresh Tape does). |
| **4** | **direct top-tracks fetch** ✅ DONE | high | med | shipped: scan fetches each recommended artist's top tracks (Spotify/Deezer), resolving the artist id by name-search when the similar-artist row lacks one — guarded by a strict name-match so it never pulls the wrong artist. bounded (top 20 recs), per-call guarded, fail-soft to the pool. iTunes has no top-tracks API → pool-only there. needs a live scan to populate. |
| **5** | **genre-affinity boost** | med | low | we already compute your genre breakdown. boost recs whose genres match your top genres → tighter taste alignment. pure scoring add. |
| **6** | **adventurousness dial** | med | low | the ranker already supports `min_seed_count` (consensus floor). expose it as a "Safe ↔ Adventurous" slider on the row. |
| **7** | **diversity pass** | low-med | low | avoid 40 recs all orbiting your single heaviest seed — cap picks-per-seed so the row spans your taste. |
the core is built to absorb all of these without re-plumbing — `similarity_from_rank`, `build_recency_weighted_seeds`, and the scoring formula are all pure + tested.
---
## full discover-page generator audit (every soulsync-built row, excluding last.fm + listenbrainz)
how each one is generated today, and whether it can be elevated. "clear win" = safe + additive. "product call" = needs your decision (changes the row's character).
### curated (built during the scan, then hydrated)
- **Fresh Tape / Release Radar** — new releases from watchlist+similar artists. **FIXED tonight** (see above). one more *clear win* available: hydration silently drops any curated id no longer in the discovery pool — could fall back to the stored `track_data_json` blob so the row can't shrink at read time.
- **The Archives / Discovery Weekly** — strong already. nice 3-tier popularity split + serendipity scoring (boost never-played artists, penalize overplayed). same hydration-drop caveat as Fresh Tape; same cheap fallback fix.
- **Seasonal Mix** — cleanest of the bunch. hydrates from a dedicated `seasonal_tracks` table (carries its own data), so it doesn't suffer the pool-drop problem. no bug.
### discovery-pool generators (live queries)
- **Popular Picks** — ranks by popularity DESC. solid. only nit: on iTunes (no popularity scale) it silently degrades to random — indistinguishable from Shuffle there. UI-label thing at most.
- **Hidden Gems***clear win*. currently `ORDER BY RANDOM()` over low-popularity tracks — so it's "random obscure," not "*best* obscure." a light ranking (popularity just under the threshold, or genre-affinity to you) would make it feel curated instead of arbitrary. (a deeper *product call*: add personalization like Archives has — bigger lift, changes its "pure underground" character.)
- **Genre Playlists** — good. pushes the genre match into SQL. `RANDOM()` ordering is fine for a browse; a popularity/affinity tiebreak (*clear win*) would make thin genres feel less arbitrary.
- **Discovery Shuffle** — random by design, correct. only possible add: exclude tracks already shown in other rows this refresh (needs a cross-section seen-set — medium plumbing).
- **Time Machine (by decade)***clear win, low risk*: decades are hardcoded, so a modern-only library shows 7 decade tabs, 5 empty. filter the tabs to decades that actually have pool data.
- **Daily Mix** — the weakest row. the "50% your library" half permanently returns nothing (library tracks have no source ids to play), so each Daily Mix is really just a relabeled Genre Playlist. real fix = backfill source ids into library rows (*schema-level, higher risk*) — worth a dedicated pass, not a quick tweak. also silently falls back to "top artists as pseudo-genres" when genre data is missing → "Daily Mix 1" becomes mislabeled artist-radio. gate/label that (*clear win*).
### cross-cutting
- **hydration fragility** (Fresh Tape + Archives): both depend on curated ids still living in the pool at read time; misses are dropped silently. Seasonal already solved this with a dedicated table. giving the two spotify-style rows the same data-blob fallback is the single most robust cross-cutting fix. low risk, clear win.
- **RANDOM-ordering pattern** (Hidden Gems, Shuffle, Genre, Decade): intentional for variety, but leaves quality signal on the table for the non-shuffle rows. adding a light ranking pass to Hidden Gems + Genre is the biggest "best-in-class" lever after tonight's work.
want me to take any of these? the Hidden Gems ranking + Time Machine empty-decade filter + the Fresh Tape/Archives hydration fallback are all safe, additive, same-shape-as-tonight wins i can knock out next.

13
RELEASE_2.7.9_discord.md Normal file
View file

@ -0,0 +1,13 @@
**SoulSync 2.7.9** is out 🎉 a big one.
🎚️ **Best-quality downloads** — downloads now follow a ranked quality profile you drag to order (FLAC 24/192 → mp3). best-quality mode grabs the highest-quality copy across *every* source; priority mode gets an opt-in rank-based order toggle. quarantine is folded into the Downloads page + safer imports (AcoustID fail-closed, silence/truncation guards).
🎧 **Discover got smart** — "Based On Your Listening" ranks artists from who you *actually play*, and "Your Listening Mix" is a playable track playlist of their top tracks (works on any source). Fresh Tape fills properly now.
**Wing It Pool** — a new spot next to Discovery Pool to review + re-match the tracks Wing It guessed at (they used to be invisible).
🔁 **Auto-Sync redesign** — the scheduling board is now clean horizontal lanes instead of a side-scrolling column wall.
🐛 **Fixes** — multi-disc albums no longer show disc-2 as "missing" (#927), playlists no longer stuck on "Never Synced" (#925), and tracks can't import while quarantined (#928). thanks @ramonskie + @nick2000713 🙏
⚠️ **re-scan your library once** so the multi-disc fix can backfill disc numbers on existing tracks. enjoy! 🎶

View file

@ -705,6 +705,12 @@ class ConfigManager:
},
"import": {
"staging_path": "./Staging",
# Master toggle for quality-filtering on import. On by default:
# downloaded files that don't meet the quality profile are
# quarantined instead of imported (same gate the download
# pipeline uses). Off → import everything regardless of quality;
# the library Quality Upgrade Scanner still flags them.
"quality_filter_enabled": True,
"replace_lower_quality": False,
# Use the top Staging folder as the artist (Artist/Album layouts,
# mixtapes). On by default to preserve the long-standing import

View file

@ -32,6 +32,8 @@ from config.settings import config_manager
from core.amazon_client import AmazonClient, AmazonClientError
from core.download_plugins.base import DownloadSourcePlugin
from core.download_plugins.types import AlbumResult, DownloadStatus, TrackResult
from core.quality.model import AudioQuality
from core.quality.source_map import quality_tier_for_source
from utils.logging_config import get_logger
logger = get_logger("amazon_download_client")
@ -76,9 +78,12 @@ class AmazonDownloadClient(DownloadSourcePlugin):
if download_path is None:
download_path = config_manager.get("soulseek.download_path", "./downloads")
self.download_path = Path(download_path)
self.download_path.mkdir(parents=True, exist_ok=True)
try:
self.download_path.mkdir(parents=True, exist_ok=True)
except OSError as e:
logger.warning(f"Could not verify download path {self.download_path}: {e}")
self._quality = config_manager.get("amazon_download.quality", "flac")
self._quality = quality_tier_for_source("amazon", default="flac")
self._allow_fallback = config_manager.get("amazon_download.allow_fallback", True)
self._client = AmazonClient(preferred_codec=self._quality)
@ -133,11 +138,17 @@ class AmazonDownloadClient(DownloadSourcePlugin):
album_map: Dict[str, AlbumResult] = {}
album_order: List[str] = []
preferred = self._client.preferred_codec
# Search results only carry the codec (real sample_rate arrives at
# stream time). Claim the format honestly — FLAC for the lossless
# codec, lossy otherwise — so audio_quality derives a real format
# instead of the display label ("Lossless"), and the post-download
# probe pins the actual sample_rate/bit_depth.
amazon_q = AudioQuality(format='flac' if _codec_key(preferred) == 'flac' else 'aac')
for item in items:
quality = _quality_label(preferred)
if item.is_track:
track_results.append(TrackResult(
tr = TrackResult(
username="amazon",
filename=f"{item.asin}||{item.artist_name} - {item.title}",
size=0,
@ -155,7 +166,9 @@ class AmazonDownloadClient(DownloadSourcePlugin):
"album_asin": item.album_asin,
"isrc": item.isrc,
},
))
)
tr.set_quality(amazon_q)
track_results.append(tr)
elif item.is_album:
album_asin = item.album_asin or item.asin
if album_asin not in album_map:
@ -173,6 +186,7 @@ class AmazonDownloadClient(DownloadSourcePlugin):
title=item.title,
album=item.album_name,
)
placeholder.set_quality(amazon_q)
album_map[album_asin] = AlbumResult(
username="amazon",
album_path=album_asin,

View file

@ -21,6 +21,7 @@ from typing import Any, Dict, List, Optional, Tuple
import requests
from core.download_plugins.types import AlbumResult, DownloadStatus, TrackResult
from core.quality.source_map import quality_from_deezer, quality_tier_for_source
from utils.logging_config import get_logger
logger = get_logger("deezer_download")
@ -92,7 +93,10 @@ class DeezerDownloadClient(DownloadSourcePlugin):
if download_path is None:
download_path = config_manager.get('soulseek.download_path', './downloads')
self.download_path = Path(download_path)
self.download_path.mkdir(parents=True, exist_ok=True)
try:
self.download_path.mkdir(parents=True, exist_ok=True)
except OSError as e:
logger.warning(f"Could not verify download path {self.download_path}: {e}")
# Engine reference is populated by set_engine() at registration
# time. None until orchestrator wires the registry.
@ -119,7 +123,7 @@ class DeezerDownloadClient(DownloadSourcePlugin):
self._authenticated = False
# Quality preference
self._quality = config_manager.get('deezer_download.quality', 'flac')
self._quality = quality_tier_for_source('deezer', default='flac')
# Try to authenticate on init if ARL is configured
arl = config_manager.get('deezer_download.arl', '')
@ -597,7 +601,7 @@ class DeezerDownloadClient(DownloadSourcePlugin):
bitrate = 128
quality = 'mp3'
results.append(TrackResult(
tr = TrackResult(
username='deezer_dl',
filename=f"{track_id}||{artist} - {title}",
size=est_size,
@ -611,7 +615,10 @@ class DeezerDownloadClient(DownloadSourcePlugin):
title=title,
album=album,
track_number=item.get('track_position'),
))
)
# Stamp CD-quality FLAC (16/44.1) so lossless ranks correctly.
tr.set_quality(quality_from_deezer(self._quality))
results.append(tr)
logger.info(f"Deezer search for '{query}' returned {len(results)} results")
return results, []

View file

@ -46,6 +46,81 @@ def _get(row: object, attr: str):
return getattr(row, attr, None)
def choose_mix_fetch_source(active_source: object, active_can_fetch: bool) -> str:
"""Pick which source to fetch the "Listening Mix" top tracks from.
The mix is a list of (artist, title) pairs acquired via Soulseek, so the fetch source need
NOT match the user's active metadata source. Use the active source when it can fetch top
tracks itself (Spotify/Deezer); otherwise fall back to Deezer, whose public ``artist/{id}/top``
needs no auth and is available to every user so iTunes / Discogs / MusicBrainz users still
get a full mix without switching sources. Pure.
"""
if str(active_source or "").lower() in ("spotify", "deezer") and active_can_fetch:
return str(active_source).lower()
return "deezer"
def names_match(a: object, b: object) -> bool:
"""Strict artist-name equality after stripping case + non-alphanumerics.
Used to verify a name-search result before fetching that artist's top tracks, so the
"Listening Mix" can never pull the WRONG artist's songs (e.g. a same-name act). Exact
alphanumeric match: "Tyler, The Creator" == "Tyler The Creator", but "Drake" != "Drake Bell".
Pure.
"""
def _alnum(x: object) -> str:
return "".join(ch for ch in str(x or "").lower() if ch.isalnum())
na, nb = _alnum(a), _alnum(b)
return bool(na) and na == nb
def similarity_from_rank(rank: object, max_rank: int = 10) -> float:
"""Turn a stored ``similarity_rank`` (1 = most similar … 10 = least) into a 01 weight.
SoulSync stores each ``(seed similar)`` edge with a 110 rank (``1`` is the closest
match). The ranker multiplies this into the score so a seed's *closest* matches count
for more than its long-tail ones. Linear decay over the documented range: rank 1 1.0,
rank 5 0.6, rank 10 0.1, with a 0.1 floor so a far match still contributes. A
missing/garbage rank falls back to 1.0 (treat as "no rank info, full weight"). Pure.
"""
try:
r = int(rank)
except (TypeError, ValueError):
return 1.0
floor = round(1.0 / max_rank, 4)
if r <= 1:
return 1.0
if r >= max_rank:
return floor
return round((max_rank - r + 1) / max_rank, 4)
def build_recency_weighted_seeds(
top_artists: Sequence[dict],
recent_play_counts: Optional[Dict[str, float]] = None,
*,
recency_factor: float = 1.5,
) -> List[dict]:
"""Blend lifetime + recent play counts into seed weights — "what you're into NOW".
``weight = lifetime_plays + recency_factor × recent_plays``. An artist you've played a
lot *recently* outranks one you played a lot years ago, so the recommendations track
your current taste instead of your all-time history. ``recency_factor`` is the dial
(0 = pure lifetime). Returns ``[{'name', 'weight'}]`` for :func:`rank_recommended_artists`.
Pure the caller supplies both play-count maps from the listening history.
"""
recent = {_norm(k): _positive_float(v, 0.0) for k, v in (recent_play_counts or {}).items()}
out: List[dict] = []
for a in top_artists or ():
name = str(a.get("name") or "").strip()
if not name:
continue
lifetime = _positive_float(a.get("play_count", a.get("weight", 1.0)))
boost = recency_factor * recent.get(_norm(name), 0.0)
out.append({"name": name, "weight": lifetime + boost})
return out
def group_similars_by_seed(
seeds: Sequence[dict],
similar_rows: Sequence,
@ -53,14 +128,21 @@ def group_similars_by_seed(
*,
source_id_attr: str = "source_artist_id",
similar_name_attr: str = "similar_artist_name",
rank_attr: Optional[str] = None,
) -> Dict[str, List[dict]]:
"""Reshape flat ``similar_artists`` rows into ``{seed_name_lower: [{'name': similar}]}``.
"""Reshape flat ``similar_artists`` rows into ``{seed_name_lower: [{'name', 'score'?}]}``.
The stored rows key the similar artist by the SEED's source id (``source_artist_id``),
not its name, so :func:`rank_recommended_artists` can't consume them directly. This
resolves each row's source id to a name via ``id_to_name`` (``{source_artist_id:
artist_name}`` for the library, built by the caller) and keeps only rows that resolve
to one of the ``seeds``. Rows may be dataclass objects or dicts. Pure no I/O.
``id_to_name`` MUST be keyed by whatever id the edges actually store for SoulSync that
is the artist's SOURCE id (Spotify/iTunes/Deezer/MusicBrainz), NOT the internal row id.
When ``rank_attr`` is given, each row's rank is converted via :func:`similarity_from_rank`
and carried as ``score`` so closer matches weigh more; without it every similar comes out
score-less (the ranker then treats similarity as 1.0 original behavior).
"""
seed_names = {_norm(s.get("name")) for s in seeds}
seed_names.discard("")
@ -72,8 +154,12 @@ def group_similars_by_seed(
if not seed_name or seed_name not in seed_names:
continue
sim_name = str(_get(row, similar_name_attr) or "").strip()
if sim_name:
out.setdefault(seed_name, []).append({"name": sim_name})
if not sim_name:
continue
entry = {"name": sim_name}
if rank_attr is not None:
entry["score"] = similarity_from_rank(_get(row, rank_attr))
out.setdefault(seed_name, []).append(entry)
return out
@ -197,8 +283,57 @@ def aggregate_candidate_tracks(
return out[:limit]
def to_mix_track(track: object, source: str) -> Optional[dict]:
"""Shape one source "top tracks" API dict into the flat dict the Discover compact
playlist row renders + syncs (the "Listening Mix" #913 playlist).
Spotify's ``artist_top_tracks`` and Deezer's ``get_artist_top_tracks`` both return the
same Spotify-shape object (``id, name, artists[], album{name,images[]}, duration_ms``).
This flattens that into the renderer's field names (``track_name/artist_name/album_name/
album_cover_url/duration_ms``), keeps the original under ``track_data_json`` for sync, and
sets the source-specific id field. Returns None for anything without a usable id/title so
the caller can filter. A ``name`` key is kept so :func:`aggregate_candidate_tracks` can
dedup by title. Pure no I/O.
"""
if not isinstance(track, dict):
return None
tid = track.get("id")
name = str(track.get("name") or "").strip()
if not tid or not name:
return None
artists = track.get("artists") or []
artist_name = ""
if artists and isinstance(artists[0], dict):
artist_name = str(artists[0].get("name") or "").strip()
album = track.get("album") if isinstance(track.get("album"), dict) else {}
album_name = str(album.get("name") or "").strip()
images = album.get("images") or []
cover = images[0].get("url") if images and isinstance(images[0], dict) else None
out = {
"track_id": str(tid),
"name": name, # for aggregate_candidate_tracks dedup
"track_name": name, # for the renderer
"artist_name": artist_name,
"album_name": album_name,
"album_cover_url": cover,
"duration_ms": track.get("duration_ms") or 0,
"track_data_json": track, # full payload for sync/download
"source": source,
}
id_field = {"spotify": "spotify_track_id", "deezer": "deezer_track_id",
"itunes": "itunes_track_id"}.get(source)
if id_field:
out[id_field] = str(tid)
return out
__all__ = [
"RecommendedArtist",
"choose_mix_fetch_source",
"names_match",
"similarity_from_rank",
"build_recency_weighted_seeds",
"to_mix_track",
"group_similars_by_seed",
"rank_recommended_artists",
"aggregate_candidate_tracks",

View file

@ -21,7 +21,11 @@ from core.metadata.registry import get_client_for_source
from core.metadata.types import Album
from core.wishlist.payloads import ensure_wishlist_track_format
logger = logging.getLogger(__name__)
# Use the project logger namespace ("soulsync.*") so the scanner's progress and
# diagnostics actually surface in the app log — plain getLogger(__name__) lands
# under "core.discovery.quality_scanner", which the app log view doesn't show.
from utils.logging_config import get_logger
logger = get_logger("discovery.quality_scanner")
# Per-source typed converter dispatch — same registry pattern as

View file

@ -25,6 +25,7 @@ big-bang switchover.
from __future__ import annotations
import asyncio
import threading
from typing import Any, Dict, Iterator, List, Optional, Tuple
@ -391,6 +392,16 @@ class DownloadEngine:
(tracks, albums) tuple, or ``([], [])`` when every source
in the chain is exhausted.
Priority mode is deliberately quality-AGNOSTIC at search time source
order is king and the first source that returns any tracks wins, exactly
matching pre-quality-system behaviour byte-for-byte (#896 review #3).
Quality-gating the priority path would deprioritise e.g. a soulseek
mp3 whose bitrate slskd omitted (``bitrate=None`` "unsatisfied"),
changing which source wins and adding latency for users who never opted
in. Cross-source quality pooling is the job of best_quality mode
(``search_all_sources``); final per-result ranking still happens in the
orchestrator's match/quality filter. RAW tracks are returned.
Replaces orchestrator's hand-rolled hybrid search loop. The
chain is ordered (most-preferred first).
"""
@ -406,9 +417,10 @@ class DownloadEngine:
try:
logger.info(f"Trying {source_name} (priority {i+1}): {query}")
tracks, albums = await plugin.search(query, timeout, progress_callback)
if tracks:
logger.info(f"{source_name} found {len(tracks)} tracks")
return (tracks, albums)
if not tracks:
continue
logger.info(f"{source_name} found {len(tracks)} tracks")
return (tracks, albums)
except Exception as e:
logger.warning(f"{source_name} search failed: {e}")
@ -418,6 +430,75 @@ class DownloadEngine:
)
return ([], [])
async def search_all_sources(self, query: str, source_chain,
timeout=None, progress_callback=None,
exclude_sources=None):
"""Best-quality mode: pool RAW tracks from EVERY configured source in
``source_chain`` instead of stopping at the first satisfying one.
Unlike :meth:`search_with_fallback`, no source short-circuits the
search the caller (orchestrator/worker) ranks the combined pool
bestworst by actual audio quality. ``exclude_sources`` drops sources
whose per-source retry budget is already spent (so their candidates
never re-enter the pool). Unconfigured / unregistered / raising sources
are skipped exactly like the fallback path. Returns
``(combined_tracks, combined_albums)``.
"""
excluded = {s.lower() for s in (exclude_sources or []) if s}
pooled_tracks = []
pooled_albums = []
# Per-source contribution for an honest pool log — e.g. a release-level
# source like usenet/torrent that returns nothing for a track-title
# query should read "usenet=0", not silently hide behind the chain name.
contributions = []
# Decide which sources to actually query, recording why the rest were
# skipped. Searches then run CONCURRENTLY so the pool waits only for the
# slowest source (e.g. usenet/Prowlarr, which can be slow) rather than
# the sum of every source's latency.
to_search = [] # (source_name, plugin)
for source_name in source_chain:
if source_name.lower() in excluded:
contributions.append(f"{source_name}=excluded")
continue
plugin = self._plugins.get(source_name)
if plugin is None:
logger.info(f"Skipping {source_name} (not available)")
contributions.append(f"{source_name}=unavailable")
continue
if hasattr(plugin, 'is_configured') and not plugin.is_configured():
logger.info(f"Skipping {source_name} (not configured)")
contributions.append(f"{source_name}=unconfigured")
continue
to_search.append((source_name, plugin))
async def _one(plugin):
return await plugin.search(query, timeout, progress_callback)
results = await asyncio.gather(
*[_one(plugin) for _, plugin in to_search],
return_exceptions=True,
)
for (source_name, _), result in zip(to_search, results, strict=True):
if isinstance(result, Exception):
logger.warning(f"{source_name} search failed: {result}")
contributions.append(f"{source_name}=error")
continue
tracks, albums = result
n = len(tracks) if tracks else 0
if tracks:
pooled_tracks.extend(tracks)
if albums:
pooled_albums.extend(albums)
contributions.append(f"{source_name}={n}")
logger.info(
"Best-quality pool: %d candidates [%s] for: %s",
len(pooled_tracks), ', '.join(contributions), query,
)
return (pooled_tracks, pooled_albums)
async def download_with_fallback(self, username: str, filename: str,
file_size: int, source_chain) -> Optional[str]:
"""Try each source in ``source_chain`` until one accepts the

View file

@ -28,6 +28,7 @@ from config.settings import config_manager
from core.download_engine import DownloadEngine
from core.download_plugins.registry import DownloadPluginRegistry, build_default_registry
from core.download_plugins.types import TrackResult, AlbumResult, DownloadStatus
from core.quality.selection import load_search_mode
logger = get_logger("download_orchestrator")
@ -102,12 +103,14 @@ class DownloadOrchestrator:
deezer_dl = self.client('deezer_dl')
if deezer_arl and deezer_dl:
deezer_dl.reconnect(deezer_arl)
deezer_dl._quality = config_manager.get('deezer_download.quality', 'flac')
from core.quality.source_map import quality_tier_for_source
deezer_dl._quality = quality_tier_for_source('deezer', default='flac')
# Reload Amazon quality preference (T2Tunes needs no reconnect — public proxy)
amazon = self.client('amazon')
if amazon:
quality = config_manager.get('amazon_download.quality', 'flac')
from core.quality.source_map import quality_tier_for_source
quality = quality_tier_for_source('amazon', default='flac')
amazon._quality = quality
amazon._allow_fallback = config_manager.get('amazon_download.allow_fallback', True)
if hasattr(amazon, '_client') and amazon._client:
@ -140,7 +143,10 @@ class DownloadOrchestrator:
continue
if hasattr(client, 'download_path') and client.download_path != new_path:
client.download_path = new_path
client.download_path.mkdir(parents=True, exist_ok=True)
try:
client.download_path.mkdir(parents=True, exist_ok=True)
except OSError as e:
logger.warning(f"Could not verify download path {new_path}: {e}")
# YouTube also caches path in yt-dlp opts
if hasattr(client, 'download_opts') and 'outtmpl' in client.download_opts:
client.download_opts['outtmpl'] = str(new_path / '%(title)s.%(ext)s')
@ -342,6 +348,11 @@ class DownloadOrchestrator:
if not chain:
logger.warning("Hybrid search exhausted: no eligible sources after exclusion filter")
return [], []
if load_search_mode() == 'best_quality':
logger.info(f"Best-quality search ({''.join(chain)}): {query}")
return await self.engine.search_all_sources(
query, chain, timeout, progress_callback,
)
logger.info(f"Hybrid search ({''.join(chain)}): {query}")
return await self.engine.search_with_fallback(query, chain, timeout, progress_callback)
@ -412,9 +423,17 @@ class DownloadOrchestrator:
if scored:
scored.sort(key=lambda x: x._match_confidence, reverse=True)
filtered_results = scored
# Match filter done (right track); now prefer the best quality
# among the confidence-passing survivors so streaming isn't
# quality-blind like Soulseek already isn't. Stable ranking
# keeps confidence order within an equal quality tier; the
# `or scored` fail-safe never leaves us with nothing to try.
from core.quality.selection import rank_for_profile
ranked, _ = rank_for_profile(scored)
filtered_results = ranked or scored
logger.info(f"Streaming validation: {len(scored)}/{len(tracks)} passed "
f"(best: {scored[0]._match_confidence:.2f})")
f"(best: {scored[0]._match_confidence:.2f}, "
f"quality pick: {filtered_results[0].audio_quality.label()})")
else:
logger.warning(f"No streaming results passed validation for: {query}")
return None

View file

@ -13,10 +13,11 @@ import from a neutral package per Cin's contract-first standard.
from __future__ import annotations
from dataclasses import dataclass
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional
from core.imports.filename import parse_filename_metadata
from core.quality.model import AudioQuality
@dataclass
@ -32,6 +33,36 @@ class SearchResult:
upload_speed: int
queue_length: int
result_type: str = "track" # "track" or "album"
# Rich quality metadata — populated by sources that provide it.
# None means "unknown", not "absent".
sample_rate: Optional[int] = None # Hz (e.g. 44100, 96000, 192000)
bit_depth: Optional[int] = None # bits per sample (16, 24)
@property
def audio_quality(self) -> AudioQuality:
"""Unified quality descriptor derived from this result's fields."""
return AudioQuality(
format=self.quality.lower() if self.quality else 'unknown',
bitrate=self.bitrate,
sample_rate=self.sample_rate,
bit_depth=self.bit_depth,
)
def set_quality(self, aq: AudioQuality) -> None:
"""Merge a mapped :class:`AudioQuality` onto this result's fields.
Used by streaming sources to stamp their claimed tier (Tidal/HiFi
tier strings, Qobuz API values, ) so ``audio_quality`` ranks
correctly. Mapper-provided fields win; a ``None`` from the mapper
leaves any already-reported value (e.g. a probed bitrate) intact.
"""
self.quality = aq.format
if aq.bitrate is not None:
self.bitrate = aq.bitrate
if aq.sample_rate is not None:
self.sample_rate = aq.sample_rate
if aq.bit_depth is not None:
self.bit_depth = aq.bit_depth
@property
def quality_score(self) -> float:
@ -127,6 +158,19 @@ class AlbumResult:
queue_length: int = 0
result_type: str = "album"
@property
def audio_quality(self) -> AudioQuality:
"""Unified quality descriptor derived from dominant track quality."""
sample_rates = [t.sample_rate for t in self.tracks if t.sample_rate]
bit_depths = [t.bit_depth for t in self.tracks if t.bit_depth]
bitrates = [t.bitrate for t in self.tracks if t.bitrate]
return AudioQuality(
format=self.dominant_quality.lower() if self.dominant_quality else 'unknown',
bitrate=max(bitrates) if bitrates else None,
sample_rate=max(sample_rates) if sample_rates else None,
bit_depth=max(bit_depths) if bit_depths else None,
)
@property
def quality_score(self) -> float:
"""Calculate album quality score based on dominant quality and track count"""

View file

@ -83,9 +83,26 @@ def clear_completed_local() -> int:
"""
cleared = 0
with tasks_lock:
# Protect tasks belonging to a still-active batch. A batch is "active"
# while any of its queued tasks is non-terminal (still searching /
# downloading / queued / post-processing). Pruning a batch's completed
# or failed tasks mid-run would yank them out of the Downloads page —
# and failed/cancelled rows aren't recoverable from library_history —
# so the user would never see them until the batch ended. Keep the whole
# active batch intact; it gets cleaned by a later run once it finishes.
protected_task_ids: set = set()
for batch in download_batches.values():
queue = batch.get('queue', []) if isinstance(batch, dict) else []
batch_active = any(
download_tasks.get(tid, {}).get('status') not in _TERMINAL_STATUSES
for tid in queue if tid in download_tasks
)
if batch_active:
protected_task_ids.update(queue)
task_ids_to_remove = [
tid for tid, task in download_tasks.items()
if task.get('status') in _TERMINAL_STATUSES
if task.get('status') in _TERMINAL_STATUSES and tid not in protected_task_ids
]
for tid in task_ids_to_remove:
del download_tasks[tid]

View file

@ -47,6 +47,55 @@ from core.runtime_state import (
logger = logging.getLogger(__name__)
def _priority_sort_key(r):
"""Today's confidence-first key: never download a high-quality WRONG file."""
return (
getattr(r, 'confidence', 0) or 0,
getattr(r, 'quality_score', 0) or 0,
getattr(r, 'upload_speed', 0) or 0,
-(getattr(r, 'queue_length', 0) or 0),
getattr(r, 'free_upload_slots', 0) or 0,
getattr(r, 'size', 0) or 0,
)
def _quality_first_sort_key(r, targets):
"""Best-quality key: the user's profile quality rank dominates; all the
priority-mode signals (confidence, speed, ) become tiebreakers.
Every candidate reaching this point already passed match filtering, so it
is "correct enough" ordering by quality among correct candidates is safe.
Candidates with no usable quality info, or that match no target, sort last
(never dropped). Lower target index = better target, so it's negated to fit
the descending (reverse=True) sort.
"""
from core.quality.model import rank_candidate
aq = getattr(r, 'audio_quality', None)
if aq is None or not targets:
target_idx, tier = (len(targets) if targets else 0), 0.0
else:
try:
target_idx, tier = rank_candidate(aq, targets)
except Exception:
target_idx, tier = len(targets), 0.0
return (-target_idx, tier) + _priority_sort_key(r)
def order_candidates(candidates, *, quality_first=False, targets=None):
"""Return *candidates* ordered best-first for the download walk.
``quality_first=False`` (priority mode) confidence-first, byte-for-byte
today's behaviour. ``quality_first=True`` (best-quality mode) → the user's
profile quality rank dominates, confidence/peer signals break ties.
"""
if quality_first:
key = lambda r: _quality_first_sort_key(r, targets or [])
else:
key = _priority_sort_key
return sorted(candidates, key=key, reverse=True)
@dataclass
class CandidatesDeps:
"""Bundle of cross-cutting deps the candidate-fallback logic needs."""
@ -59,25 +108,25 @@ class CandidatesDeps:
on_download_completed: Callable
def attempt_download_with_candidates(task_id, candidates, track, batch_id=None, deps: CandidatesDeps = None):
def attempt_download_with_candidates(task_id, candidates, track, batch_id=None,
deps: CandidatesDeps = None, *,
quality_first=False, quality_targets=None):
"""
Attempts to download with fallback candidate logic (matches GUI's retry_parallel_download_with_fallback).
Returns True if successful, False if all candidates fail.
``quality_first`` (best-quality search mode) orders the walk by the user's
profile quality rank instead of confidence-first; ``quality_targets`` is the
profile target list used for that ranking. Defaults preserve priority-mode
behaviour exactly.
"""
# Sort candidates by match confidence first, then peer quality. Upstream
# Soulseek validation already considers peer speed/slots/queue when scores
# are close; preserve that signal here instead of flattening ties back to
# arbitrary slskd response order.
candidates.sort(
key=lambda r: (
getattr(r, 'confidence', 0) or 0,
getattr(r, 'quality_score', 0) or 0,
getattr(r, 'upload_speed', 0) or 0,
-(getattr(r, 'queue_length', 0) or 0),
getattr(r, 'free_upload_slots', 0) or 0,
getattr(r, 'size', 0) or 0,
),
reverse=True,
# Sort candidates. Priority mode: confidence-first, then peer quality —
# upstream Soulseek validation already considers peer speed/slots/queue when
# scores are close; preserve that signal instead of flattening ties back to
# arbitrary slskd response order. Best-quality mode: profile quality rank
# dominates (all candidates here already passed match filtering).
candidates = order_candidates(
candidates, quality_first=quality_first, targets=quality_targets,
)
with tasks_lock:

View file

@ -26,6 +26,7 @@ Lifted verbatim from web_server.py. Dependencies injected via
from __future__ import annotations
import logging
import os
import shutil
import time
import traceback
@ -44,6 +45,27 @@ from core.runtime_state import (
logger = logging.getLogger(__name__)
# A task that has been in 'post_processing' longer than this is treated as stuck.
# Post-processing (AcoustID + quality + import) is serialized, so a large batch
# legitimately backs up — keep this generous so genuinely-slow imports aren't
# cut off mid-flight (the old 5-min cutoff falsely "completed" queued tasks).
_POST_PROCESSING_STUCK_TIMEOUT = 1800 # 30 minutes
def _resolve_stuck_post_processing_status(task: dict) -> str:
"""Decide the terminal status for a task stuck in post_processing.
Only call it 'completed' if the import actually produced a file on disk
(``final_file_path`` is set at the end of successful post-processing). Without
a real file, force-completing is a lie the task shows as a downloaded track
that isn't anywhere. Mark those 'failed' so they're retryable and honest.
"""
final_path = task.get('final_file_path')
if final_path and os.path.exists(final_path):
return 'completed'
return 'failed'
def _safe_batch_dirname(batch_id: str) -> str:
safe = ''.join(ch if ch.isalnum() or ch in ('-', '_') else '_' for ch in str(batch_id or 'batch'))
return safe or 'batch'
@ -435,9 +457,15 @@ def on_download_completed(batch_id: str, task_id: str, success: bool, deps: Life
retrying_count += 1
elif task_status == 'post_processing':
task_age = current_time - task.get('status_change_time', current_time)
if task_age > 300: # 5 minutes (post-processing should be fast)
logger.info(f"⏰ [Stuck Detection] Task {queue_task_id} stuck in post_processing for {task_age:.0f}s - forcing completion")
task['status'] = 'completed' # Assume it worked if file verification is taking too long
if task_age > _POST_PROCESSING_STUCK_TIMEOUT:
new_status = _resolve_stuck_post_processing_status(task)
if new_status == 'completed':
logger.info(f"⏰ [Stuck Detection] Task {queue_task_id} stuck in post_processing for {task_age:.0f}s but file exists — completing")
task['status'] = 'completed'
else:
logger.warning(f"⏰ [Stuck Detection] Task {queue_task_id} stuck in post_processing for {task_age:.0f}s with no output file — marking failed")
task['status'] = 'failed'
task['error_message'] = 'Post-processing timed out without producing a file'
finished_count += 1
else:
retrying_count += 1
@ -667,9 +695,15 @@ def check_batch_completion_v2(batch_id: str, deps: LifecycleDeps) -> Optional[bo
retrying_count += 1
elif task_status == 'post_processing':
task_age = current_time - task.get('status_change_time', current_time)
if task_age > 300: # 5 minutes (post-processing should be fast)
logger.info(f"⏰ [Stuck Detection V2] Task {task_id} stuck in post_processing for {task_age:.0f}s - forcing completion")
task['status'] = 'completed' # Assume it worked if file verification is taking too long
if task_age > _POST_PROCESSING_STUCK_TIMEOUT:
new_status = _resolve_stuck_post_processing_status(task)
if new_status == 'completed':
logger.info(f"⏰ [Stuck Detection V2] Task {task_id} stuck in post_processing for {task_age:.0f}s but file exists — completing")
task['status'] = 'completed'
else:
logger.warning(f"⏰ [Stuck Detection V2] Task {task_id} stuck in post_processing for {task_age:.0f}s with no output file — marking failed")
task['status'] = 'failed'
task['error_message'] = 'Post-processing timed out without producing a file'
finished_count += 1
else:
retrying_count += 1

View file

@ -760,7 +760,8 @@ class WebUIDownloadMonitor:
# used_sources keys are formatted as "{username}_{filename}", so startswith is exact.
is_tidal = any(s.startswith('tidal_') for s in tried_sources)
if is_tidal:
tidal_quality = config_manager.get('tidal_download.quality', 'lossless')
from core.quality.source_map import quality_tier_for_source
tidal_quality = quality_tier_for_source('tidal', default='lossless')
allow_fb = config_manager.get('tidal_download.allow_fallback', True)
if tidal_quality == 'hires' and not allow_fb:
task['error_message'] = (

View file

@ -171,6 +171,21 @@ def run_post_processing_worker(task_id: str, batch_id: str, deps: PostProcessDep
logger.info(f"[Post-Processing] Task {task_id} already completed by stream processor, skipping verification")
return
# RACE GUARD: the monitor sets status -> 'post_processing' immediately
# before submitting this worker. If the status is now anything else, the
# browser-poll post-processor already took ownership of this task — e.g.
# it quarantined the file and requeued the next-best candidate (status
# -> 'searching', source identity cleared). Bail WITHOUT marking failed
# or notifying batch completion: otherwise we clobber that in-flight
# retry with a bogus "missing file or source information" failure while a
# parallel attempt is importing the song.
if task['status'] != 'post_processing':
logger.info(
f"[Post-Processing] Task {task_id} no longer in 'post_processing' "
f"(now '{task['status']}') — another path took over, skipping"
)
return
# Extract file information for verification
track_info = task.get('track_info', {})
task_filename = task.get('filename') or track_info.get('filename')

View file

@ -94,6 +94,10 @@ class StatusDeps:
run_async: Optional[Callable] = None
on_download_completed: Optional[Callable[[str, str, bool], None]] = None
get_persistent_download_history: Optional[Callable[[int], list[dict]]] = None
# Returns ALL library_history rows with verification_status in
# ('unverified', 'force_imported') — no recency limit, so historical
# entries are never buried by the general history tail cap.
get_unverified_download_history: Optional[Callable[[], list[dict]]] = None
# Streaming sources the engine fallback applies to. Soulseek goes through
@ -796,6 +800,13 @@ def build_unified_downloads_response(limit: int, deps: StatusDeps) -> dict:
'progress': progress,
'error': task.get('error_message'),
'verification_status': task.get('verification_status'),
# library_history row id (set at import) so the Unverified review
# queue can act on a still-live completed task before it becomes
# a persistent-history row.
'history_id': task.get('history_id'),
# Real probed audio quality (mutagen-read from the actual file),
# surfaced so the Downloads page can show what was downloaded.
'quality': task.get('quality') or '',
'retry_info': task.get('retry_info'),
'retry_trigger': task.get('retry_trigger'),
'batch_id': batch_id,
@ -812,6 +823,32 @@ def build_unified_downloads_response(limit: int, deps: StatusDeps) -> dict:
'is_persistent_history': False,
})
# --- Unverified history (unconditional, no limit) ---
# Always load every library_history row that still needs human confirmation
# (verification_status IN ('unverified', 'force_imported')). This is NOT
# gated on len(items) < limit so that historical entries from past batches
# are visible even during a large active batch that would otherwise exhaust
# the limit before the history tail is read. Dedup against live tasks by
# identity so a track currently in post-processing isn't shown twice.
if deps.get_unverified_download_history is not None:
try:
unverified_entries = deps.get_unverified_download_history() or []
except Exception as exc:
logger.debug("[Downloads] unverified history lookup failed: %s", exc)
unverified_entries = []
for entry in unverified_entries:
item = _build_history_download_item(entry)
identity = _download_identity(item.get('title'), item.get('artist'), item.get('album'))
if identity in live_identities:
continue
items.append(item)
live_identities.add(identity)
# --- General recent-history tail (capped, recency-ordered) ---
# Fills in the completed/verified tail so the full Downloads list looks
# populated. Gated on len(items) < limit so a busy batch doesn't trigger
# an extra DB round-trip when we're already at capacity.
if deps.get_persistent_download_history is not None and len(items) < limit:
history_limit = min(limit - len(items), _PERSISTENT_HISTORY_TAIL_LIMIT)
try:
@ -832,7 +869,14 @@ def build_unified_downloads_response(limit: int, deps: StatusDeps) -> dict:
live_identities.add(identity)
appended_history += 1
# Sort: active first (by priority), then by timestamp desc within each group
# Sort: active first (by priority), then by timestamp desc within each group.
# NOTE: the array order is presentation-only — the Downloads page filters
# client-side per tab. What matters is that EVERY live task is present: an
# earlier `items[:limit]` truncation (active-first) starved completed/failed/
# unverified rows off the end during a busy batch, so those tabs stayed empty
# until the batch drained. `limit` now bounds only the persistent-history
# tail (handled above); live in-memory tasks are always returned in full
# (they're already bounded by the 5-min cleanup automation).
items.sort(key=lambda x: (x['priority'], -x['timestamp']))
# Build batch summaries for the batch context panel
@ -860,7 +904,7 @@ def build_unified_downloads_response(limit: int, deps: StatusDeps) -> dict:
return {
'success': True,
'downloads': items[:limit],
'downloads': items,
'total': len(items),
'batches': batch_summaries,
'timestamp': time.time(),

View file

@ -54,6 +54,37 @@ def _cand_user_file(candidate):
return getattr(candidate, 'username', None), getattr(candidate, 'filename', None)
def _candidate_ordering():
"""Return ``(quality_first, targets)`` for the active search mode + toggle.
The candidate walk is ordered by the user's profile quality rank
(bestworst) instead of confidence-first when EITHER:
- best-quality search mode is active (always quality-first), OR
- priority mode and the ``rank_candidates_by_quality`` toggle is on
(opt-in; default off keeps the byte-for-byte confidence-first walk).
Quality-first ordering also makes the version-mismatch force-import pick
the highest-quality candidate, because that fallback accepts the
first-tried (= best-ordered) quarantined entry.
Fails closed to confidence-first ordering on any error so a profile/DB
hiccup never blocks a download. See
docs/superpowers/specs/2026-06-14-best-quality-search-mode-design.md.
"""
try:
from core.quality.selection import (
load_search_mode,
load_profile_targets,
load_rank_candidates_by_quality,
)
if load_search_mode() == 'best_quality' or load_rank_candidates_by_quality():
targets, _ = load_profile_targets()
return True, targets
except Exception as exc:
logger.debug("[Modal Worker] quality ordering unavailable: %s", exc)
return False, None
def _try_cached_candidates(task_id, batch_id, track, deps):
"""Quarantine-retry fast path: attempt the already-found candidates before
re-searching anything.
@ -91,7 +122,10 @@ def _try_cached_candidates(task_id, batch_id, track, deps):
f"[Modal Worker] Quarantine retry: trying {len(remaining)} cached "
f"candidate(s) before re-searching (task {task_id})"
)
return deps.attempt_download_with_candidates(task_id, remaining, track, batch_id)
_qf, _qt = _candidate_ordering()
return deps.attempt_download_with_candidates(
task_id, remaining, track, batch_id, quality_first=_qf, quality_targets=_qt,
)
def _private_album_bundle_staging_miss_reason(batch_id: Optional[str], deps: Any) -> Optional[str]:
@ -369,6 +403,11 @@ def download_track_worker(task_id: str, batch_id: Optional[str], deps: TaskWorke
logger.info(f"[Modal Worker] Generated {len(search_queries)} smart search queries for '{track.name}': {search_queries}")
logger.info(f"[Modal Worker] About to start search loop for task {task_id} (track: '{track.name}')")
# Best-quality search mode: the orchestrator already pooled candidates
# across every source for each query, so order the candidate walk by the
# user's profile quality rank (best→worst). Computed once per task.
_best_quality, _quality_targets = _candidate_ordering()
# 2. Sequential Query Search (matches GUI's start_search_worker_parallel logic)
search_diagnostics = [] # Track what happened per query for detailed error messages
all_raw_results = [] # Collect raw results across queries for candidate review modal
@ -495,7 +534,10 @@ def download_track_worker(task_id: str, batch_id: Optional[str], deps: TaskWorke
download_tasks[task_id]['cached_candidates'] = candidates
# Try to download with these candidates
success = deps.attempt_download_with_candidates(task_id, candidates, track, batch_id)
success = deps.attempt_download_with_candidates(
task_id, candidates, track, batch_id,
quality_first=_best_quality, quality_targets=_quality_targets,
)
if success:
# Download initiated successfully - let the download monitoring system handle completion
if batch_id:
@ -529,7 +571,10 @@ def download_track_worker(task_id: str, batch_id: Optional[str], deps: TaskWorke
# === HYBRID FALLBACK: If primary source failed, try remaining sources directly ===
# The orchestrator's hybrid search stops at the first source with results, even if
# those results all fail quality filtering. Try remaining sources individually.
if getattr(deps.download_orchestrator, 'mode', '') == 'hybrid':
#
# Best-quality mode already searched EVERY source per query (the pool), so this
# block would only re-search the same sources — skip it there.
if not _best_quality and getattr(deps.download_orchestrator, 'mode', '') == 'hybrid':
try:
orch = deps.download_orchestrator
hybrid_order = getattr(orch, 'hybrid_order', None) or []

View file

@ -36,9 +36,34 @@ import requests as http_requests
from utils.logging_config import get_logger
from config.settings import config_manager
from core.download_plugins.types import TrackResult, AlbumResult, DownloadStatus
from core.quality.source_map import quality_from_tidal_tier, quality_tier_for_source
logger = get_logger("hifi_client")
# A media playlist whose total runtime is below this fraction of the track's
# real duration is a preview (some Monochrome instances only have 30s Tidal
# DOWNLOAD access — a 220s track comes back as ~30s of segments + ENDLIST).
_PREVIEW_DURATION_RATIO = 0.85
_EXTINF_RE = re.compile(r'#EXTINF:\s*([0-9.]+)')
def hls_total_seconds(playlist_text: str) -> float:
"""Sum the ``#EXTINF`` segment durations in an HLS media playlist."""
return sum(float(x) for x in _EXTINF_RE.findall(playlist_text or ''))
def is_preview_playlist(playlist_s: float, track_s: float,
ratio: float = _PREVIEW_DURATION_RATIO) -> bool:
"""True when the playlist runtime is far shorter than the track's real
duration (a preview). Returns False when either duration is unknown, so a
missing reference never false-positives the post-download audio guard is
the safety net.
"""
if not playlist_s or not track_s or track_s <= 0:
return False
return playlist_s < track_s * ratio
# HLS quality presets mapping to /trackManifests/ format parameters
HLS_QUALITY_MAP = {
'hires': {
@ -232,7 +257,10 @@ class HiFiClient(DownloadSourcePlugin):
if download_path is None:
download_path = config_manager.get('soulseek.download_path', './downloads')
self.download_path = Path(download_path)
self.download_path.mkdir(parents=True, exist_ok=True)
try:
self.download_path.mkdir(parents=True, exist_ok=True)
except OSError as e:
logger.warning(f"Could not verify download path {self.download_path}: {e}")
self._instances = []
self._instance_lock = threading.Lock()
@ -697,7 +725,8 @@ class HiFiClient(DownloadSourcePlugin):
return init_uri, segment_uris
def _get_hls_manifest(self, track_id: int, quality: str = 'lossless') -> Optional[Dict]:
def _get_hls_manifest(self, track_id: int, quality: str = 'lossless',
expected_duration_s: float = 0) -> Optional[Dict]:
q_info = HLS_QUALITY_MAP.get(quality, HLS_QUALITY_MAP['lossless'])
formats = q_info['formats']
@ -751,6 +780,20 @@ class HiFiClient(DownloadSourcePlugin):
logger.warning(f"Failed to fetch variant playlist for track {track_id}: {e}")
return None
# Preview detection — some instances only have 30s Tidal DOWNLOAD
# access, returning a playlist far shorter than the real track. Decline
# it (and rotate the instance) so the orchestrator falls through to a
# real source instead of fetching a 30s file that gets quarantined.
playlist_s = hls_total_seconds(media_text)
if is_preview_playlist(playlist_s, expected_duration_s):
logger.warning(
f"HiFi manifest for track {track_id} ({quality}) is a "
f"{playlist_s:.0f}s preview of a {expected_duration_s:.0f}s track — "
f"declining this instance"
)
self._rotate_instance(self._current_instance)
return None
if init_uri:
logger.info(f"HiFi HLS manifest for track {track_id}: "
f"init segment + {len(segment_uris)} segments ({quality})")
@ -873,13 +916,18 @@ class HiFiClient(DownloadSourcePlugin):
loop = asyncio.get_event_loop()
tracks = await loop.run_in_executor(None, lambda: self.search_raw(query))
quality_key = config_manager.get('hifi_download.quality', 'lossless')
quality_key = quality_tier_for_source('hifi', default='lossless')
q_info = HLS_QUALITY_MAP.get(quality_key, HLS_QUALITY_MAP['lossless'])
# HiFi is Tidal-backed; stamp the configured tier so the global
# ranker sees real sample_rate/bit_depth, not just 'flac'.
tier_quality = quality_from_tidal_tier(quality_key)
results = []
for t in tracks:
try:
tr = self._to_track_result(t, q_info)
tr.set_quality(tier_quality)
results.append(tr)
except Exception as e:
logger.debug(f"Skipping track result conversion: {e}")
@ -950,7 +998,7 @@ class HiFiClient(DownloadSourcePlugin):
)
def _download_sync(self, download_id: str, track_id: int, display_name: str) -> Optional[str]:
quality_key = config_manager.get('hifi_download.quality', 'lossless')
quality_key = quality_tier_for_source('hifi', default='lossless')
chain = ['hires', 'lossless', 'high', 'low']
start = chain.index(quality_key) if quality_key in chain else 1
allow_fallback = config_manager.get('hifi_download.allow_fallback', True)
@ -958,21 +1006,26 @@ class HiFiClient(DownloadSourcePlugin):
MIN_AUDIO_SIZE = 100 * 1024
# Expected track length (for the preview/truncation guards). Best-effort: a 0
# here just disables the duration checks for this track, never rejects.
# Expected track length, drives every preview/truncation guard here:
# * _get_hls_manifest's pre-download is_preview_playlist check
# * the pre-download is_short_audio manifest check
# * the post-download is_preview_download faked-header decode check
# Best-effort: a 0 here just disables the duration checks, never rejects.
expected_s = 0.0
try:
info = self.get_track_info(track_id) or {}
expected_s = float(info.get('duration_s') or 0)
except Exception:
expected_s = 0.0
expected_duration_s = expected_s # alias for _get_hls_manifest's param name
for q_key in chain:
if self.shutdown_check and self.shutdown_check():
logger.info("Shutdown detected, aborting HiFi download")
return None
manifest_info = self._get_hls_manifest(track_id, quality=q_key)
manifest_info = self._get_hls_manifest(track_id, quality=q_key,
expected_duration_s=expected_duration_s)
if (
not manifest_info
or (

View file

@ -230,6 +230,9 @@ def get_audio_quality_string(file_path):
if ext == ".flac":
from mutagen.flac import FLAC
audio = FLAC(file_path)
sr = getattr(audio.info, "sample_rate", 0) or 0
if sr:
return f"FLAC {audio.info.bits_per_sample}bit/{sr / 1000:g}kHz"
return f"FLAC {audio.info.bits_per_sample}bit"
if ext == ".mp3":
@ -263,6 +266,106 @@ def get_audio_quality_string(file_path):
return ""
def probe_audio_quality(file_path: str):
"""Read the actual file and return an AudioQuality with real measured values.
Uses mutagen to extract sample_rate, bit_depth, and bitrate from the
downloaded file these are ground-truth values, not estimates.
Returns None when the file cannot be read.
"""
from core.quality.model import AudioQuality
try:
ext = os.path.splitext(file_path)[1].lower().lstrip('.')
if ext == 'flac':
from mutagen.flac import FLAC
audio = FLAC(file_path)
return AudioQuality(
format='flac',
bitrate=audio.info.bitrate // 1000 if audio.info.bitrate else None,
sample_rate=audio.info.sample_rate,
bit_depth=audio.info.bits_per_sample,
)
if ext == 'mp3':
from mutagen.mp3 import MP3
audio = MP3(file_path)
return AudioQuality(
format='mp3',
bitrate=audio.info.bitrate // 1000,
sample_rate=audio.info.sample_rate,
)
if ext in ('m4a', 'aac', 'mp4'):
from mutagen.mp4 import MP4
audio = MP4(file_path)
# .m4a can carry AAC (lossy) OR ALAC (lossless) — only the real
# codec tells them apart, which is why extension-based classification
# defaults to 'aac' and we correct it here from the probed file.
codec = (getattr(audio.info, 'codec', '') or '').lower()
if 'alac' in codec:
return AudioQuality(
format='alac',
bitrate=audio.info.bitrate // 1000 if audio.info.bitrate else None,
sample_rate=audio.info.sample_rate,
bit_depth=getattr(audio.info, 'bits_per_sample', None) or None,
)
return AudioQuality(
format='aac',
bitrate=audio.info.bitrate // 1000,
sample_rate=audio.info.sample_rate,
)
if ext == 'ogg':
from mutagen.oggvorbis import OggVorbis
audio = OggVorbis(file_path)
return AudioQuality(
format='ogg',
bitrate=audio.info.bitrate // 1000,
sample_rate=audio.info.sample_rate,
)
if ext == 'opus':
from mutagen.oggopus import OggOpus
audio = OggOpus(file_path)
return AudioQuality(
format='opus',
bitrate=audio.info.bitrate // 1000,
sample_rate=audio.info.sample_rate,
)
if ext in ('wav', 'aiff', 'aif'):
# AIFF must use mutagen.aiff.AIFF — WAVE() can't parse it and would
# raise, making the file fail open and silently bypass the quality
# filter. Both are uncompressed PCM, so they share the 'wav' tier.
if ext == 'wav':
from mutagen.wave import WAVE
audio = WAVE(file_path)
else:
from mutagen.aiff import AIFF
audio = AIFF(file_path)
return AudioQuality(
format='wav',
bitrate=audio.info.bitrate // 1000 if audio.info.bitrate else None,
sample_rate=audio.info.sample_rate,
bit_depth=getattr(audio.info, 'bits_per_sample', None),
)
if ext == 'wma':
from mutagen.asf import ASF
audio = ASF(file_path)
return AudioQuality(
format='wma',
bitrate=audio.info.bitrate // 1000 if audio.info.bitrate else None,
sample_rate=getattr(audio.info, 'sample_rate', None),
)
return None
except Exception as e:
logger.debug("probe_audio_quality failed for %s: %s", file_path, e)
return None
def get_quality_tier_from_extension(file_path):
"""Classify a file extension into a quality tier."""
if not file_path:

View file

@ -105,30 +105,72 @@ def move_to_quarantine(file_path: str, context: dict, reason: str, automation_en
def check_flac_bit_depth(file_path: str, context: dict) -> Optional[str]:
"""Return a rejection message if a FLAC file violates the configured bit depth."""
if not context.get("_audio_quality", "").startswith("FLAC"):
"""Legacy wrapper — delegates to check_quality_target.
Kept for callers that still pass trigger='bit_depth'; the new guard
covers bit_depth as part of the full quality target check.
"""
return check_quality_target(file_path, context)
def check_quality_target(file_path: str, context: dict) -> Optional[str]:
"""Return a rejection message when the downloaded file does not satisfy
the user's quality priority list.
Probes the actual file with mutagen (ground-truth sample_rate,
bit_depth, bitrate) and checks it against the profile's
``ranked_targets``. Falls back gracefully when fallback_enabled=True.
Works for all formats and all download sources no Soulseek-specific
logic here.
"""
from core.imports.file_ops import probe_audio_quality
from core.quality.selection import targets_from_profile, quality_meets_profile
# Master toggle (Settings → Import). When OFF, the quality check is skipped
# entirely and files import regardless of quality — the user opted out of
# quality-filtering on import. Default ON preserves existing behaviour. The
# library Quality Upgrade scanner still flags below-profile files either way.
if _get_config_manager().get("import.quality_filter_enabled", True) is False:
logger.debug(
"[QualityGuard] import.quality_filter_enabled=False — skipping quality "
"filter for %s", os.path.basename(file_path),
)
return None
quality_profile = MusicDatabase().get_quality_profile()
flac_config = quality_profile.get("qualities", {}).get("flac", {})
flac_pref = flac_config.get("bit_depth", "any")
if flac_pref == "any":
aq = probe_audio_quality(file_path)
if aq is None:
logger.debug("[QualityGuard] Could not probe %s — skipping check", os.path.basename(file_path))
return None
actual_bits = context["_audio_quality"].replace("FLAC ", "").replace("bit", "")
if actual_bits == flac_pref:
profile = MusicDatabase().get_quality_profile()
targets, fallback_enabled = targets_from_profile(profile)
if not targets:
return None
flac_fallback = flac_config.get("bit_depth_fallback", True)
downsample_enabled = _get_config_manager().get("lossy_copy.downsample_hires", False)
matched = quality_meets_profile(aq, targets)
track_info = context.get("track_info", {})
track_name = track_info.get("name", os.path.basename(file_path))
actual_label = aq.label()
if flac_fallback or downsample_enabled:
if downsample_enabled:
logger.info("[FLAC Downsample] Accepted %s-bit FLAC (will be downsampled to %s-bit): %s", actual_bits, flac_pref, track_name)
else:
logger.warning("[FLAC Fallback] Accepted %s-bit FLAC (preferred %s-bit): %s", actual_bits, flac_pref, track_name)
if matched:
logger.info("[QualityGuard] %s meets profile: %s", track_name, actual_label)
return None
return f"FLAC bit depth mismatch: file is {actual_bits}-bit, preference is {flac_pref}-bit"
# No target matched
best_label = targets[0].label if targets else "?"
if fallback_enabled or downsample_enabled:
logger.warning(
"[QualityGuard] %s did not match any target (got %s, wanted %s) — accepting via fallback",
track_name, actual_label, best_label,
)
return None
return (
f"Quality mismatch: file is {actual_label}, "
f"does not satisfy any configured target (best wanted: {best_label})"
)

View file

@ -34,9 +34,11 @@ from core.imports.context import (
)
from core.imports.file_integrity import check_audio_integrity, expected_duration_for_check, resolve_duration_tolerance
from core.imports.filename import extract_track_number_from_filename
from core.imports.guards import check_flac_bit_depth, move_to_quarantine
from core.imports.guards import check_flac_bit_depth, check_quality_target, move_to_quarantine
from core.imports.silence import detect_broken_audio
from core.imports.quarantine import (
approve_quarantine_entry,
delete_quarantine_entry,
entry_id_from_quarantined_filename,
list_quarantine_entries,
)
@ -160,7 +162,9 @@ def import_rejection_reason(context: dict) -> str | None:
f"{context.get('_acoustid_failure_msg', 'fingerprint mismatch')}"
)
if context.get('_bitdepth_rejected'):
return "rejected by bit-depth filter"
return "rejected by quality filter"
if context.get('_silence_rejected'):
return "rejected by audio guard (incomplete or silent audio)"
if context.get('_race_guard_failed'):
return "source file disappeared before import completed"
return None
@ -353,6 +357,116 @@ def post_process_matched_download(context_key, context, file_path, runtime, meta
f"drift={integrity.checks.get('length_drift_s', 'n/a')})"
)
# Audio-completeness guard — runs right where the length is verified,
# BEFORE the AcoustID/quality gates, so a truncated file (container
# claims the full length but only ~30s actually decodes, e.g. HiFi/
# Monochrome HLS assembly) or a mostly-silent file is caught regardless
# of its quality verdict and gets the right reason. Same quarantine +
# next-candidate retry pattern as the integrity check.
#
# Opt-in (default OFF): this is the one check that fully DECODES the file
# with ffmpeg, so it is the most CPU-expensive step in post-processing.
# Most preview/truncation cases are already caught at the source
# (HiFi/Qobuz have their own guards), so it stays off unless the user
# turns it on under Settings → Post-processing.
_audio_guard_enabled = config_manager.get('post_processing.audio_completeness_check', False)
_skip_audio = (not _audio_guard_enabled) or _should_skip_quarantine_check(context, 'silence')
audio_reason = None if _skip_audio else detect_broken_audio(file_path)
if audio_reason:
logger.error(f"[AudioGuard] Rejected {_basename}: {audio_reason}")
context['_silence_rejected'] = True
try:
quarantine_path = move_to_quarantine(
file_path, context, audio_reason, automation_engine,
trigger='silence',
)
_mark_task_quarantined(context, quarantine_path)
logger.warning("File quarantined — incomplete/silent audio: %s", quarantine_path)
except Exception as quarantine_error:
logger.error(f"Quarantine failed ({quarantine_error}), deleting file: {file_path}")
try:
os.remove(file_path)
except Exception as del_error:
logger.debug("delete broken file fallback: %s", del_error)
with matched_context_lock:
matched_downloads_context.pop(context_key, None)
task_id = context.get('task_id')
batch_id = context.get('batch_id')
# Try the next-best candidate before giving up — same pattern as
# the integrity / AcoustID / quality failures.
if _requeue_quarantined_task_for_retry(task_id, batch_id, 'silence'):
logger.info(
"Incomplete/silent audio for task %s — retrying next-best candidate: %s",
task_id, audio_reason,
)
return
if task_id:
with tasks_lock:
if task_id in download_tasks:
download_tasks[task_id]['status'] = 'failed'
download_tasks[task_id]['error_message'] = f"Audio guard: {audio_reason}"
if task_id and batch_id:
_notify_download_completed(batch_id, task_id, success=False)
return
# QUALITY GATE — runs BEFORE AcoustID so (1) a wrong-quality file is
# rejected without paying for an AcoustID fingerprint, and (2) the real
# audio quality is recorded on the context (→ quarantine sidecar) for
# EVERY trigger, so it's known when reviewing/approving any quarantined
# file. force_import still never fires on a quality mismatch.
context['_audio_quality'] = get_audio_quality_string(file_path)
if context['_audio_quality']:
logger.info(f"Audio quality detected: {context['_audio_quality']}")
_skip_quality = _should_skip_quarantine_check(context, 'bit_depth') or \
_should_skip_quarantine_check(context, 'quality')
rejection_reason = None if _skip_quality else check_quality_target(file_path, context)
if _skip_quality:
logger.info(f"[QualityGuard] Skipped (user approval) for {_basename}")
if rejection_reason:
try:
quarantine_path = move_to_quarantine(
file_path,
context,
rejection_reason,
automation_engine,
trigger='quality',
)
_mark_task_quarantined(context, quarantine_path)
logger.info(f"File quarantined due to quality mismatch: {quarantine_path}")
except Exception as quarantine_error:
logger.error(f"Quarantine failed ({quarantine_error}), deleting file: {file_path}")
try:
os.remove(file_path)
except Exception as e:
logger.debug("delete quarantine fallback: %s", e)
context['_bitdepth_rejected'] = True
task_id = context.get('task_id')
batch_id = context.get('batch_id')
with matched_context_lock:
matched_downloads_context.pop(context_key, None)
# Try the next-best candidate before giving up — same pattern
# as AcoustID and integrity failures.
if _requeue_quarantined_task_for_retry(task_id, batch_id, 'quality'):
logger.info(
"Quality mismatch for task %s — retrying next-best candidate: %s",
task_id, rejection_reason,
)
return
if task_id:
with tasks_lock:
if task_id in download_tasks:
download_tasks[task_id]['status'] = 'failed'
download_tasks[task_id]['error_message'] = f"Quality filter: {rejection_reason}"
if task_id and batch_id:
_notify_download_completed(batch_id, task_id, success=False)
return
_skip_acoustid = _should_skip_quarantine_check(context, 'acoustid')
if _skip_acoustid:
logger.info(f"[AcoustID] Skipped (user approval) for {_basename}")
@ -390,14 +504,33 @@ def post_process_matched_download(context_key, context, file_path, runtime, meta
logger.info(f"AcoustID verification result: {verification_result.value} - {verification_msg}")
context['_acoustid_result'] = verification_result.value
if verification_result == VerificationResult.FAIL:
# Fail-closed mode: when the user requires a hard AcoustID
# PASS, a SKIP (ran but couldn't confirm — no fingerprint
# match / cross-script metadata) is treated like a FAIL:
# quarantine + try the next candidate, instead of importing
# an unverified file. ERROR (rate-limit / infra) is NOT
# blocked — that would stall the whole pipeline during an
# outage; those still import with their existing flag.
require_verified = config_manager.get('acoustid.require_verified', False)
_skip_as_fail = (
require_verified
and verification_result == VerificationResult.SKIP
)
if _skip_as_fail:
verification_msg = (
f"AcoustID could not confirm the track and 'require verified' "
f"is on — rejecting unverified file ({verification_msg})"
)
logger.warning("[AcoustID] Require-verified: SKIP treated as FAIL — %s", verification_msg)
if verification_result == VerificationResult.FAIL or _skip_as_fail:
try:
quarantine_path = move_to_quarantine(
file_path,
context,
verification_msg,
automation_engine,
trigger='acoustid',
trigger='acoustid_unverified' if _skip_as_fail else 'acoustid',
)
_mark_task_quarantined(context, quarantine_path)
logger.error(f"File quarantined due to verification failure: {quarantine_path}")
@ -577,48 +710,6 @@ def post_process_matched_download(context_key, context, file_path, runtime, meta
album_info.get('album_name', 'None'),
)
context['_audio_quality'] = get_audio_quality_string(file_path)
if context['_audio_quality']:
logger.info(f"Audio quality detected: {context['_audio_quality']}")
_skip_bit_depth = _should_skip_quarantine_check(context, 'bit_depth')
rejection_reason = None if _skip_bit_depth else check_flac_bit_depth(file_path, context)
if _skip_bit_depth:
logger.info(f"[BitDepth] Skipped (user approval) for {_basename}")
if rejection_reason:
try:
quarantine_path = move_to_quarantine(
file_path,
context,
rejection_reason,
automation_engine,
trigger='bit_depth',
)
_mark_task_quarantined(context, quarantine_path)
logger.info(f"File quarantined due to bit depth filter: {quarantine_path}")
except Exception as quarantine_error:
logger.error(f"Quarantine failed ({quarantine_error}), deleting file: {file_path}")
try:
os.remove(file_path)
except Exception as e:
logger.debug("delete quarantine fallback: %s", e)
context['_bitdepth_rejected'] = True
with matched_context_lock:
if context_key in matched_downloads_context:
del matched_downloads_context[context_key]
task_id = context.get('task_id')
batch_id = context.get('batch_id')
if task_id:
with tasks_lock:
if task_id in download_tasks:
download_tasks[task_id]['status'] = 'failed'
download_tasks[task_id]['error_message'] = f"Bit depth filter: {rejection_reason}"
if task_id and batch_id:
_notify_download_completed(batch_id, task_id, success=False)
return
file_ext = os.path.splitext(file_path)[1]
clean_track_name = get_import_clean_title(
context,
@ -987,6 +1078,22 @@ def post_process_matched_download_with_verification(context_key, context, file_p
if original_batch_id:
context['batch_id'] = original_batch_id
# Quality / audio-guard quarantine. Unlike acoustid/integrity (whose
# retry+fail is driven by THIS wrapper below), the inner pipeline fully
# owns the quality and audio-guard outcome: it quarantines the file and
# then either re-queues the next-best candidate or marks the task failed
# and notifies. The wrapper must NOT continue to the "assume success"
# fall-through — that marked the quarantined file Completed, so the same
# track showed in BOTH Completed and Quarantine (e.g. an MP3-VBR rejected
# by a FLAC-only profile). It must also NOT mark it failed here, which
# would clobber a successful next-candidate retry. Just return.
if context.get('_bitdepth_rejected') or context.get('_silence_rejected'):
logger.info(
f"Task {task_id} quarantined by the quality/audio guard — inner "
f"pipeline already handled retry/fail; wrapper not marking completed"
)
return
if context.get('_race_guard_failed'):
logger.info(f"Race guard: source file gone for task {task_id} — marking as failed")
with tasks_lock:
@ -1000,6 +1107,33 @@ def post_process_matched_download_with_verification(context_key, context, file_p
return
if context.get('_acoustid_quarantined'):
# Race-condition guard: if the user approved an alternative quarantine
# entry for this track while this download was already in flight, the
# pipeline will have created a new quarantine entry for the just-finished
# file. Delete that entry and exit — the user's choice already won.
_approved_alt = False
if task_id:
with tasks_lock:
_ct = download_tasks.get(task_id)
if isinstance(_ct, dict) and _ct.get('_quarantine_approved_alternative'):
_approved_alt = True
if _approved_alt:
_eid = context.get('_quarantine_entry_id')
if _eid:
try:
from core.imports.guards import _get_config_manager
_dl_dir = _get_config_manager().get('soulseek.download_path', './downloads')
_qdir = os.path.join(docker_resolve_path(_dl_dir), 'ss_quarantine')
delete_quarantine_entry(_qdir, _eid)
logger.info(
f"[Quarantine] Discarded late entry {_eid} — task {task_id} "
f"was cancelled by prior quarantine approval"
)
except Exception as _dqe:
logger.debug(f"[Quarantine] Could not clean up late entry {_eid}: {_dqe}")
_notify_download_completed(batch_id, task_id, success=False)
return
failure_msg = context.get('_acoustid_failure_msg', 'AcoustID verification failed')
# Before failing outright, try the next-best candidate. The wrong
# file was just quarantined; re-running the worker (with the bad
@ -1116,6 +1250,10 @@ def post_process_matched_download_with_verification(context_key, context, file_p
_mark_task_completed(task_id, context.get('track_info'))
if context.get('_verification_status'):
download_tasks[task_id]['verification_status'] = context['_verification_status']
if context.get('_history_id'):
download_tasks[task_id]['history_id'] = context['_history_id']
if context.get('_audio_quality'):
download_tasks[task_id]['quality'] = context['_audio_quality']
with matched_context_lock:
if context_key in matched_downloads_context:
del matched_downloads_context[context_key]
@ -1130,6 +1268,10 @@ def post_process_matched_download_with_verification(context_key, context, file_p
download_tasks[task_id]['metadata_enhanced'] = True
if context.get('_verification_status'):
download_tasks[task_id]['verification_status'] = context['_verification_status']
if context.get('_history_id'):
download_tasks[task_id]['history_id'] = context['_history_id']
if context.get('_audio_quality'):
download_tasks[task_id]['quality'] = context['_audio_quality']
redownload_ctx = download_tasks[task_id].get('_redownload_context')
with matched_context_lock:

View file

@ -168,16 +168,15 @@ def quarantine_group_key(
fetch NOT the downloaded file's own tags. That matters: the file's
metadata is frequently *wrong* (that's why it failed acoustid /
integrity), whereas the target is fixed and identical across every
alternative for one song (they're all Soulseek uploads of the *same*
source track), so grouping by it is an exact relationship, not a fuzzy
metadata guess.
alternative for one song.
Prefers a stable target-track id from the sidecar `context.track_info`
when present isrc, then source id, then uri since those are exact
and constant across siblings. Falls back to the normalized
artist|track name only for legacy/thin sidecars that carry no context.
Keys are kind-prefixed so an id-based key never collides with a
name-based one.
Uses ISRC when available (truly universal across sources and batches).
Falls back to normalized artist|track name, which is stable across
different batches and sources.
Source-specific IDs (Spotify track id, Qobuz id, uri) are intentionally
NOT used: the same song imported from different playlists or sources gets
different source IDs, so id-based keys break cross-batch sibling matching.
Returns ``None`` when nothing identifies the target (no usable id and
both name fields empty). Callers treat a ``None`` key as "its own
@ -191,12 +190,6 @@ def quarantine_group_key(
isrc = str(ti.get("isrc") or "").strip().lower()
if isrc:
return f"isrc:{isrc}"
tid = str(ti.get("id") or "").strip()
if tid:
return f"id:{tid}"
uri = str(ti.get("uri") or "").strip()
if uri:
return f"uri:{uri}"
artist = " ".join(str(expected_artist or "").split()).lower()
track = " ".join(str(expected_track or "").split()).lower()
if not artist and not track:
@ -302,6 +295,10 @@ def list_quarantine_entries(quarantine_dir: str) -> List[Dict[str, Any]]:
"source_username": source_username,
"source_filename": source_filename,
"thumb_url": _extract_context_thumb(ctx),
# Real probed audio quality (recorded on the context before the
# quality/AcoustID gates) so the review UI shows what the file
# actually is when deciding to approve/delete.
"quality": ctx.get("_audio_quality", "") if isinstance(ctx, dict) else "",
}
)

View file

@ -252,7 +252,7 @@ def record_library_history_download(context: Dict[str, Any]) -> None:
origin, origin_context = derive_download_origin(context)
db = get_database()
db.add_library_history_entry(
_history_id = db.add_library_history_entry(
event_type="download",
title=title,
artist_name=artist_name,
@ -270,6 +270,10 @@ def record_library_history_download(context: Dict[str, Any]) -> None:
origin_context=origin_context,
verification_status=context.get("_verification_status"),
)
# Stash the row id so the live download task can link to its
# library_history row (the Unverified review queue needs it).
if isinstance(_history_id, int) and _history_id > 0:
context["_history_id"] = _history_id
except Exception as e:
logger.debug("library history record failed: %s", e)

277
core/imports/silence.py Normal file
View file

@ -0,0 +1,277 @@
"""Audio-completeness guard — detect files whose container duration looks
right but whose REAL audio is far shorter, or mostly silence.
Motivating bug: HiFi/Monochrome HLS assembly can yield a file whose container
claims the full track length (e.g. 3:08) while only ~30s of audio actually
decodes the rest is missing. The duration-agreement and quality guards both
pass (mutagen reads the container's 3:08 and the format/bitrate are fine), so
nothing catches it until you listen. ffmpeg's ``time=`` even reports 0 with no
error on such a file, so the robust signal is to DECODE the audio and compare
the real duration (sample count / sample rate, via ``astats``) against the
container duration. A separate ``silencedetect`` pass also flags genuine
silence-padding.
The parsers here are pure and unit-tested; the ffmpeg invocations are
integration glue that fails open (returns None) when ffmpeg or mutagen can't
run, so a tooling problem never blocks a legitimate import.
"""
from __future__ import annotations
import re
import subprocess
from typing import Optional
from utils.logging_config import get_logger
logger = get_logger("imports.silence")
# Real decoded audio must cover at least this fraction of the container
# duration. A legit file decodes to ~100% (encoder padding aside); a truncated
# file decodes to a small fraction (the Blossom file: 30s of a 188s container
# = 16%). 0.85 leaves generous headroom against false positives.
DEFAULT_MIN_DURATION_RATIO = 0.85
_SAMPLES_RE = re.compile(r"Number of samples:\s*([0-9]+)")
# Defaults: treat audio below -50 dB lasting >= 2s as silence, and reject when
# more than half the track is silent. A normal song — even with quiet intros/
# outros — sits far below 0.5; a 30s-real + padded-silence file sits near 0.85.
DEFAULT_NOISE_DB = -50
DEFAULT_MIN_SILENCE_S = 2.0
DEFAULT_THRESHOLD = 0.5
_SILENCE_DURATION_RE = re.compile(r"silence_duration:\s*([0-9]+(?:\.[0-9]+)?)")
def silence_ratio_from_output(ffmpeg_stderr: str, total_duration_s: float) -> float:
"""Fraction of *total_duration_s* covered by detected silence.
Sums every ``silence_duration: X`` reported by ffmpeg ``silencedetect``
and divides by the track length. Capped at 1.0; returns 0.0 when the
duration is unknown/zero or no silence was reported.
"""
if not total_duration_s or total_duration_s <= 0:
return 0.0
total_silence = sum(float(m) for m in _SILENCE_DURATION_RE.findall(ffmpeg_stderr or ""))
if total_silence <= 0:
return 0.0
return min(total_silence / total_duration_s, 1.0)
def is_mostly_silent_reason(
ffmpeg_stderr: str,
total_duration_s: float,
*,
threshold: float = DEFAULT_THRESHOLD,
) -> Optional[str]:
"""Return a rejection reason when the silent fraction meets *threshold*."""
ratio = silence_ratio_from_output(ffmpeg_stderr, total_duration_s)
if ratio >= threshold:
pct = round(ratio * 100)
audible_s = round(total_duration_s * (1 - ratio))
return (
f"Audio is mostly silent: {pct}% silence (only ~{audible_s}s audible of "
f"{round(total_duration_s)}s) — likely a truncated/preview file padded "
f"to full length"
)
return None
def _ffmpeg_available() -> bool:
try:
subprocess.run(
["ffmpeg", "-version"],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
timeout=10, check=True,
)
return True
except (FileNotFoundError, subprocess.SubprocessError, OSError):
return False
def _probe_duration_s(file_path: str) -> Optional[float]:
try:
from mutagen import File as MutagenFile
audio = MutagenFile(file_path)
if audio and audio.info and getattr(audio.info, "length", None):
return float(audio.info.length)
except Exception as exc: # pragma: no cover - defensive
logger.debug("silence guard duration probe failed for %s: %s", file_path, exc)
return None
def detect_mostly_silent(
file_path: str,
*,
threshold: float = DEFAULT_THRESHOLD,
noise_db: int = DEFAULT_NOISE_DB,
min_silence_s: float = DEFAULT_MIN_SILENCE_S,
) -> Optional[str]:
"""Run ffmpeg ``silencedetect`` over *file_path* and return a rejection
reason when the file is mostly silence, else None.
Fails open: returns None when ffmpeg/mutagen are unavailable or error, so
a tooling problem never quarantines a legitimate file.
"""
if not _ffmpeg_available():
logger.debug("silence guard skipped — ffmpeg not available")
return None
total_duration_s = _probe_duration_s(file_path)
if not total_duration_s:
return None
try:
proc = subprocess.run(
[
"ffmpeg", "-hide_banner", "-nostats", "-i", file_path,
"-af", f"silencedetect=noise={noise_db}dB:d={min_silence_s}",
"-f", "null", "-",
],
stdout=subprocess.DEVNULL, stderr=subprocess.PIPE,
timeout=120,
)
except (subprocess.SubprocessError, OSError) as exc:
logger.debug("silence guard ffmpeg run failed for %s: %s", file_path, exc)
return None
stderr = proc.stderr.decode("utf-8", errors="replace") if proc.stderr else ""
return is_mostly_silent_reason(stderr, total_duration_s, threshold=threshold)
# ── Truncation: real decoded duration vs container duration ────────────────
def measured_duration_from_astats(astats_stderr: str, sample_rate: int) -> Optional[float]:
"""Real decoded audio duration in seconds from ffmpeg ``astats`` output.
``astats`` reports the per-channel ``Number of samples``; dividing by the
sample rate gives the true decoded length. Returns None when the sample
count or sample rate is unavailable.
"""
if not sample_rate or sample_rate <= 0:
return None
m = _SAMPLES_RE.search(astats_stderr or "")
if not m:
return None
return int(m.group(1)) / float(sample_rate)
def incomplete_audio_reason(
measured_s: Optional[float],
container_s: Optional[float],
*,
min_ratio: float = DEFAULT_MIN_DURATION_RATIO,
) -> Optional[str]:
"""Return a rejection reason when the real decoded duration falls short of
the container duration (a truncated file whose metadata over-states length).
"""
if not measured_s or not container_s or container_s <= 0:
return None
if measured_s >= container_s * min_ratio:
return None
pct = round(measured_s / container_s * 100)
return (
f"Incomplete audio: only ~{round(measured_s)}s actually decodes of a "
f"{round(container_s)}s file ({pct}%) — truncated/broken download "
f"(container duration over-states the real audio)"
)
def _measured_audio_duration_s(file_path: str, sample_rate: int) -> Optional[float]:
try:
proc = subprocess.run(
["ffmpeg", "-hide_banner", "-nostats", "-i", file_path,
"-af", "astats=metadata=1", "-f", "null", "-"],
stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, timeout=120,
)
except (subprocess.SubprocessError, OSError) as exc:
logger.debug("astats run failed for %s: %s", file_path, exc)
return None
stderr = proc.stderr.decode("utf-8", errors="replace") if proc.stderr else ""
return measured_duration_from_astats(stderr, sample_rate)
def detect_incomplete_audio(
file_path: str,
*,
min_ratio: float = DEFAULT_MIN_DURATION_RATIO,
) -> Optional[str]:
"""Decode the file and reject when the real audio is far shorter than the
container claims. Fails open when ffmpeg/mutagen are unavailable.
"""
if not _ffmpeg_available():
return None
try:
from mutagen import File as MutagenFile
audio = MutagenFile(file_path)
if not (audio and audio.info):
return None
container_s = float(getattr(audio.info, "length", 0) or 0)
sample_rate = int(getattr(audio.info, "sample_rate", 0) or 0)
except Exception as exc: # pragma: no cover - defensive
logger.debug("container probe failed for %s: %s", file_path, exc)
return None
measured_s = _measured_audio_duration_s(file_path, sample_rate)
return incomplete_audio_reason(measured_s, container_s, min_ratio=min_ratio)
def detect_broken_audio(
file_path: str,
*,
min_ratio: float = DEFAULT_MIN_DURATION_RATIO,
threshold: float = DEFAULT_THRESHOLD,
noise_db: int = DEFAULT_NOISE_DB,
min_silence_s: float = DEFAULT_MIN_SILENCE_S,
) -> Optional[str]:
"""Combined post-download audio guard: reject a file that is truncated
(real audio far shorter than the container) or mostly silence. Returns the
first failure reason, or None when the audio looks complete.
Runs a SINGLE ffmpeg decode pass with both the ``astats`` (truncation) and
``silencedetect`` (silence) filters chained one decode of the file feeds
both checks instead of two full decodes. Halves the CPU cost versus running
``detect_incomplete_audio`` and ``detect_mostly_silent`` back to back.
Fails open: returns None when ffmpeg/mutagen are unavailable or error, so a
tooling problem never quarantines a legitimate file.
"""
if not _ffmpeg_available():
logger.debug("audio guard skipped — ffmpeg not available")
return None
try:
from mutagen import File as MutagenFile
audio = MutagenFile(file_path)
if not (audio and audio.info):
return None
container_s = float(getattr(audio.info, "length", 0) or 0)
sample_rate = int(getattr(audio.info, "sample_rate", 0) or 0)
except Exception as exc: # pragma: no cover - defensive
logger.debug("container probe failed for %s: %s", file_path, exc)
return None
try:
proc = subprocess.run(
[
"ffmpeg", "-hide_banner", "-nostats", "-i", file_path,
"-af", f"astats=metadata=1,silencedetect=noise={noise_db}dB:d={min_silence_s}",
"-f", "null", "-",
],
stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, timeout=120,
)
except (subprocess.SubprocessError, OSError) as exc:
logger.debug("audio guard ffmpeg run failed for %s: %s", file_path, exc)
return None
stderr = proc.stderr.decode("utf-8", errors="replace") if proc.stderr else ""
# Truncation check first (real audio far shorter than the container).
measured_s = measured_duration_from_astats(stderr, sample_rate)
reason = incomplete_audio_reason(measured_s, container_s, min_ratio=min_ratio)
if reason:
return reason
# Then silence-padding (mostly-silent file).
return is_mostly_silent_reason(stderr, container_s, threshold=threshold)

View file

@ -103,9 +103,12 @@ def select_version_mismatch_fallback(
# don't guess which the user wants.
return None
# First tried = oldest = highest-confidence (the retry walks candidates
# best-first). The id is a "<date>_<time>_<name>" timestamp prefix, so the
# lexicographically smallest id is the earliest attempt.
# First tried = oldest = best (the retry walks candidates best-first; what
# "best" means follows the active ordering — confidence-first by default, or
# ranked-target quality when best_quality mode / the rank_candidates_by_quality
# toggle is on, so this naturally accepts the highest-quality candidate then).
# The id is a "<date>_<time>_<name>" timestamp prefix, so the lexicographically
# smallest id is the earliest attempt.
return min((e for _, e in candidates), key=lambda e: e["id"])

View file

@ -105,6 +105,7 @@ class JellyfinTrack:
self.title = jellyfin_data.get('Name', 'Unknown Track')
self.duration = jellyfin_data.get('RunTimeTicks', 0) // 10000 # Convert from ticks to milliseconds
self.trackNumber = jellyfin_data.get('IndexNumber')
self.discNumber = jellyfin_data.get('ParentIndexNumber') # multi-disc: disc number
self.year = jellyfin_data.get('ProductionYear')
self.userRating = jellyfin_data.get('UserData', {}).get('Rating')
self.addedAt = self._parse_date(jellyfin_data.get('DateCreated'))

View file

@ -138,10 +138,25 @@ def _collect_base_dirs(
except Exception as e:
logger.debug("music paths read failed: %s", e)
# Normalize to absolute forms so resolution does NOT depend on the calling
# thread's CWD. A relative config like "./Transfer" otherwise only resolves
# when os.path.isdir("./Transfer") happens to be true from the current CWD —
# which fails in background workers whose CWD isn't the app root, leaving
# base_dirs empty and every track "unresolved". For each candidate we try
# the raw form first (cheap, preserves an already-absolute path), then its
# os.path.abspath() form so "./Transfer" → "/app/Transfer".
expanded: list[str] = []
for c in candidates:
if not c:
continue
expanded.append(c)
if not os.path.isabs(c):
expanded.append(os.path.abspath(c))
# De-duplicate while preserving order, drop empties / non-existent dirs.
seen: set[str] = set()
out: list[str] = []
for c in candidates:
for c in expanded:
if not c or c in seen:
continue
seen.add(c)
@ -224,10 +239,23 @@ def resolve_library_file_path_with_diagnostic(
if not base_dirs:
return None, attempt
# Skip index 0 to avoid drive-letter / leading-slash artifacts
# (e.g. "E:" or "" from a leading "/").
# Try progressively shorter path suffixes against each base dir.
#
# Start at index 0 so a clean RELATIVE library path is tried in FULL first.
# SoulSync's own library scanner stores paths like
# "Asketa/Another Side/01 - Track.flac" (no leading slash) — index 0 is the
# artist folder and dropping it (the old range(1, ...)) meant the artist
# segment was never joined, so nothing under transfer/ ever resolved and
# every track looked unreadable to the quality scanner.
#
# For ABSOLUTE media-server paths ("/music/Artist/Album/track.flac") index 0
# is the empty leading segment and i=0 yields os.path.join(base, "", ...) ==
# base/Artist/... which simply won't exist and harmlessly falls through to
# i=1 ("music/...") etc. A Windows drive part ("E:") at i=0 likewise just
# fails on POSIX and falls through. So starting at 0 is safe for every form
# and only ADDS the relative-full-path match that was missing.
for base in base_dirs:
for i in range(1, len(path_parts)):
for i in range(0, len(path_parts)):
candidate = os.path.join(base, *path_parts[i:])
if os.path.exists(candidate):
return candidate, attempt

View file

@ -47,7 +47,10 @@ class LidarrDownloadClient(DownloadSourcePlugin):
if download_path is None:
download_path = config_manager.get('soulseek.download_path', './downloads')
self.download_path = Path(download_path)
self.download_path.mkdir(parents=True, exist_ok=True)
try:
self.download_path.mkdir(parents=True, exist_ok=True)
except OSError as e:
logger.warning(f"Could not verify download path {self.download_path}: {e}")
self.active_downloads: Dict[str, Dict[str, Any]] = {}
self._download_lock = threading.Lock()

View file

@ -110,6 +110,7 @@ class NavidromeTrack:
self.title = navidrome_data.get('title', 'Unknown Track')
self.duration = navidrome_data.get('duration', 0) * 1000 # Convert to milliseconds
self.trackNumber = navidrome_data.get('track')
self.discNumber = navidrome_data.get('discNumber') # multi-disc: disc number
self.year = navidrome_data.get('year')
self.userRating = navidrome_data.get('userRating')
self.addedAt = self._parse_date(navidrome_data.get('created'))

View file

@ -25,3 +25,4 @@ from core.personalized.generators import daily_mix # noqa: F401
from core.personalized.generators import fresh_tape # noqa: F401
from core.personalized.generators import archives # noqa: F401
from core.personalized.generators import seasonal_mix # noqa: F401
from core.personalized.generators import listening_mix # noqa: F401

View file

@ -0,0 +1,68 @@
"""Listening Mix (#913) generator.
Reads the full, render-ready track dicts the watchlist scan stored under the
``listening_recs_tracks_full`` metadata key built from the recommended artists'
top tracks (see ``core.watchlist_scanner._build_listening_recommendations``) and
coerces them into ``Track`` records.
Unlike Fresh Tape / Archives this needs NO discovery-pool hydration: the stored
dicts are already complete, so the snapshot can't shrink when the pool rotates. It
also means the generator is a pure read generation/network all happen during the
scan; this just hands the stored tracks to the personalized manager so the mix can
participate in the Sync-page mirror + Auto-Sync pipeline like every other kind."""
from __future__ import annotations
import json
from typing import Any, List
from core.personalized.specs import PlaylistKindSpec, get_registry
from core.personalized.types import PlaylistConfig, Track
KIND = 'listening_mix'
METADATA_KEY = 'listening_recs_tracks_full'
def generate(deps: Any, variant: str, config: PlaylistConfig) -> List[Track]:
"""Return the stored Listening Mix tracks, trimmed to ``config.limit``.
Empty (not an error) when the scan hasn't produced a mix yet — the manager
preserves any prior snapshot rather than dropping it. Tolerates a missing/garbage
metadata blob the same way.
"""
db = getattr(deps, 'database', None) or (deps.get('database') if isinstance(deps, dict) else None)
if db is None:
raise RuntimeError("Listening Mix generator deps missing `database`")
raw = db.get_metadata(METADATA_KEY)
if not raw:
return []
try:
rows = json.loads(raw) or []
except (ValueError, TypeError):
return []
tracks: List[Track] = []
for d in rows:
if not isinstance(d, dict):
continue
tracks.append(Track.from_dict(d))
if len(tracks) >= config.limit:
break
return tracks
SPEC = PlaylistKindSpec(
kind=KIND,
name_template='Your Listening Mix',
description='Tracks from artists matched to what you actually listen to.',
default_config=PlaylistConfig(limit=50, max_per_album=5, max_per_artist=3),
generator=generate,
requires_variant=False,
tags=['curated', 'listening'],
)
if get_registry().get(KIND) is None:
get_registry().register(SPEC)

View file

@ -111,7 +111,10 @@ class PersonalizedPlaylistsService:
Either value can be None to skip that filter for that source.
- Spotify: 60 / 40 (the existing 0-100 popularity scale)
- Deezer: 500000 / 100000 (rank values ballpark from real data)
- Deezer: 60 / 50 (ALSO 0-100 the discovery pool SYNTHESIZES deezer popularity to a
0-100 score at scan time, capped at 100; it is NOT Deezer's raw rank. The old
500000/100000 rank thresholds matched nothing, so Popular Picks came back empty for
deezer users while Hidden Gems' < 100000 caught the whole pool.)
- iTunes / others: None / None (no popularity data; fall back to no
threshold filter, just diversity-on-random)
@ -121,7 +124,7 @@ class PersonalizedPlaylistsService:
if normalized == 'spotify':
return 60, 40
if normalized == 'deezer':
return 500_000, 100_000
return 60, 50
# iTunes, hydrabase, anything else — no usable popularity data
return None, None

View file

@ -29,6 +29,7 @@ from config.settings import config_manager
# Import Soulseek data structures for drop-in replacement compatibility
from core.download_plugins.types import TrackResult, AlbumResult, DownloadStatus
from core.quality.source_map import quality_from_qobuz, quality_tier_for_source
logger = get_logger("qobuz_client")
@ -119,7 +120,10 @@ class QobuzClient(DownloadSourcePlugin):
download_path = config_manager.get('soulseek.download_path', './downloads')
self.download_path = Path(download_path)
self.download_path.mkdir(parents=True, exist_ok=True)
try:
self.download_path.mkdir(parents=True, exist_ok=True)
except OSError as e:
logger.warning(f"Could not verify download path {self.download_path}: {e}")
logger.info(f"Qobuz client using download path: {self.download_path}")
@ -987,7 +991,7 @@ class QobuzClient(DownloadSourcePlugin):
return ([], [])
# Get configured quality for display
quality_key = config_manager.get('qobuz.quality', 'lossless')
quality_key = quality_tier_for_source('qobuz', default='lossless')
quality_info = QOBUZ_QUALITY_MAP.get(quality_key, QOBUZ_QUALITY_MAP['lossless'])
track_results = []
@ -1072,6 +1076,14 @@ class QobuzClient(DownloadSourcePlugin):
track_number=track.get('track_number'),
)
# Stamp real API quality so the global ranker sees actual
# sample_rate/bit_depth. Hi-res only when the track itself is
# hires-streamable; otherwise it streams as CD-quality FLAC.
if hires_streamable and max_bit_depth >= 24:
track_result.set_quality(quality_from_qobuz(max_sample_rate, max_bit_depth))
else:
track_result.set_quality(quality_from_qobuz(44.1, 16))
return track_result
# ===================== Download =====================
@ -1181,7 +1193,7 @@ class QobuzClient(DownloadSourcePlugin):
try:
# Determine quality
quality_key = config_manager.get('qobuz.quality', 'lossless')
quality_key = quality_tier_for_source('qobuz', default='lossless')
quality_info = QOBUZ_QUALITY_MAP.get(quality_key, QOBUZ_QUALITY_MAP['lossless'])
# Quality fallback chain: hires_max → hires → lossless → mp3

0
core/quality/__init__.py Normal file
View file

284
core/quality/model.py Normal file
View file

@ -0,0 +1,284 @@
"""Source-agnostic audio quality model.
Every download source maps its result into ``AudioQuality``.
The ``QualityTarget`` list in the user's profile defines the
priority order (1st choice, 2nd choice, ). ``rank_candidate``
scores any ``AudioQuality`` against that list so the same
logic drives Soulseek, Tidal, Deezer, torrent no per-source
quality pipelines needed.
Soulseek attribute type codes (Soulseek protocol spec):
0 = bitrate (kbps)
1 = duration (seconds)
2 = VBR flag
4 = sample rate (Hz) FLAC / WAV only
5 = bit depth FLAC / WAV only
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import List, Optional, Tuple
@dataclass
class AudioQuality:
"""Unified audio quality descriptor — source-agnostic."""
format: str # 'flac', 'mp3', 'aac', 'ogg', 'wav', 'unknown'
bitrate: Optional[int] = None # kbps
sample_rate: Optional[int] = None # Hz (e.g. 44100, 96000, 192000)
bit_depth: Optional[int] = None # bits per sample (16, 24, 32)
def tier_score(self) -> float:
"""Continuous score for ranking within a matched target bucket.
Higher = better. Used as a tiebreaker after target-list matching.
"""
# NOTE: this only orders the *fallback* path (nothing matched a ranked
# target) and tie-breaks candidates of the SAME format within one
# matched target. Cross-format PRIORITY is decided solely by the user's
# ranked-target list (target index), never by these numbers.
format_base: dict[str, float] = {
'flac': 100.0,
'alac': 98.0, # lossless (Apple)
'wav': 95.0,
'ogg': 70.0,
'opus': 65.0,
'aac': 60.0,
'mp3': 50.0,
'wma': 30.0,
}
base = format_base.get(self.format.lower(), 10.0)
if self.format.lower() in ('flac', 'wav'):
sr = self.sample_rate or 44100
bd = self.bit_depth or 16
# sample-rate contribution: 44.1 kHz = 0, 192 kHz = +20
sr_score = min(sr / 192_000, 1.0) * 20
# bit-depth contribution: 16-bit = 0, 24-bit = +10
bd_score = max(bd - 16, 0) / 8 * 10
return base + sr_score + bd_score
else:
br = self.bitrate or 0
return base + min(br / 320, 1.0) * 10
def matches_target(self, target: QualityTarget) -> bool:
"""True when this quality satisfies every constraint in *target*."""
if target.format and target.format.lower() != self.format.lower():
return False
if target.min_bitrate and (self.bitrate or 0) < target.min_bitrate:
return False
if target.min_sample_rate:
if self.sample_rate is not None:
if self.sample_rate < target.min_sample_rate:
return False
else:
# No sample-rate metadata (common on slskd FLAC). Use the kbps
# heuristic when a bitrate is present; otherwise we CANNOT
# confirm the spec, so fail the strict target rather than
# over-claim it — an unknown-spec FLAC must not outrank a known
# 16/44 FLAC under a hi-res target (#896 review #4). It falls to
# the plain-flac bucket instead.
# 16-bit/44.1 kHz ≈ 1411 kbps; 24-bit/96 kHz ≈ 4608 kbps.
if self.format.lower() == 'flac' and self.bitrate:
required_kbps = _sample_rate_to_min_kbps(target.min_sample_rate, target.bit_depth or 24)
if self.bitrate < required_kbps:
return False
else:
return False
if target.bit_depth:
if self.bit_depth is not None:
if self.bit_depth < target.bit_depth:
return False
else:
# No bit-depth metadata. A hi-res (>=24-bit) target needs proof:
# use the kbps heuristic if a bitrate is present, else fail
# rather than over-claim. The 16-bit baseline still matches an
# unknown-spec FLAC (any FLAC is at least CD quality). #896 review #4.
if self.format.lower() == 'flac' and target.bit_depth >= 24:
if self.bitrate:
if self.bitrate < 1450:
return False
else:
return False
return True
def label(self) -> str:
"""Human-readable label, e.g. 'FLAC 24-bit/192kHz' or 'MP3 320kbps'."""
fmt = self.format.upper()
if self.format.lower() in ('flac', 'wav'):
bd = f"{self.bit_depth}-bit/" if self.bit_depth else ""
sr = f"{self.sample_rate // 1000}kHz" if self.sample_rate else ""
detail = f" {bd}{sr}".rstrip()
return f"{fmt}{detail}" if detail.strip() else fmt
else:
br = f" {self.bitrate}kbps" if self.bitrate else ""
return f"{fmt}{br}"
@classmethod
def from_slskd_file(cls, file_data: dict, extension: str) -> 'AudioQuality':
"""Build from a raw slskd API file entry.
slskd exposes Soulseek protocol file attributes as:
``{"attributes": [{"type": 4, "value": 96000}, {"type": 5, "value": 24}, ...]}``
"""
attrs = {a['type']: a['value'] for a in file_data.get('attributes', [])}
return cls(
format=extension.lower().lstrip('.'),
bitrate=file_data.get('bitRate') or attrs.get(0),
sample_rate=attrs.get(4),
bit_depth=attrs.get(5),
)
@classmethod
def from_tier(cls, fmt: str, bitrate: int, sample_rate: Optional[int] = None, bit_depth: Optional[int] = None) -> 'AudioQuality':
"""Build from a hardcoded quality tier (Tidal, Deezer, Qobuz)."""
return cls(format=fmt, bitrate=bitrate, sample_rate=sample_rate, bit_depth=bit_depth)
@classmethod
def from_extension_and_bitrate(cls, extension: str, bitrate: Optional[int]) -> 'AudioQuality':
"""Minimal constructor when only format + bitrate are known (torrent, YouTube)."""
return cls(format=extension.lower().lstrip('.'), bitrate=bitrate)
@dataclass
class QualityTarget:
"""One ranked entry in the user's quality priority list."""
label: str = ""
format: Optional[str] = None # 'flac', 'mp3', 'aac', …
bit_depth: Optional[int] = None # 16, 24
min_sample_rate: Optional[int] = None # Hz
min_bitrate: Optional[int] = None # kbps (lossy)
def to_dict(self) -> dict:
return {k: v for k, v in self.__dict__.items() if v not in (None, "")}
@classmethod
def from_dict(cls, d: dict) -> 'QualityTarget':
known = {f.name for f in cls.__dataclass_fields__.values()} # type: ignore[attr-defined]
return cls(**{k: v for k, v in d.items() if k in known})
# ── Default priority list ──────────────────────────────────────────────────────
DEFAULT_RANKED_TARGETS: List[QualityTarget] = [
QualityTarget(label='FLAC 24-bit/192kHz', format='flac', bit_depth=24, min_sample_rate=192_000),
QualityTarget(label='FLAC 24-bit/96kHz', format='flac', bit_depth=24, min_sample_rate=96_000),
QualityTarget(label='FLAC 24-bit/48kHz', format='flac', bit_depth=24, min_sample_rate=48_000),
QualityTarget(label='FLAC 24-bit/44.1kHz',format='flac', bit_depth=24, min_sample_rate=44_100),
QualityTarget(label='FLAC 16-bit', format='flac', bit_depth=16),
QualityTarget(label='MP3 320kbps', format='mp3', min_bitrate=320),
QualityTarget(label='MP3 256kbps', format='mp3', min_bitrate=256),
QualityTarget(label='MP3 192kbps', format='mp3', min_bitrate=192),
]
# ── Ranking helpers ────────────────────────────────────────────────────────────
def rank_candidate(aq: AudioQuality, targets: List[QualityTarget]) -> Tuple[int, float]:
"""Return *(target_index, tier_score)* for sorting.
Lower ``target_index`` higher priority match.
Candidates that satisfy no target get ``index = len(targets)``
(they sort last but are not discarded the caller decides that).
"""
for i, target in enumerate(targets):
if aq.matches_target(target):
return (i, aq.tier_score())
return (len(targets), aq.tier_score())
def filter_and_rank(
candidates: list,
targets: List[QualityTarget],
*,
fallback_enabled: bool = True,
) -> list:
"""Sort *candidates* (any objects with an ``audio_quality`` attribute)
by quality priority.
Returns the subset that matched the *highest-priority* satisfied target,
sorted by ``tier_score`` descending within that group.
Falls back to all candidates sorted by score when ``fallback_enabled``
and nothing matches, or when targets list is empty.
"""
if not targets:
candidates_copy = list(candidates)
candidates_copy.sort(key=lambda c: c.audio_quality.tier_score(), reverse=True)
return candidates_copy
scored = [(rank_candidate(c.audio_quality, targets), c) for c in candidates]
# Best target index that any candidate reached
best_idx = min((s[0][0] for s in scored), default=len(targets))
if best_idx < len(targets):
winners = [c for (idx, _), c in scored if idx == best_idx]
winners.sort(key=lambda c: c.audio_quality.tier_score(), reverse=True)
return winners
if fallback_enabled:
all_sorted = list(candidates)
all_sorted.sort(key=lambda c: c.audio_quality.tier_score(), reverse=True)
return all_sorted
return []
# ── Migration helper ───────────────────────────────────────────────────────────
def v2_qualities_to_ranked_targets(qualities: dict) -> List[dict]:
"""Convert old v2 ``qualities`` dict to a ranked-targets list.
Preserves the user's existing priority order while upgrading to the
richer target format.
"""
_FORMAT_MAP = {
'flac': {'format': 'flac', 'bit_depth': None},
'mp3_320': {'format': 'mp3', 'min_bitrate': 320},
'mp3_256': {'format': 'mp3', 'min_bitrate': 256},
'mp3_192': {'format': 'mp3', 'min_bitrate': 192},
# AAC (#886): opt-in tier. Match on format alone — Soulseek AAC/.m4a
# rarely carries a bitrate attribute, so a min_bitrate gate would
# reject every bitrate-less AAC. Priority order (above MP3, below FLAC)
# is preserved by the caller's priority sort, not by min_bitrate.
'aac': {'format': 'aac'},
}
enabled = [
(cfg.get('priority', 999), name, cfg)
for name, cfg in qualities.items()
if cfg.get('enabled', False)
]
enabled.sort()
targets = []
for _, name, cfg in enabled:
base = _FORMAT_MAP.get(name, {}).copy()
if not base:
continue
if name == 'flac':
bd = cfg.get('bit_depth', 'any')
if bd == '24':
base['bit_depth'] = 24
base['label'] = 'FLAC 24-bit'
elif bd == '16':
base['bit_depth'] = 16
base['label'] = 'FLAC 16-bit'
else:
base['label'] = 'FLAC (any)'
else:
base['label'] = name.upper().replace('_', ' ')
targets.append(base)
return targets
# ── Internal helpers ───────────────────────────────────────────────────────────
def _sample_rate_to_min_kbps(sample_rate: int, bit_depth: int) -> int:
"""Approximate minimum kbps for a lossless file at the given spec.
Used as heuristic when actual sample-rate metadata is absent.
"""
# kbps = sample_rate * channels * bit_depth / 1000 * compression_ratio
# Assume stereo (2 ch) and ~0.6 FLAC compression ratio
raw_kbps = sample_rate * 2 * bit_depth / 1000
return int(raw_kbps * 0.55) # conservative compressed estimate

148
core/quality/selection.py Normal file
View file

@ -0,0 +1,148 @@
"""Quality-aware candidate selection shared by the search engine and the
download orchestrator.
``rank_with_targets`` is the pure core: it ranks candidates against a target
list and reports whether any candidate met a *real* target (strict, fallback
off). The engine uses that ``satisfied`` flag to decide whether the current
source is good enough or it should fall through to the next source in the
hybrid chain.
``rank_for_profile`` is the thin DB-backed wrapper that loads the user's
quality profile (with v2->v3 migration) and delegates.
"""
from __future__ import annotations
from typing import List, Tuple
from core.quality.model import (
QualityTarget,
filter_and_rank,
v2_qualities_to_ranked_targets,
)
def rank_with_targets(
candidates: list,
targets: List[QualityTarget],
*,
fallback_enabled: bool = True,
) -> Tuple[list, bool]:
"""Rank *candidates* against *targets*.
Returns ``(ranked, satisfied)`` where ``satisfied`` is True when at least
one candidate meets a real target. When no targets are configured the
profile imposes no constraint, so any non-empty result counts as
satisfied (the first source wins, quality-sorted).
"""
if not candidates:
return [], False
if not targets:
ranked = filter_and_rank(candidates, targets, fallback_enabled=True)
return ranked, bool(ranked)
strict = filter_and_rank(candidates, targets, fallback_enabled=False)
if strict:
return strict, True
if fallback_enabled:
return filter_and_rank(candidates, targets, fallback_enabled=True), False
return [], False
def targets_from_profile(profile: dict) -> Tuple[List[QualityTarget], bool]:
"""Convert a quality-profile dict into ``(targets, fallback_enabled)`` with
v2->v3 migration applied. The single conversion path shared by the import
guard, the download ranker and the library quality scanner."""
raw_targets = profile.get('ranked_targets')
if not raw_targets and 'qualities' in profile:
raw_targets = v2_qualities_to_ranked_targets(profile['qualities'])
targets = [QualityTarget.from_dict(t) for t in (raw_targets or [])]
fallback_enabled = profile.get('fallback_enabled', True)
return targets, fallback_enabled
def load_profile_targets() -> Tuple[List[QualityTarget], bool]:
"""Load the user's quality profile from the DB and return
``(targets, fallback_enabled)`` with v2->v3 migration applied.
Callers that rank across many sources should load once and reuse via
:func:`rank_with_targets` rather than calling :func:`rank_for_profile`
per source.
"""
from database.music_database import MusicDatabase
return targets_from_profile(MusicDatabase().get_quality_profile())
def quality_meets_profile(aq, targets: List[QualityTarget]) -> bool:
"""Strict: True iff *aq* satisfies at least one ranked *target*.
The shared definition of "good enough" for both the import guard and the
library scanner bit depth + sample rate are minimums (see
:meth:`AudioQuality.matches_target`). Fallback is NOT consulted here; it's a
download-time last-resort concession, not part of what counts as meeting the
profile. ``targets`` empty no constraint (True). ``aq`` None (probe
failed) True, so an unreadable file is never falsely flagged.
"""
if not targets:
return True
if aq is None:
return True
from core.quality.model import rank_candidate
idx, _ = rank_candidate(aq, targets)
return idx < len(targets)
_VALID_SEARCH_MODES = ("priority", "best_quality")
def load_search_mode() -> str:
"""Return the download search strategy from the user's quality profile.
``'priority'`` (default) keeps today's behaviour — the first source in the
hybrid chain that meets a quality target wins. ``'best_quality'`` pools
candidates across all sources and works them bestworst by actual audio
quality. Any missing/unknown value resolves to ``'priority'`` so existing
installs are unaffected.
"""
from database.music_database import MusicDatabase
try:
profile = MusicDatabase().get_quality_profile()
mode = profile.get("search_mode", "priority")
except Exception:
return "priority"
return mode if mode in _VALID_SEARCH_MODES else "priority"
def load_rank_candidates_by_quality() -> bool:
"""Opt-in: order the priority-mode download walk (and thus the
version-mismatch force-import pick, which takes the first-tried = best)
by ranked-target quality instead of confidence-first.
Best-quality search mode is always quality-first regardless of this flag;
this toggle only affects *priority* mode. Default ``False`` keeps the
byte-for-byte old behaviour (confidence/peer-speed first), so existing
installs are unaffected unless they opt in. Any missing value or DB error
resolves to ``False``.
"""
from database.music_database import MusicDatabase
try:
profile = MusicDatabase().get_quality_profile()
return bool(profile.get("rank_candidates_by_quality", False))
except Exception:
return False
def rank_for_profile(candidates: list) -> Tuple[list, bool]:
"""Load the user's quality profile and rank *candidates* against it.
Returns ``(ranked, satisfied)`` see :func:`rank_with_targets`.
"""
targets, fallback_enabled = load_profile_targets()
return rank_with_targets(candidates, targets, fallback_enabled=fallback_enabled)

203
core/quality/source_map.py Normal file
View file

@ -0,0 +1,203 @@
"""Per-source quality mappers — turn each download source's tier string or
API values into a unified :class:`~core.quality.model.AudioQuality`.
Every streaming source describes quality differently (Tidal/HiFi use tier
strings, Qobuz reports real kHz + bit depth, Deezer uses config codes,
Amazon mixes real values with HD/UHD tiers). Centralising the knowledge
here keeps the per-client code to a single call and keeps the tier tables
in one auditable place.
Each value is a *claim*: the download client populates its ``TrackResult``
from it so the global ranker can choose a source, and the post-download
quality guard later verifies the real file. Over-claiming is the danger
an unknown tier maps to ``format='unknown'`` rather than pretending to be
lossless.
"""
from __future__ import annotations
from typing import Optional
from core.quality.model import AudioQuality
from core.quality.selection import load_profile_targets
# ── Extension → format string (source-agnostic) ────────────────────────────
#
# The single source of truth for mapping a file extension to the unified
# AudioQuality ``format``. Every extension-based download source (Soulseek,
# torrent/usenet file lists, …) classifies through this, so the ranked-target
# system behaves identically across sources and adding a format here lights it
# up everywhere at once. Unknown extensions → 'unknown' (never matches a
# target, so it only ever comes through via the fallback toggle).
#
# AIFF/AIF are uncompressed PCM like WAV → the same 'wav' tier. ``.m4a``
# defaults to 'aac'; an ALAC-in-m4a file can't be told apart by extension
# alone, so probe_audio_quality corrects it from the real codec post-download.
_EXTENSION_FORMAT_MAP = {
'flac': 'flac',
'alac': 'alac',
'wav': 'wav', 'wave': 'wav',
'aiff': 'wav', 'aif': 'wav', 'aifc': 'wav',
'mp3': 'mp3',
'm4a': 'aac', 'mp4': 'aac', 'aac': 'aac',
'ogg': 'ogg', 'oga': 'ogg',
'opus': 'opus',
'wma': 'wma',
}
# Audio extensions worth probing/classifying at all — derived from the map so
# the allow-list and the classifier never drift apart.
AUDIO_EXTENSIONS = {f'.{e}' for e in _EXTENSION_FORMAT_MAP}
def format_from_extension(ext: str) -> str:
"""Map a file extension (with or without leading dot) to the unified
AudioQuality format string. Unknown 'unknown'."""
return _EXTENSION_FORMAT_MAP.get(str(ext or '').lower().lstrip('.'), 'unknown')
# ── Tidal / HiFi (Monochrome is Tidal-backed) ──────────────────────────────
#
# Tidal exposes UPPER_SNAKE tier strings (``HI_RES_LOSSLESS``); HiFi's config
# uses lowercase keys (``hires``/``lossless``). We normalise both into the
# same lookup so one mapper serves both sources.
_TIDAL_HIRES = AudioQuality(format='flac', sample_rate=96000, bit_depth=24)
_TIDAL_LOSSLESS = AudioQuality(format='flac', sample_rate=44100, bit_depth=16)
_TIDAL_HIGH = AudioQuality(format='aac', bitrate=320)
_TIDAL_LOW = AudioQuality(format='aac', bitrate=96)
TIDAL_TIER_MAP = {
'HI_RES_LOSSLESS': _TIDAL_HIRES,
'HI_RES': _TIDAL_HIRES,
'HIRES': _TIDAL_HIRES,
'LOSSLESS': _TIDAL_LOSSLESS,
'HIGH': _TIDAL_HIGH,
'LOW': _TIDAL_LOW,
}
def quality_from_tidal_tier(tier: str) -> AudioQuality:
"""Map a Tidal/HiFi quality tier string to an AudioQuality.
Case-insensitive; accepts both ``HI_RES`` and ``hires`` spellings.
Unrecognised tiers map to ``format='unknown'`` so they never
over-claim lossless quality.
"""
key = (tier or '').strip().upper()
return TIDAL_TIER_MAP.get(key, AudioQuality(format='unknown'))
# ── Qobuz (real API values) ────────────────────────────────────────────────
def quality_from_qobuz(sampling_rate_khz: float, bit_depth: int) -> AudioQuality:
"""Qobuz reports ``maximum_sampling_rate`` in kHz (e.g. 44.1, 96, 192)
and ``maximum_bit_depth``. These are real values from the API.
"""
sample_rate = int(round(sampling_rate_khz * 1000)) if sampling_rate_khz else None
return AudioQuality(format='flac', sample_rate=sample_rate, bit_depth=bit_depth)
# ── Deezer (config code) ───────────────────────────────────────────────────
DEEZER_CODE_MAP = {
'flac': AudioQuality(format='flac', sample_rate=44100, bit_depth=16),
'mp3_320': AudioQuality(format='mp3', bitrate=320),
'mp3_128': AudioQuality(format='mp3', bitrate=128),
}
def quality_from_deezer(code: str) -> AudioQuality:
"""Map a Deezer download quality code to AudioQuality.
Deezer FLAC is always CD-quality (16-bit/44.1 kHz).
"""
return DEEZER_CODE_MAP.get((code or '').lower(), AudioQuality(format='unknown'))
# ── Amazon Music (real sampleRate preferred, HD/UHD tier fallback) ─────────
_AMAZON_TIER_MAP = {
'UHD': AudioQuality(format='flac', sample_rate=96000, bit_depth=24),
'HD': AudioQuality(format='flac', sample_rate=44100, bit_depth=16),
}
def quality_from_amazon(
tier: str,
sample_rate: Optional[int] = None,
bit_depth: Optional[int] = None,
) -> AudioQuality:
"""Amazon Music is FLAC; prefer the real ``sampleRate``/``bitDepth`` from
the stream info when present, otherwise fall back to the HD/UHD tier.
"""
base = _AMAZON_TIER_MAP.get((tier or '').strip().upper(), AudioQuality(format='flac'))
return AudioQuality(
format='flac',
sample_rate=sample_rate if sample_rate is not None else base.sample_rate,
bit_depth=bit_depth if bit_depth is not None else base.bit_depth,
)
# ── Profile-driven download tier (replaces per-source quality settings) ─────
#
# Each source's selectable download tiers, ordered best → worst, with the
# AudioQuality the tier delivers. ``quality_tier_for_source`` walks these to
# request the LOWEST tier that satisfies the user's top global target — so the
# global quality profile, not a per-source dropdown, decides what each source
# fetches.
_SOURCE_TIER_LADDERS: dict[str, list[tuple[str, AudioQuality]]] = {
'tidal': [
('hires', AudioQuality('flac', sample_rate=96000, bit_depth=24)),
('lossless', AudioQuality('flac', sample_rate=44100, bit_depth=16)),
('high', AudioQuality('aac', bitrate=320)),
('low', AudioQuality('aac', bitrate=96)),
],
'hifi': [
('hires', AudioQuality('flac', sample_rate=96000, bit_depth=24)),
('lossless', AudioQuality('flac', sample_rate=44100, bit_depth=16)),
('high', AudioQuality('aac', bitrate=320)),
('low', AudioQuality('aac', bitrate=96)),
],
'qobuz': [
('hires_max', AudioQuality('flac', sample_rate=192000, bit_depth=24)),
('hires', AudioQuality('flac', sample_rate=96000, bit_depth=24)),
('lossless', AudioQuality('flac', sample_rate=44100, bit_depth=16)),
('mp3', AudioQuality('mp3', bitrate=320)),
],
'deezer': [
('flac', AudioQuality('flac', sample_rate=44100, bit_depth=16)),
('mp3_320', AudioQuality('mp3', bitrate=320)),
('mp3_128', AudioQuality('mp3', bitrate=128)),
],
'amazon': [
('flac', AudioQuality('flac', sample_rate=48000, bit_depth=24)),
('opus', AudioQuality('aac', bitrate=320)),
],
}
def quality_tier_for_source(source_name: str, *, default: Optional[str] = None) -> Optional[str]:
"""Return the source tier key to request, derived from the global profile.
Picks the lowest tier in the source's ladder that satisfies the user's
top (most-preferred) target respecting the quality ceiling and saving
bandwidth. Falls back to the source's max tier when none can satisfy it
(best effort), or to the source's max when no targets are configured.
Returns *default* for an unknown source.
"""
ladder = _SOURCE_TIER_LADDERS.get(source_name)
if not ladder:
return default
targets, _ = load_profile_targets()
if not targets:
return ladder[0][0]
top = targets[0]
for key, aq in reversed(ladder): # low → high
if aq.matches_target(top):
return key
return ladder[0][0] # best effort: max tier

View file

@ -40,6 +40,7 @@ _JOB_MODULES = [
'core.repair_jobs.metadata_gap_filler',
'core.repair_jobs.album_completeness',
'core.repair_jobs.fake_lossless_detector',
'core.repair_jobs.quality_upgrade_scanner',
'core.repair_jobs.library_reorganize',
'core.repair_jobs.mbid_mismatch_detector',
'core.repair_jobs.single_album_dedup',

View file

@ -14,9 +14,10 @@ is queued until you review and Apply the finding — at which point the matched
track (carrying its album context) is added to the wishlist, exactly like every
other acquisition path.
The quality decision (``meets_preferred_quality``) is a pure function so it can be
unit-tested without a database or network. Transcode/"fake lossless" detection is
intentionally NOT done here that's the separate Fake Lossless Detector job.
Quality is judged using the real file (mutagen-measured bit depth / sample rate /
bitrate) checked against the user's v3 ranked profile targets — fully profile-driven,
no hardcoded thresholds. Transcode/"fake lossless" detection is the separate Fake
Lossless Detector job.
"""
from __future__ import annotations
@ -39,30 +40,25 @@ from core.discovery.quality_scanner import (
)
from core.library.file_tags import read_embedded_tags
from core.library.path_resolver import resolve_library_file_path
# v3 quality: probe the real file + check it against the ranked profile targets,
# the SAME definition the download import guard uses. Module-level so they're
# monkeypatchable in tests.
from core.imports.file_ops import probe_audio_quality
from core.quality.model import rank_candidate
from core.quality.selection import targets_from_profile, quality_meets_profile
from utils.logging_config import get_logger
logger = get_logger("repair_jobs.quality_upgrade")
# Quality ranks — higher is better. Lossless tops everything; lossy tiers fall out
# of bitrate. 0 means "below the lowest tracked tier / unknown".
RANK_LOSSLESS = 4
RANK_320 = 3
RANK_256 = 2
RANK_192 = 1
RANK_BELOW = 0
def _to_bool(val) -> bool:
"""Coerce a setting value to bool. Handles Python bool, string 'true'/'false', and int."""
if isinstance(val, bool):
return val
if isinstance(val, str):
return val.lower() == 'true'
return bool(val) if val is not None else False
LOSSLESS_EXTENSIONS = {'.flac', '.alac', '.ape', '.wav', '.aiff', '.aif', '.dsf', '.dff', '.m4a'}
# NB: .m4a is ambiguous (ALAC vs AAC); we treat the *format* as lossy-capable and
# rely on bitrate below — a true ALAC .m4a reports a lossless-scale bitrate.
# Quality-profile bucket key -> rank.
_PROFILE_KEY_RANK = {
'flac': RANK_LOSSLESS,
'mp3_320': RANK_320,
'mp3_256': RANK_256,
'mp3_192': RANK_192,
}
# Per-source file-tag key holding that source's own track ID (written by enrichment).
_SOURCE_TRACK_ID_TAG = {
@ -79,93 +75,6 @@ _SOURCE_TRACK_ID_TAG = {
_DURATION_TOLERANCE_MS = 5000
def _normalize_kbps(bitrate: Optional[int]) -> Optional[int]:
"""Library bitrate may be stored in bps (e.g. 320000) or kbps (320).
Normalize to kbps. Returns None when unknown/zero."""
if not bitrate:
return None
try:
b = int(bitrate)
except (TypeError, ValueError):
return None
if b <= 0:
return None
return b // 1000 if b > 4000 else b
def classify_track_quality(file_path: str, bitrate: Optional[int]) -> Optional[int]:
"""Rank a file by format + bitrate. Returns a RANK_* value, or None when it
can't be judged (a lossy file with no known bitrate)."""
ext = os.path.splitext(file_path or '')[1].lower()
kbps = _normalize_kbps(bitrate)
# Lossless containers: a real lossless file has a high bitrate; a low one is a
# lossy stream in a lossless container — but flagging that is the Fake Lossless
# Detector's job, so here we treat the lossless *format* as top rank.
if ext in {'.flac', '.alac', '.ape', '.wav', '.aiff', '.aif', '.dsf', '.dff'}:
return RANK_LOSSLESS
# .m4a / lossy: judge purely by bitrate. A lossless-scale bitrate (ALAC in m4a,
# or a mislabeled lossless) ranks as lossless.
if kbps is None:
return None
if kbps >= 800:
return RANK_LOSSLESS
if kbps >= 280:
return RANK_320
if kbps >= 200:
return RANK_256
if kbps >= 150:
return RANK_192
return RANK_BELOW
def preferred_quality_floor(quality_profile: Dict[str, Any]) -> Optional[int]:
"""The lowest acceptable quality rank from the profile's ENABLED buckets — the
floor a track must meet. Returns None when nothing is enabled (caller should
then flag nothing, rather than flagging everything)."""
qualities = (quality_profile or {}).get('qualities', {}) or {}
enabled_ranks = [
_PROFILE_KEY_RANK[key]
for key, cfg in qualities.items()
if isinstance(cfg, dict) and cfg.get('enabled') and key in _PROFILE_KEY_RANK
]
if not enabled_ranks:
return None
return min(enabled_ranks)
def meets_preferred_quality(file_path: str, bitrate: Optional[int],
quality_profile: Dict[str, Any]) -> bool:
"""Pure decision: does this track already meet the user's preferred quality?
A track meets quality when its format+bitrate rank is at least the profile's
floor (the worst quality the user still accepts). This honors a profile that
enables, say, FLAC *and* MP3-320: a 320 kbps MP3 passes, a 128 kbps MP3 does
not. With nothing enabled, everything passes (we never flag the whole library
on an empty profile)."""
floor = preferred_quality_floor(quality_profile)
if floor is None:
return True
file_rank = classify_track_quality(file_path, bitrate)
if file_rank is None:
# Lossy file with unknown bitrate: only judgeable when the floor is
# lossless (then any lossy file is below it). Otherwise don't flag.
ext = os.path.splitext(file_path or '')[1].lower()
if floor == RANK_LOSSLESS and ext not in LOSSLESS_EXTENSIONS:
return False
return True
return file_rank >= floor
def _rank_label(rank: Optional[int]) -> str:
return {
RANK_LOSSLESS: 'Lossless', RANK_320: 'MP3 320', RANK_256: 'MP3 256',
RANK_192: 'MP3 192', RANK_BELOW: 'low bitrate',
}.get(rank, 'unknown')
def _norm_isrc(value: Any) -> str:
"""Canonicalize an ISRC for comparison: uppercase, strip dashes/spaces."""
if not value:
@ -173,14 +82,14 @@ def _norm_isrc(value: Any) -> str:
return str(value).upper().replace('-', '').replace(' ', '').strip()
def _read_file_ids(file_path: str) -> Dict[str, str]:
def _read_file_ids(file_path: str, resolved_path: Optional[str] = None) -> Dict[str, str]:
"""Read the identifiers enrichment embedded in the file's tags.
Enrichment matches every track to the metadata sources and writes the IDs
(ISRC + per-source track IDs) into the file so an already-enriched track
carries its exact identity. Returns a dict with a normalized ``isrc`` plus any
``<source>_track_id`` tags present; empty dict when unreadable / not enriched."""
resolved = resolve_library_file_path(file_path) if file_path else None
resolved = resolved_path or (resolve_library_file_path(file_path) if file_path else None)
if not resolved and file_path and os.path.isfile(file_path):
resolved = file_path
if not resolved:
@ -436,45 +345,61 @@ def _find_best_match(engine: Any, source_priority: List[str], title: str, artist
@register_job
class QualityUpgradeJob(RepairJob):
job_id = 'quality_upgrade'
display_name = 'Quality Upgrade Finder'
description = 'Finds library tracks below your preferred quality and proposes a better version'
display_name = 'Quality Upgrade Finder (active — proposes a replacement)'
description = 'Finds library tracks below your quality profile and actively searches a better version to add to the wishlist'
help_text = (
'Scans your library (or just your watchlist artists) and compares each '
"track against your Quality Profile using BOTH the file format and its "
'bitrate — so a 128 kbps MP3 is no longer treated the same as a 320 kbps '
'one, and enabling MP3-320/256 in your profile actually counts.\n\n'
'For every track below your preferred quality it resolves the exact better '
'version using the most precise identity available, in order: the source '
"track ID enrichment wrote into the file → the file's ISRC → the album's "
'tracklist (by stored album ID or album search) → a name/artist search. The '
'fuzzy steps also reject candidates whose length is off (wrong live/edit cut). '
'It skips tracks it already proposed, so re-runs are cheap. Nothing is queued '
'automatically: applying a finding adds that matched track — with its album '
'context — to the wishlist, the same as any other download.\n\n'
'ACTIVE quality job. For every library track below your quality profile it '
'goes one step further than the Quality Check (flag-only) scanner: it '
'actively SEARCHES your metadata source for the exact better version and '
'attaches it to the finding, so Apply adds that track straight to your '
'wishlist.\n\n'
'Quality is judged the SAME way as the download/import pipeline — it reads '
'the REAL file with mutagen (measured bit depth / sample rate / bitrate) and '
'checks it against your v3 quality profile targets (strict: fallback is '
'ignored, that\'s a download-time concession, not "good enough" for an '
'upgrade). So a 128 kbps MP3, a 16-bit FLAC where you want 24-bit, etc. are '
'all caught accurately.\n\n'
'For every below-profile track it resolves the better version by the most '
'precise identity available, in order: the source track ID enrichment wrote '
"into the file → the file's ISRC → the album's tracklist (by stored album ID "
'or album search) → a name/artist search (with a duration guard against wrong '
'live/edit cuts). It skips tracks it already proposed, so re-runs are cheap. '
'Nothing is queued automatically — applying a finding adds the matched track '
'(with album context) to the wishlist, like any other download.\n\n'
'Settings:\n'
'- Scope: "watchlist" (watchlisted artists only) or "all" (whole library)\n'
'- Min confidence: minimum match confidence (0-1) to surface a finding\n\n'
'Note: detecting fake/transcoded lossless files is handled by the separate '
'Fake Lossless Detector job.'
'- Min confidence: minimum match confidence (0-1) to surface a finding\n'
'- Deep audio verify (default OFF): also run the ffmpeg decode guard '
'(truncation + silence) per track — catches broken/incomplete files the '
'header hides, but decodes every file (seconds per track, CPU-heavy).\n\n'
'Sibling job: "Quality Check (flag only)" finds the same below-profile tracks '
'but only flags them for you to decide per finding (re-download / delete / '
'ignore) instead of searching a replacement. Fake/transcoded lossless '
'detection is the separate Fake Lossless Detector job.'
)
icon = 'repair-icon-lossy'
default_enabled = False
default_interval_hours = 168
default_settings = {'scope': 'watchlist', 'min_confidence': 0.7}
setting_options = {'scope': ['watchlist', 'all']}
default_settings = {'scope': 'all', 'min_confidence': 0.7, 'deep_audio_verify': False, 'require_top_target': False}
setting_options = {'scope': ['all', 'watchlist'], 'deep_audio_verify': [True, False], 'require_top_target': [True, False]}
auto_fix = False
def _get_settings(self, context: JobContext) -> Dict[str, Any]:
cfg = context.config_manager
scope = 'watchlist'
min_conf = 0.7
if cfg:
scope = cfg.get(self.get_config_key('settings.scope'), 'watchlist') or 'watchlist'
merged = dict(self.default_settings)
if context.config_manager:
try:
min_conf = float(cfg.get(self.get_config_key('settings.min_confidence'), 0.7))
except (TypeError, ValueError):
min_conf = 0.7
return {'scope': scope, 'min_confidence': min_conf}
cfg = context.config_manager.get(f'repair.jobs.{self.job_id}.settings', {})
if isinstance(cfg, dict):
merged.update(cfg)
except Exception as e:
logger.debug("settings read failed: %s", e)
try:
merged['min_confidence'] = float(merged.get('min_confidence', 0.7))
except (TypeError, ValueError):
merged['min_confidence'] = 0.7
merged['deep_audio_verify'] = _to_bool(merged.get('deep_audio_verify'))
merged['require_top_target'] = _to_bool(merged.get('require_top_target'))
return merged
def _load_tracks(self, db: Any, scope: str) -> List[dict]:
conn = db._get_connection()
@ -530,13 +455,27 @@ class QualityUpgradeJob(RepairJob):
settings = self._get_settings(context)
scope = settings['scope']
min_conf = settings['min_confidence']
deep_verify = settings['deep_audio_verify']
require_top = settings['require_top_target']
# v3 quality: judge the REAL file (mutagen-measured bit depth / sample rate
# / bitrate) against the profile's ranked targets — the SAME definition the
# download import guard uses. Strict: fallback is ignored (a download-time
# concession, not "good enough" for an upgrade). targets_from_profile /
# quality_meets_profile / probe_audio_quality are imported at module level.
db = context.db
quality_profile = db.get_quality_profile()
if preferred_quality_floor(quality_profile) is None:
logger.info("[Quality Upgrade] No quality buckets enabled in profile — nothing to flag")
targets, _fallback = targets_from_profile(quality_profile)
if not targets:
logger.info("[Quality Upgrade] No quality targets in profile — nothing to flag")
return result
logger.info(
"[Quality Upgrade] scope=%s require_top=%s · all targets: %s",
scope, require_top,
[t.label for t in targets] if targets else '(none — all pass)',
)
try:
tracks = self._load_tracks(db, scope)
except Exception as e:
@ -583,13 +522,53 @@ class QualityUpgradeJob(RepairJob):
result.findings_skipped_dedup += 1
continue
if meets_preferred_quality(file_path, bitrate, quality_profile):
# v3 quality decision — probe the REAL file. Resolve the library path
# first (the DB stores a possibly-relative path). Pass config_manager so
# the resolver can find the transfer/music folders and expand relative paths.
resolved_path = resolve_library_file_path(
file_path,
transfer_folder=context.transfer_folder,
config_manager=context.config_manager,
) if file_path else None
if not resolved_path and file_path and os.path.isfile(file_path):
resolved_path = file_path
measured_aq = probe_audio_quality(resolved_path) if resolved_path else None
# Optional ffmpeg deep verify (default off): a truncated/silent file is
# treated as "needs a replacement" just like a below-profile one.
broken_reason = None
if deep_verify and resolved_path:
try:
from core.imports.silence import detect_broken_audio
broken_reason = detect_broken_audio(resolved_path)
except Exception as e:
logger.debug("[Quality Upgrade] deep verify failed for %s: %s", file_path, e)
if measured_aq is None and not broken_reason:
# Can't read the file → can't judge it; leave it alone.
result.skipped += 1
if context.update_progress and (i + 1) % 25 == 0:
context.update_progress(i + 1, total)
continue
# Below preferred quality — find a better version to propose.
if not broken_reason and measured_aq is not None:
if require_top:
# ranking-based: skip only if the file already sits at rank 0
# (the top configured target). Any lower rank → flag for upgrade.
idx, _ = rank_candidate(measured_aq, targets)
already_best = (idx == 0)
else:
# default: skip if the file meets ANY configured target (i.e.
# it's not below the acceptable floor).
already_best = quality_meets_profile(measured_aq, targets)
if already_best:
result.skipped += 1
if context.update_progress and (i + 1) % 25 == 0:
context.update_progress(i + 1, total)
continue
# Below profile (or broken) — find a better version to propose.
if engine is None:
from core.matching_engine import MusicMatchingEngine
engine = MusicMatchingEngine()
@ -602,17 +581,20 @@ class QualityUpgradeJob(RepairJob):
logger.info("[Quality Upgrade] Spotify rate-limited — stopping scan early")
return result
current_rank = classify_track_quality(file_path, bitrate)
current_label = _rank_label(current_rank)
current_label = measured_aq.label() if measured_aq is not None else 'broken/unreadable'
if broken_reason:
current_label = f'{current_label} (broken: {broken_reason})' if measured_aq is not None else f'broken ({broken_reason})'
if context.report_progress:
_why = 'broken audio' if broken_reason else 'low quality'
context.report_progress(
scanned=i + 1, total=total,
log_line=f'Low quality ({current_label}): {artist_name} - {title}',
log_line=f'{_why} ({current_label}): {artist_name} - {title}',
log_type='info')
# Read the identifiers enrichment embedded in the file once (ISRC +
# per-source track IDs), used by the two most-exact tiers below.
file_ids = _read_file_ids(file_path)
# Pass resolved_path so the inner resolver doesn't redo the lookup.
file_ids = _read_file_ids(file_path, resolved_path=resolved_path)
# Tiered match, best identity first, loosest last:
# 0. The active source's OWN track ID, embedded in the file by
@ -685,8 +667,9 @@ class QualityUpgradeJob(RepairJob):
file_path=file_path,
title=f'Upgrade: {artist_name} - {title} ({current_label})',
description=(
f'"{title}" by {artist_name} is {current_label}, below your preferred '
f'quality. Best match: "{_track_name(best)}" via {source} '
f'"{title}" by {artist_name} is {current_label}'
+ (f', below your preferred quality ({targets[0].label})' if require_top and targets else ', below your preferred quality')
+ f'. Best match: "{_track_name(best)}" via {source} '
f'({_MATCH_NOTE.get(matched_via, "matched") if matched_via != "search" else f"confidence {conf:.0%}"}). '
'Apply to add it to the wishlist.'),
details={
@ -715,6 +698,13 @@ class QualityUpgradeJob(RepairJob):
if context.update_progress:
context.update_progress(total, total)
logger.info("[Quality Upgrade] %d scanned, %d upgrades found, %d met/skip",
logger.info("[Quality Upgrade] %d scanned, %d upgrades found, %d already met profile / skipped",
result.scanned, result.findings_created, result.skipped)
if result.scanned > 0 and result.findings_created == 0 and result.errors == 0:
logger.info(
"[Quality Upgrade] All tracks already satisfy the configured targets. "
"If you expected upgrades, check your quality profile — the current "
"top target is: %s",
targets[0].label if targets else '(none)',
)
return result

View file

@ -0,0 +1,416 @@
"""Quality Upgrade Scanner Job — flags library tracks below the user's profile.
Walks the music folders ON DISK (transfer + download + every configured library
path) exactly like the Orphan / Fake-Lossless detectors those reliably "see"
files because they os.walk real directories instead of trying to resolve the
DB's stored (often relative) paths. For each audio file it probes the ACTUAL
measured audio quality (bit depth / sample rate / bitrate via the same
`probe_audio_quality` the download import guard uses) and checks it against the
user's v3 ranked targets with `quality_meets_profile` (strict — fallback
ignored, that's a download-time concession, not a definition of "good enough").
Every file that satisfies none of the targets becomes a finding the user can:
- 'redownload': add the track to the wishlist and delete the low-quality file
- 'delete': remove the low-quality file (+ DB row when known)
- 'ignore': dismiss the finding (handled in the UI via the dismiss endpoint)
Each walked file is matched back to its DB track (by path suffix) so the finding
carries the real title/artist/album + track id; when no DB row matches, the
file's own tags are used and the finding is filed as a loose 'file'.
"""
import os
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_job.quality_upgrade")
AUDIO_EXTENSIONS = {'.mp3', '.flac', '.ogg', '.opus', '.m4a', '.aac', '.wav', '.wma', '.aiff', '.aif'}
@register_job
class QualityUpgradeScannerJob(RepairJob):
job_id = 'quality_upgrade_scanner'
display_name = 'Quality Check (flag only — you decide per finding)'
description = 'Flags library tracks below your quality profile; you choose re-download / delete / ignore per finding'
help_text = (
'FLAG-ONLY quality job. Walks your music library folder on disk (so it also '
'catches loose files not in the DB) and checks every track against your v3 '
'quality profile — then just FLAGS what is below profile. Unlike the active '
'"Quality Upgrade Finder", it does NOT search a replacement; you decide what '
'to do per finding: Re-download / Delete / Ignore.\n\n'
'Two-stage check (same as the download/import pipeline):\n'
'1. Real-audio guard (optional, ffmpeg) — decodes the file (truncation + '
'silence detection) to catch broken/incomplete audio the header hides.\n'
'2. Quality gate — measured bit depth / sample rate / bitrate vs your '
'profile targets.\n\n'
'Settings:\n'
'- Deep audio verify (default OFF): run the ffmpeg decode guard. Off = fast '
'header-only quality pass (milliseconds/track). On = full decode '
'(seconds/track, CPU-heavy) but catches broken/silent audio.\n'
'- library_tracks_only (default off): only check files matched to a '
'library DB track (skip loose/orphan files).\n\n'
'The scan only reports — it never deletes or re-downloads on its own. '
'Use the sibling "Quality Upgrade Finder" instead if you want it to actively '
'find and queue a better version for you.'
)
icon = 'repair-icon-lossless'
default_enabled = False
default_interval_hours = 168
# library_tracks_only: when ON, only check files that match a library DB
# track (skips loose/orphan files). Default OFF — the scan checks EVERY
# audio file in the Music Library output folder, which is what users expect
# ("check my library folder"). DB matching after a reset is unreliable and
# would wrongly skip everything. Turn ON to ignore non-DB files.
#
# deep_audio_verify default OFF: the ffmpeg decode is the CPU-heavy step. Most
# users want the fast header-only quality pass; turn it on for a deep scan that
# also catches broken/silent audio. (Matches the download pipeline's default.)
default_settings = {'library_tracks_only': False, 'deep_audio_verify': False, 'require_top_target': False}
setting_options = {'library_tracks_only': [True, False],
'deep_audio_verify': [True, False],
'require_top_target': [True, False]}
auto_fix = False # User chooses fix action per finding
def scan(self, context: JobContext) -> JobResult:
result = JobResult()
# Load the user's v3 ranked targets — the SAME definition the download
# import guard uses. Strict: a track is below-profile when its measured
# quality satisfies NONE of the targets (fallback is not consulted).
from core.quality.selection import targets_from_profile, quality_meets_profile
try:
profile = context.db.get_quality_profile()
except Exception as e:
logger.warning("Could not load quality profile: %s", e)
return result
targets, _fallback = targets_from_profile(profile)
if not targets:
logger.info("Quality profile has no targets — nothing to check against")
return result
logger.info("Quality upgrade scan — profile targets (strict): %s",
[t.label for t in targets])
from core.imports.file_ops import probe_audio_quality
# Same real-file AudioGuard the download/import pipeline runs: ffmpeg
# DECODES the file (astats + silencedetect) to catch truncated or
# mostly-silent audio the header can't reveal.
from core.imports.silence import detect_broken_audio
# --- Collect the music folders to walk (real dirs, abspath'd) ---
base_dirs = self._collect_music_dirs(context)
if not base_dirs:
logger.warning(
"[QualityScan] No existing music folder to walk (transfer=%r, cwd=%r). "
"Set soulseek.transfer_path to the real mount or add your library under "
"Settings → Library → Music Paths.",
context.transfer_folder, os.getcwd())
return result
logger.info("[QualityScan] Walking %d folder(s): %r", len(base_dirs), base_dirs)
# --- Gather audio files (dedup by real path) ---
audio_files = []
seen = set()
for base in base_dirs:
for root, _dirs, files in os.walk(base):
if context.check_stop():
return result
for fname in files:
if os.path.splitext(fname)[1].lower() in AUDIO_EXTENSIONS:
fpath = os.path.join(root, fname)
rp = os.path.realpath(fpath)
if rp in seen:
continue
seen.add(rp)
audio_files.append(fpath)
total = len(audio_files)
logger.info("[QualityScan] Found %d audio file(s) to check", total)
if context.report_progress:
context.report_progress(phase=f'Checking {total} files...', total=total)
if context.update_progress:
context.update_progress(0, total)
# --- DB suffix index so a walked file maps back to its track row ---
db_index = self._build_db_suffix_index(context)
# Only check files that are part of the LIBRARY (have a DB track row).
# The transfer/download folders also hold pre-import leftovers (e.g.
# residue after a DB reset) — those are orphans, not library tracks, and
# belong to the Orphan File Detector, not a quality upgrade scan. Default
# ON so the scan reflects the user's actual library, not download junk.
_settings = self._get_settings(context)
library_only = _settings.get('library_tracks_only', False)
# Deep verify = run the ffmpeg AudioGuard (real decode) per file, exactly
# like the download pipeline. Slower than a header read (seconds vs ms) but
# it verifies the REAL audio, not just the metadata. OFF by default (the
# decode is the CPU-heavy step); turn on for a deep scan.
deep_verify = _settings.get('deep_audio_verify', False)
# require_top_target: flag files that meet a lower target but not the
# highest-priority one (e.g. 16-bit FLAC when 24-bit is preferred).
require_top = _settings.get('require_top_target', False)
check_targets = targets[:1] if require_top and len(targets) > 1 else targets
probe_failed = 0
not_in_library = 0
for i, fpath in enumerate(audio_files):
if context.check_stop():
return result
if i % 20 == 0 and context.wait_if_paused():
return result
fname = os.path.basename(fpath)
# Map to a DB track up front (cheap suffix lookup). When scoping to
# the library, skip anything with no DB row BEFORE probing — no point
# reading hundreds of orphan files.
meta = self._match_db(fpath, db_index)
if library_only and meta is None:
not_in_library += 1
result.skipped += 1
continue
if meta is None:
meta = self._read_file_tags(fpath)
result.scanned += 1
if context.report_progress and i % 25 == 0:
context.report_progress(
scanned=i + 1, total=total,
phase=f'Checking {i + 1} / {total}',
log_line=f'Checking: {fname}',
log_type='info',
)
# === Real-file verification — the SAME two stages the download /
# import pipeline runs on every file ===
# 1) AudioGuard: ffmpeg DECODES the audio (astats / silencedetect)
# to catch truncated or mostly-silent files the header hides.
# 2) Quality gate: measured quality (mutagen) vs the ranked profile.
try:
broken_reason = detect_broken_audio(fpath) if deep_verify else None
except Exception as e:
logger.debug("AudioGuard failed for %s: %s", fname, e)
broken_reason = None
try:
aq = probe_audio_quality(fpath)
except Exception as e:
logger.debug("Probe failed for %s: %s", fname, e)
aq = None
if broken_reason:
issue = 'broken_audio'
current_label = aq.label() if aq is not None else 'unknown'
elif aq is None:
# Header unreadable → can't judge quality; leave it unflagged.
probe_failed += 1
result.skipped += 1
continue
elif not quality_meets_profile(aq, check_targets):
issue = 'below_profile'
current_label = aq.label()
else:
# Decodes fully AND meets the profile → genuinely good.
if context.update_progress and (i + 1) % 25 == 0:
context.update_progress(i + 1, total)
continue
# Build the finding (broken audio OR below profile).
target_labels = [t.label for t in targets]
disp_title = meta.get('title') or os.path.splitext(fname)[0]
disp_artist = meta.get('artist') or 'Unknown'
if issue == 'broken_audio':
_title = f'Broken/incomplete audio: {disp_title}'
_desc = (f'"{disp_title}" by {disp_artist} failed real-audio '
f'verification (ffmpeg): {broken_reason}')
_severity = 'warning'
else:
_pref = targets[0].label if require_top and len(targets) > 1 else None
_title = f'{"Upgradeable" if _pref else "Below quality"}: {disp_title} ({current_label})'
_desc = (f'"{disp_title}" by {disp_artist} is {current_label}'
+ (f', below your preferred quality ({_pref}).' if _pref else
f', which does not meet your quality profile '
f'({", ".join(target_labels[:3])}'
f'{"" if len(target_labels) > 3 else ""}).'))
_severity = 'info'
if context.report_progress:
context.report_progress(log_line=_title, log_type='error')
if context.create_finding:
inserted = context.create_finding(
job_id=self.job_id,
finding_type='quality_upgrade',
severity=_severity,
entity_type='track' if meta.get('track_id') else 'file',
entity_id=str(meta['track_id']) if meta.get('track_id') else None,
file_path=fpath,
title=_title,
description=_desc,
details={
'quality_issue': issue,
'broken_audio_reason': broken_reason or '',
'current_quality': current_label,
'current_format': aq.format if aq is not None else '',
'current_bitrate': aq.bitrate if aq is not None else None,
'current_sample_rate': aq.sample_rate if aq is not None else None,
'current_bit_depth': aq.bit_depth if aq is not None else None,
'target_qualities': target_labels,
'expected_title': disp_title,
'expected_artist': disp_artist,
'album_title': meta.get('album', ''),
'track_number': meta.get('track_number'),
'album_thumb_url': meta.get('album_thumb_url'),
'artist_thumb_url': meta.get('artist_thumb_url'),
},
)
if inserted:
result.findings_created += 1
else:
result.findings_skipped_dedup += 1
if context.update_progress and (i + 1) % 25 == 0:
context.update_progress(i + 1, total)
if context.update_progress:
context.update_progress(total, total)
if probe_failed:
logger.warning("[QualityScan] %d/%d files could not be probed (unreadable)",
probe_failed, total)
if not_in_library:
logger.info(
"[QualityScan] %d/%d files skipped — not in the library DB (orphan "
"leftovers in transfer/downloads; disable 'library_tracks_only' to "
"include them)", not_in_library, total)
logger.info("Quality upgrade scan: %d checked, %d below profile, %d skipped",
result.scanned, result.findings_created, result.skipped)
return result
def _get_settings(self, context: JobContext) -> dict:
merged = dict(self.default_settings)
if context.config_manager:
try:
cfg = context.config_manager.get(f'repair.jobs.{self.job_id}.settings', {})
if isinstance(cfg, dict):
merged.update(cfg)
except Exception as e:
logger.debug("settings read failed: %s", e)
for key in ('library_tracks_only', 'deep_audio_verify', 'require_top_target'):
val = merged.get(key)
if not isinstance(val, bool):
merged[key] = str(val).lower() == 'true' if val is not None else False
return merged
def _collect_music_dirs(self, context: JobContext) -> list:
"""The music-library directories to walk, as absolute paths (dedup).
Only the user's MUSIC LIBRARY is scanned — that's the "Output Folder
(Music Library)" setting (soulseek.transfer_path) plus any custom
library paths (library.music_paths, for media-server setups). The
download/staging folders are deliberately NOT walked: they hold raw,
pre-import downloads and leftovers, not the finished library, and the
user expects quality checks to run on their library only. Whatever
custom path the user configured for the output folder is respected,
because it's read live from config here.
"""
cm = context.config_manager
raw = [context.transfer_folder]
if cm:
try:
raw.append(cm.get('soulseek.transfer_path', './Transfer'))
mp = cm.get('library.music_paths', []) or []
if isinstance(mp, list):
raw.extend([p for p in mp if isinstance(p, str) and p.strip()])
except Exception as e:
logger.debug("music dir config read failed: %s", e)
out, seen = [], set()
for d in raw:
if not d:
continue
ad = os.path.abspath(d)
if ad in seen:
continue
seen.add(ad)
if os.path.isdir(ad):
out.append(ad)
return out
def _build_db_suffix_index(self, context: JobContext) -> dict:
"""Map normalized path suffixes (last 1-3 components, lowercased) →
track metadata, so a walked absolute file can be matched to its DB row
even when the DB stores a different (relative) path prefix."""
index = {}
conn = None
try:
conn = context.db._get_connection()
cursor = conn.cursor()
cursor.execute("""
SELECT t.id, t.title,
COALESCE(NULLIF(t.track_artist, ''), ar.name) AS artist,
t.file_path, t.track_number,
al.title AS album_title, al.thumb_url, ar.thumb_url
FROM tracks t
LEFT JOIN artists ar ON ar.id = t.artist_id
LEFT JOIN albums al ON al.id = t.album_id
WHERE t.file_path IS NOT NULL AND t.file_path != ''
""")
for row in cursor.fetchall():
fp = (row[3] or '').replace('\\', '/')
if not fp:
continue
parts = fp.split('/')
meta = {
'track_id': row[0],
'title': row[1] or '',
'artist': row[2] or '',
'track_number': row[4],
'album': row[5] or '',
'album_thumb_url': row[6] or None,
'artist_thumb_url': row[7] or None,
}
for depth in range(1, min(4, len(parts) + 1)):
suffix = '/'.join(parts[-depth:]).lower()
index.setdefault(suffix, meta)
except Exception as e:
logger.error("Error building DB suffix index: %s", e)
finally:
if conn:
conn.close()
return index
def _match_db(self, fpath: str, db_index: dict):
"""Match a walked file to a DB track via path suffix. Returns the track
meta dict, or None when the file isn't part of the library."""
parts = fpath.replace('\\', '/').split('/')
for depth in range(min(3, len(parts)), 0, -1):
suffix = '/'.join(parts[-depth:]).lower()
hit = db_index.get(suffix)
if hit:
return hit
return None
def _read_file_tags(self, fpath: str) -> dict:
"""Read title/artist/album from the file's own tags (for loose files
when library_tracks_only is off)."""
meta = {'track_id': None}
try:
from mutagen import File as MutagenFile
audio = MutagenFile(fpath, easy=True)
if audio:
meta['title'] = (audio.get('title') or [None])[0] or ''
meta['artist'] = (audio.get('artist') or audio.get('albumartist') or [None])[0] or ''
meta['album'] = (audio.get('album') or [None])[0] or ''
except Exception as e:
logger.debug("tag read failed for %s: %s", os.path.basename(fpath), e)
return meta
def estimate_scope(self, context: JobContext) -> int:
count = 0
for base in self._collect_music_dirs(context):
for _root, _dirs, files in os.walk(base):
for fname in files:
if os.path.splitext(fname)[1].lower() in AUDIO_EXTENSIONS:
count += 1
return count

View file

@ -994,9 +994,9 @@ class RepairWorker:
'unwanted_content': self._fix_unwanted_content,
'unknown_artist': self._fix_unknown_artist,
'acoustid_mismatch': self._fix_acoustid_mismatch,
'quality_upgrade': self._fix_quality_upgrade,
'missing_discography_track': self._fix_discography_backfill,
'library_retag': self._fix_library_retag,
'quality_upgrade': self._fix_quality_upgrade,
}
handler = handlers.get(finding_type)
if not handler:
@ -1024,9 +1024,43 @@ class RepairWorker:
return {'success': False, 'error': str(e)}
def _fix_quality_upgrade(self, entity_type, entity_id, file_path, details):
"""Add the matched higher-quality version to the wishlist (with album
context). Applying a Quality Upgrade finding is the user-approved step
that the old auto-acting Quality Scanner did without review."""
"""Apply a Quality Upgrade finding (user-approved; the old Quality
Scanner did this without review). Action via ``details['_fix_action']``:
'redownload' (default): add the matched higher-quality version to the
wishlist (with album context) for a profile-gated re-download.
The low-quality file stays in place it's replaced only after the
better version actually imports (safe pattern; auto-delete-on-
import is handled separately).
'delete': remove the low-quality file + its DB row outright.
'ignore' is handled in the UI by dismissing the finding never here.
"""
fix_action = details.get('_fix_action', 'redownload')
if fix_action == 'delete':
if file_path:
resolved = _resolve_file_path(
file_path, self.transfer_folder,
config_manager=self._config_manager)
if resolved and os.path.exists(resolved):
try:
os.remove(resolved)
self._cleanup_empty_parents(resolved)
except Exception as e:
logger.warning("Could not delete low-quality file %s: %s",
resolved, e)
if entity_id:
try:
conn = self.db._get_connection()
conn.cursor().execute("DELETE FROM tracks WHERE id = ?", (entity_id,))
conn.commit()
conn.close()
except Exception as e:
return {'success': False, 'error': f'DB delete failed: {e}'}
return {'success': True, 'action': 'deleted_file',
'message': f'Deleted low-quality file: '
f'{os.path.basename(file_path or "")}'}
track_data = details.get('matched_track_data')
if not track_data:
return {'success': False, 'error': 'No matched track in finding'}
@ -3384,7 +3418,8 @@ class RepairWorker:
'album_tag_inconsistency',
'incomplete_album', 'path_mismatch',
'missing_lossy_copy', 'missing_replaygain', 'empty_folder',
'missing_discography_track', 'acoustid_mismatch')
'missing_discography_track', 'acoustid_mismatch',
'quality_upgrade')
placeholders = ','.join(['?'] * len(fixable_types))
where_parts = [f"finding_type IN ({placeholders})", "status = 'pending'"]
params = list(fixable_types)

View file

@ -66,6 +66,39 @@ def add_activity_item(icon, title, subtitle, time_ago="Now", show_toast=True):
return activity_item
def claim_for_post_processing(task_id: str) -> bool:
"""Atomically claim a download task for post-processing.
The browser-poll status endpoint AND the background download monitor both
watch the same slskd/streaming transfers and each tries to post-process a
completed file. Without a single claim, BOTH run the verification pipeline
on the same download double imports, and a nasty race where one path
quarantines + requeues the next-best candidate (clearing the source identity
and resetting status to ``searching``) while the other, mid-flight, then
reports a bogus "missing file or source information" failure that clobbers
the in-flight retry.
The claim is the ``downloading``/``queued`` -> ``post_processing`` status
transition, done under ``tasks_lock``. Exactly one caller wins:
- Returns ``True`` (and flips the status) for the caller that claimed it.
- Returns ``False`` if the task is gone, already ``post_processing`` (owned
by the other path), requeued (``searching``), or terminal the caller
must then NOT process the file.
Acquires ``tasks_lock`` itself, so callers must NOT already hold it.
"""
with tasks_lock:
task = download_tasks.get(task_id)
if not task:
return False
if task.get("status") in ("downloading", "queued"):
task["status"] = "post_processing"
task["status_change_time"] = time.time()
return True
return False
@caller_must_hold_tasks_lock
def mark_task_completed(task_id: str, track_info: Optional[Dict[str, Any]] = None) -> bool:
"""Mark a download task as completed.

View file

@ -24,6 +24,8 @@ from core.download_plugins.album_bundle import (
get_poll_timeout,
)
from core.download_plugins.base import DownloadSourcePlugin
from core.quality.model import QualityTarget, filter_and_rank, v2_qualities_to_ranked_targets
from core.quality.source_map import AUDIO_EXTENSIONS, format_from_extension
from utils.async_helpers import run_async
logger = get_logger("soulseek_client")
@ -409,7 +411,7 @@ class SoulseekClient(DownloadSourcePlugin):
# Audio file extensions to filter for
audio_extensions = {'.mp3', '.flac', '.ogg', '.aac', '.wma', '.wav', '.m4a'}
audio_extensions = AUDIO_EXTENSIONS
for response_data in responses_data:
username = response_data.get('username', '')
@ -426,26 +428,28 @@ class SoulseekClient(DownloadSourcePlugin):
if f'.{file_ext}' not in audio_extensions:
continue
# .m4a is the usual AAC container — bucket it as 'aac' (the
# quality filter treats AAC as an opt-in tier; off by default).
quality = 'aac' if file_ext == 'm4a' else (
file_ext if file_ext in ['flac', 'mp3', 'ogg', 'aac', 'wma'] else 'unknown')
# Source-agnostic extension → format (shared with every other
# extension-based source). Ranked targets do the rest.
quality = format_from_extension(file_ext)
# Create TrackResult
# Convert duration from seconds to milliseconds (slskd returns seconds, Spotify uses ms)
raw_duration = file_data.get('length')
duration_ms = raw_duration * 1000 if raw_duration else None
slskd_attrs = {a['type']: a['value'] for a in file_data.get('attributes', [])}
track = TrackResult(
username=username,
filename=filename,
size=size,
bitrate=file_data.get('bitRate'),
bitrate=file_data.get('bitRate') or slskd_attrs.get(0),
duration=duration_ms,
quality=quality,
free_upload_slots=response_data.get('freeUploadSlots', 0),
upload_speed=response_data.get('uploadSpeed', 0),
queue_length=response_data.get('queueLength', 0)
queue_length=response_data.get('queueLength', 0),
sample_rate=slskd_attrs.get(4),
bit_depth=slskd_attrs.get(5),
)
all_tracks.append(track)
@ -1136,7 +1140,7 @@ class SoulseekClient(DownloadSourcePlugin):
Returns:
List of TrackResult objects for audio files
"""
audio_extensions = {'.mp3', '.flac', '.ogg', '.aac', '.wma', '.wav', '.m4a'}
audio_extensions = AUDIO_EXTENSIONS
results = []
if files:
logger.debug(f"Browse raw file sample: {files[0]}")
@ -1150,15 +1154,17 @@ class SoulseekClient(DownloadSourcePlugin):
ext = Path(filename).suffix.lower()
if ext not in audio_extensions:
continue
_qext = ext.lstrip('.')
quality = 'aac' if _qext == 'm4a' else (
_qext if _qext in ['flac', 'mp3', 'ogg', 'aac', 'wma'] else 'unknown')
quality = format_from_extension(ext)
raw_duration = file_data.get('length')
duration_ms = raw_duration * 1000 if raw_duration else None
slskd_attrs = {a['type']: a['value'] for a in file_data.get('attributes', [])}
results.append(TrackResult(
username=username, filename=filename, size=file_data.get('size', 0),
bitrate=file_data.get('bitRate'), duration=duration_ms, quality=quality,
free_upload_slots=free_slots, upload_speed=upload_speed, queue_length=queue_length
bitrate=file_data.get('bitRate') or slskd_attrs.get(0),
duration=duration_ms, quality=quality,
free_upload_slots=free_slots, upload_speed=upload_speed, queue_length=queue_length,
sample_rate=slskd_attrs.get(4),
bit_depth=slskd_attrs.get(5),
))
return results
@ -2008,189 +2014,57 @@ class SoulseekClient(DownloadSourcePlugin):
return kept
def filter_results_by_quality_preference(self, results: List[TrackResult]) -> List[TrackResult]:
"""
Filter candidates based on user's quality profile with bitrate density constraints.
Uses priority waterfall logic: tries highest priority quality first, falls back to lower priorities.
Returns candidates matching quality profile constraints, sorted by confidence and effective bitrate.
"""Filter and rank candidates using the global quality target list.
Issue #652: also drops candidates whose `(username, filename)`
matches a previously-quarantined download. Without this pre-filter
the auto-wishlist processor's ranking is deterministic — the same
`(uploader, file)` keeps winning the quality picker, downloading,
failing AcoustID, quarantining, and re-queueing in an infinite
loop. Users wake up to hundreds of duplicate `.quarantined` files
for the same source URL.
Replaces the old bucket+heuristic approach with ``core.quality.model``
so every download source shares the same ranking logic.
Issue #652: also drops candidates whose ``(username, filename)``
matches a previously-quarantined download to break infinite retry loops.
"""
from database.music_database import MusicDatabase
if not results:
return []
# Drop sources already quarantined — bypass the quality picker
# entirely so the same bad upload doesn't get re-selected on the
# next wishlist cycle. Filesystem read is bounded (~few hundred
# sidecars in practical use × <1ms each).
# Issue #652: drop candidates on the quarantine record BEFORE ranking,
# so a previously-quarantined source can't win the quality picker by
# superior bitrate and re-trigger the same failed download in a loop.
results = self._drop_quarantined_sources(results)
if not results:
return []
# Get quality profile from database
db = MusicDatabase()
profile = db.get_quality_profile()
logger.debug(f"Quality Filter: Using profile preset '{profile.get('preset', 'custom')}', filtering {len(results)} candidates")
# Build ranked target list — v3 profiles carry it directly;
# v2 profiles are converted on the fly (no DB write needed here).
raw_targets = profile.get('ranked_targets')
if not raw_targets and 'qualities' in profile:
raw_targets = v2_qualities_to_ranked_targets(profile['qualities'])
# Categorize candidates by quality with bitrate density constraints
quality_buckets = {
'flac': [],
'mp3_320': [],
'mp3_256': [],
'mp3_192': [],
'aac': [],
'other': []
}
targets = [QualityTarget.from_dict(t) for t in (raw_targets or [])]
fallback_enabled = profile.get('fallback_enabled', True)
# Track all candidates that pass checks (for fallback)
density_filtered_all = []
# Every format (AAC included) follows the SAME universal rule: a
# candidate passes only if it matches a ranked target; if nothing
# matches, the fallback toggle decides. No per-format special-casing.
for candidate in results:
if not candidate.quality:
quality_buckets['other'].append(candidate)
continue
logger.debug(
"Quality Filter: profile='%s', %d targets, %d candidates",
profile.get('preset', 'custom'), len(targets), len(results),
)
track_format = candidate.quality.lower()
track_bitrate = candidate.bitrate or 0
ranked = filter_and_rank(results, targets, fallback_enabled=fallback_enabled)
# Determine quality key
if track_format == 'flac':
quality_key = 'flac'
elif track_format == 'mp3':
if track_bitrate >= 320:
quality_key = 'mp3_320'
elif track_bitrate >= 256:
quality_key = 'mp3_256'
elif track_bitrate >= 192:
quality_key = 'mp3_192'
else:
quality_buckets['other'].append(candidate)
continue
elif track_format in ('aac', 'm4a'):
# Opt-in AAC tier. ADDITIVE: when AAC isn't enabled in the
# profile (the default, and every profile that predates this),
# route exactly where AAC went before — the 'other' bucket — so
# behaviour is byte-identical. Only a user who turns AAC on lets
# it become a first-class, selectable tier.
aac_cfg = profile['qualities'].get('aac')
if not (aac_cfg and aac_cfg.get('enabled')):
quality_buckets['other'].append(candidate)
continue
quality_key = 'aac'
else:
quality_buckets['other'].append(candidate)
continue
quality_config = profile['qualities'].get(quality_key, {})
min_kbps = quality_config.get('min_kbps', 0)
max_kbps = quality_config.get('max_kbps', 99999)
effective_kbps = self._calculate_effective_kbps(candidate.size, candidate.duration)
if effective_kbps is not None:
# Primary: bitrate density check
if min_kbps <= effective_kbps <= max_kbps:
if quality_config.get('enabled', False):
quality_buckets[quality_key].append(candidate)
density_filtered_all.append(candidate)
else:
logger.debug(f"Quality Filter: {quality_key} rejected - {effective_kbps:.0f} kbps outside {min_kbps}-{max_kbps} kbps range")
else:
# Fallback: duration unavailable, use generous raw-size sanity check
file_size_mb = candidate.size / (1024 * 1024)
size_min, size_max = self._FALLBACK_SIZE_LIMITS.get(quality_key, (0, 500))
if size_min <= file_size_mb <= size_max:
if quality_config.get('enabled', False):
quality_buckets[quality_key].append(candidate)
density_filtered_all.append(candidate)
logger.debug(f"Quality Filter: {quality_key} accepted via size fallback ({file_size_mb:.1f} MB, no duration available)")
else:
logger.debug(f"Quality Filter: {quality_key} rejected via size fallback - {file_size_mb:.1f} MB outside {size_min}-{size_max} MB safety limits")
# Sort each bucket: effective bitrate first (prefer highest audio quality),
# then peer quality score as tiebreaker (prefer fastest peer at same quality)
for bucket in quality_buckets.values():
bucket.sort(key=lambda x: (self._calculate_effective_kbps(x.size, x.duration) or 0, x.quality_score), reverse=True)
# Enforce FLAC bit depth preference from quality profile
flac_config = profile['qualities'].get('flac', {})
bit_depth_pref = flac_config.get('bit_depth', 'any')
bit_depth_fallback = flac_config.get('bit_depth_fallback', True)
if bit_depth_pref != 'any' and quality_buckets['flac']:
# 16-bit/44.1kHz FLAC theoretical max is 1411 kbps; 24-bit starts at ~2116 kbps
# Real-world compressed: 16-bit = 800-1400 kbps, 24-bit = 1500+ kbps
DEPTH_THRESHOLD = 1450
if bit_depth_pref == '24':
hi_res = [c for c in quality_buckets['flac']
if (self._calculate_effective_kbps(c.size, c.duration) or 0) > DEPTH_THRESHOLD]
if hi_res:
logger.info(f"Quality Filter: Bit depth 24-bit preference — {len(hi_res)}/{len(quality_buckets['flac'])} FLAC candidates are hi-res")
quality_buckets['flac'] = hi_res
elif not bit_depth_fallback:
logger.info("Quality Filter: No 24-bit FLAC found and fallback disabled — rejecting all FLAC")
quality_buckets['flac'] = []
else:
logger.info("Quality Filter: No 24-bit FLAC found — falling back to 16-bit")
elif bit_depth_pref == '16':
lo_res = [c for c in quality_buckets['flac']
if (self._calculate_effective_kbps(c.size, c.duration) or 0) <= DEPTH_THRESHOLD]
if lo_res:
logger.info(f"Quality Filter: Bit depth 16-bit preference — {len(lo_res)}/{len(quality_buckets['flac'])} FLAC candidates are standard")
quality_buckets['flac'] = lo_res
elif not bit_depth_fallback:
logger.info("Quality Filter: No 16-bit FLAC found and fallback disabled — rejecting all FLAC")
quality_buckets['flac'] = []
else:
logger.info("Quality Filter: No 16-bit FLAC found — falling back to 24-bit")
# Debug logging
for quality, bucket in quality_buckets.items():
if bucket:
logger.debug(f"Quality Filter: Found {len(bucket)} '{quality}' candidates (after bitrate + bit depth filtering)")
# Waterfall priority logic: try qualities in priority order
# Build priority list from enabled qualities
quality_priorities = []
for quality_name, quality_config in profile['qualities'].items():
if quality_config.get('enabled', False):
priority = quality_config.get('priority', 999)
quality_priorities.append((priority, quality_name))
# Sort by priority (lower number = higher priority)
quality_priorities.sort()
# Try each quality in priority order
for priority, quality_name in quality_priorities:
candidates_for_quality = quality_buckets.get(quality_name, [])
if candidates_for_quality:
logger.info(f"Quality Filter: Returning {len(candidates_for_quality)} '{quality_name}' candidates (priority {priority})")
return candidates_for_quality
# If no enabled qualities matched, check if fallback is enabled
if profile.get('fallback_enabled', True):
logger.warning("Quality Filter: No enabled qualities matched, falling back to density-filtered candidates")
if density_filtered_all:
density_filtered_all.sort(key=lambda x: (x.quality_score, self._calculate_effective_kbps(x.size, x.duration) or 0), reverse=True)
logger.info(f"Quality Filter: Returning {len(density_filtered_all)} fallback candidates (bitrate-filtered, any quality)")
return density_filtered_all
else:
logger.warning("Quality Filter: All candidates failed bitrate checks, returning empty (respecting constraints)")
return []
if ranked:
best_label = ranked[0].audio_quality.label()
logger.info("Quality Filter: returning %d candidate(s), best=%s", len(ranked), best_label)
else:
logger.warning("Quality Filter: No enabled qualities matched and fallback is disabled, returning empty")
return []
logger.warning("Quality Filter: no candidates passed quality constraints")
return ranked
async def get_session_info(self) -> Optional[Dict[str, Any]]:
"""Get slskd session information including version"""
if not self.base_url:

View file

@ -128,7 +128,14 @@ class SoundcloudClient(DownloadSourcePlugin):
download_path = config_manager.get('soulseek.download_path', './downloads')
self.download_path = Path(download_path)
self.download_path.mkdir(parents=True, exist_ok=True)
# Don't crash construction if the path isn't creatable yet (e.g. an
# unmounted/misconfigured volume) — the registry would null the whole
# client and the source vanishes. Warn and continue, same as
# SoulseekClient; the dir is (re)created lazily at download time.
try:
self.download_path.mkdir(parents=True, exist_ok=True)
except OSError as e:
logger.warning(f"Could not verify SoundCloud download path {self.download_path}: {e}")
logger.info(f"SoundCloud client using download path: {self.download_path}")

View file

@ -33,6 +33,7 @@ from config.settings import config_manager
# Import Soulseek data structures for drop-in replacement compatibility
from core.download_plugins.types import TrackResult, AlbumResult, DownloadStatus
from core.quality.source_map import quality_from_tidal_tier, quality_tier_for_source
logger = get_logger("tidal_download_client")
@ -119,7 +120,10 @@ class TidalDownloadClient(DownloadSourcePlugin):
download_path = config_manager.get('soulseek.download_path', './downloads')
self.download_path = Path(download_path)
self.download_path.mkdir(parents=True, exist_ok=True)
try:
self.download_path.mkdir(parents=True, exist_ok=True)
except OSError as e:
logger.warning(f"Could not verify download path {self.download_path}: {e}")
logger.info(f"Tidal download client using download path: {self.download_path}")
@ -455,13 +459,17 @@ class TidalDownloadClient(DownloadSourcePlugin):
if successful_query and successful_query != query:
logger.info(f"Tidal fallback query succeeded: '{successful_query}' (original: '{query}')")
quality_key = config_manager.get('tidal_download.quality', 'lossless')
quality_key = quality_tier_for_source('tidal', default='lossless')
quality_info = QUALITY_MAP.get(quality_key, QUALITY_MAP['lossless'])
# Stamp the configured tier (what will actually be downloaded) so
# the global ranker sees real sample_rate/bit_depth.
tier_quality = quality_from_tidal_tier(quality_key)
track_results = []
for track in tidal_tracks:
try:
track_result = self._tidal_to_track_result(track, quality_info)
track_result.set_quality(tier_quality)
track_results.append(track_result)
except Exception as e:
logger.debug(f"Skipping track conversion error: {e}")
@ -774,7 +782,7 @@ class TidalDownloadClient(DownloadSourcePlugin):
logger.error("Tidal session not authenticated")
return None
quality_key = config_manager.get('tidal_download.quality', 'lossless')
quality_key = quality_tier_for_source('tidal', default='lossless')
chain = ['hires', 'lossless', 'high', 'low']
start = chain.index(quality_key) if quality_key in chain else 1
allow_fallback = config_manager.get('tidal_download.allow_fallback', True)

View file

@ -3633,8 +3633,13 @@ class WatchlistScanner:
for source in sources_to_process:
logger.info(f"Curating Release Radar for {source}...")
# 1. Curate Release Radar - 50 tracks from recent albums
recent_albums = self.database.get_discovery_recent_albums(limit=50, source=source, profile_id=profile_id)
# 1. Curate Release Radar - 50 tracks from recent albums.
# Fetch a GENEROUS album budget (not 50) and exclude next-year announcements at the
# query: future-dated albums sort to the top of release_date DESC and used to eat the
# budget before the in-loop is_future_release skip ran, starving Fresh Tape to a few
# tracks. The downstream caps (6/artist, top 75, take 50) still bound the output.
recent_albums = self.database.get_discovery_recent_albums(
limit=300, source=source, profile_id=profile_id, exclude_future_years=True)
release_radar_tracks = []
if not recent_albums:
@ -4004,45 +4009,130 @@ class WatchlistScanner:
The ranking lives in core.discovery.listening_recommendations (pure + tested); this only
gathers inputs all already in the DB, NO new network and stores the result under NEW
metadata/curated keys. Fully self-contained and guarded: any failure logs and returns, so
it can never disturb the scan. Phase-1 candidate tracks come from the discovery pool (like
BYLT); a later phase swaps in a direct top-tracks fetch for richer coverage.
it can never disturb the scan.
"Best in class" generation (the elevation over the first cut):
Seeds are recency-weighted recent plays boost lifetime favourites so the picks
track what you're into NOW, not just all-time totals.
The ranker is fed the RAW per-seed edges (one row per seedsimilar), so an artist
similar to several of your seeds accumulates real CONSENSUS the old code fed the
name-collapsed ``get_top_similar_artists`` query, which flattened every similar to a
single seed (consensus could never fire). The raw edges also carry ``similarity_rank``,
so a seed's CLOSEST matches outweigh its long-tail ones.
``source_artist_id`` is a SOURCE id (Spotify/iTunes/Deezer/MusicBrainz), so the idname
map is built from the artists' source-id columns, NOT the internal row id (the first
cut keyed it by ``artists.id`` and resolved nothing the feature produced 0 recs).
Phase-1 candidate tracks still come from the discovery pool (like BYLT); a later phase
swaps in a direct top-tracks fetch for richer coverage.
"""
try:
import json as _json
from core.discovery.listening_recommendations import (
aggregate_candidate_tracks,
build_recency_weighted_seeds,
choose_mix_fetch_source,
group_similars_by_seed,
names_match,
rank_recommended_artists,
to_mix_track,
)
seeds = [{'name': s['name'], 'weight': s.get('play_count', 1)}
for s in (self.database.get_top_artists('all', 30) or []) if s.get('name')]
if not seeds:
# Recency-weighted seeds: lifetime top artists, boosted by recent (30d) plays.
lifetime = [s for s in (self.database.get_top_artists('all', 30) or []) if s.get('name')]
if not lifetime:
return
recent_rows = self.database.get_top_artists('30d', 50) or []
recent_counts = {r['name'].lower(): r.get('play_count', 0)
for r in recent_rows if r.get('name')}
seeds = build_recency_weighted_seeds(lifetime, recent_counts)
seed_names = {s['name'].lower() for s in seeds}
# id -> name + owned-artist set for the WHOLE library (similar_artists rows key the
# similar artist by the seed artist's id).
id_to_name, owned = {}, set()
# Owned-artist set (for exclusion) + the seeds' SOURCE ids (similar_artists.source_artist_id
# is a Spotify/iTunes/Deezer/MusicBrainz id, never the internal artists.id). We only need
# id→name for the SEED ids, since the edge query below is already scoped to them.
owned, seed_source_ids, seed_id_to_name = set(), [], {}
with self.database._get_connection() as conn:
cur = conn.cursor()
cur.execute("SELECT id, name FROM artists WHERE name IS NOT NULL AND name != ''")
cur.execute("SELECT name, spotify_artist_id, itunes_artist_id, deezer_id, "
"musicbrainz_id FROM artists WHERE name IS NOT NULL AND name != ''")
for row in cur.fetchall():
id_to_name[str(row[0])] = row[1]
owned.add((row[1] or '').lower())
nm = row[0]
lname = (nm or '').lower()
owned.add(lname)
if lname in seed_names:
for sid in (row[1], row[2], row[3], row[4]):
if sid:
seed_source_ids.append(str(sid))
seed_id_to_name[str(sid)] = nm
# RAW per-seed edges (preserve consensus + similarity_rank). Scoped to the seeds.
edges, edge_cols = [], ('source_artist_id', 'similar_artist_name', 'similarity_rank',
'spotify_id', 'itunes_id', 'deezer_id', 'image_url', 'genres')
if seed_source_ids:
placeholders = ",".join("?" * len(seed_source_ids))
cur.execute(
f"SELECT source_artist_id, similar_artist_name, similarity_rank, "
f"similar_artist_spotify_id, similar_artist_itunes_id, "
f"similar_artist_deezer_id, image_url, genres FROM similar_artists "
f"WHERE profile_id = ? AND source_artist_id IN ({placeholders})",
[profile_id, *seed_source_ids])
edges = [dict(zip(edge_cols, r, strict=False)) for r in cur.fetchall()]
# Per-name enrichment (image/ids/genres) so the Discover row can render rich cards.
artist_meta_by_name = {}
for e in edges:
key = (e['similar_artist_name'] or '').lower()
if not key:
continue
m = artist_meta_by_name.setdefault(key, {})
for src_k, dst_k in (('spotify_id', 'spotify_artist_id'),
('itunes_id', 'itunes_artist_id'),
('deezer_id', 'deezer_artist_id')):
if e.get(src_k) and not m.get(dst_k):
m[dst_k] = e[src_k]
if e.get('image_url') and not m.get('image_url'):
m['image_url'] = e['image_url']
if e.get('genres') and not m.get('genres'):
m['genres'] = e['genres']
similars_by_seed = group_similars_by_seed(
seeds, edges, seed_id_to_name, rank_attr='similarity_rank')
# Fallback: if the raw edges resolved nothing (e.g. source ids not yet populated),
# degrade to the aggregated query so the feature still works rather than going dark.
if not similars_by_seed:
agg = self.database.get_top_similar_artists(limit=1000, profile_id=profile_id)
similars_by_seed = group_similars_by_seed(
seeds, agg, seed_id_to_name, rank_attr='similarity_rank')
similar_rows = self.database.get_top_similar_artists(limit=1000, profile_id=profile_id)
similars_by_seed = group_similars_by_seed(seeds, similar_rows, id_to_name)
recs = rank_recommended_artists(seeds, similars_by_seed, owned, limit=40)
if not recs:
logger.info("[Listening Recs] no recommendations yet (no similar-artist coverage)")
return
self.database.set_metadata('listening_recs_artists', _json.dumps([
{'name': r.name, 'seed_count': r.seed_count, 'seeds': r.seeds[:5], 'score': r.score}
for r in recs
]))
def _enrich(r):
m = artist_meta_by_name.get(r.name.lower(), {})
genres = m.get('genres')
if isinstance(genres, str):
try:
genres = _json.loads(genres)
except Exception:
genres = None
return {'name': r.name, 'seed_count': r.seed_count, 'seeds': r.seeds[:5],
'score': r.score, 'spotify_artist_id': m.get('spotify_artist_id'),
'itunes_artist_id': m.get('itunes_artist_id'),
'deezer_artist_id': m.get('deezer_artist_id'),
'image_url': m.get('image_url'),
'genres': (genres[:3] if isinstance(genres, list) else None)}
# Candidate tracks from the discovery pool grouped by artist (phase 1: no new network).
self.database.set_metadata('listening_recs_artists',
_json.dumps([_enrich(r) for r in recs]))
# Candidate tracks for the "Listening Mix" playlist row: each recommended artist's
# top tracks. Prefer a DIRECT top-tracks fetch (Spotify/Deezer — richest + most
# accurate), fall back to the discovery pool (covers iTunes + any artist the fetch
# missed). Stored as full render-ready dicts so the row needs NO pool re-hydration —
# robust against pool rotation (the bug that shrinks Fresh Tape/Archives at read time).
pool, active_source = [], None
for src in (sources_to_process or []):
pool = self.database.get_discovery_pool_tracks(
@ -4050,24 +4140,94 @@ class WatchlistScanner:
if pool:
active_source = src
break
if not active_source:
active_source = (sources_to_process or ['spotify'])[0]
track_ids = []
if pool:
by_artist = {}
for t in pool:
an = (getattr(t, 'artist_name', '') or '').lower()
tid = (getattr(t, 'spotify_track_id', None) if active_source == 'spotify'
else getattr(t, 'itunes_track_id', None) if active_source == 'itunes'
else getattr(t, 'deezer_track_id', None))
if an and tid:
by_artist.setdefault(an, []).append({'name': getattr(t, 'track_name', ''), 'id': tid})
candidates = aggregate_candidate_tracks(recs, by_artist, per_artist=3, limit=50)
track_ids = [c['id'] for c in candidates if c.get('id')]
if track_ids:
self.database.save_curated_playlist('listening_recs_tracks', track_ids, profile_id=profile_id)
# Pool baseline grouped by artist (full render dicts), no network.
pool_by_artist = {}
for t in (pool or []):
an = (getattr(t, 'artist_name', '') or '').lower()
tid = (getattr(t, 'spotify_track_id', None) if active_source == 'spotify'
else getattr(t, 'itunes_track_id', None) if active_source == 'itunes'
else getattr(t, 'deezer_track_id', None))
name = getattr(t, 'track_name', '') or ''
if not an or not tid or not name:
continue
tdj = getattr(t, 'track_data_json', None)
if isinstance(tdj, str):
try:
tdj = _json.loads(tdj)
except Exception:
tdj = None
pool_by_artist.setdefault(an, []).append({
'track_id': str(tid), 'name': name, 'track_name': name,
'artist_name': getattr(t, 'artist_name', '') or '',
'album_name': getattr(t, 'album_name', '') or '',
'album_cover_url': getattr(t, 'album_cover_url', None),
'duration_ms': getattr(t, 'duration_ms', 0) or 0,
'track_data_json': tdj, 'source': active_source,
f'{active_source}_track_id': str(tid)})
logger.info("[Listening Recs] %d recommended artists, %d candidate tracks",
len(recs), len(track_ids))
# Direct top-tracks enrichment — guarded, bounded (top 20 recs), fail-soft per artist.
# Source-independent: the active source is used when it can fetch top tracks
# (Spotify/Deezer); otherwise we fall back to Deezer's public top-tracks API (no auth,
# available to every user) so iTunes / Discogs / MusicBrainz users still get a full mix
# without switching sources — the tracks are acquired via Soulseek by artist+title, so
# the fetch source need not match the user's active source.
fetched_by_artist = {}
try:
active_client = get_client_for_source(active_source) if active_source in ('spotify', 'deezer') else None
active_can_fetch = bool(active_client and hasattr(active_client, 'get_artist_top_tracks'))
fetch_source = choose_mix_fetch_source(active_source, active_can_fetch)
client = active_client if (active_can_fetch and fetch_source == active_source) \
else get_client_for_source(fetch_source)
if client and hasattr(client, 'get_artist_top_tracks'):
id_key = 'spotify_artist_id' if fetch_source == 'spotify' else 'deezer_artist_id'
can_search = hasattr(client, 'search_artists')
for r in recs[:20]:
aid = artist_meta_by_name.get(r.name.lower(), {}).get(id_key)
# Most similar-artist rows store a name but no source id (and a fallback
# fetch source won't have the active source's ids at all), so resolve it
# by name-search — guarded by names_match so we never fetch the WRONG
# artist's tracks (a same-name act).
if not aid and can_search:
try:
found = client.search_artists(r.name, limit=1) or []
except Exception as _s_err:
logger.debug("[Listening Recs] artist search failed for %s: %s",
r.name, _s_err)
found = []
if found and names_match(r.name, getattr(found[0], 'name', '')):
aid = getattr(found[0], 'id', None)
if not aid:
continue
try:
raw = client.get_artist_top_tracks(str(aid), limit=8) or []
except Exception as _tt_err:
logger.debug("[Listening Recs] top-tracks fetch failed for %s: %s",
r.name, _tt_err)
continue
shaped = [s for s in (to_mix_track(x, fetch_source) for x in raw) if s][:5]
if shaped:
fetched_by_artist[r.name.lower()] = shaped
except Exception as _enr_err:
logger.debug("[Listening Recs] top-tracks enrichment skipped: %s", _enr_err)
# Merge: prefer fetched top tracks, fall back to the pool per artist.
top_tracks_by_artist = {}
for r in recs:
merged = fetched_by_artist.get(r.name.lower()) or pool_by_artist.get(r.name.lower())
if merged:
top_tracks_by_artist[r.name.lower()] = merged
mix = aggregate_candidate_tracks(recs, top_tracks_by_artist, per_artist=3, limit=50)
track_ids = [m.get('track_id') for m in mix if m.get('track_id')]
if mix:
self.database.set_metadata('listening_recs_tracks_full', _json.dumps(mix))
self.database.save_curated_playlist('listening_recs_tracks', track_ids, profile_id=profile_id)
logger.info("[Listening Recs] %d recommended artists, %d mix tracks (%d artists via top-tracks fetch)",
len(recs), len(mix), len(fetched_by_artist))
except Exception as e:
logger.debug("[Listening Recs] generation skipped: %s", e)

View file

@ -677,6 +677,12 @@ class MusicDatabase:
cursor.execute(f"ALTER TABLE library_history ADD COLUMN {_col} TEXT")
logger.info(f"Added {_col} column to library_history")
# Index on verification_status — MUST come after the ALTER above:
# on a fresh DB the base CREATE TABLE has no verification_status
# column, so indexing it before the migration adds it raises
# "no such column: verification_status" and aborts DB init.
cursor.execute("CREATE INDEX IF NOT EXISTS idx_lh_verification_status ON library_history (verification_status)")
# One-time backfill: derive verification_status for history rows
# written before the column existed (or by pipeline exits that
# missed it) from the acoustid_result those imports already
@ -1165,6 +1171,13 @@ class MusicDatabase:
if track_cols and 'year' not in track_cols:
cursor.execute("ALTER TABLE tracks ADD COLUMN year INTEGER")
logger.info("Repaired missing year column on tracks table (#910)")
# #927 — multi-disc fix: the scan now writes a real disc_number, but the column
# was only ever added by a separate migration that doesn't run on fresh installs,
# so the new INSERT/UPDATE would hard-fail with "no column named disc_number".
# Same shape as the year repair above: additive, defaults to 1, ensured on every DB.
if track_cols and 'disc_number' not in track_cols:
cursor.execute("ALTER TABLE tracks ADD COLUMN disc_number INTEGER DEFAULT 1")
logger.info("Repaired missing disc_number column on tracks table (#927)")
cursor.execute("PRAGMA table_info(albums)")
album_cols = {c[1] for c in cursor.fetchall()}
@ -6629,6 +6642,19 @@ class MusicDatabase:
track_id = str(track_obj.ratingKey)
title = track_obj.title
track_number = getattr(track_obj, 'trackNumber', None)
# Multi-disc: capture the disc number so multi-disc albums don't all
# collapse onto disc 1 (which mis-files disc-2+ tracks and flags them
# "missing"). Jellyfin/Navidrome wrappers set .discNumber; plexapi's Track
# exposes .parentIndex. Floor to >=1 — a missing/0 disc is disc 1.
_raw_disc = getattr(track_obj, 'discNumber', None)
if _raw_disc is None:
_raw_disc = getattr(track_obj, 'parentIndex', None)
try:
disc_number = int(_raw_disc)
if disc_number < 1:
disc_number = 1
except (TypeError, ValueError):
disc_number = 1
duration = getattr(track_obj, 'duration', None)
# Get file path and media info (Plex-specific, Jellyfin may not have these)
@ -6720,9 +6746,9 @@ class MusicDatabase:
if is_new_track:
cursor.execute("""
INSERT INTO tracks
(id, album_id, artist_id, title, track_number, duration, file_path, bitrate, file_size, server_source, track_artist, musicbrainz_recording_id, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
""", (track_id, album_id, artist_id, title, track_number, duration, file_path, bitrate, file_size, server_source, track_artist, mbid))
(id, album_id, artist_id, title, track_number, disc_number, duration, file_path, bitrate, file_size, server_source, track_artist, musicbrainz_recording_id, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
""", (track_id, album_id, artist_id, title, track_number, disc_number, duration, file_path, bitrate, file_size, server_source, track_artist, mbid))
else:
# Update server-provided fields only — preserves spotify_track_id, deezer_id,
# isrc, bpm, and all other enrichment data. file_size uses
@ -6731,7 +6757,7 @@ class MusicDatabase:
# an existing value.
cursor.execute("""
UPDATE tracks
SET album_id = ?, artist_id = ?, title = ?, track_number = ?,
SET album_id = ?, artist_id = ?, title = ?, track_number = ?, disc_number = ?,
duration = ?, file_path = ?, bitrate = ?,
file_size = COALESCE(?, file_size),
server_source = ?,
@ -6739,7 +6765,7 @@ class MusicDatabase:
musicbrainz_recording_id = COALESCE(?, musicbrainz_recording_id),
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
""", (album_id, artist_id, title, track_number, duration, file_path, bitrate, file_size, server_source, track_artist, mbid, track_id))
""", (album_id, artist_id, title, track_number, disc_number, duration, file_path, bitrate, file_size, server_source, track_artist, mbid, track_id))
conn.commit()
@ -8742,7 +8768,7 @@ class MusicDatabase:
# Quality profile management methods
def get_quality_profile(self) -> dict:
"""Get the quality profile configuration, returns default if not set"""
"""Get the quality profile configuration, returns default if not set."""
import json
profile_json = self.get_preference('quality_profile')
@ -8750,21 +8776,86 @@ class MusicDatabase:
if profile_json:
try:
profile = json.loads(profile_json)
# Migrate v1 profiles (min_mb/max_mb) to v2 (min_kbps/max_kbps)
if profile.get('version', 1) < 2:
logger.info("Migrating quality profile from v1 (file size) to v2 (bitrate density)")
version = profile.get('version', 1)
if version < 2:
logger.info("Migrating quality profile v1 → v3")
return self._get_default_quality_profile()
if version == 2:
logger.info("Migrating quality profile v2 → v3 (adding ranked_targets)")
return self._migrate_v2_to_v3(profile)
return profile
except json.JSONDecodeError:
logger.error("Failed to parse quality profile JSON, returning default")
return self._get_default_quality_profile()
# 24-bit FLAC ladder seeded on migration for users who had a streaming
# source on Hi-Res under the old (now removed) per-source quality dropdowns.
_HIRES_24BIT_TARGETS = [
{"label": "FLAC 24-bit/192kHz", "format": "flac", "bit_depth": 24, "min_sample_rate": 192000},
{"label": "FLAC 24-bit/96kHz", "format": "flac", "bit_depth": 24, "min_sample_rate": 96000},
{"label": "FLAC 24-bit/48kHz", "format": "flac", "bit_depth": 24, "min_sample_rate": 48000},
{"label": "FLAC 24-bit/44.1kHz","format": "flac", "bit_depth": 24, "min_sample_rate": 44100},
]
def _had_hires_source_preference(self) -> bool:
"""True if the user had any streaming source set to a Hi-Res tier under
the old per-source quality dropdowns (tidal_download/qobuz/hifi_download
.quality = 'hires'|'hires_max'), which #896 removed in favour of the
global profile. Used to preserve their intent on migration."""
try:
from config.settings import config_manager
except Exception:
return False
hires = {'hires', 'hires_max'}
for key in ('tidal_download.quality', 'qobuz.quality', 'hifi_download.quality'):
try:
if str(config_manager.get(key) or '').strip().lower() in hires:
return True
except Exception:
continue
return False
def _migrate_v2_to_v3(self, profile: dict) -> dict:
"""Add ranked_targets to a v2 profile without losing its qualities dict."""
from core.quality.model import v2_qualities_to_ranked_targets
profile = dict(profile)
profile['version'] = 3
if 'ranked_targets' not in profile:
ranked = v2_qualities_to_ranked_targets(profile.get('qualities', {}))
# #896 review #5: the per-source quality dropdowns are gone — sources
# now derive their tier from this profile. If the user had a source on
# Hi-Res, seed 24-bit FLAC targets at the top so they keep Hi-Res
# instead of silently dropping to lossless. Skip when the profile
# already expresses 24-bit (don't duplicate the ladder).
already_24bit = any(
t.get('format') == 'flac' and (t.get('bit_depth') or 0) >= 24
for t in ranked
)
if not already_24bit and self._had_hires_source_preference():
ranked = [dict(t) for t in self._HIRES_24BIT_TARGETS] + ranked
profile['ranked_targets'] = ranked
return profile
def _get_default_quality_profile(self) -> dict:
"""Return the default v2 quality profile (balanced preset)"""
"""Return the default v3 quality profile (balanced preset)."""
return {
"version": 2,
"version": 3,
"preset": "balanced",
"fallback_enabled": True,
"search_mode": "priority",
"rank_candidates_by_quality": False,
"ranked_targets": [
{"label": "FLAC 24-bit/192kHz", "format": "flac", "bit_depth": 24, "min_sample_rate": 192000},
{"label": "FLAC 24-bit/96kHz", "format": "flac", "bit_depth": 24, "min_sample_rate": 96000},
{"label": "FLAC 24-bit/48kHz", "format": "flac", "bit_depth": 24, "min_sample_rate": 48000},
{"label": "FLAC 24-bit/44.1kHz","format": "flac", "bit_depth": 24, "min_sample_rate": 44100},
{"label": "FLAC 16-bit", "format": "flac", "bit_depth": 16},
{"label": "MP3 320kbps", "format": "mp3", "min_bitrate": 320},
{"label": "MP3 256kbps", "format": "mp3", "min_bitrate": 256},
{"label": "MP3 192kbps", "format": "mp3", "min_bitrate": 192},
],
# Keep qualities dict for backwards compat with any old code paths still reading it
"qualities": {
"flac": {
"enabled": True,
@ -8801,141 +8892,119 @@ class MusicDatabase:
"priority": 1.5
}
},
"fallback_enabled": True
}
# Presets whose per-preset customizations we remember across switches.
_KNOWN_PRESETS = ('audiophile', 'balanced', 'space_saver')
def set_quality_profile(self, profile: dict) -> bool:
"""Save quality profile configuration"""
"""Save quality profile configuration.
Besides the single active profile (read by the download pipeline), we also
stash the profile under its preset name so switching presets and coming
back restores the user's edits instead of the factory defaults. 'custom'
and unknown preset names are not stashed."""
import json
try:
profile_json = json.dumps(profile)
self.set_preference('quality_profile', profile_json)
preset_name = profile.get('preset')
if preset_name in self._KNOWN_PRESETS:
store = self._load_preset_store()
store[preset_name] = profile
self.set_preference('quality_profile_presets', json.dumps(store))
logger.info(f"Quality profile saved: preset={profile.get('preset', 'custom')}")
return True
except Exception as e:
logger.error(f"Failed to save quality profile: {e}")
return False
def get_quality_preset(self, preset_name: str) -> dict:
"""Get a predefined quality preset"""
def _load_preset_store(self) -> dict:
"""Per-preset customizations, keyed by preset name. {} if none saved."""
import json
raw = self.get_preference('quality_profile_presets')
if raw:
try:
parsed = json.loads(raw)
if isinstance(parsed, dict):
return parsed
except json.JSONDecodeError:
logger.error("Failed to parse quality_profile_presets, ignoring")
return {}
def reset_quality_preset(self, preset_name: str) -> dict:
"""Forget a preset's saved customizations and return its factory defaults."""
import json
store = self._load_preset_store()
if preset_name in store:
del store[preset_name]
self.set_preference('quality_profile_presets', json.dumps(store))
return self.get_quality_preset(preset_name, customized=False)
def get_quality_preset(self, preset_name: str, *, customized: bool = True) -> dict:
"""Get a quality preset (v3 format with ranked_targets).
With ``customized`` (default), a preset the user has edited is returned in
its saved form; otherwise the hard-coded factory defaults are returned."""
if customized:
saved = self._load_preset_store().get(preset_name)
if saved:
return saved
return self._factory_quality_preset(preset_name)
def _factory_quality_preset(self, preset_name: str) -> dict:
"""The hard-coded factory defaults for a preset (ignores customizations)."""
# Strict 24-bit FLAC ladder — no 16-bit, no lossy. This is what
# "audiophile" means: only true hi-res passes.
_FLAC_24BIT_TARGETS = [
{"label": "FLAC 24-bit/192kHz", "format": "flac", "bit_depth": 24, "min_sample_rate": 192000},
{"label": "FLAC 24-bit/96kHz", "format": "flac", "bit_depth": 24, "min_sample_rate": 96000},
{"label": "FLAC 24-bit/48kHz", "format": "flac", "bit_depth": 24, "min_sample_rate": 48000},
{"label": "FLAC 24-bit/44.1kHz","format": "flac", "bit_depth": 24, "min_sample_rate": 44100},
]
# Lossless ladder used by "balanced" — hi-res first, then CD-quality 16-bit.
_FLAC_HI_RES_TARGETS = _FLAC_24BIT_TARGETS + [
{"label": "FLAC 16-bit", "format": "flac", "bit_depth": 16},
]
_MP3_TARGETS = [
{"label": "MP3 320kbps", "format": "mp3", "min_bitrate": 320},
{"label": "MP3 256kbps", "format": "mp3", "min_bitrate": 256},
{"label": "MP3 192kbps", "format": "mp3", "min_bitrate": 192},
]
# Legacy v2 ``qualities`` dict carried alongside ranked_targets for
# backwards compat — read by the settings UI and the #886 AAC opt-in
# toggle. AAC ships OFF in every preset; its priority sits it above MP3
# but below FLAC (space_saver puts it at 0.5, still above its MP3 tiers).
def _quals(*, flac_en, flac_prio, mp3_320_en, mp3_256_en, mp3_192_en,
mp3_320_prio=2, mp3_256_prio=3, mp3_192_prio=4, aac_prio=1.5):
return {
"flac": {"enabled": flac_en, "min_kbps": 500, "max_kbps": 10000, "priority": flac_prio, "bit_depth": "any"},
"mp3_320": {"enabled": mp3_320_en, "min_kbps": 280, "max_kbps": 500, "priority": mp3_320_prio},
"mp3_256": {"enabled": mp3_256_en, "min_kbps": 200, "max_kbps": 400, "priority": mp3_256_prio},
"mp3_192": {"enabled": mp3_192_en, "min_kbps": 150, "max_kbps": 300, "priority": mp3_192_prio},
"aac": {"enabled": False, "min_kbps": 128, "max_kbps": 400, "priority": aac_prio},
}
presets = {
"audiophile": {
"version": 2,
"preset": "audiophile",
"qualities": {
"flac": {
"enabled": True,
"min_kbps": 500,
"max_kbps": 10000,
"priority": 1,
"bit_depth": "any"
},
"mp3_320": {
"enabled": False,
"min_kbps": 280,
"max_kbps": 500,
"priority": 2
},
"mp3_256": {
"enabled": False,
"min_kbps": 200,
"max_kbps": 400,
"priority": 3
},
"mp3_192": {
"enabled": False,
"min_kbps": 150,
"max_kbps": 300,
"priority": 4
},
"aac": {
"enabled": False,
"min_kbps": 128,
"max_kbps": 400,
"priority": 1.5
}
},
"fallback_enabled": False
"version": 3, "preset": "audiophile", "fallback_enabled": False,
"ranked_targets": _FLAC_24BIT_TARGETS,
"qualities": _quals(flac_en=True, flac_prio=1, mp3_320_en=False, mp3_256_en=False, mp3_192_en=False),
},
"balanced": {
"version": 2,
"preset": "balanced",
"qualities": {
"flac": {
"enabled": True,
"min_kbps": 500,
"max_kbps": 10000,
"priority": 1,
"bit_depth": "any"
},
"mp3_320": {
"enabled": True,
"min_kbps": 280,
"max_kbps": 500,
"priority": 2
},
"mp3_256": {
"enabled": True,
"min_kbps": 200,
"max_kbps": 400,
"priority": 3
},
"mp3_192": {
"enabled": False,
"min_kbps": 150,
"max_kbps": 300,
"priority": 4
},
"aac": {
"enabled": False,
"min_kbps": 128,
"max_kbps": 400,
"priority": 1.5
}
},
"fallback_enabled": True
"version": 3, "preset": "balanced", "fallback_enabled": True,
"ranked_targets": _FLAC_HI_RES_TARGETS + _MP3_TARGETS,
"qualities": _quals(flac_en=True, flac_prio=1, mp3_320_en=True, mp3_256_en=True, mp3_192_en=False),
},
"space_saver": {
"version": 2,
"preset": "space_saver",
"qualities": {
"flac": {
"enabled": False,
"min_kbps": 500,
"max_kbps": 10000,
"priority": 4,
"bit_depth": "any"
},
"mp3_320": {
"enabled": True,
"min_kbps": 280,
"max_kbps": 500,
"priority": 1
},
"mp3_256": {
"enabled": True,
"min_kbps": 200,
"max_kbps": 400,
"priority": 2
},
"mp3_192": {
"enabled": True,
"min_kbps": 150,
"max_kbps": 300,
"priority": 3
},
# Space-saver favours small files, where AAC shines — but it
# still ships OFF (opt-in). Priority 0.5 puts it above MP3.
"aac": {
"enabled": False,
"min_kbps": 128,
"max_kbps": 400,
"priority": 0.5
}
},
"fallback_enabled": True
}
"version": 3, "preset": "space_saver", "fallback_enabled": True,
"ranked_targets": _MP3_TARGETS,
"qualities": _quals(flac_en=False, flac_prio=4, mp3_320_en=True, mp3_256_en=True, mp3_192_en=True,
mp3_320_prio=1, mp3_256_prio=2, mp3_192_prio=3, aac_prio=0.5),
},
}
return presets.get(preset_name, presets["balanced"])
@ -10836,23 +10905,40 @@ class MusicDatabase:
logger.error(f"Error caching discovery recent album: {e}")
return False
def get_discovery_recent_albums(self, limit: int = 10, source: Optional[str] = None, profile_id: int = 1) -> List[Dict[str, Any]]:
"""Get cached recent albums for discover page, optionally filtered by source"""
def get_discovery_recent_albums(self, limit: int = 10, source: Optional[str] = None, profile_id: int = 1,
exclude_future_years: bool = False) -> List[Dict[str, Any]]:
"""Get cached recent albums for discover page, optionally filtered by source.
exclude_future_years: drop announced-but-unreleased albums dated to a LATER YEAR.
Because rows are ordered ``release_date DESC``, future-dated albums otherwise sort to
the very top and consume the ``limit`` budget which is exactly why Fresh Tape / Release
Radar starved down to a handful of tracks. Year-level so it's precision-safe across
'YYYY' / 'YYYY-MM' / 'YYYY-MM-DD'; same-year future months are left for the caller's precise
``is_future_release`` check. NULL/blank dates are kept (treated as released).
"""
try:
with self._get_connection() as conn:
cursor = conn.cursor()
future_clause = ""
if exclude_future_years:
future_clause = (
" AND (release_date IS NULL OR release_date = '' "
"OR CAST(substr(release_date, 1, 4) AS INTEGER) "
"<= CAST(strftime('%Y','now') AS INTEGER))"
)
if source:
cursor.execute("""
cursor.execute(f"""
SELECT * FROM discovery_recent_albums
WHERE source = ? AND profile_id = ?
WHERE source = ? AND profile_id = ?{future_clause}
ORDER BY release_date DESC
LIMIT ?
""", (source, profile_id, limit))
else:
cursor.execute("""
cursor.execute(f"""
SELECT * FROM discovery_recent_albums
WHERE profile_id = ?
WHERE profile_id = ?{future_clause}
ORDER BY release_date DESC
LIMIT ?
""", (profile_id, limit))
@ -13075,6 +13161,73 @@ class MusicDatabase:
logger.error(f"Error getting discovery pool stats: {e}")
return {'matched': 0, 'failed': 0}
# Wing It Pool: two states on a mirrored track's extra_data. Both key off wing_it_fallback,
# which is set by the wing-it stub and SURVIVES a manual fix (update_mirrored_track_extra_data
# merges rather than replaces), so the only difference is the manual_match flag:
# needs attention : wing_it_fallback=true AND NOT manual_match (unverified guess)
# resolved : wing_it_fallback=true AND manual_match=true (user fixed it — incl. fixes
# made before this feature existed, since the flag was never wiped)
_WING_IT_ATTENTION = ("mpt.extra_data LIKE '%\"wing_it_fallback\": true%' "
"AND mpt.extra_data NOT LIKE '%\"manual_match\": true%'")
_WING_IT_RESOLVED = ("mpt.extra_data LIKE '%\"wing_it_fallback\": true%' "
"AND mpt.extra_data LIKE '%\"manual_match\": true%'")
def get_wing_it_pool(self, profile_id: int = None, playlist_id: int = None,
resolved: bool = False) -> list:
"""Get Wing It tracks — the unverified guesses (default) or the ones you've resolved.
Wing-it tracks are persisted on extra_data with ``wing_it_fallback: true`` (a best-effort
stub when a track couldn't match a metadata source). They count as 'discovered', so the
Discovery Pool hides them this is the only surface that lists them. ``resolved=True``
returns the ones a manual match has since fixed (carrying the ``was_wing_it`` marker).
"""
try:
conn = self._get_connection()
cursor = conn.cursor()
where = self._WING_IT_RESOLVED if resolved else self._WING_IT_ATTENTION
query = f"""
SELECT mpt.id, mpt.track_name, mpt.artist_name, mpt.album_name,
mpt.playlist_id, mp.name as playlist_name, mpt.extra_data
FROM mirrored_playlist_tracks mpt
JOIN mirrored_playlists mp ON mpt.playlist_id = mp.id
WHERE {where}
"""
params = []
if playlist_id:
query += " AND mpt.playlist_id = ?"
params.append(playlist_id)
elif profile_id:
query += " AND mp.profile_id = ?"
params.append(profile_id)
query += " ORDER BY mp.name, mpt.track_name"
cursor.execute(query, params)
return [dict(row) for row in cursor.fetchall()]
except Exception as e:
logger.error(f"Error getting wing it pool: {e}")
return []
def get_wing_it_pool_stats(self, profile_id: int = None) -> dict:
"""Counts for both Wing It states: unverified (``wing_it``) + resolved (``matched``)."""
try:
conn = self._get_connection()
cursor = conn.cursor()
def _count(where):
q = (f"SELECT COUNT(*) as cnt FROM mirrored_playlist_tracks mpt "
f"JOIN mirrored_playlists mp ON mpt.playlist_id = mp.id WHERE {where}")
params = []
if profile_id:
q += " AND mp.profile_id = ?"
params.append(profile_id)
cursor.execute(q, params)
return cursor.fetchone()['cnt']
return {'wing_it': _count(self._WING_IT_ATTENTION),
'matched': _count(self._WING_IT_RESOLVED)}
except Exception as e:
logger.error(f"Error getting wing it pool stats: {e}")
return {'wing_it': 0, 'matched': 0}
# ==================== Retag Tool Methods ====================
def add_retag_group(self, group_type: str, artist_name: str, album_name: str,
@ -13483,7 +13636,10 @@ class MusicDatabase:
download_source, source_track_id, source_track_title, source_filename,
acoustid_result, source_artist, origin, origin_context, verification_status))
conn.commit()
return True
# Return the new row id (truthy on success) so callers can link the
# live download task to its library_history row — e.g. the Unverified
# review queue needs the id for its play/approve/delete actions.
return cursor.lastrowid
except Exception as e:
logger.debug(f"Error adding library history entry: {e}")
return False
@ -13681,6 +13837,26 @@ class MusicDatabase:
logger.error(f"Error querying library history: {e}")
return [], 0
def get_library_history_unverified(self) -> list[dict]:
"""Return every library_history row that still needs human confirmation.
Fetches all rows where verification_status is 'unverified' or
'force_imported', ordered newest-first. No row limit the full
set must always be visible on the Downloads Unverified tab.
"""
try:
conn = self._get_connection()
cursor = conn.cursor()
cursor.execute("""
SELECT * FROM library_history
WHERE verification_status IN ('unverified', 'force_imported')
ORDER BY created_at DESC
""")
return [dict(row) for row in cursor.fetchall()]
except Exception as e:
logger.error("Error querying unverified library history: %s", e)
return []
def get_library_history_stats(self):
"""Return counts per event_type and per download_source."""
try:

View file

@ -1,38 +1,49 @@
# soulsync 2.7.8`dev``main`
# soulsync 2.7.9`dev``main`
a feature patch on top of 2.7.7 — playlists can now be put back in order on the server, you can re-wishlist a missed track straight from sync history, plus a couple of reported fixes.
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).
---
## what's new
### align playlists — server order, not just contents
the server-playlist editor only ever cared about *which* tracks were on the server, never their order — and it rendered the server column in the source's order, so a playlist with the right tracks in the wrong sequence read as "in sync" when it wasn't. now it tells the truth:
- an **"out of order"** badge appears when the tracks match but the sequence differs (relative order, so missing/extra tracks don't false-flag it), and a **read-only view** shows the server's *actual* order with cover art.
- a new **"Align playlists"** action reorders the server playlist to match the source — **Plex** (in-place via moveItem), **Navidrome** (ordered rewrite), and **Jellyfin** (Move endpoint), all of which preserve the playlist's identity/poster. two choices for server-only extras: **mirror source** (drop them) or **keep extras** (park them at the end). it's order-only — it never adds the missing tracks (that's a normal sync's job) and never touches metadata, just reshuffles ids already on the server.
### best-quality downloads + a real quality profile
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:
- **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.
- each streaming source's download tier is derived from the one global profile, so you set it once.
- AAC is an opt-in tier; old per-source Hi-Res preferences migrate into the profile automatically.
### re-add to wishlist from sync history
in the dashboard's **Recent Syncs → details**, the "→ Wishlist" status on an unmatched track is now a button — click it to re-add that exact track to the wishlist with the **same context the sync used** (source playlist, cover art, everything), so it's indistinguishable from the original auto-add. the re-add and the live sync now build the *identical* payload from one shared path, so the cover and album/single classification carry through. wing-it fallback stubs (tracks that couldn't be resolved to real metadata) are correctly shown as **"Unmatched"** and aren't re-addable — matching what the sync itself does.
### quarantine, cleaned up + safer
- 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.
- **AcoustID fail-closed mode** (opt-in): only import tracks that actually verify, so a wrong file never lands in your library.
- **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.
### fixes
- **import search said "Deezer" for Spotify Free users (#922)** — manual album-import told no-auth Spotify users that Deezer was their primary source. the functional source legitimately downgrades to a working fallback (the free path has no album-name search), but the *label* should name what you configured. now it reads "Spotify."
- **iTunes albums >50 tracks could still truncate (#918 follow-up)** — the limit=200 fix only helped fresh fetches; albums cached at 50 before the fix kept serving 50 from the persistent cache. now a cached tracklist shorter than the album's known track count self-heals on next load.
### Discover got a lot smarter
- **"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.
- **"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 510 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.
### under the hood
- `.gitignore` now covers **all** `database/*.db` (+ wal/shm/backup), not just `music_library` — so the video db and any future db can't be committed by accident.
### Wing It Pool
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.
---
## a brief recap of what came before
2.7.7 was a fix-heavy patch — the metadata-parity fix so downloads tag + path right without a manual reorganize (#915), the listening-recs foundation (#913), jellyfin atomic writes, and a big reported-issue sweep (#905/#908#912/#914/#916#918). 2.7.6 exported playlists TO listenbrainz + youtube liked-music sync; 2.7.5 matching & artwork accuracy; 2.7.4 re-identify; 2.7.3 the Quality Upgrade Finder; 2.7.2 playlist-folder mirroring; 2.7.1 download verification; 2.7.0 made multi-user real.
## 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.
---
## tests
additive + scoped — the new write paths are their own routes that don't touch the normal sync. new seam/regression suites for the order-status detection (incl. the reported "moved to #2" case + missing/extra false-flag guards), the pure align-rewrite planner (mirror vs keep-extras, never-injects-a-foreign-track, stale-data rejection), the sync re-add payload (a direct parity assertion that the re-add == the live-sync payload, plus the wing-it skip), and the `get_primary_source_label` fix (#922). iTunes self-heal proven against the real persistent-cache shape. relevant suites green; `ruff check` clean.
## 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.
## post-merge
- [ ] tag `v2.7.8` on `main`
- [ ] docker-publish with `version_tag: 2.7.8`
- [ ] discord announce (auto-fired by the workflow)
- [ ] reply on #922 and the #918 follow-up
---

View file

@ -0,0 +1,79 @@
"""Listening Mix v2 generator (#913) — reads the scan's stored mix into Track records.
The generator is what lets the Listening Mix appear on the Sync page's SoulSync
Discovery tab + flow through the mirror/Auto-Sync pipeline like the other kinds. It
must hand back exactly what the scan stored under 'listening_recs_tracks_full', with
NO pool hydration, and degrade to empty (never raise) when there's no mix yet.
"""
from __future__ import annotations
import json
from types import SimpleNamespace
from core.personalized.generators.listening_mix import KIND, generate
from core.personalized.specs import get_registry
from core.personalized.types import PlaylistConfig
class _FakeDB:
def __init__(self, meta):
self._meta = meta
def get_metadata(self, key):
return self._meta.get(key)
def _full_track(tid, name, artist, source='spotify'):
d = {'track_id': tid, 'name': name, 'track_name': name, 'artist_name': artist,
'album_name': f'{name} - Album', 'album_cover_url': f'http://cdn/{tid}.jpg',
'duration_ms': 200000, 'track_data_json': {'id': tid}, 'source': source,
f'{source}_track_id': tid, '_seed_artist': artist}
return d
def _deps(meta):
return SimpleNamespace(database=_FakeDB(meta))
def test_generate_reads_stored_full_tracks():
rows = [_full_track('s1', 'One', 'Arcangel'), _full_track('s2', 'Two', 'Maluma')]
out = generate(_deps({'listening_recs_tracks_full': json.dumps(rows)}), '', PlaylistConfig(limit=50))
assert [t.track_name for t in out] == ['One', 'Two']
assert [t.artist_name for t in out] == ['Arcangel', 'Maluma']
assert out[0].spotify_track_id == 's1' # source id carried -> mirror can sync
assert out[0].album_cover_url == 'http://cdn/s1.jpg'
assert out[0].track_data_json == {'id': 's1'} # full payload preserved for download
def test_generate_respects_limit():
rows = [_full_track(f's{i}', f'T{i}', f'A{i}') for i in range(10)]
out = generate(_deps({'listening_recs_tracks_full': json.dumps(rows)}), '', PlaylistConfig(limit=3))
assert len(out) == 3
def test_generate_empty_when_no_mix_yet():
assert generate(_deps({}), '', PlaylistConfig(limit=50)) == []
assert generate(_deps({'listening_recs_tracks_full': ''}), '', PlaylistConfig(limit=50)) == []
def test_generate_tolerates_bad_json_and_non_dict_rows():
assert generate(_deps({'listening_recs_tracks_full': 'not json'}), '', PlaylistConfig(limit=50)) == []
rows = json.dumps([_full_track('s1', 'Good', 'A'), 'garbage', None])
out = generate(_deps({'listening_recs_tracks_full': rows}), '', PlaylistConfig(limit=50))
assert [t.track_name for t in out] == ['Good'] # junk rows skipped, valid kept
def test_generator_is_registered():
# importing the package must register the kind so the manager + Sync tab discover it.
import core.personalized.generators # noqa: F401
spec = get_registry().get(KIND)
assert spec is not None and spec.requires_variant is False
assert spec.display_name('') == 'Your Listening Mix'
def test_generate_supports_deezer_id_tracks():
# iTunes/MusicBrainz users get Deezer-sourced tracks -> deezer_track_id must carry.
rows = [_full_track('d9', 'Song', 'Artist', source='deezer')]
out = generate(_deps({'listening_recs_tracks_full': json.dumps(rows)}), '', PlaylistConfig(limit=50))
assert out[0].deezer_track_id == 'd9' and out[0].spotify_track_id is None

View file

@ -4,14 +4,87 @@ from __future__ import annotations
from core.discovery.listening_recommendations import (
aggregate_candidate_tracks,
build_recency_weighted_seeds,
choose_mix_fetch_source,
names_match,
rank_recommended_artists,
similarity_from_rank,
to_mix_track,
)
# ── choose_mix_fetch_source (universal Deezer fallback) ───────────────────────
def test_fetch_source_uses_active_when_it_can_fetch():
assert choose_mix_fetch_source("spotify", True) == "spotify"
assert choose_mix_fetch_source("deezer", True) == "deezer"
def test_fetch_source_falls_back_to_deezer_for_other_sources():
# iTunes / Discogs / MusicBrainz can't fetch top tracks -> Deezer public.
assert choose_mix_fetch_source("itunes", False) == "deezer"
assert choose_mix_fetch_source("musicbrainz", False) == "deezer"
assert choose_mix_fetch_source("discogs", False) == "deezer"
def test_fetch_source_falls_back_when_active_client_unavailable():
# Active source is Spotify but its client isn't usable (not authed) -> Deezer.
assert choose_mix_fetch_source("spotify", False) == "deezer"
assert choose_mix_fetch_source(None, False) == "deezer"
# ── names_match (guards the top-tracks fetch against wrong-artist results) ─────
def test_names_match_ignores_case_and_punctuation():
assert names_match("Tyler, The Creator", "Tyler The Creator")
assert names_match("BEYONCÉ", "beyoncé")
assert names_match("AC/DC", "ac dc")
def test_names_match_rejects_near_misses_and_empty():
assert not names_match("Drake", "Drake Bell")
assert not names_match("", "anything")
assert not names_match("X", None)
def _seed(name, weight=1.0):
return {"name": name, "weight": weight}
# ── similarity_from_rank (1=closest .. 10=farthest -> 1.0 .. 0.1) ─────────────
def test_similarity_from_rank_decays_over_documented_range():
assert similarity_from_rank(1) == 1.0
assert similarity_from_rank(5) == 0.6
assert similarity_from_rank(10) == 0.1
def test_similarity_from_rank_clamps_and_defaults():
assert similarity_from_rank(0) == 1.0 # <=1 -> full weight
assert similarity_from_rank(50) == 0.1 # beyond range -> floor
assert similarity_from_rank(None) == 1.0 # missing -> full weight (no rank info)
assert similarity_from_rank("nan") == 1.0
# ── build_recency_weighted_seeds (lifetime + factor*recent) ───────────────────
def test_recency_boost_reorders_toward_current_taste():
# Old-fav has more lifetime plays, but New-fav dominates recently.
lifetime = [{"name": "OldFav", "play_count": 100}, {"name": "NewFav", "play_count": 40}]
recent = {"newfav": 60}
seeds = build_recency_weighted_seeds(lifetime, recent, recency_factor=1.5)
by = {s["name"]: s["weight"] for s in seeds}
assert by["OldFav"] == 100.0 # no recent plays -> unchanged
assert by["NewFav"] == 40 + 1.5 * 60 # 130 -> now outranks OldFav
def test_recency_factor_zero_is_pure_lifetime():
seeds = build_recency_weighted_seeds(
[{"name": "A", "play_count": 7}], {"a": 99}, recency_factor=0)
assert seeds == [{"name": "A", "weight": 7.0}]
def test_recency_seeds_skip_blank_names_and_tolerate_missing_recent():
seeds = build_recency_weighted_seeds([{"name": ""}, {"name": "A", "play_count": 3}])
assert seeds == [{"name": "A", "weight": 3.0}]
# ── rank_recommended_artists ─────────────────────────────────────────────────
def test_consensus_outranks_single_endorsement():
# 'Common' is similar to BOTH seeds; 'Solo' to one. Equal weights/scores.
@ -117,6 +190,46 @@ def test_aggregate_skips_artist_with_no_tracks():
assert [t["name"] for t in out] == ["only"] # sim-b had no tracks -> skipped
# ── to_mix_track (source top-track dict -> Discover compact-row dict) ─────────
def _sp_track(tid="t1", name="Song", artist="Artist", album="Album", cover="http://cdn/c.jpg"):
return {"id": tid, "name": name, "artists": [{"name": artist}],
"album": {"name": album, "images": ([{"url": cover}] if cover else [])},
"duration_ms": 210000, "popularity": 55}
def test_to_mix_track_shapes_render_fields():
out = to_mix_track(_sp_track(), "spotify")
assert out["track_name"] == "Song" and out["artist_name"] == "Artist"
assert out["album_name"] == "Album" and out["album_cover_url"] == "http://cdn/c.jpg"
assert out["duration_ms"] == 210000
assert out["spotify_track_id"] == "t1" and out["track_id"] == "t1"
assert out["track_data_json"]["id"] == "t1" # full payload kept for sync
assert out["name"] == "Song" # kept for aggregate dedup
def test_to_mix_track_source_id_field_per_source():
assert to_mix_track(_sp_track(), "deezer")["deezer_track_id"] == "t1"
assert to_mix_track(_sp_track(), "itunes")["itunes_track_id"] == "t1"
def test_to_mix_track_rejects_unusable_and_tolerates_missing_album():
assert to_mix_track({"id": "x"}, "spotify") is None # no title
assert to_mix_track({"name": "y"}, "spotify") is None # no id
assert to_mix_track("garbage", "spotify") is None
bare = to_mix_track({"id": "z", "name": "Z"}, "spotify") # no artists/album
assert bare["artist_name"] == "" and bare["album_cover_url"] is None
def test_to_mix_track_feeds_aggregate_end_to_end():
# The real pipeline: shape source tracks, then aggregate by recommended artist.
recs = _recs("A") # recommends 'sim-A'
shaped = [to_mix_track(_sp_track(tid="1", name="One", artist="sim-A"), "spotify"),
to_mix_track(_sp_track(tid="2", name="Two", artist="sim-A"), "spotify")]
out = aggregate_candidate_tracks(recs, {"sim-a": shaped}, per_artist=5, limit=10)
assert [t["track_name"] for t in out] == ["One", "Two"]
assert out[0]["spotify_track_id"] == "1"
# ── group_similars_by_seed (id->name join) ───────────────────────────────────
from dataclasses import dataclass as _dc # noqa: E402
@ -167,3 +280,37 @@ def test_group_then_rank_end_to_end():
ranked = rank_recommended_artists(seeds, grouped, owned_artist_names={"solo"})
assert ranked[0].name == "Common" and ranked[0].seed_count == 2
assert all(r.name != "Solo" for r in ranked) # owned excluded
# ── rank-aware grouping (similarity_rank -> score) ────────────────────────────
@_dc
class _RankRow:
source_artist_id: str
similar_artist_name: str
similarity_rank: int
def test_group_with_rank_attr_carries_similarity_score():
seeds = [_seed("A")]
rows = [_RankRow("ia", "Close", 1), _RankRow("ia", "Far", 10)]
out = group_similars_by_seed(seeds, rows, {"ia": "A"}, rank_attr="similarity_rank")
by = {e["name"]: e["score"] for e in out["a"]}
assert by == {"Close": 1.0, "Far": 0.1}
def test_group_without_rank_attr_is_scoreless_backcompat():
seeds = [_seed("A")]
rows = [_RankRow("ia", "X", 3)]
out = group_similars_by_seed(seeds, rows, {"ia": "A"})
assert out["a"] == [{"name": "X"}] # no score key -> original behavior
def test_rank_threading_changes_winner_within_a_seed():
# The production fix: a CLOSER match (rank 1) on a heavy seed beats a far match (rank 9),
# even though both come from the same seed. Without rank threading they'd tie.
seeds = [_seed("Fav", weight=10)]
rows = [_RankRow("if", "Close", 1), _RankRow("if", "Far", 9)]
grouped = group_similars_by_seed(seeds, rows, {"if": "Fav"}, rank_attr="similarity_rank")
ranked = rank_recommended_artists(seeds, grouped)
assert [r.name for r in ranked] == ["Close", "Far"]
assert ranked[0].score > ranked[1].score

View file

@ -0,0 +1,57 @@
"""Fresh Tape / Release Radar candidate fetch must not be starved by future albums.
Regression: get_discovery_recent_albums orders release_date DESC, so announced-but-
unreleased albums dated to a LATER YEAR sort to the very top and consumed the album
budget before the scanner's in-loop is_future_release skip ran — leaving only a handful
of released albums to draw tracks from (the reported "Fresh Tape only has 5-10 tracks").
exclude_future_years drops next-year albums at the query so released ones fill the budget.
"""
from __future__ import annotations
from datetime import datetime
from database.music_database import MusicDatabase
def _insert(db, **kw):
with db._get_connection() as conn:
conn.execute(
"""INSERT INTO discovery_recent_albums
(album_spotify_id, album_name, artist_name, artist_spotify_id,
album_cover_url, release_date, album_type, source, profile_id)
VALUES (?,?,?,?,?,?,?,?,?)""",
(kw['id'], kw['name'], kw['artist'], kw['id'] + '_a', '',
kw['release_date'], kw.get('album_type', 'album'), kw.get('source', 'spotify'), 1))
conn.commit()
def test_future_year_albums_excluded_released_kept(tmp_path):
db = MusicDatabase(database_path=str(tmp_path / "t.db"))
this_year = datetime.now().year
next_year = this_year + 1
_insert(db, id='past1', name='Released A', artist='X', release_date=f'{this_year - 1}-03-01')
_insert(db, id='past2', name='Released B', artist='Y', release_date=f'{this_year}-01-15')
_insert(db, id='fut1', name='Announced', artist='Z', release_date=f'{next_year}-02-01')
_insert(db, id='fut2', name='Year Only Future', artist='W', release_date=str(next_year))
_insert(db, id='blank', name='Unknown Date', artist='Q', release_date='')
names_all = {a['album_name'] for a in db.get_discovery_recent_albums(limit=50, source='spotify')}
assert 'Announced' in names_all # without the flag, futures are present (and sort first)
filtered = db.get_discovery_recent_albums(limit=50, source='spotify', exclude_future_years=True)
names = {a['album_name'] for a in filtered}
assert 'Announced' not in names # next-year album dropped
assert 'Year Only Future' not in names # YYYY-only future dropped
assert {'Released A', 'Released B'} <= names # released kept
assert 'Unknown Date' in names # blank date kept (treated as released)
def test_future_filter_does_not_over_trim_when_all_released(tmp_path):
db = MusicDatabase(database_path=str(tmp_path / "t2.db"))
this_year = datetime.now().year
for i in range(8):
_insert(db, id=f'r{i}', name=f'Album {i}', artist=f'A{i}',
release_date=f'{this_year}-0{(i % 9) + 1}-01')
filtered = db.get_discovery_recent_albums(limit=300, source='spotify', exclude_future_years=True)
assert len(filtered) == 8 # every released album survives, budget honored

View file

@ -0,0 +1,69 @@
"""order_candidates — the candidate sort used by attempt_download_with_candidates.
Default (priority mode) sorts confidence-first, then peer quality today's
behaviour, locked here as a regression guard. quality_first=True (best-quality
mode) makes the user's profile quality rank dominate, with confidence as the
tiebreaker. Both keep correctly-matched candidates; ordering only changes which
is tried first.
"""
from core.downloads.candidates import order_candidates
from core.quality.model import AudioQuality, QualityTarget
class _Cand:
def __init__(self, name, aq, confidence, quality_score=0,
upload_speed=0, queue_length=0, free_upload_slots=0, size=0):
self.name = name
self.audio_quality = aq
self.confidence = confidence
self.quality_score = quality_score
self.upload_speed = upload_speed
self.queue_length = queue_length
self.free_upload_slots = free_upload_slots
self.size = size
FLAC_HI = AudioQuality('flac', sample_rate=96000, bit_depth=24)
FLAC_CD = AudioQuality('flac', sample_rate=44100, bit_depth=16)
TARGETS = [
QualityTarget(label='FLAC 24', format='flac', bit_depth=24, min_sample_rate=96000),
QualityTarget(label='FLAC 16', format='flac', bit_depth=16),
]
def test_priority_mode_is_confidence_first():
hi = _Cand('hi-flac', FLAC_HI, confidence=0.80)
lo = _Cand('cd-flac', FLAC_CD, confidence=0.95)
ordered = order_candidates([hi, lo], quality_first=False, targets=TARGETS)
assert [c.name for c in ordered] == ['cd-flac', 'hi-flac'] # higher confidence wins
def test_quality_first_lets_better_quality_win_over_confidence():
hi = _Cand('hi-flac', FLAC_HI, confidence=0.80)
lo = _Cand('cd-flac', FLAC_CD, confidence=0.95)
ordered = order_candidates([hi, lo], quality_first=True, targets=TARGETS)
assert [c.name for c in ordered] == ['hi-flac', 'cd-flac'] # 24-bit beats higher-confidence 16-bit
def test_quality_first_uses_confidence_as_tiebreak_within_same_quality():
a = _Cand('a', FLAC_HI, confidence=0.70)
b = _Cand('b', FLAC_HI, confidence=0.90)
ordered = order_candidates([a, b], quality_first=True, targets=TARGETS)
assert [c.name for c in ordered] == ['b', 'a'] # same quality → confidence breaks tie
def test_quality_first_ranks_unmatched_quality_last():
matched = _Cand('matched', FLAC_CD, confidence=0.50)
off_list = _Cand('off', AudioQuality('mp3', bitrate=320), confidence=0.99)
ordered = order_candidates([off_list, matched], quality_first=True, targets=TARGETS)
assert [c.name for c in ordered] == ['matched', 'off'] # off-list sorts last despite high confidence

View file

@ -176,15 +176,30 @@ def test_clear_completed_drops_empty_batches():
assert download_batches['b3']['queue'] == ['t3']
def test_clear_completed_prunes_terminal_task_ids_from_batch_queues():
"""Batch with mix of terminal + active tasks gets queue trimmed, not deleted."""
def test_clear_completed_keeps_terminal_tasks_in_active_batch():
"""A completed/failed task in a batch that still has active work is KEPT, so
the Downloads page shows it for the whole run (the 5-min Clean Completed
automation must not yank terminal tasks out from under a live batch)."""
download_tasks['t1'] = {'status': 'completed'}
download_tasks['t2'] = {'status': 'downloading'}
download_tasks['t2'] = {'status': 'downloading'} # batch still active
download_batches['b1'] = {'queue': ['t1', 't2']}
cancel.clear_completed_local()
cleared = cancel.clear_completed_local()
assert cleared == 0 # nothing removed — batch is live
assert 'b1' in download_batches
assert download_batches['b1']['queue'] == ['t2']
assert download_batches['b1']['queue'] == ['t1', 't2'] # queue intact
def test_clear_completed_clears_terminal_tasks_once_batch_is_finished():
"""When every task in a batch is terminal, the batch is done — its tasks are
cleared and the empty batch dropped."""
download_tasks['t1'] = {'status': 'completed'}
download_tasks['t2'] = {'status': 'failed'}
download_batches['b1'] = {'queue': ['t1', 't2']}
cleared = cancel.clear_completed_local()
assert cleared == 2
assert 'b1' not in download_batches
def test_clear_completed_drops_batch_locks_for_deleted_batches():

View file

@ -480,11 +480,33 @@ def test_stuck_searching_task_forced_to_not_found():
assert download_tasks['t1']['status'] == 'not_found'
def test_stuck_post_processing_task_forced_to_completed():
"""Task post_processing > 5min gets forced to completed."""
def test_stuck_post_processing_without_file_forced_to_failed():
"""Task stuck in post_processing past the timeout with NO output file must
be marked FAILED, not falsely completed otherwise it shows as a phantom
download with nothing on disk (big batches back up post-processing)."""
download_tasks['t1'] = {
'status': 'post_processing', 'track_info': {'name': 'X'},
'status_change_time': 0, # ancient → past any timeout
}
download_batches['b1'] = {
'queue': ['t1'], 'queue_index': 1, 'active_count': 1,
'max_concurrent': 1, 'permanently_failed_tracks': [],
'cancelled_tracks': set(),
}
deps, _ = _build_deps()
lc.on_download_completed('b1', 't1', True, deps)
assert download_tasks['t1']['status'] == 'failed'
def test_stuck_post_processing_with_existing_file_completed(tmp_path):
"""If the import really finished (final_file_path exists on disk), a stuck
post_processing task is legitimately completed."""
real_file = tmp_path / 'track.flac'
real_file.write_bytes(b'x')
download_tasks['t1'] = {
'status': 'post_processing', 'track_info': {'name': 'X'},
'status_change_time': 0,
'final_file_path': str(real_file),
}
download_batches['b1'] = {
'queue': ['t1'], 'queue_index': 1, 'active_count': 1,

View file

@ -117,6 +117,31 @@ def test_stream_processed_task_returns_early():
assert rec.calls == []
def test_requeued_task_bails_without_marking_failed():
"""RACE GUARD: the monitor sets status -> 'post_processing' and submits this
worker. If, before the worker runs, the browser-poll post-processor
quarantines the file and requeues the next-best candidate (status ->
'searching', username/filename cleared), this worker must bail WITHOUT
marking failed or notifying batch completion. Otherwise it clobbers the
in-flight retry with a false 'missing file or source information' failure
while a parallel attempt imports the song."""
download_tasks['t1'] = {'status': 'searching', 'track_info': {}}
deps, rec = _build_deps()
pp.run_post_processing_worker('t1', 'b1', deps)
assert download_tasks['t1']['status'] == 'searching' # untouched
assert 'error_message' not in download_tasks['t1']
assert not any(c[0] == 'on_complete' for c in rec.calls)
def test_queued_task_bails_without_marking_failed():
"""Same race guard for a task another path reset to 'queued'."""
download_tasks['t1'] = {'status': 'queued', 'track_info': {}}
deps, rec = _build_deps()
pp.run_post_processing_worker('t1', 'b1', deps)
assert download_tasks['t1']['status'] == 'queued'
assert not any(c[0] == 'on_complete' for c in rec.calls)
def test_missing_filename_marks_failed_and_calls_on_complete():
download_tasks['t1'] = {'status': 'post_processing', 'username': 'u1', 'track_info': {}}
deps, rec = _build_deps()

View file

@ -667,15 +667,44 @@ def test_unified_response_includes_batch_summaries():
assert bs['queued'] == 1
def test_unified_response_respects_limit():
def test_unified_response_returns_all_live_tasks_even_past_limit():
"""`limit` bounds the persistent-history tail, NOT live in-memory tasks.
Live tasks are already bounded by the 5-min cleanup, and the Downloads page
filters them client-side per tab truncating them (active-first) starved
completed/failed/unverified rows out of the response during a busy batch so
they never showed until the batch drained.
"""
deps, _ = _build_deps()
for i in range(20):
download_tasks[f't{i}'] = {
'track_index': i, 'status': 'completed', 'track_info': {},
}
out = st.build_unified_downloads_response(5, deps)
assert len(out['downloads']) == 5
assert out['total'] == 20 # total still reflects all
assert len(out['downloads']) == 20 # all live tasks returned
assert out['total'] == 20
def test_unified_response_does_not_truncate_terminal_tasks_behind_active():
"""A busy batch (many queued/active tasks) must not push completed/failed
rows off the end of the response they're what the Completed/Failed tabs
show during the run."""
deps, _ = _build_deps()
for i in range(120):
download_tasks[f'q{i}'] = {
'track_index': i, 'status': 'queued',
'track_info': {'name': f'Q{i}'}, 'status_change_time': i,
}
download_tasks['done'] = {
'status': 'completed', 'track_info': {'name': 'DONE'}, 'status_change_time': 999,
}
download_tasks['fail'] = {
'status': 'failed', 'track_info': {'name': 'FAIL'}, 'status_change_time': 999,
}
out = st.build_unified_downloads_response(100, deps)
titles = {d['title'] for d in out['downloads']}
assert 'DONE' in titles
assert 'FAIL' in titles
def test_unified_response_includes_persistent_download_history():
@ -763,6 +792,68 @@ def test_unified_response_caps_persistent_history_tail():
assert out['total'] == 50
def test_unverified_history_always_loaded_even_when_at_limit():
"""Unverified entries must appear even during a large batch that fills the limit."""
for i in range(200):
download_tasks[f'live-{i}'] = {
'track_index': i,
'status': 'completed',
'track_info': {'name': f'Track {i}', 'artist': f'Artist {i}', 'album': 'Album'},
'verification_status': 'verified',
}
deps, _ = _build_deps(
persistent_history=lambda limit: [],
)
deps.get_unverified_download_history = lambda: [
{
'id': 999,
'title': 'Old Unverified',
'artist_name': 'Forgotten Artist',
'album_name': 'Old Album',
'created_at': '2026-01-01 00:00:00',
'verification_status': 'unverified',
}
]
out = st.build_unified_downloads_response(200, deps)
titles = {d['title'] for d in out['downloads']}
assert 'Old Unverified' in titles, "historical unverified entry must appear regardless of limit"
unverified = [d for d in out['downloads'] if d.get('verification_status') == 'unverified']
assert len(unverified) == 1
assert unverified[0]['task_id'] == 'history-999'
assert unverified[0]['is_persistent_history'] is True
def test_unverified_history_deduped_against_live_task():
"""A live task that's still unverified must not appear twice."""
download_tasks['live-unv'] = {
'track_index': 0,
'status': 'completed',
'track_info': {'name': 'Live Unverified', 'artist': 'Artsy', 'album': 'Alb'},
'verification_status': 'unverified',
}
deps, _ = _build_deps()
deps.get_unverified_download_history = lambda: [
{
'id': 77,
'title': 'Live Unverified',
'artist_name': 'Artsy',
'album_name': 'Alb',
'created_at': '2026-06-15 10:00:00',
'verification_status': 'unverified',
}
]
out = st.build_unified_downloads_response(200, deps)
matches = [d for d in out['downloads'] if d['title'] == 'Live Unverified']
assert len(matches) == 1, "same track must not be duplicated"
assert matches[0]['is_persistent_history'] is False
# ---------------------------------------------------------------------------
# #836 — a rejected slskd transfer must not hang the task at 'downloading'
# forever. The monitor normally retries; when it can't make progress, a backstop

View file

@ -352,7 +352,7 @@ def test_first_query_success_returns_after_storing_source():
_seed_task()
rec = _Recorder()
def _attempt_success(task_id, candidates, track, batch_id):
def _attempt_success(task_id, candidates, track, batch_id, **kwargs):
download_tasks[task_id]['filename'] = 'song.flac'
download_tasks[task_id]['username'] = 'u1'
return True
@ -478,7 +478,7 @@ def test_hybrid_fallback_tries_secondary_sources():
},
)
def _attempt_yt_success(task_id, candidates, track, batch_id):
def _attempt_yt_success(task_id, candidates, track, batch_id, **kwargs):
return True
deps, _ = _build_deps(
@ -528,7 +528,7 @@ def test_quarantine_retry_tries_cached_candidates_without_searching():
)
attempted = []
def _attempt(task_id, candidates, track, batch_id):
def _attempt(task_id, candidates, track, batch_id, **kwargs):
attempted.append([getattr(c, 'filename', None) for c in candidates])
return True
@ -555,7 +555,7 @@ def test_quarantine_retry_skips_cached_from_exhausted_source():
)
attempted = []
def _attempt(task_id, candidates, track, batch_id):
def _attempt(task_id, candidates, track, batch_id, **kwargs):
attempted.append([getattr(c, 'username', None) for c in candidates])
return True

View file

@ -0,0 +1,66 @@
"""orchestrator.search routes to the right engine method per search_mode:
- priority (default) engine.search_with_fallback (first source wins)
- best_quality + hybrid engine.search_all_sources (pool every source)
- single-source mode the single client's search (toggle is a no-op)
"""
import asyncio
import core.download_orchestrator as orch_mod
from core.download_orchestrator import DownloadOrchestrator
def _run(coro):
loop = asyncio.new_event_loop()
try:
return loop.run_until_complete(coro)
finally:
loop.close()
class _SpyEngine:
def __init__(self):
self.calls = []
async def search_with_fallback(self, query, chain, timeout=None, progress_callback=None):
self.calls.append(('fallback', tuple(chain)))
return (['fb'], [])
async def search_all_sources(self, query, chain, timeout=None,
progress_callback=None, exclude_sources=None):
self.calls.append(('all', tuple(chain)))
return (['pool'], [])
def _hybrid_orch():
orch = DownloadOrchestrator.__new__(DownloadOrchestrator)
orch.mode = 'hybrid'
orch.hybrid_order = ['soulseek', 'hifi']
orch.hybrid_primary = 'soulseek'
orch.hybrid_secondary = 'hifi'
orch.engine = _SpyEngine()
# _resolve_source_chain normalizes names through the registry; stub it so the
# test doesn't need a full registry.
orch._resolve_source_chain = lambda: ['soulseek', 'hifi']
return orch
def test_priority_mode_uses_search_with_fallback(monkeypatch):
monkeypatch.setattr(orch_mod, 'load_search_mode', lambda: 'priority')
orch = _hybrid_orch()
tracks, _ = _run(orch.search('q'))
assert orch.engine.calls == [('fallback', ('soulseek', 'hifi'))]
assert tracks == ['fb']
def test_best_quality_mode_uses_search_all_sources(monkeypatch):
monkeypatch.setattr(orch_mod, 'load_search_mode', lambda: 'best_quality')
orch = _hybrid_orch()
tracks, _ = _run(orch.search('q'))
assert orch.engine.calls == [('all', ('soulseek', 'hifi'))]
assert tracks == ['pool']

View file

@ -38,14 +38,14 @@ def _cand(quality, size_mb, bitrate=None):
queue_length=0, artist='A', title='Song', album='B', track_number=1)
def _q(enabled_flac=True, enabled_mp3=True, aac=None):
def _q(enabled_flac=True, enabled_mp3=True, aac=None, fallback=True):
qualities = {
'flac': {'enabled': enabled_flac, 'min_kbps': 500, 'max_kbps': 10000, 'priority': 1, 'bit_depth': 'any'},
'mp3_320': {'enabled': enabled_mp3, 'min_kbps': 280, 'max_kbps': 500, 'priority': 2},
}
if aac is not None: # None => omit the tier entirely (pre-existing profile)
if aac is not None: # None => omit the tier entirely
qualities['aac'] = {'enabled': aac, 'min_kbps': 128, 'max_kbps': 400, 'priority': 1.5}
return {'preset': 'custom', 'qualities': qualities, 'fallback_enabled': True}
return {'preset': 'custom', 'qualities': qualities, 'fallback_enabled': fallback}
def _filter(candidates, profile):
@ -56,51 +56,44 @@ def _filter(candidates, profile):
return c.filter_results_by_quality_preference(candidates)
# ── additive proof: AAC off == today (dropped) ────────────────────────────────
def test_aac_dropped_when_tier_absent_pre_existing_profile():
# A profile saved before this feature has no 'aac' key at all.
out = _filter([_cand('aac', 5)], _q(aac=None))
assert out == [] # AAC went to 'other' -> never returned, exactly as before
# ── AAC follows the UNIVERSAL rule (no per-format special-casing) ──────────────
# AAC is just another format: it passes only if it matches a ranked target;
# when nothing matches, the fallback toggle decides — exactly like every format.
def test_aac_not_targeted_fallback_off_is_dropped():
out = _filter([_cand('aac', 5)], _q(aac=None, fallback=False))
assert out == [] # no AAC target + fallback off → nothing comes through
def test_aac_dropped_when_tier_present_but_disabled():
out = _filter([_cand('aac', 5)], _q(aac=False))
assert out == []
def test_aac_not_targeted_fallback_on_comes_through():
out = _filter([_cand('aac', 5)], _q(aac=None, fallback=True))
assert len(out) == 1 and out[0].quality == 'aac' # fallback grabs it
def test_aac_disabled_tier_behaves_like_not_targeted():
# An explicitly-disabled aac tier is not a target → same as absent.
assert _filter([_cand('aac', 5)], _q(aac=False, fallback=False)) == []
def test_flac_mp3_selection_unchanged_when_aac_absent():
# The headline no-regression guard: a normal FLAC/MP3 mix is unaffected.
flac, mp3 = _cand('flac', 30), _cand('mp3', 5, bitrate=320)
out = _filter([mp3, flac], _q(aac=None))
assert out and out[0].quality == 'flac' # FLAC still wins, as before
assert out and out[0].quality == 'flac' # FLAC still wins
# ── opt-in: AAC on makes it a real tier ───────────────────────────────────────
def test_aac_selected_when_enabled():
# ── AAC as a real ranked target ───────────────────────────────────────────────
def test_aac_selected_when_targeted():
out = _filter([_cand('aac', 5)], _q(aac=True))
assert len(out) == 1 and out[0].quality == 'aac'
def test_flac_beats_aac_when_both_present():
def test_flac_beats_aac_when_listed_higher():
flac, aac = _cand('flac', 30), _cand('aac', 5)
out = _filter([aac, flac], _q(aac=True))
assert out[0].quality == 'flac' # priority 1 < 1.5
assert out[0].quality == 'flac' # flac target ranked above aac (priority 1 < 1.5)
def test_aac_beats_mp3_when_both_present():
def test_aac_beats_mp3_when_listed_higher():
mp3, aac = _cand('mp3', 5, bitrate=320), _cand('aac', 5)
out = _filter([mp3, aac], _q(aac=True))
assert out[0].quality == 'aac' # priority 1.5 < 2
def test_default_and_presets_ship_aac_disabled_above_mp3():
from database.music_database import MusicDatabase
db = MusicDatabase.__new__(MusicDatabase) # no DB init
profiles = [db._get_default_quality_profile()]
profiles += [db.get_quality_preset(p) for p in ('audiophile', 'balanced', 'space_saver')]
for prof in profiles:
aac = prof['qualities']['aac']
assert aac['enabled'] is False # opt-in everywhere
# above MP3: lower priority number than the best MP3 tier present
mp3_prios = [v['priority'] for k, v in prof['qualities'].items() if k.startswith('mp3')]
assert aac['priority'] < min(mp3_prios)
assert out[0].quality == 'aac' # aac target (1.5) ranked above mp3 (2)

View file

@ -0,0 +1,47 @@
"""core.downloads.task_worker._candidate_ordering — decides whether the
download walk is ordered quality-first or confidence-first.
Quality-first applies when:
- best_quality search mode is active (always), OR
- priority mode AND the rank_candidates_by_quality toggle is on.
Default (priority mode, toggle off) confidence-first, the byte-for-byte old
behaviour. Any error fails closed to confidence-first so a DB hiccup never
blocks a download.
"""
import core.quality.selection as selection
import core.downloads.task_worker as task_worker
_TARGETS = ["t1", "t2"]
def _patch(monkeypatch, *, mode, rank_toggle):
monkeypatch.setattr(selection, "load_search_mode", lambda: mode)
monkeypatch.setattr(
selection, "load_rank_candidates_by_quality", lambda: rank_toggle
)
monkeypatch.setattr(selection, "load_profile_targets", lambda: (_TARGETS, True))
def test_priority_mode_toggle_off_is_confidence_first(monkeypatch):
_patch(monkeypatch, mode="priority", rank_toggle=False)
assert task_worker._candidate_ordering() == (False, None)
def test_priority_mode_toggle_on_is_quality_first(monkeypatch):
_patch(monkeypatch, mode="priority", rank_toggle=True)
assert task_worker._candidate_ordering() == (True, _TARGETS)
def test_best_quality_mode_is_quality_first_regardless_of_toggle(monkeypatch):
_patch(monkeypatch, mode="best_quality", rank_toggle=False)
assert task_worker._candidate_ordering() == (True, _TARGETS)
def test_fails_closed_to_confidence_first_on_error(monkeypatch):
def _boom():
raise RuntimeError("db down")
monkeypatch.setattr(selection, "load_search_mode", _boom)
assert task_worker._candidate_ordering() == (False, None)

View file

@ -1,47 +1,64 @@
"""``check_flac_bit_depth`` is a thin legacy wrapper that delegates to the
unified ``check_quality_target`` guard, which probes the REAL file (mutagen)
and ranks it against the v3 ranked-targets list.
The old per-quality ``bit_depth_fallback`` config and the "reject a higher bit
depth" semantics are gone by design: bit depth is now a MINIMUM, so a 24-bit
file satisfies a 16-bit target. These tests pin the wrapper's current
behaviour (deeper coverage of ``check_quality_target`` lives in
``tests/imports/test_quality_guard.py``).
"""
from types import SimpleNamespace
from core.imports import guards
import core.imports.guards as guards
import core.imports.file_ops as file_ops
from core.quality.model import AudioQuality
class _FakeDB:
def __init__(self, quality_profile):
self._quality_profile = quality_profile
def __init__(self, profile):
self._profile = profile
def get_quality_profile(self):
return self._quality_profile
return self._profile
def test_check_flac_bit_depth_rejects_strict_mismatch(monkeypatch):
_WANT_FLAC24 = {
"fallback_enabled": False,
"ranked_targets": [
{"label": "FLAC 24-bit/96kHz", "format": "flac", "bit_depth": 24, "min_sample_rate": 96000},
],
}
def _patch(monkeypatch, aq, profile):
monkeypatch.setattr(file_ops, "probe_audio_quality", lambda fp: aq)
monkeypatch.setattr(guards, "MusicDatabase", lambda: _FakeDB(profile))
# Key-aware config stub: the import quality filter is ON (its default), so
# the guard runs; everything else (downsample, etc.) is OFF. A blanket False
# would wrongly disable the filter itself via import.quality_filter_enabled.
def _cfg_get(key, default=None):
if key == "import.quality_filter_enabled":
return True
return False
monkeypatch.setattr(
guards,
"MusicDatabase",
lambda: _FakeDB({"qualities": {"flac": {"bit_depth": "16", "bit_depth_fallback": False}}}),
)
monkeypatch.setattr(
guards,
"_get_config_manager",
lambda: SimpleNamespace(get=lambda _key, default=None: False),
)
context = {"_audio_quality": "FLAC 24bit", "track_info": {"name": "Song One"}}
assert guards.check_flac_bit_depth("/tmp/Song One.flac", context) == (
"FLAC bit depth mismatch: file is 24-bit, preference is 16-bit"
guards, "_get_config_manager",
lambda: SimpleNamespace(get=_cfg_get),
)
def test_check_flac_bit_depth_allows_fallback_when_enabled(monkeypatch):
monkeypatch.setattr(
guards,
"MusicDatabase",
lambda: _FakeDB({"qualities": {"flac": {"bit_depth": "16", "bit_depth_fallback": True}}}),
)
monkeypatch.setattr(
guards,
"_get_config_manager",
lambda: SimpleNamespace(get=lambda _key, default=None: False),
)
def test_check_flac_bit_depth_rejects_below_target(monkeypatch):
# 16-bit file, target wants 24-bit, fallback off → rejected.
_patch(monkeypatch, AudioQuality("flac", sample_rate=44100, bit_depth=16), _WANT_FLAC24)
reason = guards.check_flac_bit_depth("/tmp/Song One.flac", {})
assert reason is not None
assert "FLAC 24-bit/96kHz" in reason
context = {"_audio_quality": "FLAC 24bit", "track_info": {"name": "Song One"}}
assert guards.check_flac_bit_depth("/tmp/Song One.flac", context) is None
def test_check_flac_bit_depth_accepts_when_meeting_target(monkeypatch):
# 24-bit/96k file meets the 24-bit target → accepted.
_patch(monkeypatch, AudioQuality("flac", sample_rate=96000, bit_depth=24), _WANT_FLAC24)
assert guards.check_flac_bit_depth("/tmp/Song One.flac", {}) is None

View file

@ -235,6 +235,48 @@ def test_post_process_matched_download_forwards_separate_metadata_runtime(tmp_pa
# write to the task directly — it stashes on context and the wrapper applies it)
# ---------------------------------------------------------------------------
def test_quality_gate_runs_before_acoustid(tmp_path, monkeypatch):
"""The quality check must run BEFORE AcoustID: a wrong-quality file is
quarantined with trigger='quality' and AcoustID is never fingerprinted (so
quality is known on every quarantine entry, and no wasted AcoustID call)."""
src = tmp_path / "source.flac"
src.write_bytes(b"fLaC")
# Reach the quality gate: bypass integrity + silence guards.
from core.imports.file_integrity import IntegrityResult
monkeypatch.setattr(import_pipeline, "check_audio_integrity",
lambda *_a, **_kw: IntegrityResult(ok=True, checks={}))
monkeypatch.setattr(import_pipeline, "detect_broken_audio", lambda *_a, **_kw: None)
# Wrong quality → rejection.
monkeypatch.setattr(import_pipeline, "get_audio_quality_string", lambda fp: "FLAC 16bit/44.1kHz")
monkeypatch.setattr(import_pipeline, "check_quality_target", lambda fp, ctx: "Quality mismatch: FLAC 16bit")
triggers = []
monkeypatch.setattr(import_pipeline, "move_to_quarantine",
lambda fp, ctx, reason, eng, trigger=None: triggers.append(trigger) or "/q/x.flac.quarantined")
monkeypatch.setattr(import_pipeline, "_mark_task_quarantined", lambda *a, **k: None)
monkeypatch.setattr(import_pipeline, "_requeue_quarantined_task_for_retry", lambda *a, **k: False)
# Spy: AcoustID must NOT be constructed when quality already rejected.
acoustid_constructed = []
fake_mod = types.SimpleNamespace(
AcoustIDVerification=lambda *a, **k: acoustid_constructed.append(True),
VerificationResult=types.SimpleNamespace(FAIL="fail"),
)
monkeypatch.setitem(sys.modules, "core.acoustid_verification", fake_mod)
runtime = types.SimpleNamespace(automation_engine=None, on_download_completed=None,
web_scan_manager=None, repair_worker=None)
context = {"track_info": {}, "task_id": None, "batch_id": None}
import_pipeline.post_process_matched_download("ctx", context, str(src), runtime)
assert triggers == ["quality"] # quarantined for quality
assert acoustid_constructed == [] # AcoustID never ran
assert context.get("_audio_quality") == "FLAC 16bit/44.1kHz" # recorded for the sidecar
def test_mark_task_quarantined_stashes_entry_id_when_task_id_absent():
ctx = {} # wrapper popped task_id before the inner pipeline ran
import_pipeline._mark_task_quarantined(ctx, "/q/20260514_120000_song.flac.quarantined")

View file

@ -48,9 +48,11 @@ def test_acoustid_quarantine_without_message_still_flags():
def test_bitdepth_rejection_detected():
# The _bitdepth_rejected flag now signals any quality-target rejection
# (bit depth, sample rate, format, bitrate) — the unified quality guard.
reason = import_rejection_reason({'_bitdepth_rejected': True})
assert reason is not None
assert 'bit-depth' in reason.lower()
assert 'quality filter' in reason.lower()
def test_race_guard_failure_detected():

View file

@ -0,0 +1,127 @@
"""Quality guard + quarantine isolation.
Locks two invariants the global-quality work depends on:
1. ``check_quality_target`` rejects with a 'what the file IS vs what was
WANTED' reason (surfaced in the track-detail modal), and accepts when a
target is met or fallback is on.
2. A quality mismatch is isolated from the AcoustID/force_import path: it
uses ``trigger='quality'`` and the ``'quality'`` bypass flag, which must
NOT bypass the AcoustID check and vice-versa. force_imported stays
reserved for AcoustID version-mismatch acceptance.
"""
import json
import types
import pytest
import core.imports.guards as guards
import core.imports.file_ops as file_ops
from core.imports.pipeline import _should_skip_quarantine_check
from core.quality.model import AudioQuality
class _FakeDB:
def __init__(self, profile):
self._p = profile
def get_quality_profile(self):
return self._p
def _patch_guard(monkeypatch, probe_aq, profile, downsample=False, quality_filter=True):
monkeypatch.setattr(file_ops, 'probe_audio_quality', lambda fp: probe_aq)
monkeypatch.setattr(guards, 'MusicDatabase', lambda: _FakeDB(profile))
def _cfg_get(k, d=None):
if 'downsample' in k:
return downsample
if k == 'import.quality_filter_enabled':
return quality_filter
return d
monkeypatch.setattr(
guards, '_get_config_manager',
lambda: types.SimpleNamespace(get=_cfg_get),
)
_WANT_FLAC24 = {
'fallback_enabled': False,
'ranked_targets': [
{'label': 'FLAC 24-bit/96kHz', 'format': 'flac', 'bit_depth': 24, 'min_sample_rate': 96000},
],
}
_WANT_FLAC24_FALLBACK = {**_WANT_FLAC24, 'fallback_enabled': True}
# ── check_quality_target ───────────────────────────────────────────────────
def test_rejects_with_wanted_vs_got_reason(monkeypatch):
_patch_guard(monkeypatch, AudioQuality('flac', sample_rate=44100, bit_depth=16), _WANT_FLAC24)
reason = guards.check_quality_target('/x/song.flac', {})
assert reason is not None
assert 'FLAC 16-bit' in reason # what the file IS
assert 'FLAC 24-bit/96kHz' in reason # what was WANTED
def test_accepts_when_target_met(monkeypatch):
_patch_guard(monkeypatch, AudioQuality('flac', sample_rate=96000, bit_depth=24), _WANT_FLAC24)
assert guards.check_quality_target('/x/song.flac', {}) is None
def test_accepts_via_fallback(monkeypatch):
_patch_guard(monkeypatch, AudioQuality('flac', sample_rate=44100, bit_depth=16), _WANT_FLAC24_FALLBACK)
assert guards.check_quality_target('/x/song.flac', {}) is None
def test_master_toggle_off_skips_filter(monkeypatch):
# import.quality_filter_enabled = False → a below-target file is accepted
# (imported) regardless of quality, even with fallback off.
_patch_guard(
monkeypatch, AudioQuality('flac', sample_rate=44100, bit_depth=16),
_WANT_FLAC24, quality_filter=False,
)
assert guards.check_quality_target('/x/song.flac', {}) is None
def test_skips_when_unprobeable(monkeypatch):
_patch_guard(monkeypatch, None, _WANT_FLAC24)
assert guards.check_quality_target('/x/song.flac', {}) is None
# ── force_import isolation ─────────────────────────────────────────────────
def test_quality_bypass_does_not_skip_acoustid():
ctx = {'_skip_quarantine_check': 'quality'}
assert _should_skip_quarantine_check(ctx, 'quality') is True
assert _should_skip_quarantine_check(ctx, 'acoustid') is False
def test_acoustid_bypass_does_not_skip_quality():
ctx = {'_skip_quarantine_check': 'acoustid'}
assert _should_skip_quarantine_check(ctx, 'acoustid') is True
assert _should_skip_quarantine_check(ctx, 'quality') is False
def test_quality_quarantine_persists_quality_trigger(monkeypatch, tmp_path):
# A quality reject writes trigger='quality' (not 'acoustid') into the
# sidecar, so Approve never routes it through the force_import path.
monkeypatch.setattr(
guards, '_get_config_manager',
lambda: types.SimpleNamespace(get=lambda k, d=None: str(tmp_path) if 'download_path' in k else d),
)
src = tmp_path / 'song.flac'
src.write_bytes(b'FLACfake')
qpath = guards.move_to_quarantine(
str(src), {}, 'Quality mismatch: file is FLAC 16-bit, wanted FLAC 24-bit/96kHz',
automation_engine=None, trigger='quality',
)
sidecars = list((tmp_path / 'ss_quarantine').glob('*.json'))
assert len(sidecars) == 1
meta = json.loads(sidecars[0].read_text(encoding='utf-8'))
assert meta['trigger'] == 'quality'
assert meta['trigger'] != 'acoustid'
assert 'wanted FLAC 24-bit/96kHz' in meta['quarantine_reason']
assert qpath.endswith('.quarantined')

View file

@ -470,9 +470,14 @@ def test_group_key_prefers_isrc_over_everything():
assert quarantine_group_key("Artist", "Track", ctx) == "isrc:usrc12345678"
def test_group_key_falls_back_to_source_id_then_uri():
assert quarantine_group_key("A", "T", {"track_info": {"id": "abc123"}}) == "id:abc123"
assert quarantine_group_key("A", "T", {"track_info": {"uri": "spotify:track:z"}}) == "uri:spotify:track:z"
def test_group_key_uses_isrc_and_ignores_source_specific_ids():
# ISRC is the universal target identity → wins.
assert quarantine_group_key("A", "T", {"track_info": {"isrc": "USABC1234567"}}) == "isrc:usabc1234567"
# Source-specific ids / uris are intentionally NOT used (they differ across
# sources/batches and break cross-batch sibling matching) — with no ISRC the
# key falls back to the normalized artist|track name, NOT the id/uri.
assert quarantine_group_key("A", "T", {"track_info": {"id": "abc123"}}) == "nm:a|t"
assert quarantine_group_key("A", "T", {"track_info": {"uri": "spotify:track:z"}}) == "nm:a|t"
def test_group_key_falls_back_to_normalized_name_without_context():

View file

@ -0,0 +1,104 @@
"""Audio-completeness guard — catches files whose container duration is
correct but whose real audio is far shorter (HiFi/Monochrome truncated files:
container claims 3:08 but only ~30s actually decodes) or mostly silence. Pure
parsers are tested here; the ffmpeg call is integration.
"""
import pytest
from core.imports.silence import (
silence_ratio_from_output,
is_mostly_silent_reason,
measured_duration_from_astats,
incomplete_audio_reason,
)
_ONE_LONG_TAIL = """
Input #0, flac, from 'song.flac':
[silencedetect @ 0x55] silence_start: 31.512
[silencedetect @ 0x55] silence_end: 210.300 | silence_duration: 178.788
"""
_TWO_GAPS = """
[silencedetect @ 0x55] silence_start: 0
[silencedetect @ 0x55] silence_end: 1.5 | silence_duration: 1.5
[silencedetect @ 0x55] silence_start: 200
[silencedetect @ 0x55] silence_end: 203 | silence_duration: 3.0
"""
_NO_SILENCE = "Input #0, flac\n[some other ffmpeg chatter]\n"
def test_ratio_single_long_trailing_silence():
r = silence_ratio_from_output(_ONE_LONG_TAIL, total_duration_s=210.3)
assert r == pytest.approx(178.788 / 210.3, rel=1e-3)
assert r > 0.8
def test_ratio_sums_multiple_silences():
r = silence_ratio_from_output(_TWO_GAPS, total_duration_s=210.0)
assert r == pytest.approx(4.5 / 210.0, rel=1e-3)
def test_ratio_no_silence_is_zero():
assert silence_ratio_from_output(_NO_SILENCE, total_duration_s=210.0) == 0.0
def test_ratio_zero_duration_is_zero():
assert silence_ratio_from_output(_ONE_LONG_TAIL, total_duration_s=0) == 0.0
def test_ratio_capped_at_one():
# Defensive: bogus silence longer than the track can't exceed 1.0.
out = "[silencedetect @ 0x] silence_end: 999 | silence_duration: 999\n"
assert silence_ratio_from_output(out, total_duration_s=210.0) == 1.0
def test_reason_when_mostly_silent():
reason = is_mostly_silent_reason(_ONE_LONG_TAIL, total_duration_s=210.3, threshold=0.5)
assert reason is not None
assert "silent" in reason.lower()
def test_no_reason_for_normal_song():
assert is_mostly_silent_reason(_TWO_GAPS, total_duration_s=210.0, threshold=0.5) is None
# ── truncation: real decoded duration vs container duration ────────────────
_ASTATS_TRUNCATED = """
[Parsed_astats_0 @ 0x55] Number of samples: 1318912
[Parsed_astats_0 @ 0x55] Number of NaNs: 0
"""
_ASTATS_FULL = "[Parsed_astats_0 @ 0x55] Number of samples: 9261000\n"
def test_measured_duration_from_samples():
# 1318912 samples / 44100 Hz ≈ 29.9s (the real Blossom file)
assert measured_duration_from_astats(_ASTATS_TRUNCATED, 44100) == pytest.approx(29.9, abs=0.1)
def test_measured_duration_none_without_samples():
assert measured_duration_from_astats("no stats here", 44100) is None
def test_measured_duration_none_without_sample_rate():
assert measured_duration_from_astats(_ASTATS_TRUNCATED, 0) is None
def test_incomplete_reason_for_truncated_file():
# 30s of real audio in a 188s container → truncated.
reason = incomplete_audio_reason(29.9, 188.4, min_ratio=0.85)
assert reason is not None
assert "30s" in reason and "188s" in reason
def test_no_incomplete_reason_for_full_file():
assert incomplete_audio_reason(187.5, 188.4, min_ratio=0.85) is None
def test_no_incomplete_reason_when_unmeasurable():
assert incomplete_audio_reason(None, 188.4, min_ratio=0.85) is None
assert incomplete_audio_reason(30.0, 0, min_ratio=0.85) is None

View file

@ -74,6 +74,25 @@ def test_finds_file_via_transfer_folder_suffix_walk(tmp_path: Path) -> None:
assert result == str(actual)
def test_finds_file_via_relative_db_path_full_suffix(tmp_path: Path) -> None:
"""SoulSync's own library scanner stores RELATIVE paths with NO leading
slash, e.g. "Artist/Album/track.flac". Index 0 is the artist folder, so the
resolver must try the FULL path first the old range(1, ...) dropped the
artist segment and every such track came back unresolved (quality scanner:
"could not be probed"). Regression guard for that fix."""
transfer = tmp_path / "Transfer"
(transfer / "Asketa" / "Another Side").mkdir(parents=True)
actual = transfer / "Asketa" / "Another Side" / "01 - Another Side.flac"
actual.write_bytes(b"audio")
result = resolve_library_file_path(
"Asketa/Another Side/01 - Another Side.flac",
transfer_folder=str(transfer),
)
assert result == str(actual)
def test_finds_file_via_download_folder_when_transfer_misses(tmp_path: Path) -> None:
transfer = tmp_path / "Transfer"
transfer.mkdir()

View file

@ -0,0 +1,127 @@
"""Engine ``search_with_fallback`` is the PRIORITY-mode search path and is
deliberately quality-agnostic: the first source in the chain that returns any
tracks wins (source order is king), byte-for-byte like the pre-quality-system
hybrid loop (#896 review #3). It skips unconfigured/unavailable sources,
swallows per-source exceptions, and returns RAW tracks (match-filtering +
final quality ranking happen later). Cross-source quality pooling lives in
best_quality mode (``search_all_sources``), not here.
"""
import asyncio
import pytest
from core.download_engine.engine import DownloadEngine
from core.quality.model import AudioQuality
class _Cand:
def __init__(self, aq, name):
self.audio_quality = aq
self.name = name
class _FakePlugin:
def __init__(self, tracks, configured=True, raises=False):
self._tracks = tracks
self._configured = configured
self._raises = raises
self.searched = False
def is_configured(self):
return self._configured
async def search(self, query, timeout=None, progress_callback=None):
self.searched = True
if self._raises:
raise RuntimeError("boom")
return (self._tracks, [])
FLAC = AudioQuality('flac', sample_rate=44100, bit_depth=16)
MP3 = AudioQuality('mp3', bitrate=320)
MP3_NO_BITRATE = AudioQuality('mp3') # slskd often omits bitrate
def _engine_with(plugins):
eng = object.__new__(DownloadEngine)
eng._plugins = plugins
return eng
def test_returns_first_source_with_tracks_source_priority_king():
first = _FakePlugin([_Cand(MP3, 'a-mp3')])
second = _FakePlugin([_Cand(FLAC, 'b-flac')])
eng = _engine_with({'first': first, 'second': second})
tracks, _ = asyncio.run(eng.search_with_fallback('q', ['first', 'second']))
assert [t.name for t in tracks] == ['a-mp3'] # first source wins regardless of quality
assert second.searched is False # never queried — priority is king
def test_metadata_less_mp3_still_wins_in_priority_mode():
"""An mp3 whose bitrate slskd omitted must NOT be deprioritised in priority
mode the whole point of #896 review #3."""
first = _FakePlugin([_Cand(MP3_NO_BITRATE, 'a-mp3')])
second = _FakePlugin([_Cand(FLAC, 'b-flac')])
eng = _engine_with({'first': first, 'second': second})
tracks, _ = asyncio.run(eng.search_with_fallback('q', ['first', 'second']))
assert [t.name for t in tracks] == ['a-mp3']
assert second.searched is False
def test_skips_to_next_source_when_first_empty():
first = _FakePlugin([])
second = _FakePlugin([_Cand(FLAC, 'b-flac')])
eng = _engine_with({'first': first, 'second': second})
tracks, _ = asyncio.run(eng.search_with_fallback('q', ['first', 'second']))
assert [t.name for t in tracks] == ['b-flac']
assert second.searched is True
def test_returns_raw_tracks_not_pruned():
# All of the winning source's tracks come back so the orchestrator's match
# filter can pick the correct one.
first = _FakePlugin([_Cand(MP3, 'one'), _Cand(FLAC, 'two')])
eng = _engine_with({'first': first})
tracks, _ = asyncio.run(eng.search_with_fallback('q', ['first']))
assert {t.name for t in tracks} == {'one', 'two'}
def test_skips_unconfigured_source():
first = _FakePlugin([_Cand(FLAC, 'a')], configured=False)
second = _FakePlugin([_Cand(MP3, 'b')])
eng = _engine_with({'first': first, 'second': second})
tracks, _ = asyncio.run(eng.search_with_fallback('q', ['first', 'second']))
assert [t.name for t in tracks] == ['b']
assert first.searched is False
def test_swallows_per_source_exception_and_continues():
first = _FakePlugin([], raises=True)
second = _FakePlugin([_Cand(MP3, 'b')])
eng = _engine_with({'first': first, 'second': second})
tracks, _ = asyncio.run(eng.search_with_fallback('q', ['first', 'second']))
assert [t.name for t in tracks] == ['b']
def test_returns_empty_when_all_sources_empty():
first = _FakePlugin([])
second = _FakePlugin([])
eng = _engine_with({'first': first, 'second': second})
tracks, albums = asyncio.run(eng.search_with_fallback('q', ['first', 'second']))
assert tracks == []
assert albums == []

130
tests/quality/test_model.py Normal file
View file

@ -0,0 +1,130 @@
"""AudioQuality.matches_target + v2->v3 migration.
Locks the bitrate-as-threshold behaviour: lossy formats match on a MINIMUM
bitrate (>=, a range), and lossless matches on bit depth + sample rate NOT
on exact bitrate, so a FLAC's wildly-varying bitrate (stereo vs mono, FLAC
compression) never falsely rejects it.
"""
import pytest
from core.quality.model import (
AudioQuality,
QualityTarget,
v2_qualities_to_ranked_targets,
)
# ── lossy: bitrate is a minimum threshold (a range), never exact ───────────
def test_mp3_meets_minimum_bitrate():
t = QualityTarget(format='mp3', min_bitrate=320)
assert AudioQuality('mp3', bitrate=320).matches_target(t) is True
assert AudioQuality('mp3', bitrate=400).matches_target(t) is True # above floor ok
def test_mp3_below_minimum_bitrate_rejected():
t = QualityTarget(format='mp3', min_bitrate=320)
assert AudioQuality('mp3', bitrate=300).matches_target(t) is False
def test_mp3_matches_lower_threshold():
t = QualityTarget(format='mp3', min_bitrate=192)
assert AudioQuality('mp3', bitrate=320).matches_target(t) is True
# ── lossless: matched on bit depth + sample rate, NOT exact bitrate ────────
def test_flac_matches_on_depth_and_rate_regardless_of_bitrate():
t = QualityTarget(format='flac', bit_depth=24, min_sample_rate=96000)
# An unusual/low bitrate (e.g. a mono or highly-compressed FLAC) must
# still match when bit depth + sample rate satisfy the target.
weird = AudioQuality('flac', bitrate=300, sample_rate=96000, bit_depth=24)
assert weird.matches_target(t) is True
def test_flac_below_target_sample_rate_rejected():
t = QualityTarget(format='flac', bit_depth=24, min_sample_rate=96000)
assert AudioQuality('flac', sample_rate=44100, bit_depth=24).matches_target(t) is False
def test_flac_below_target_bit_depth_rejected():
t = QualityTarget(format='flac', bit_depth=24)
assert AudioQuality('flac', sample_rate=96000, bit_depth=16).matches_target(t) is False
def test_format_mismatch_rejected():
t = QualityTarget(format='flac', bit_depth=16)
assert AudioQuality('mp3', bitrate=320).matches_target(t) is False
# ── metadata-less FLAC must not over-claim a hi-res target (#896 review #4) ─
def test_metadata_less_flac_does_not_overclaim_hires_target():
"""A FLAC with no sample_rate/bit_depth metadata (common on slskd) must NOT
satisfy a strict hi-res target otherwise it outranks and discards a real
16/44 FLAC. Unknown spec fails the high tier, falling to a plain flac bucket."""
hires = QualityTarget(format='flac', bit_depth=24, min_sample_rate=192000)
assert AudioQuality('flac').matches_target(hires) is False
def test_metadata_less_flac_does_not_overclaim_bit_depth_only_hires():
"""Same guard when the hi-res target constrains only bit depth."""
hires = QualityTarget(format='flac', bit_depth=24)
assert AudioQuality('flac').matches_target(hires) is False
def test_metadata_less_flac_matches_plain_flac_target():
"""A bare FLAC still matches a plain 'flac (any)' target (the baseline)."""
assert AudioQuality('flac').matches_target(QualityTarget(format='flac')) is True
def test_metadata_less_flac_matches_16bit_baseline_target():
"""A bare FLAC satisfies the 16-bit baseline (any FLAC is >= CD quality)."""
assert AudioQuality('flac').matches_target(QualityTarget(format='flac', bit_depth=16)) is True
# ── ranked targets work for EVERY format, not just flac/mp3 (universal) ────
def test_opus_target_matches_opus_candidate():
t = QualityTarget(format='opus', min_bitrate=128)
assert AudioQuality('opus', bitrate=160).matches_target(t) is True
assert AudioQuality('opus', bitrate=96).matches_target(t) is False
assert AudioQuality('mp3', bitrate=320).matches_target(t) is False # wrong format
def test_wav_target_matches_on_bit_depth_and_sample_rate():
t = QualityTarget(format='wav', bit_depth=24, min_sample_rate=96000)
assert AudioQuality('wav', sample_rate=96000, bit_depth=24).matches_target(t) is True
assert AudioQuality('wav', sample_rate=44100, bit_depth=16).matches_target(t) is False
def test_wma_and_alac_targets_match_their_formats():
assert AudioQuality('wma', bitrate=192).matches_target(QualityTarget(format='wma', min_bitrate=128)) is True
assert AudioQuality('alac', sample_rate=96000, bit_depth=24).matches_target(
QualityTarget(format='alac', bit_depth=24)) is True
def test_only_listed_format_passes_others_rank_last():
"""An Opus-only target list: only opus matches; everything else ranks last
(index == len(targets)), so with fallback off the caller drops them."""
from core.quality.model import rank_candidate
targets = [QualityTarget(format='opus')]
assert rank_candidate(AudioQuality('opus', bitrate=160), targets)[0] == 0
assert rank_candidate(AudioQuality('flac', sample_rate=96000, bit_depth=24), targets)[0] == 1
assert rank_candidate(AudioQuality('mp3', bitrate=320), targets)[0] == 1
# ── v2 -> v3 migration preserves the user's priority order ─────────────────
def test_v2_to_v3_preserves_order_and_maps_fields():
qualities = {
'flac': {'enabled': True, 'priority': 1, 'bit_depth': '24'},
'mp3_320': {'enabled': True, 'priority': 2},
'mp3_192': {'enabled': False, 'priority': 3}, # disabled → dropped
}
targets = v2_qualities_to_ranked_targets(qualities)
formats = [t['format'] for t in targets]
assert formats == ['flac', 'mp3'] # disabled mp3_192 omitted
assert targets[0]['bit_depth'] == 24
assert targets[1]['min_bitrate'] == 320

View file

@ -0,0 +1,60 @@
"""quality_meets_profile / targets_from_profile — the shared strict
quality-decision core used by BOTH the import quality guard and the library
quality scanner. A file "meets" the profile iff its real measured quality
satisfies at least one ranked target (bit depth + sample rate are minimums;
fallback is NOT consulted that's a download-time concession, not a definition
of quality).
"""
from core.quality.model import AudioQuality, QualityTarget
from core.quality.selection import quality_meets_profile, targets_from_profile
FLAC_HI = AudioQuality('flac', sample_rate=96000, bit_depth=24)
FLAC_CD = AudioQuality('flac', sample_rate=44100, bit_depth=16)
MP3 = AudioQuality('mp3', bitrate=320)
WANT_24BIT = [
QualityTarget(label='FLAC 24/96', format='flac', bit_depth=24, min_sample_rate=96000),
QualityTarget(label='FLAC 24/44.1', format='flac', bit_depth=24, min_sample_rate=44100),
]
def test_24bit_meets_24bit_target():
assert quality_meets_profile(FLAC_HI, WANT_24BIT) is True
def test_16bit_does_not_meet_24bit_target():
assert quality_meets_profile(FLAC_CD, WANT_24BIT) is False
def test_mp3_does_not_meet_flac_target():
assert quality_meets_profile(MP3, WANT_24BIT) is False
def test_no_targets_means_no_constraint():
assert quality_meets_profile(FLAC_CD, []) is True
def test_unprobeable_file_is_not_flagged():
# aq=None (probe failed) → don't act (avoid false re-downloads).
assert quality_meets_profile(None, WANT_24BIT) is True
def test_targets_from_profile_reads_v3_ranked_targets():
profile = {
'version': 3,
'fallback_enabled': False,
'ranked_targets': [
{'label': 'FLAC 24/96', 'format': 'flac', 'bit_depth': 24, 'min_sample_rate': 96000},
],
}
targets, fallback = targets_from_profile(profile)
assert [t.label for t in targets] == ['FLAC 24/96']
assert fallback is False
def test_targets_from_profile_migrates_v2_qualities():
profile = {'qualities': {'flac': {'enabled': True, 'priority': 1}}}
targets, _ = targets_from_profile(profile)
assert any(t.format == 'flac' for t in targets)

View file

@ -0,0 +1,81 @@
"""Quality presets — the built-in ranked-target ladders behind the preset
buttons. `audiophile` must be STRICT hi-res (no 16-bit, no lossy, fallback off)
so a user who wants "24-bit only" gets it in one click; `balanced` keeps the
fuller ladder (16-bit + MP3) with fallback on.
"""
from database.music_database import MusicDatabase
def _preset(name):
# _factory_quality_preset is pure (no self/DB) — call unbound to avoid setup.
return MusicDatabase._factory_quality_preset(None, name)
def _labels(profile):
return [t['label'] for t in profile['ranked_targets']]
def test_audiophile_is_strict_24bit_only():
p = _preset('audiophile')
assert p['fallback_enabled'] is False
labels = _labels(p)
assert all('24-bit' in l for l in labels) # only 24-bit FLAC
assert 'FLAC 16-bit' not in labels
assert not any('MP3' in l for l in labels)
def test_balanced_still_includes_16bit_and_mp3():
p = _preset('balanced')
labels = _labels(p)
assert 'FLAC 16-bit' in labels
assert any('MP3' in l for l in labels)
assert p['fallback_enabled'] is True
# ── v2 → v3 migration seeds Hi-Res from the old per-source dropdowns (#896 #5) ──
class _FakeCfg:
def __init__(self, values):
self._v = values
def get(self, key, default=None):
return self._v.get(key, default)
_V2_LOSSLESS = {
'version': 2, 'preset': 'balanced',
'qualities': {
'flac': {'enabled': True, 'priority': 1, 'bit_depth': 'any'},
'mp3_320': {'enabled': True, 'priority': 2},
},
}
def _migrate(profile, cfg_values, monkeypatch):
monkeypatch.setattr('config.settings.config_manager', _FakeCfg(cfg_values), raising=False)
db = MusicDatabase.__new__(MusicDatabase)
return db._migrate_v2_to_v3(profile)
def test_v2_to_v3_seeds_hires_when_a_source_was_hires(monkeypatch):
"""A user who had Tidal on Hi-Res keeps it: the migrated profile gains 24-bit
targets at the top so quality_tier_for_source resolves to 'hires', not a
silent drop to lossless."""
out = _migrate(dict(_V2_LOSSLESS), {'tidal_download.quality': 'hires'}, monkeypatch)
top = out['ranked_targets'][0]
assert top['format'] == 'flac' and top['bit_depth'] == 24
# the user's existing lossy fallback is preserved below the seeded ladder
assert any(t.get('format') == 'mp3' for t in out['ranked_targets'])
def test_v2_to_v3_no_seed_without_hires_preference(monkeypatch):
out = _migrate(dict(_V2_LOSSLESS), {'tidal_download.quality': 'lossless'}, monkeypatch)
assert not any(t.get('bit_depth') == 24 for t in out['ranked_targets'])
def test_v2_to_v3_no_duplicate_when_profile_already_24bit(monkeypatch):
v2 = dict(_V2_LOSSLESS)
v2['qualities'] = {'flac': {'enabled': True, 'priority': 1, 'bit_depth': '24'}}
out = _migrate(v2, {'qobuz.quality': 'hires_max'}, monkeypatch)
assert sum(1 for t in out['ranked_targets'] if t.get('bit_depth') == 24) == 1

View file

@ -0,0 +1,113 @@
"""Engine.search_all_sources pools candidates from EVERY configured source
(best-quality mode), instead of stopping at the first satisfying one like
search_with_fallback. Ranking happens later in the orchestrator this just
combines raw tracks. Excluded/exhausted and raising sources are skipped.
"""
import asyncio
class _Cand:
def __init__(self, name):
self.name = name
class _FakePlugin:
def __init__(self, tracks, configured=True, raises=False):
self._tracks = tracks
self._configured = configured
self._raises = raises
self.searched = False
def is_configured(self):
return self._configured
async def search(self, query, timeout=None, progress_callback=None):
self.searched = True
if self._raises:
raise RuntimeError("boom")
return (list(self._tracks), [])
def _engine_with(plugins):
from core.download_engine.engine import DownloadEngine
eng = object.__new__(DownloadEngine)
eng._plugins = plugins
return eng
def test_pools_candidates_from_all_sources():
a = _FakePlugin([_Cand('a1'), _Cand('a2')])
b = _FakePlugin([_Cand('b1')])
eng = _engine_with({'a': a, 'b': b})
tracks, albums = asyncio.run(eng.search_all_sources('q', ['a', 'b']))
assert {t.name for t in tracks} == {'a1', 'a2', 'b1'}
assert a.searched and b.searched
assert albums == []
def test_skips_excluded_sources():
a = _FakePlugin([_Cand('a1')])
b = _FakePlugin([_Cand('b1')])
eng = _engine_with({'a': a, 'b': b})
tracks, _ = asyncio.run(
eng.search_all_sources('q', ['a', 'b'], exclude_sources=['b'])
)
assert {t.name for t in tracks} == {'a1'}
assert b.searched is False
def test_skips_unconfigured_and_swallows_raising_source():
a = _FakePlugin([_Cand('a1')], configured=False)
b = _FakePlugin([_Cand('b1')], raises=True)
c = _FakePlugin([_Cand('c1')])
eng = _engine_with({'a': a, 'b': b, 'c': c})
tracks, _ = asyncio.run(eng.search_all_sources('q', ['a', 'b', 'c']))
assert {t.name for t in tracks} == {'c1'} # a skipped, b raised, c survives
def test_searches_run_concurrently_and_wait_for_slow_source():
import time
class _SlowPlugin:
def __init__(self, name, delay):
self._name = name
self._delay = delay
self.searched = False
def is_configured(self):
return True
async def search(self, query, timeout=None, progress_callback=None):
self.searched = True
await asyncio.sleep(self._delay)
return ([_Cand(self._name)], [])
slow = _SlowPlugin('usenet', 0.20) # a slow release-level source
fast = _SlowPlugin('hifi', 0.05)
eng = _engine_with({'usenet': slow, 'hifi': fast})
start = time.monotonic()
tracks, _ = asyncio.run(eng.search_all_sources('q', ['usenet', 'hifi']))
elapsed = time.monotonic() - start
# Both included — the pool waits for the slow source.
assert {t.name for t in tracks} == {'usenet', 'hifi'}
# Concurrent: total ≈ max(0.20, 0.05), well under the sequential sum 0.25.
assert elapsed < 0.20 + 0.04
def test_empty_when_all_sources_empty():
a = _FakePlugin([])
eng = _engine_with({'a': a})
tracks, albums = asyncio.run(eng.search_all_sources('q', ['a']))
assert tracks == []
assert albums == []

View file

@ -0,0 +1,64 @@
"""core.quality.selection.load_search_mode — reads the download search
strategy from the quality profile.
'priority' today's behaviour: first satisfying source wins.
'best_quality' pool all sources, work bestworst by actual quality.
Default and any unknown value resolve to 'priority' so every existing
install keeps its current behaviour.
"""
import core.quality.selection as selection
import database.music_database as music_database
def _patch_profile(monkeypatch, profile):
class _FakeDB:
def get_quality_profile(self):
return profile
monkeypatch.setattr(music_database, "MusicDatabase", _FakeDB)
def test_defaults_to_priority_when_key_absent(monkeypatch):
_patch_profile(monkeypatch, {"version": 3, "ranked_targets": []})
assert selection.load_search_mode() == "priority"
def test_returns_best_quality_when_set(monkeypatch):
_patch_profile(monkeypatch, {"version": 3, "search_mode": "best_quality"})
assert selection.load_search_mode() == "best_quality"
def test_unknown_value_falls_back_to_priority(monkeypatch):
_patch_profile(monkeypatch, {"version": 3, "search_mode": "nonsense"})
assert selection.load_search_mode() == "priority"
# ── rank_candidates_by_quality toggle ───────────────────────────────────────
# Opt-in: order the priority-mode download walk by ranked-target quality
# instead of confidence-first. Default OFF = byte-for-byte old behaviour.
def test_rank_by_quality_defaults_false_when_key_absent(monkeypatch):
_patch_profile(monkeypatch, {"version": 3, "ranked_targets": []})
assert selection.load_rank_candidates_by_quality() is False
def test_rank_by_quality_true_when_enabled(monkeypatch):
_patch_profile(monkeypatch, {"version": 3, "rank_candidates_by_quality": True})
assert selection.load_rank_candidates_by_quality() is True
def test_rank_by_quality_false_when_disabled(monkeypatch):
_patch_profile(monkeypatch, {"version": 3, "rank_candidates_by_quality": False})
assert selection.load_rank_candidates_by_quality() is False
def test_rank_by_quality_false_on_db_error(monkeypatch):
class _BoomDB:
def get_quality_profile(self):
raise RuntimeError("db down")
monkeypatch.setattr(music_database, "MusicDatabase", _BoomDB)
assert selection.load_rank_candidates_by_quality() is False

View file

@ -0,0 +1,64 @@
"""core.quality.selection — quality-aware ranking + the satisfied flag that
drives the engine's source fall-through.
A source is "satisfied" when at least one of its candidates meets a real
target (strict, fallback off). The engine uses that to decide whether to
stop on the current source or escalate to the next.
"""
import pytest
from core.quality.model import AudioQuality, QualityTarget
from core.quality.selection import rank_with_targets
class _Cand:
"""Minimal candidate: filter_and_rank only needs ``.audio_quality``."""
def __init__(self, aq, name=""):
self.audio_quality = aq
self.name = name
def __repr__(self):
return f"_Cand({self.name})"
FLAC_HIRES = AudioQuality('flac', sample_rate=96000, bit_depth=24)
FLAC_CD = AudioQuality('flac', sample_rate=44100, bit_depth=16)
MP3_320 = AudioQuality('mp3', bitrate=320)
WANT_HIRES = [QualityTarget(label='FLAC 24', format='flac', bit_depth=24, min_sample_rate=96000)]
WANT_FLAC_ONLY = [QualityTarget(label='FLAC 16', format='flac', bit_depth=16)]
def test_satisfied_when_a_candidate_meets_a_target():
cands = [_Cand(MP3_320, 'mp3'), _Cand(FLAC_HIRES, 'hires')]
ranked, satisfied = rank_with_targets(cands, WANT_HIRES, fallback_enabled=True)
assert satisfied is True
assert ranked[0].name == 'hires' # the matching candidate wins
def test_unsatisfied_but_fallback_returns_sorted_when_enabled():
cands = [_Cand(MP3_320, 'mp3')]
ranked, satisfied = rank_with_targets(cands, WANT_FLAC_ONLY, fallback_enabled=True)
assert satisfied is False # no FLAC → no target met
assert [c.name for c in ranked] == ['mp3'] # but fallback keeps it
def test_unsatisfied_and_fallback_off_returns_empty():
cands = [_Cand(MP3_320, 'mp3')]
ranked, satisfied = rank_with_targets(cands, WANT_FLAC_ONLY, fallback_enabled=False)
assert satisfied is False
assert ranked == []
def test_empty_targets_accepts_everything_satisfied():
cands = [_Cand(MP3_320, 'mp3'), _Cand(FLAC_CD, 'cd')]
ranked, satisfied = rank_with_targets(cands, [], fallback_enabled=True)
assert satisfied is True # no constraint → first source wins
assert ranked[0].name == 'cd' # still quality-sorted
def test_no_candidates_is_unsatisfied():
ranked, satisfied = rank_with_targets([], WANT_FLAC_ONLY, fallback_enabled=True)
assert satisfied is False
assert ranked == []

View file

@ -0,0 +1,147 @@
"""Tests for core.quality.source_map — each download source's tier/string
mapped into a unified AudioQuality.
These mappers express each source's *claimed* capability tier. The values
are later verified post-download by the quality guard, so over-claiming is
the failure mode to avoid: an unknown tier must NOT pretend to be lossless.
"""
import pytest
from core.quality.source_map import (
quality_from_tidal_tier,
quality_from_qobuz,
quality_from_deezer,
quality_from_amazon,
format_from_extension,
AUDIO_EXTENSIONS,
)
# ── Shared extension → format (used by every extension-based source) ────────
@pytest.mark.parametrize("ext,fmt", [
("flac", "flac"), (".flac", "flac"),
("mp3", "mp3"),
("m4a", "aac"), ("aac", "aac"), ("mp4", "aac"),
("ogg", "ogg"), ("oga", "ogg"),
("opus", "opus"),
("wav", "wav"), ("wave", "wav"),
("aiff", "wav"), ("aif", "wav"), # PCM → wav tier
("wma", "wma"),
("alac", "alac"),
("xyz", "unknown"), ("", "unknown"), (None, "unknown"),
])
def test_format_from_extension(ext, fmt):
assert format_from_extension(ext) == fmt
def test_audio_extensions_cover_all_known_formats():
for e in (".flac", ".mp3", ".m4a", ".ogg", ".opus", ".wav", ".aiff", ".wma"):
assert e in AUDIO_EXTENSIONS
# ── Tidal / HiFi (Tidal-backed) ────────────────────────────────────────────
@pytest.mark.parametrize("tier", ["HI_RES_LOSSLESS", "HI_RES", "hi_res", "hires", "HIRES"])
def test_tidal_hires_is_flac_24_96(tier):
aq = quality_from_tidal_tier(tier)
assert aq.format == "flac"
assert aq.bit_depth == 24
assert aq.sample_rate == 96000
@pytest.mark.parametrize("tier", ["LOSSLESS", "lossless"])
def test_tidal_lossless_is_flac_16_44(tier):
aq = quality_from_tidal_tier(tier)
assert aq.format == "flac"
assert aq.bit_depth == 16
assert aq.sample_rate == 44100
def test_tidal_high_is_lossy_aac_320():
aq = quality_from_tidal_tier("HIGH")
assert aq.format == "aac"
assert aq.bitrate == 320
assert aq.bit_depth is None # lossy: no bit depth
def test_tidal_low_is_lossy_aac_96():
aq = quality_from_tidal_tier("LOW")
assert aq.format == "aac"
assert aq.bitrate == 96
def test_tidal_unknown_tier_does_not_overclaim():
# An unrecognised tier must not masquerade as lossless.
aq = quality_from_tidal_tier("SOMETHING_NEW")
assert aq.format == "unknown"
assert aq.bit_depth is None
assert aq.sample_rate is None
# ── Qobuz (real API values) ────────────────────────────────────────────────
def test_qobuz_hires_khz_to_hz():
aq = quality_from_qobuz(96.0, 24)
assert aq.format == "flac"
assert aq.sample_rate == 96000
assert aq.bit_depth == 24
def test_qobuz_cd_quality_fractional_khz():
aq = quality_from_qobuz(44.1, 16)
assert aq.format == "flac"
assert aq.sample_rate == 44100
assert aq.bit_depth == 16
def test_qobuz_192k():
aq = quality_from_qobuz(192.0, 24)
assert aq.sample_rate == 192000
assert aq.bit_depth == 24
# ── Deezer (config code) ───────────────────────────────────────────────────
def test_deezer_flac_is_16_44():
aq = quality_from_deezer("flac")
assert aq.format == "flac"
assert aq.bit_depth == 16
assert aq.sample_rate == 44100
def test_deezer_mp3_320():
aq = quality_from_deezer("mp3_320")
assert aq.format == "mp3"
assert aq.bitrate == 320
assert aq.bit_depth is None
def test_deezer_mp3_128():
aq = quality_from_deezer("mp3_128")
assert aq.format == "mp3"
assert aq.bitrate == 128
# ── Amazon (real sampleRate preferred, tier fallback) ──────────────────────
def test_amazon_prefers_real_sample_rate():
aq = quality_from_amazon("HD", sample_rate=88200, bit_depth=24)
assert aq.format == "flac"
assert aq.sample_rate == 88200
assert aq.bit_depth == 24
def test_amazon_hd_tier_fallback():
aq = quality_from_amazon("HD")
assert aq.format == "flac"
assert aq.sample_rate == 44100
assert aq.bit_depth == 16
def test_amazon_uhd_tier_fallback():
aq = quality_from_amazon("UHD")
assert aq.format == "flac"
assert aq.bit_depth == 24
assert aq.sample_rate == 96000

View file

@ -0,0 +1,62 @@
"""quality_tier_for_source — derive a source's requested download tier from
the GLOBAL quality profile instead of a per-source setting.
Rule: pick the LOWEST source tier that satisfies the user's top (most
preferred) target respecting the user's quality ceiling and saving
bandwidth or the source's max tier when none can satisfy it (best effort).
"""
import pytest
import core.quality.source_map as sm
from core.quality.model import QualityTarget
def _patch_targets(monkeypatch, targets, fallback=True):
monkeypatch.setattr(sm, 'load_profile_targets', lambda: (targets, fallback))
T_FLAC24_96 = [QualityTarget(label='', format='flac', bit_depth=24, min_sample_rate=96000)]
T_FLAC24_192 = [QualityTarget(label='', format='flac', bit_depth=24, min_sample_rate=192000)]
T_FLAC16 = [QualityTarget(label='', format='flac', bit_depth=16)]
T_MP3_320 = [QualityTarget(label='', format='mp3', min_bitrate=320)]
def test_tidal_hires_when_top_wants_24_96(monkeypatch):
_patch_targets(monkeypatch, T_FLAC24_96)
assert sm.quality_tier_for_source('tidal') == 'hires'
def test_tidal_lossless_respects_16bit_ceiling(monkeypatch):
# User caps at 16-bit → request lossless, NOT hires (saves bandwidth).
_patch_targets(monkeypatch, T_FLAC16)
assert sm.quality_tier_for_source('tidal') == 'lossless'
def test_tidal_best_effort_max_when_unsatisfiable(monkeypatch):
# Source maxes at 24/96 but user wants 24/192 → best effort = max tier.
_patch_targets(monkeypatch, T_FLAC24_192)
assert sm.quality_tier_for_source('tidal') == 'hires'
def test_no_targets_requests_max(monkeypatch):
_patch_targets(monkeypatch, [])
assert sm.quality_tier_for_source('tidal') == 'hires'
assert sm.quality_tier_for_source('deezer') == 'flac'
def test_deezer_flac_and_mp3(monkeypatch):
_patch_targets(monkeypatch, T_FLAC16)
assert sm.quality_tier_for_source('deezer') == 'flac'
_patch_targets(monkeypatch, T_MP3_320)
assert sm.quality_tier_for_source('deezer') == 'mp3_320'
def test_qobuz_hires_max(monkeypatch):
_patch_targets(monkeypatch, T_FLAC24_192)
assert sm.quality_tier_for_source('qobuz') == 'hires_max'
def test_unknown_source_returns_default(monkeypatch):
_patch_targets(monkeypatch, T_FLAC16)
assert sm.quality_tier_for_source('nope', default='x') == 'x'

View file

@ -0,0 +1,39 @@
"""TrackResult.set_quality() — merge a mapped AudioQuality onto a result so
its derived ``audio_quality`` reflects the source's real/claimed tier.
"""
from core.download_plugins.types import TrackResult
from core.quality.model import AudioQuality
def _tr(**kw):
base = dict(
username='x', filename='f', size=0, bitrate=None, duration=None,
quality='', free_upload_slots=0, upload_speed=0, queue_length=0,
)
base.update(kw)
return TrackResult(**base)
def test_set_quality_populates_lossless_fields():
tr = _tr(quality='flac', bitrate=1411)
tr.set_quality(AudioQuality('flac', sample_rate=96000, bit_depth=24))
assert tr.quality == 'flac'
assert tr.sample_rate == 96000
assert tr.bit_depth == 24
# derived descriptor must reflect the merge
assert tr.audio_quality.sample_rate == 96000
assert tr.audio_quality.bit_depth == 24
def test_set_quality_keeps_existing_bitrate_when_mapper_has_none():
tr = _tr(quality='flac', bitrate=950)
tr.set_quality(AudioQuality('flac', sample_rate=44100, bit_depth=16))
assert tr.bitrate == 950 # mapper bitrate is None → preserve probed/reported
def test_set_quality_overwrites_bitrate_for_lossy():
tr = _tr(quality='mp3', bitrate=128)
tr.set_quality(AudioQuality('mp3', bitrate=320))
assert tr.bitrate == 320
assert tr.bit_depth is None

View file

@ -38,6 +38,25 @@ NOTHING_ENABLED = {'qualities': {'flac': {'enabled': False}, 'mp3_320': {'enable
# --- pure quality decision -------------------------------------------------
#
# The old extension-only classifier (meets_preferred_quality / classify_track_
# quality / preferred_quality_floor / RANK_*) was deleted in favour of the
# shared v3 path: probe → AudioQuality → quality_meets_profile against the
# profile's ranked targets. These pin the same behavioural contract through the
# new API. (Bps→kbps normalisation now lives in probe_audio_quality and is
# covered by its own tests; the deleted-internals tests for it were removed.)
def meets(path, bitrate, profile):
"""Does a file of this format+bitrate satisfy the profile? Mirrors the
scanner's decision: build the measured AudioQuality and check it against the
v3 ranked targets derived from the profile (empty targets nothing flagged)."""
from core.quality.model import AudioQuality
from core.quality.selection import quality_meets_profile, targets_from_profile
ext = path.rsplit('.', 1)[-1].lower()
targets, _ = targets_from_profile(profile)
return quality_meets_profile(AudioQuality(format=ext, bitrate=bitrate), targets)
def test_balanced_profile_accepts_320_mp3_REGRESSION():
"""The headline bug: with FLAC+320+256 enabled, a 320 kbps MP3 is acceptable.
@ -69,30 +88,6 @@ def test_nothing_enabled_flags_nothing():
assert meets('song.mp3', 64, NOTHING_ENABLED) is True
def test_bitrate_in_bps_is_normalized():
"""Library bitrate stored as bps (320000) classifies the same as 320 kbps."""
assert qu.classify_track_quality('song.mp3', 320000) == qu.RANK_320
assert meets('song.mp3', 320000, BALANCED) is True
def test_unknown_lossy_bitrate_not_flagged_under_lossy_floor():
"""A lossy file with no bitrate can't be judged against a lossy floor → don't
flag (avoid false positives); but under a lossless floor it's clearly below."""
assert meets('song.mp3', None, BALANCED) is True
assert meets('song.mp3', None, LOSSLESS_ONLY) is False
def test_floor_is_worst_enabled_not_best():
# FLAC+320+256 enabled → floor is MP3-256 (rank 2), not FLAC.
assert qu.preferred_quality_floor(BALANCED) == qu.RANK_256
assert qu.preferred_quality_floor(LOSSLESS_ONLY) == qu.RANK_LOSSLESS
assert qu.preferred_quality_floor(NOTHING_ENABLED) is None
def meets(path, bitrate, profile):
return qu.meets_preferred_quality(path, bitrate, profile)
# --- scan produces a finding (seam) ----------------------------------------
class _FakeConn:
@ -149,7 +144,25 @@ def _row(track_id=1, title='Song One', path='/music/a.mp3', bitrate=128, duratio
return (track_id, title, path, bitrate, duration, artist, album, album_id, track_number)
def _stub_quality(monkeypatch, *, meets: bool):
"""Stub the v3 quality path so scan() works on fake (non-existent) file paths.
The job now probes the REAL file (mutagen) and checks it against the v3
ranked targets. Tests use fake paths, so we resolve the path to itself,
return a dummy measured quality, and force the meets/below verdict.
"""
from core.quality.model import AudioQuality, QualityTarget
monkeypatch.setattr(qu, 'targets_from_profile',
lambda profile: ([QualityTarget(label='MP3 320', format='mp3', min_bitrate=320)], False))
monkeypatch.setattr(qu, 'resolve_library_file_path', lambda p, **kw: p)
monkeypatch.setattr(qu, 'probe_audio_quality',
lambda p: AudioQuality(format='mp3', bitrate=128))
monkeypatch.setattr(qu, 'quality_meets_profile', lambda aq, targets: meets)
def _stub_engine(monkeypatch):
# Below-profile by default — the finding-creating tests want a flagged track.
_stub_quality(monkeypatch, meets=False)
monkeypatch.setattr(qu, 'get_primary_source', lambda: 'spotify')
monkeypatch.setattr(qu, 'get_source_priority', lambda src: ['spotify'])
monkeypatch.setattr(
@ -168,7 +181,7 @@ def test_scan_creates_finding_for_low_quality_track(monkeypatch):
fake_match = {'id': 'sp1', 'name': 'Song One', 'artists': ['Artist A'],
'album': {'name': 'Album X', 'images': []}}
# No track-id / ISRC / album hit → exercise the search tier.
monkeypatch.setattr(qu, '_read_file_ids', lambda fp: {})
monkeypatch.setattr(qu, '_read_file_ids', lambda fp, **kw: {})
monkeypatch.setattr(qu, '_match_via_track_id', lambda *a, **k: (None, None))
monkeypatch.setattr(qu, '_match_via_album', lambda *a, **k: (None, None))
monkeypatch.setattr(qu, '_find_best_match',
@ -212,7 +225,7 @@ def test_scan_prefers_track_id_tier(monkeypatch):
"""The source's own track ID (from file tags) wins over every other tier."""
db = _FakeDB([_row()], BALANCED)
_stub_engine(monkeypatch)
monkeypatch.setattr(qu, '_read_file_ids', lambda fp: {'spotify_track_id': 'sp9', 'isrc': 'X'})
monkeypatch.setattr(qu, '_read_file_ids', lambda fp, **kw: {'spotify_track_id': 'sp9', 'isrc': 'X'})
fake = {'id': 'sp9', 'name': 'Song One', 'album': {'name': 'Album X'}}
monkeypatch.setattr(qu, '_match_via_track_id', lambda ids, sp: (fake, 'spotify'))
monkeypatch.setattr(qu, '_normalize_track_match', lambda t, s: dict(fake))
@ -275,7 +288,7 @@ def test_scan_prefers_isrc_exact_match_over_fuzzy(monkeypatch):
and do NOT run the album/search tiers."""
db = _FakeDB([_row()], BALANCED)
_stub_engine(monkeypatch)
monkeypatch.setattr(qu, '_read_file_ids', lambda fp: {'isrc': 'USRC17607839'})
monkeypatch.setattr(qu, '_read_file_ids', lambda fp, **kw: {'isrc': 'USRC17607839'})
monkeypatch.setattr(qu, '_match_via_track_id', lambda *a, **k: (None, None))
fake = {'id': 'sp1', 'name': 'Song One', 'artists': ['Artist A'], 'album': {'name': 'Album X'}}
monkeypatch.setattr(qu, '_match_via_isrc', lambda isrc, sp: (fake, 'spotify'))
@ -297,7 +310,7 @@ def test_scan_falls_back_to_search_without_ids(monkeypatch):
"""No track-ID / ISRC / album hit → fall back to fuzzy search."""
db = _FakeDB([_row()], BALANCED)
_stub_engine(monkeypatch)
monkeypatch.setattr(qu, '_read_file_ids', lambda fp: {}) # un-enriched
monkeypatch.setattr(qu, '_read_file_ids', lambda fp, **kw: {}) # un-enriched
monkeypatch.setattr(qu, '_match_via_track_id', lambda *a, **k: (None, None))
monkeypatch.setattr(qu, '_match_via_album', lambda *a, **k: (None, None))
fake = {'id': 'sp1', 'name': 'Song One', 'artists': ['Artist A'], 'album': {'name': 'Album X'}}
@ -316,7 +329,7 @@ def test_scan_uses_album_tier_when_no_ids(monkeypatch):
'album', and the fuzzy search is never reached."""
db = _FakeDB([_row()], BALANCED)
_stub_engine(monkeypatch)
monkeypatch.setattr(qu, '_read_file_ids', lambda fp: {})
monkeypatch.setattr(qu, '_read_file_ids', lambda fp, **kw: {})
monkeypatch.setattr(qu, '_match_via_track_id', lambda *a, **k: (None, None))
fake = {'id': 'sp1', 'name': 'Song One', 'artists': ['Artist A'], 'album': {'name': 'Album X'}}
monkeypatch.setattr(qu, '_match_via_album', lambda *a, **k: (fake, 'spotify'))
@ -346,8 +359,9 @@ def test_find_track_in_album_exact_title_with_track_number(monkeypatch):
def test_scan_skips_tracks_meeting_quality(monkeypatch):
# A 320 kbps MP3 meets the balanced profile → no finding, no metadata calls.
# A track that meets the profile → no finding, no metadata calls.
db = _FakeDB([_row(track_id=2, title='Good Song', bitrate=320)], BALANCED)
_stub_quality(monkeypatch, meets=True)
def _boom(*a, **k): # must never be called for an acceptable track
raise AssertionError("matching should not run for an acceptable track")

View file

@ -0,0 +1,70 @@
"""HiFi/Monochrome preview detection.
Some Monochrome instances only have 30-second Tidal preview access for
usage=DOWNLOAD: the HLS variant playlist for a 220s track contains only ~30s
of segments (with #EXT-X-ENDLIST). Detect that at manifest time so HiFi
declines and the orchestrator falls through to a real source instead of
downloading the 30s file and quarantining it after the fact.
"""
import sys
import types
import pytest
# hifi_client imports config/db at module load; stub the heavy bits if needed.
from core.hifi_client import hls_total_seconds, is_preview_playlist
_PREVIEW_PLAYLIST = """#EXTM3U
#EXT-X-VERSION:6
#EXT-X-PLAYLIST-TYPE:VOD
#EXT-X-TARGETDURATION:4
#EXTINF:3.994,
seg1.mp4
#EXTINF:3.994,
seg2.mp4
#EXTINF:3.994,
seg3.mp4
#EXTINF:3.994,
seg4.mp4
#EXTINF:3.994,
seg5.mp4
#EXTINF:3.994,
seg6.mp4
#EXTINF:3.994,
seg7.mp4
#EXTINF:1.9,
seg8.mp4
#EXT-X-ENDLIST
"""
def test_total_seconds_sums_extinf():
assert hls_total_seconds(_PREVIEW_PLAYLIST) == pytest.approx(29.86, abs=0.1)
def test_total_seconds_empty():
assert hls_total_seconds("") == 0.0
assert hls_total_seconds("#EXTM3U\nno segments\n") == 0.0
def test_preview_when_playlist_far_shorter_than_track():
# 30s playlist for a 220s track → preview.
assert is_preview_playlist(playlist_s=29.9, track_s=220) is True
def test_not_preview_when_playlist_matches_track():
assert is_preview_playlist(playlist_s=218.0, track_s=220) is False
def test_not_preview_when_track_duration_unknown():
# No reference → can't decide; don't false-positive (post-download guard
# is the safety net).
assert is_preview_playlist(playlist_s=29.9, track_s=0) is False
assert is_preview_playlist(playlist_s=0, track_s=220) is False
def test_short_real_track_not_flagged():
# A genuinely ~30s track whose playlist is ~30s is not a preview.
assert is_preview_playlist(playlist_s=29.0, track_s=31) is False

View file

@ -85,10 +85,13 @@ def _bare_client(tmp_path):
def test_download_sync_skips_preview_manifests_and_never_downloads(tmp_path, monkeypatch):
monkeypatch.setattr(hc, 'config_manager', _Cfg())
# Pin the starting tier so the chain is deterministic and independent of the
# global quality profile (which now drives quality_tier_for_source).
monkeypatch.setattr(hc, 'quality_tier_for_source', lambda *a, **k: 'lossless')
c = _bare_client(tmp_path)
c.get_track_info = lambda tid: {'duration_s': 215} # real track length
tiers = []
c._get_hls_manifest = lambda tid, quality='lossless': (
c._get_hls_manifest = lambda tid, quality='lossless', **kw: (
tiers.append(quality) or
{'segment_uris': ['seg'], 'init_uri': None, 'extension': 'flac',
'manifest_duration': 30.0}) # preview at EVERY tier
@ -105,7 +108,7 @@ def test_download_sync_proceeds_past_the_gate_for_a_full_manifest(tmp_path, monk
monkeypatch.setattr(hc, 'config_manager', _Cfg())
c = _bare_client(tmp_path)
c.get_track_info = lambda tid: {'duration_s': 215}
c._get_hls_manifest = lambda tid, quality='lossless': {
c._get_hls_manifest = lambda tid, quality='lossless', **kw: {
'segment_uris': ['seg'], 'init_uri': None, 'extension': 'flac',
'manifest_duration': 215.0} # full length → must NOT skip
seg_calls = []
@ -124,10 +127,11 @@ def test_download_sync_aborts_on_a_faked_full_length_file_no_tier_cascade(tmp_pa
# decodes to 30s. It must abort HiFi (return None) on the first tier — NOT drop to the
# lossy 'high' tier (the same 30s preview, which dodges the bitrate check).
monkeypatch.setattr(hc, 'config_manager', _Cfg())
monkeypatch.setattr(hc, 'quality_tier_for_source', lambda *a, **k: 'lossless')
c = _bare_client(tmp_path)
c.get_track_info = lambda tid: {'duration_s': 215}
tiers = []
c._get_hls_manifest = lambda tid, quality='lossless': (
c._get_hls_manifest = lambda tid, quality='lossless', **kw: (
tiers.append(quality) or
{'segment_uris': ['seg'], 'init_uri': None, 'extension': 'flac', 'manifest_duration': 215.0})
c._download_segment_with_retry = lambda url: b'\x00' * 200_000 # > MIN_AUDIO_SIZE
@ -144,7 +148,7 @@ def test_download_sync_does_not_reject_when_track_length_unknown(tmp_path, monke
monkeypatch.setattr(hc, 'config_manager', _Cfg())
c = _bare_client(tmp_path)
c.get_track_info = lambda tid: {'duration_s': 0} # expected unknown
c._get_hls_manifest = lambda tid, quality='lossless': {
c._get_hls_manifest = lambda tid, quality='lossless', **kw: {
'segment_uris': ['seg'], 'init_uri': None, 'extension': 'flac',
'manifest_duration': 30.0} # short, but expected is unknown
seg_calls = []

View file

@ -144,6 +144,60 @@ def test_race_guard_failure_marker_marks_task_failed(_isolate_state):
assert ('b2', 't2', False) in completion_calls
def test_quality_quarantine_does_not_mark_completed(_isolate_state):
"""When the inner pipeline quality-quarantines the file (``_bitdepth_rejected``),
the wrapper must NOT fall through to "assume success" and mark the task
Completed that made the quarantined file appear in BOTH Completed and
Quarantine. The inner pipeline already owns the retry/fail for quality, so
the wrapper just returns."""
completion_calls = []
runtime = _build_runtime(completion_calls)
_seed_task('t5', 'b5')
context = {'task_id': 't5', 'batch_id': 'b5', 'context_key': 'test::ctx5'}
def _inner(*a, **kw):
# Inner pipeline quarantined for quality and handled its own retry/fail;
# it sets the marker and leaves NO _final_processed_path.
context['_bitdepth_rejected'] = True
with patch.object(import_pipeline, 'post_process_matched_download', _inner), \
patch.object(import_pipeline, '_mark_task_completed',
lambda task_id, ti: runtime_state.download_tasks[task_id].update(
{'status': 'completed'})):
import_pipeline.post_process_matched_download_with_verification(
'test::ctx5', context, '/fake/source.mp3', 't5', 'b5', runtime,
)
assert runtime_state.download_tasks['t5']['status'] != 'completed'
assert ('b5', 't5', True) not in completion_calls
def test_silence_quarantine_does_not_mark_completed(_isolate_state):
"""Same contract for the audio guard (``_silence_rejected``)."""
completion_calls = []
runtime = _build_runtime(completion_calls)
_seed_task('t6', 'b6')
context = {'task_id': 't6', 'batch_id': 'b6', 'context_key': 'test::ctx6'}
def _inner(*a, **kw):
context['_silence_rejected'] = True
with patch.object(import_pipeline, 'post_process_matched_download', _inner), \
patch.object(import_pipeline, '_mark_task_completed',
lambda task_id, ti: runtime_state.download_tasks[task_id].update(
{'status': 'completed'})):
import_pipeline.post_process_matched_download_with_verification(
'test::ctx6', context, '/fake/source.flac', 't6', 'b6', runtime,
)
assert runtime_state.download_tasks['t6']['status'] != 'completed'
assert ('b6', 't6', True) not in completion_calls
def test_no_failure_markers_still_assumes_success(_isolate_state):
"""The pre-existing "assume success" fallback must STILL fire when
no failure markers are set some legitimate flows complete without

View file

@ -0,0 +1,68 @@
"""Multi-disc fix (#927): the media-server scan must store the real disc number.
Every track was stored with disc_number=1 because the Jellyfin/Plex/Navidrome scan never
read the disc field so multi-disc albums collapsed onto disc 1, mis-filing disc-2+ tracks
and flagging them "missing". insert_or_update_media_track now reads the disc number off the
track object (Jellyfin/Navidrome `.discNumber`, Plex `.parentIndex`), floored to >=1.
"""
from __future__ import annotations
from types import SimpleNamespace
from database.music_database import MusicDatabase
def _db(tmp_path):
db = MusicDatabase(database_path=str(tmp_path / "t.db"))
with db._get_connection() as c:
c.execute("INSERT OR IGNORE INTO artists (id, name) VALUES ('art1', 'Evan Call')")
c.execute("INSERT OR IGNORE INTO albums (id, artist_id, title) VALUES ('alb1', 'art1', 'Frieren OST')")
c.commit()
return db
def _track(rating_key, title, track_number, **kw):
return SimpleNamespace(ratingKey=rating_key, title=title, trackNumber=track_number,
duration=200000, **kw)
def _disc_of(db, track_id):
with db._get_connection() as c:
row = c.execute("SELECT disc_number, track_number FROM tracks WHERE id = ?", (track_id,)).fetchone()
return (row['disc_number'], row['track_number'])
def test_jellyfin_navidrome_disc_number_stored(tmp_path):
db = _db(tmp_path)
# .discNumber is what the Jellyfin (ParentIndexNumber) + Navidrome (discNumber) wrappers set.
db.insert_or_update_media_track(_track('t-d2', 'Waltz for Stark and Fern', 34, discNumber=2), 'alb1', 'art1', 'jellyfin')
db.insert_or_update_media_track(_track('t-d1', 'The Magic Within', 32, discNumber=1), 'alb1', 'art1', 'jellyfin')
assert _disc_of(db, 't-d2') == (2, 34)
assert _disc_of(db, 't-d1') == (1, 32)
def test_plex_parent_index_used_as_disc(tmp_path):
db = _db(tmp_path)
# plexapi Track has no .discNumber — disc comes from .parentIndex.
db.insert_or_update_media_track(_track('t-plex', 'Disc 2 Track', 5, parentIndex=2), 'alb1', 'art1', 'plex')
assert _disc_of(db, 't-plex') == (2, 5)
def test_missing_or_bad_disc_floors_to_one(tmp_path):
db = _db(tmp_path)
db.insert_or_update_media_track(_track('t-none', 'No Disc', 1), 'alb1', 'art1', 'jellyfin') # no disc attr
db.insert_or_update_media_track(_track('t-zero', 'Zero Disc', 2, discNumber=0), 'alb1', 'art1', 'jellyfin')
db.insert_or_update_media_track(_track('t-str', 'Junk Disc', 3, discNumber='x'), 'alb1', 'art1', 'jellyfin')
assert _disc_of(db, 't-none')[0] == 1
assert _disc_of(db, 't-zero')[0] == 1
assert _disc_of(db, 't-str')[0] == 1
def test_update_path_backfills_disc_on_rescan(tmp_path):
db = _db(tmp_path)
# First scan (old behavior simulated): no disc -> 1. Re-scan with the real disc -> updated.
db.insert_or_update_media_track(_track('t-x', 'Track', 7), 'alb1', 'art1', 'jellyfin')
assert _disc_of(db, 't-x') == (1, 7)
db.insert_or_update_media_track(_track('t-x', 'Track', 7, discNumber=3), 'alb1', 'art1', 'jellyfin')
assert _disc_of(db, 't-x') == (3, 7)

View file

@ -429,14 +429,15 @@ def test_popularity_thresholds_spotify_returns_60_40():
assert hidden_max == 40
def test_popularity_thresholds_deezer_returns_higher_scale():
"""Deezer writes `rank` (raw integer, often 100k+) into the popularity
column, so thresholds must be in that range Spotify's 60/40 would
classify almost every Deezer track as a hidden gem."""
def test_popularity_thresholds_deezer_on_0_100_scale():
"""Corrected: the discovery pool SYNTHESIZES deezer popularity onto a 0-100 score (capped
at 100) at scan time it is NOT Deezer's raw rank. The old 500000/100000 rank thresholds
matched nothing, so Popular Picks came back empty for deezer-primary users while Hidden
Gems' < 100000 caught the whole pool. Thresholds must stay on the 0-100 scale."""
svc = PersonalizedPlaylistsService(_FakeDatabase())
popular_min, hidden_max = svc._get_popularity_thresholds('deezer')
assert popular_min is not None and popular_min >= 100_000
assert hidden_max is not None and hidden_max >= 50_000
assert (popular_min, hidden_max) == (60, 50)
assert 0 < popular_min <= 100 and 0 < hidden_max <= 100
assert popular_min > hidden_max

View file

@ -0,0 +1,65 @@
from datetime import datetime, timedelta
from web_server import (
_format_playlist_sync_status,
_resolve_spotify_playlist_sync_status,
)
class _StubDatabase:
def __init__(self, mirrored):
self._mirrored = mirrored
def get_mirrored_playlist_by_source(self, source, source_playlist_id, profile_id):
assert source == 'spotify'
assert source_playlist_id == 'spotify-playlist-1'
assert profile_id == 7
return self._mirrored
def _status(minutes_ago=0, **overrides):
timestamp = (datetime(2026, 6, 25, 12, 0, 0) - timedelta(minutes=minutes_ago)).isoformat()
return {'last_synced': timestamp, **overrides}
def test_resolve_spotify_playlist_sync_status_uses_mirrored_auto_sync_status():
sync_statuses = {
'auto_mirror_42': _status(matched_tracks=12),
}
status = _resolve_spotify_playlist_sync_status(
'spotify-playlist-1',
sync_statuses,
database=_StubDatabase({'id': 42}),
profile_id=7,
)
assert status['matched_tracks'] == 12
def test_resolve_spotify_playlist_sync_status_prefers_newest_status():
sync_statuses = {
'spotify-playlist-1': _status(minutes_ago=20, matched_tracks=1),
'auto_mirror_42': _status(minutes_ago=5, matched_tracks=2),
}
status = _resolve_spotify_playlist_sync_status(
'spotify-playlist-1',
sync_statuses,
database=_StubDatabase({'id': 42}),
profile_id=7,
)
assert status['matched_tracks'] == 2
def test_format_playlist_sync_status_treats_missing_snapshot_as_synced():
status = _status()
assert _format_playlist_sync_status(status, 'current-snapshot') == 'Synced: Jun 25, 12:00'
def test_format_playlist_sync_status_marks_snapshot_mismatch_as_last_sync():
status = _status(snapshot_id='old-snapshot')
assert _format_playlist_sync_status(status, 'current-snapshot') == 'Last Sync: Jun 25, 12:00'

View file

@ -0,0 +1,37 @@
"""Popular Picks was empty for deezer users — wrong popularity-threshold scale.
The discovery pool synthesizes deezer popularity onto a 0-100 score (base 45 + bonuses, capped
at 100), but _get_popularity_thresholds had deezer on the raw-rank scale (500000/100000). So
`popularity >= 500000` matched nothing (Popular Picks empty) while `< 100000` matched the whole
pool (Hidden Gems caught everything). Thresholds must stay on the 0-100 scale.
"""
from __future__ import annotations
from core.personalized_playlists import PersonalizedPlaylistsService
def _svc():
return PersonalizedPlaylistsService(database=None)
def test_deezer_thresholds_are_on_the_0_100_scale():
popular_min, hidden_max = _svc()._get_popularity_thresholds('deezer')
assert (popular_min, hidden_max) == (60, 50) # was (500000, 100000) — unreachable
assert 0 < popular_min <= 100 and 0 < hidden_max <= 100
assert popular_min > hidden_max # popular tier sits above the hidden tier
def test_spotify_thresholds_unchanged():
assert _svc()._get_popularity_thresholds('spotify') == (60, 40)
def test_case_insensitive():
assert _svc()._get_popularity_thresholds('Deezer') == (60, 50)
assert _svc()._get_popularity_thresholds('SPOTIFY') == (60, 40)
def test_sources_without_popularity_skip_the_filter():
assert _svc()._get_popularity_thresholds('itunes') == (None, None)
assert _svc()._get_popularity_thresholds('musicbrainz') == (None, None)
assert _svc()._get_popularity_thresholds('') == (None, None)

View file

@ -30,3 +30,34 @@ def test_mark_task_completed_succeeds_when_lock_held():
finally:
runtime_state.download_tasks.clear()
runtime_state.download_tasks.update(original_tasks)
@pytest.fixture
def _isolated_tasks():
original_tasks = dict(runtime_state.download_tasks)
runtime_state.download_tasks.clear()
yield runtime_state.download_tasks
runtime_state.download_tasks.clear()
runtime_state.download_tasks.update(original_tasks)
@pytest.mark.parametrize("start_status", ["downloading", "queued"])
def test_claim_for_post_processing_wins_from_active_status(_isolated_tasks, start_status):
_isolated_tasks["t1"] = {"status": start_status}
assert runtime_state.claim_for_post_processing("t1") is True
assert _isolated_tasks["t1"]["status"] == "post_processing"
@pytest.mark.parametrize("start_status", ["post_processing", "searching", "completed", "failed", "cancelled"])
def test_claim_for_post_processing_loses_when_already_owned(_isolated_tasks, start_status):
"""A task already being post-processed by the monitor (post_processing),
requeued by a quarantine retry (searching), or already terminal must NOT be
re-claimed that is the double-processing race that produced the bogus
'missing file or source information' failure."""
_isolated_tasks["t1"] = {"status": start_status}
assert runtime_state.claim_for_post_processing("t1") is False
assert _isolated_tasks["t1"]["status"] == start_status # untouched
def test_claim_for_post_processing_missing_task_returns_false(_isolated_tasks):
assert runtime_state.claim_for_post_processing("absent") is False

View file

@ -846,3 +846,19 @@ def test_live_download_a_known_public_track(tmp_dl: Path) -> None:
assert final_path is not None
assert os.path.exists(final_path)
assert os.path.getsize(final_path) > 100 * 1024
# ── construction robustness: an uncreatable download path must not crash init ──
# Regression: the download path is read from config (often a Docker /app path). If
# mkdir fails (unmounted/misconfigured volume, or running outside the container),
# the client must warn and continue — NOT raise. An unguarded mkdir made the
# registry null the whole client, dropping SoundCloud as a source entirely (and
# failing every orchestrator-soundcloud test outside Docker).
def test_init_survives_uncreatable_download_path(tmp_path):
from core.soundcloud_client import SoundcloudClient
blocker = tmp_path / "iam_a_file"
blocker.write_text("x")
bad_path = str(blocker / "subdir") # mkdir under a file -> NotADirectoryError (OSError)
client = SoundcloudClient(download_path=bad_path) # must not raise
assert client is not None
assert str(client.download_path) == bad_path

View file

@ -1171,7 +1171,7 @@ def test_curate_discovery_playlists_uses_source_priority_for_recent_albums(monke
"avg_daily_plays": 0.0,
"artist_play_counts": {},
})
monkeypatch.setattr(scanner.database, "get_discovery_recent_albums", lambda limit, source, profile_id: [recent_album] if source == "deezer" else [], raising=False)
monkeypatch.setattr(scanner.database, "get_discovery_recent_albums", lambda limit, source, profile_id, exclude_future_years=False: [recent_album] if source == "deezer" else [], raising=False)
monkeypatch.setattr(scanner.database, "get_discovery_pool_tracks", lambda *args, **kwargs: [discovery_track] if kwargs.get("source") == "deezer" else [], raising=False)
monkeypatch.setattr(scanner.database, "save_curated_playlist", lambda key, tracks, profile_id=1: saved_playlists.append((key, list(tracks))) or True, raising=False)
monkeypatch.setattr(scanner.database, "get_top_artists", lambda *args, **kwargs: [], raising=False)

View file

@ -0,0 +1,83 @@
"""Wing It Pool query — surfaces tracks Wing It auto-matched (best-effort guesses).
Wing-it tracks are persisted as the ``wing_it_fallback: true`` flag on a mirrored track's
extra_data and count as 'discovered', so the Discovery Pool's failed list excludes them. The
Wing It Pool is the only surface that lists them. It must: include unverified wing-it tracks,
exclude ones the user already manually matched, scope by playlist + profile, and never include
plain matched/failed tracks.
"""
from __future__ import annotations
import json
from database.music_database import MusicDatabase
def _playlist(db, name, profile_id=1, source_id='pl1'):
with db._get_connection() as conn:
cur = conn.execute(
"INSERT INTO mirrored_playlists (source, source_playlist_id, name, profile_id) VALUES (?,?,?,?)",
('spotify', source_id, name, profile_id))
conn.commit()
return cur.lastrowid
def _track(db, playlist_id, pos, name, artist, extra):
with db._get_connection() as conn:
conn.execute(
"INSERT INTO mirrored_playlist_tracks (playlist_id, position, track_name, artist_name, extra_data) "
"VALUES (?,?,?,?,?)",
(playlist_id, pos, name, artist, json.dumps(extra) if extra is not None else None))
conn.commit()
WING_IT = {'discovered': True, 'provider': 'wing_it_fallback', 'confidence': 0, 'wing_it_fallback': True}
# A resolved wing-it track: /fix MERGES extra_data, so wing_it_fallback survives alongside the
# new manual_match flag — that pairing is what marks it resolved (no separate marker needed).
WING_IT_RESOLVED = {'discovered': True, 'provider': 'spotify', 'confidence': 1.0,
'wing_it_fallback': True, 'manual_match': True,
'matched_data': {'name': 'Dopamine (Real)'}}
MATCHED = {'discovered': True, 'provider': 'spotify', 'confidence': 0.95}
FAILED = {'discovery_attempted': True, 'discovered': False}
def test_lists_only_unverified_wing_it_tracks(tmp_path):
db = MusicDatabase(database_path=str(tmp_path / "w.db"))
pid = _playlist(db, 'Liked Songs')
_track(db, pid, 0, 'Orbital Trans', 'Yoga Mao', WING_IT) # unverified -> attention
_track(db, pid, 1, 'Dopamine', 'Rvdical the Kid', WING_IT_RESOLVED) # resolved -> matched list
_track(db, pid, 2, 'Real Match', 'Some Artist', MATCHED) # normal match -> neither
_track(db, pid, 3, 'Lost Track', 'Nobody', FAILED) # failed -> Discovery Pool's
attention = db.get_wing_it_pool(profile_id=1)
assert [t['track_name'] for t in attention] == ['Orbital Trans']
assert attention[0]['artist_name'] == 'Yoga Mao'
assert attention[0]['playlist_name'] == 'Liked Songs'
resolved = db.get_wing_it_pool(profile_id=1, resolved=True)
assert [t['track_name'] for t in resolved] == ['Dopamine']
assert db.get_wing_it_pool_stats(profile_id=1) == {'wing_it': 1, 'matched': 1}
def test_scopes_by_playlist_and_profile(tmp_path):
db = MusicDatabase(database_path=str(tmp_path / "w2.db"))
a = _playlist(db, 'Playlist A', profile_id=1, source_id='a')
b = _playlist(db, 'Playlist B', profile_id=1, source_id='b')
other = _playlist(db, 'Other Profile', profile_id=2, source_id='c')
_track(db, a, 0, 'A Song', 'AA', WING_IT)
_track(db, b, 0, 'B Song', 'BB', WING_IT)
_track(db, other, 0, 'C Song', 'CC', WING_IT)
assert {t['track_name'] for t in db.get_wing_it_pool(profile_id=1)} == {'A Song', 'B Song'}
assert [t['track_name'] for t in db.get_wing_it_pool(playlist_id=a)] == ['A Song']
assert db.get_wing_it_pool_stats(profile_id=1) == {'wing_it': 2, 'matched': 0}
def test_empty_when_no_wing_it(tmp_path):
db = MusicDatabase(database_path=str(tmp_path / "w3.db"))
pid = _playlist(db, 'Clean')
_track(db, pid, 0, 'Matched', 'X', MATCHED)
assert db.get_wing_it_pool(profile_id=1) == []
assert db.get_wing_it_pool_stats(profile_id=1) == {'wing_it': 0, 'matched': 0}

View file

@ -224,7 +224,10 @@ class TestSearch:
assert t.artist == "Kendrick Lamar"
assert t.title == "Track 0"
assert t.album == "GNX"
assert t.quality == "Lossless"
# Quality is now stamped as a real format token (was the display
# label "Lossless", which broke audio_quality format derivation).
assert t.quality == "flac"
assert t.audio_quality.format == "flac"
assert t.duration == 200_000
def test_track_source_metadata(self, tmp_path):

View file

@ -40,7 +40,7 @@ logger = setup_logging(_log_level, _log_path)
# App version — single source of truth for backup metadata, system-info, update check, etc.
# Semver: MAJOR.MINOR.PATCH. Bump at each dev→main release.
_SOULSYNC_BASE_VERSION = "2.7.8"
_SOULSYNC_BASE_VERSION = "2.7.9"
def _build_version_string():
"""Append short commit hash to version when available (e.g. 2.35+abc1234)."""
@ -192,6 +192,7 @@ from core.runtime_state import (
activity_feed,
activity_feed_lock,
add_activity_item,
claim_for_post_processing,
download_batches,
download_tasks,
matched_context_lock,
@ -4542,19 +4543,26 @@ def get_quality_presets():
@app.route('/api/quality-profile/preset/<preset_name>', methods=['POST'])
def apply_quality_preset(preset_name):
"""Apply a predefined quality preset"""
"""Switch to a quality preset, restoring its saved edits if it has any."""
try:
from database.music_database import MusicDatabase
db = MusicDatabase()
preset = db.get_quality_preset(preset_name)
current = db.get_quality_profile()
preset = dict(db.get_quality_preset(preset_name))
# search_mode + rank_candidates_by_quality are global search/ordering
# strategies, not per-preset audio settings — carry the user's current
# choices across preset switches.
preset['search_mode'] = current.get('search_mode', preset.get('search_mode', 'priority'))
preset['rank_candidates_by_quality'] = current.get(
'rank_candidates_by_quality', preset.get('rank_candidates_by_quality', False))
success = db.set_quality_profile(preset)
if success:
add_activity_item("", "Quality Preset Applied", f"Applied '{preset_name}' preset", "Now")
add_activity_item("", "Quality Preset Applied", f"Switched to '{preset_name}' preset", "Now")
return jsonify({
"success": True,
"message": f"Applied '{preset_name}' preset",
"message": f"Switched to '{preset_name}' preset",
"profile": preset
})
else:
@ -4564,6 +4572,35 @@ def apply_quality_preset(preset_name):
logger.error(f"Error applying quality preset: {e}")
return jsonify({"success": False, "error": str(e)}), 500
@app.route('/api/quality-profile/preset/<preset_name>/reset', methods=['POST'])
def reset_quality_preset(preset_name):
"""Discard a preset's saved edits and restore its factory defaults."""
try:
from database.music_database import MusicDatabase
db = MusicDatabase()
current = db.get_quality_profile()
preset = dict(db.reset_quality_preset(preset_name))
preset['search_mode'] = current.get('search_mode', preset.get('search_mode', 'priority'))
preset['rank_candidates_by_quality'] = current.get(
'rank_candidates_by_quality', preset.get('rank_candidates_by_quality', False))
success = db.set_quality_profile(preset)
if success:
add_activity_item("", "Quality Preset Reset", f"Reset '{preset_name}' to defaults", "Now")
return jsonify({
"success": True,
"message": f"Reset '{preset_name}' to defaults",
"profile": preset
})
else:
return jsonify({"success": False, "error": "Failed to reset preset"}), 500
except Exception as e:
logger.error(f"Error resetting quality preset: {e}")
return jsonify({"success": False, "error": str(e)}), 500
# ===============================
# == END QUALITY PROFILE API ==
# ===============================
@ -6765,6 +6802,19 @@ def get_download_status():
_pp_task_id = context.get('task_id')
_pp_batch_id = context.get('batch_id')
if _pp_task_id and _pp_batch_id:
# Atomic claim: the download monitor ALSO watches slskd
# transfers and submits its own post-processing worker for
# this task. Losing the claim means the monitor (or a
# parallel poll) already owns it — skip to avoid a double
# import and the quarantine-requeue race that produced the
# bogus "missing file or source information" failure.
if not claim_for_post_processing(_pp_task_id):
logger.info(
f"Task {_pp_task_id} already claimed for post-processing "
f"by another path — skipping duplicate for {context_key}"
)
processed_download_ids.add(context_key)
continue
_pp_target = _post_process_matched_download_with_verification
_pp_args = (context_key, context, found_path, _pp_task_id, _pp_batch_id)
else:
@ -6786,6 +6836,15 @@ def get_download_status():
logger.error(f"Error starting post-processing thread for {context_key}: {e}")
# Don't add to processed set if thread failed to start
logger.warning(f"Will retry {context_key} on next check")
# Release the post-processing claim so a later poll (or the
# monitor) can retry — otherwise the task is stuck in
# 'post_processing' with no worker running.
_failed_task_id = context.get('task_id')
if _failed_task_id:
with tasks_lock:
_ft = download_tasks.get(_failed_task_id)
if _ft and _ft.get('status') == 'post_processing':
_ft['status'] = 'downloading'
# Start a single thread to manage the launching of all processing threads
processing_thread = threading.Thread(target=process_completed_downloads)
@ -6843,6 +6902,16 @@ def get_download_status():
_st_task_id = _ctx.get('task_id')
_st_batch_id = _ctx.get('batch_id')
if _st_task_id and _st_batch_id:
# Atomic claim — the download monitor watches
# these (non-soulseek) transfers too and would
# otherwise double-process this same task.
if not claim_for_post_processing(_st_task_id):
logger.info(
f"[{_label}] Task {_st_task_id} already claimed "
f"for post-processing — skipping duplicate for {_ctx_key}"
)
processed_download_ids.add(_ctx_key)
return
_st_target = _post_process_matched_download_with_verification
_st_args = (_ctx_key, _ctx, _path, _st_task_id, _st_batch_id)
else:
@ -6855,6 +6924,13 @@ def get_download_status():
logger.info(f"[{_label}] Marked as processed: {_ctx_key}")
except Exception as e:
logger.error(f"[{_label}] Error starting post-processing thread for {_ctx_key}: {e}")
# Release the claim so a later poll / the monitor retries.
_st_failed_id = _ctx.get('task_id')
if _st_failed_id:
with tasks_lock:
_stf = download_tasks.get(_st_failed_id)
if _stf and _stf.get('status') == 'post_processing':
_stf['status'] = 'downloading'
processing_thread = threading.Thread(target=process_streaming_download)
processing_thread.daemon = True
@ -7609,6 +7685,17 @@ def approve_quarantine_item(entry_id):
_t = download_tasks.get(_task_id)
if isinstance(_t, dict):
_batch_id = _t.get('batch_id')
else:
# Manager-tab approve: no task_id in the request. Find the task that
# owns this quarantine entry so the re-import marks it completed in
# the downloads list without waiting for the batch to finish.
with tasks_lock:
for _tid, _t in download_tasks.items():
if isinstance(_t, dict) and _t.get('quarantine_entry_id') == entry_id:
_task_id = _tid
_batch_id = _t.get('batch_id')
break
if _task_id:
context['task_id'] = _task_id
if _batch_id:
context['batch_id'] = _batch_id
@ -7637,11 +7724,35 @@ def approve_quarantine_item(entry_id):
logger.info(f"[Quarantine] Auto-removed {len(removed_siblings)} sibling alternative(s) of {entry_id}: {removed_siblings}")
except Exception as sib_exc:
logger.warning(f"[Quarantine] Sibling cleanup for {entry_id} failed: {sib_exc}")
# Cancel any still-running quarantine-retry task for the same track so
# the engine doesn't keep fetching new candidates after the user has
# already accepted one. Match by track title from the restored context.
cancelled_retry_task = None
try:
ti = context.get('track_info') if isinstance(context.get('track_info'), dict) else {}
_approved_name = (ti.get('name') or '').strip().lower()
if _approved_name:
with tasks_lock:
for _tid, _t in download_tasks.items():
if not _t.get('_quarantine_retry'):
continue
if _t.get('status') in ('completed', 'cancelled', 'failed'):
continue
_tti = _t.get('track_info') if isinstance(_t.get('track_info'), dict) else {}
if (_tti.get('name') or '').strip().lower() == _approved_name:
_t['status'] = 'cancelled'
_t['_quarantine_approved_alternative'] = True
cancelled_retry_task = _tid
logger.info(f"[Quarantine] Cancelled in-flight retry task {_tid} for '{_approved_name}' (user approved alternative)")
break
except Exception as _crt_exc:
logger.debug(f"[Quarantine] Retry-cancel scan failed: {_crt_exc}")
return jsonify({
"success": True,
"trigger_bypassed": "all",
"original_trigger": trigger,
"removed_siblings": removed_siblings,
"cancelled_retry_task": cancelled_retry_task,
})
except Exception as e:
logger.error(f"[Quarantine] Error approving {entry_id}: {e}")
@ -7752,7 +7863,8 @@ def get_verification_config():
queue collapses to quarantine-only in the UI."""
try:
enabled = bool(config_manager.get('acoustid.enabled', False))
return jsonify({"success": True, "acoustid_enabled": enabled})
require_verified = bool(config_manager.get('acoustid.require_verified', False))
return jsonify({"success": True, "acoustid_enabled": enabled, "require_verified": require_verified})
except Exception as e:
return jsonify({"success": True, "acoustid_enabled": True, "error": str(e)})
@ -11927,6 +12039,9 @@ def _sync_tracks_to_server(track_rows, server_type):
return result
_resolve_library_diag_logged = False
def _resolve_library_file_path(file_path):
"""Resolve a library file path to an actual file on disk."""
if not file_path:
@ -11963,22 +12078,58 @@ def _resolve_library_file_path(file_path):
except Exception as e:
logger.debug("library music paths read failed: %s", e)
path_parts = file_path.replace('\\', '/').split('/')
# Try progressively shorter path suffixes against each candidate directory
# (skip index 0 to avoid drive letter issues). find_on_disk matches each
# component exactly when present, else folds typographic confusables (#833:
# curly U+2019 apostrophe in DB metadata vs ASCII U+0027 on disk) — exact
# matches always win, so paths that already resolved are unaffected.
from core.library.path_resolve import find_on_disk
for base_dir in [transfer_dir, download_dir] + list(library_dirs):
if not base_dir or not os.path.isdir(base_dir):
# Build the set of candidate base directories (absolute forms only, so
# os.path.join produces unambiguous paths). Config often stores RELATIVE
# paths ("./Transfer") — take os.path.abspath() so they resolve from the
# current working directory of the server process.
library_dir_list = list(library_dirs)
raw_bases = [transfer_dir, download_dir] + library_dir_list
abs_bases = []
seen_abs = set()
for b in raw_bases:
if not b:
continue
for i in range(1, len(path_parts)):
found = find_on_disk(base_dir, path_parts[i:])
for form in [os.path.abspath(b), b, '/' + b.replace('./', '', 1).lstrip('/')]:
a = os.path.abspath(form) if not os.path.isabs(form) else form
if a and a not in seen_abs and os.path.isdir(a):
seen_abs.add(a)
abs_bases.append(a)
# --- Fast path: direct join ---
# When the DB stores a clean relative path ("Artist/Album/Track.flac") this
# is all that's needed. No component-by-component descent, no confusable
# folding. This handles the common Docker case where CWD is /app, config
# says ./Transfer, and files live at /app/Transfer/Artist/Album/Track.flac.
clean_rel = file_path.replace('\\', '/')
for abs_base in abs_bases:
candidate = os.path.join(abs_base, clean_rel)
if os.path.exists(candidate):
logger.debug("[PathResolve] direct join: %r%r", file_path, candidate)
return candidate
# --- Slow path: confusable-tolerant suffix scan ---
# Handles paths with typographic apostrophes/dashes that differ between the
# DB metadata and the actual on-disk filename (#833).
path_parts = clean_rel.split('/')
from core.library.path_resolve import find_on_disk
for abs_base in abs_bases:
for i in range(0, len(path_parts)):
found = find_on_disk(abs_base, path_parts[i:])
if found:
return found
# Couldn't resolve — log the bases we searched ONCE so a path/mount mismatch
# is diagnosable (e.g. files live under a dir that isn't transfer/download/
# a configured library path).
global _resolve_library_diag_logged
if not _resolve_library_diag_logged:
_resolve_library_diag_logged = True
logger.warning(
"[PathResolve] Could not resolve %r — tried direct-join + suffix-scan under %r (cwd=%r). "
"If files live elsewhere, set soulseek.transfer_path to the absolute mount or add "
"the dir under Settings > Library music paths.",
file_path, abs_bases, os.getcwd(),
)
return None
@ -15200,6 +15351,14 @@ def _get_file_path_from_template_raw(template: str, context: dict) -> tuple:
return '', _sanitize_filename(full_path)
def _probe_audio_quality(file_path):
"""Probe real measured audio quality (bit depth / sample rate / bitrate)
as an AudioQuality, for the library quality scanner. Delegates to the same
core the download import guard uses. Returns None on any error."""
from core.imports.file_ops import probe_audio_quality
return probe_audio_quality(file_path)
def _get_audio_quality_string(file_path):
"""
Read audio file and return a quality descriptor string.
@ -18304,9 +18463,9 @@ def _build_candidates_deps():
)
def _attempt_download_with_candidates(task_id, candidates, track, batch_id=None):
def _attempt_download_with_candidates(task_id, candidates, track, batch_id=None, **kwargs):
return _downloads_candidates.attempt_download_with_candidates(
task_id, candidates, track, batch_id, _build_candidates_deps()
task_id, candidates, track, batch_id, _build_candidates_deps(), **kwargs
)
@ -18757,6 +18916,7 @@ def _build_status_deps():
page=1,
limit=limit,
)[0],
get_unverified_download_history=lambda: get_database().get_library_history_unverified(),
)
@ -20650,6 +20810,56 @@ def _save_sync_status_file(sync_statuses):
except Exception as e:
logger.error(f"Error saving sync status: {e}")
def _sync_status_timestamp(status_info):
"""Return comparable timestamp for a persisted sync-status record."""
if not status_info or 'last_synced' not in status_info:
return None
try:
return datetime.fromisoformat(status_info['last_synced'])
except (TypeError, ValueError):
return None
def _latest_sync_status(*status_infos):
"""Pick the newest non-empty sync-status record."""
candidates = [s for s in status_infos if s]
if not candidates:
return {}
return max(candidates, key=lambda s: _sync_status_timestamp(s) or datetime.min)
def _mirrored_spotify_sync_status(playlist_id, sync_statuses, *, database=None, profile_id=None):
"""Return auto-sync status for a Spotify playlist mirrored into SoulSync."""
try:
db = database or get_database()
profile = profile_id if profile_id is not None else get_current_profile_id()
mirrored = db.get_mirrored_playlist_by_source('spotify', str(playlist_id), profile)
if not mirrored:
return {}
return sync_statuses.get(f"auto_mirror_{mirrored.get('id')}", {})
except Exception as e:
logger.debug("Spotify mirrored sync-status lookup failed for %s: %s", playlist_id, e)
return {}
def _resolve_spotify_playlist_sync_status(playlist_id, sync_statuses, *, database=None, profile_id=None):
"""Resolve direct or mirrored sync status for a Spotify playlist card."""
direct_status = sync_statuses.get(playlist_id, {})
mirrored_status = _mirrored_spotify_sync_status(
playlist_id,
sync_statuses,
database=database,
profile_id=profile_id,
)
return _latest_sync_status(direct_status, mirrored_status)
def _format_playlist_sync_status(status_info, playlist_snapshot):
"""Build user-facing sync-status text from persisted status + snapshot."""
if 'last_synced' not in status_info:
return "Never Synced"
last_sync_time = datetime.fromisoformat(status_info['last_synced']).strftime('%b %d, %H:%M')
stored_snapshot = status_info.get('snapshot_id')
if stored_snapshot and playlist_snapshot and playlist_snapshot != stored_snapshot:
return f"Last Sync: {last_sync_time}"
return f"Synced: {last_sync_time}"
def _update_and_save_sync_status(playlist_id, playlist_name, playlist_owner, snapshot_id, **kwargs):
"""Updates the sync status for a given playlist and saves to file (same logic as GUI)."""
try:
@ -20692,16 +20902,14 @@ def get_spotify_playlists():
# Add regular playlists first
for p in playlists:
status_info = sync_statuses.get(p.id, {})
sync_status = "Never Synced"
status_info = _resolve_spotify_playlist_sync_status(p.id, sync_statuses)
# Handle snapshot_id safely - may not exist in core Playlist class
playlist_snapshot = getattr(p, 'snapshot_id', '')
sync_status = _format_playlist_sync_status(status_info, playlist_snapshot)
if 'last_synced' in status_info:
stored_snapshot = status_info.get('snapshot_id')
last_sync_time = datetime.fromisoformat(status_info['last_synced']).strftime('%b %d, %H:%M')
if playlist_snapshot != stored_snapshot:
sync_status = f"Last Sync: {last_sync_time}"
if stored_snapshot and playlist_snapshot and playlist_snapshot != stored_snapshot:
logger.info(
"Playlist sync status: name=%s id=%s snapshot=%r stored_snapshot=%r result=Needs Sync display=%s",
p.name,
@ -20711,7 +20919,6 @@ def get_spotify_playlists():
sync_status,
)
else:
sync_status = f"Synced: {last_sync_time}"
logger.info(
"Playlist sync status: name=%s id=%s snapshot=%r stored_snapshot=%r result=Synced display=%s",
p.name,
@ -28968,6 +29175,96 @@ def get_discover_similar_artists():
return jsonify({"success": False, "error": str(e)}), 500
@app.route('/api/discover/listening-recommendations', methods=['GET'])
def get_discover_listening_recommendations():
"""#913: artists you'd love based on what you actually LISTEN to (play-weighted).
Distinct from /api/discover/similar-artists (which is driven by your whole library /
watchlist): this is seeded by your most-PLAYED artists, consensus-ranked across the
similar-artist graph, and recency-boosted. The heavy lifting + storage happen during the
watchlist scan (core.watchlist_scanner._build_listening_recommendations -> the
'listening_recs_artists' metadata key); this endpoint just reshapes the stored list to the
same card shape the recommended-artists row already renders. Read-only, fail-soft.
"""
try:
database = get_database()
active_source = _get_active_discovery_source()
raw = database.get_metadata('listening_recs_artists')
if not raw:
return jsonify({"success": True, "artists": [], "source": active_source, "count": 0})
try:
stored = json.loads(raw) or []
except (ValueError, TypeError):
stored = []
result_artists = []
for a in stored:
name = a.get('name')
if not name:
continue
if active_source == 'spotify':
artist_id = a.get('spotify_artist_id')
elif active_source == 'deezer':
artist_id = a.get('deezer_artist_id') or a.get('itunes_artist_id')
else:
artist_id = a.get('itunes_artist_id')
entry = {
"artist_id": artist_id,
"spotify_artist_id": a.get('spotify_artist_id'),
"itunes_artist_id": a.get('itunes_artist_id'),
"deezer_artist_id": a.get('deezer_artist_id'),
"artist_name": name,
"seed_count": a.get('seed_count'),
"source": active_source,
}
img = a.get('image_url')
if img:
entry["image_url"] = fix_artist_image_url(img)
if a.get('genres'):
entry["genres"] = a['genres'][:3]
# "because you listen to X, Y, Z" — the most-played artists that point here.
if a.get('seeds'):
entry["because"] = a['seeds']
result_artists.append(entry)
return jsonify({
"success": True,
"artists": result_artists,
"source": active_source,
"count": len(result_artists),
})
except Exception as e:
logger.error(f"Error getting listening recommendations: {e}")
return jsonify({"success": False, "error": str(e)}), 500
@app.route('/api/discover/personalized/listening-mix', methods=['GET'])
def get_discover_listening_mix():
"""#913: the "Listening Mix" playlist row — a playable track mix from the artists you'd
love based on what you actually listen to.
The tracks are built during the watchlist scan (core.watchlist_scanner
._build_listening_recommendations -> the 'listening_recs_tracks_full' metadata key) as full
render-ready dicts, so this endpoint just hands them back no discovery-pool re-hydration,
which means it can't shrink when the pool rotates (the failure mode Fresh Tape/Archives hit).
Same {success, tracks} shape renderCompactPlaylist + the sync/download chains expect.
"""
try:
database = get_database()
active_source = _get_active_discovery_source()
raw = database.get_metadata('listening_recs_tracks_full')
tracks = []
if raw:
try:
tracks = json.loads(raw) or []
except (ValueError, TypeError):
tracks = []
return jsonify({"success": True, "tracks": tracks, "source": active_source})
except Exception as e:
logger.error(f"Error getting listening mix: {e}")
return jsonify({"success": False, "error": str(e)}), 500
@app.route('/api/discover/similar-artists/enrich', methods=['POST'])
def enrich_similar_artists():
"""Enrich a batch of artist IDs with images/genres from Spotify or iTunes.
@ -34803,6 +35100,37 @@ def get_discovery_pool():
logger.error(f"Error getting discovery pool: {e}")
return jsonify({"error": str(e)}), 500
@app.route('/api/wing-it-pool', methods=['GET'])
def get_wing_it_pool():
"""List Wing It auto-matched tracks (unverified best-effort guesses), optionally per-playlist.
These are tracks that couldn't match a metadata source and got a raw-name Wing It stub. They
count as 'discovered' so the Discovery Pool hides them this surfaces them so the user can
verify and re-match. Re-matching reuses the Discovery Pool's /api/discovery-pool/fix endpoint
(both key off the mirrored_playlist_tracks.id), and a manual match drops the track from here.
"""
try:
database = get_database()
profile_id = get_current_profile_id()
playlist_id = request.args.get('playlist_id', type=int)
tracks = database.get_wing_it_pool(profile_id=profile_id, playlist_id=playlist_id)
matched = database.get_wing_it_pool(profile_id=profile_id, playlist_id=playlist_id, resolved=True)
stats = database.get_wing_it_pool_stats(profile_id=profile_id)
playlists = database.get_mirrored_playlists(profile_id=profile_id)
playlist_options = [{'id': p['id'], 'name': p['name']} for p in playlists]
return jsonify({
'tracks': tracks,
'matched': matched,
'stats': stats,
'playlists': playlist_options,
})
except Exception as e:
logger.error(f"Error getting wing it pool: {e}")
return jsonify({"error": str(e)}), 500
@app.route('/api/discovery-pool/fix', methods=['POST'])
def fix_discovery_pool_track():
"""Manually fix a failed discovery by linking a mirrored track to a Spotify/iTunes result."""
@ -34846,7 +35174,8 @@ def fix_discovery_pool_track():
'source': 'spotify',
}
# Update the mirrored track's extra_data
# Update the mirrored track's extra_data (merges, so a wing-it track keeps its
# wing_it_fallback flag — that + manual_match is how the Wing It Pool lists resolved guesses).
extra_data = {
'discovered': True,
'provider': 'spotify',

View file

@ -2041,6 +2041,7 @@
<div class="playlist-header">
<h3>Mirrored Playlists</h3>
<button class="pool-trigger-btn" onclick="openDiscoveryPoolModal()" title="View matched and failed discovery tracks">Discovery Pool</button>
<button class="pool-trigger-btn" onclick="openWingItPoolModal()" title="Review tracks Wing It auto-matched on a best-effort guess — verify or re-match them">Wing It Pool</button>
<button class="refresh-button mirrored" id="mirrored-refresh-btn">Update list</button>
</div>
<div class="playlist-scroll-container" id="mirrored-playlist-container">
@ -3133,6 +3134,53 @@
</div>
<!-- Recommended For You Section (similar-artists graph) -->
<!-- #913: listening-driven recommendations (play-weighted, consensus-ranked) -->
<div class="discover-section" id="listening-recs-section" style="display: none;">
<div class="discover-section-header">
<div>
<h2 class="discover-section-title">Based On Your Listening</h2>
<p class="discover-section-subtitle">Artists you'd love — ranked from who you actually play the most</p>
</div>
</div>
<div class="discover-carousel" id="listening-recs-carousel">
<!-- Populated by JS -->
</div>
</div>
<!-- #913: Listening Mix — a playable track mix from those recommended artists -->
<div class="discover-section" style="display: none;">
<div class="discover-section-header">
<div>
<h2 class="discover-section-title">🎧 Your Listening Mix</h2>
<p class="discover-section-subtitle">A fresh playlist of tracks from artists matched to your listening</p>
</div>
<div class="discover-section-actions">
<button class="action-button secondary"
onclick="openDownloadModalForDiscoverPlaylist('listening_mix', 'Your Listening Mix')"
title="Download missing tracks">
<span class="button-icon"></span><span class="button-text">Download</span>
</button>
<button class="action-button primary" id="listening-mix-sync-btn"
onclick="startDiscoverPlaylistSync('listening_mix', 'Your Listening Mix')"
title="Sync to media server">
<span class="button-icon"></span><span class="button-text">Sync</span>
</button>
</div>
</div>
<div class="discover-sync-status" id="listening-mix-sync-status" style="display: none;">
<div class="sync-status-content">
<div class="sync-status-label"><span class="sync-icon"></span><span>Syncing to media server...</span></div>
<div class="sync-status-stats">
<span class="sync-stat"><span id="listening-mix-sync-completed">0</span></span>
<span class="sync-stat"><span id="listening-mix-sync-pending">0</span></span>
<span class="sync-stat"><span id="listening-mix-sync-failed">0</span></span>
<span class="sync-stat">(<span id="listening-mix-sync-percentage">0</span>%)</span>
</div>
</div>
</div>
<div class="discover-playlist-container compact" id="personalized-listening-mix"></div>
</div>
<div class="discover-section" id="recommended-artists-section" style="display: none;">
<div class="discover-section-header">
<div>
@ -4224,7 +4272,8 @@
<label class="checkbox-label"
style="display: flex; align-items: center; gap: 8px; cursor: pointer;">
<input type="checkbox" id="acoustid-enabled"
style="width: 16px; height: 16px;">
style="width: 16px; height: 16px;"
onchange="if (typeof syncAcoustidRequireVerifiedVisibility === 'function') syncAcoustidRequireVerifiedVisibility();">
<span>Enable Download Verification</span>
</label>
<div style="color: #888; font-size: 0.8em; margin-top: 4px; margin-left: 24px;">
@ -4742,25 +4791,6 @@
<!-- Tidal Download Settings (shown only when tidal mode is selected) -->
<div id="tidal-download-settings-container" style="display: none;">
<div class="form-group">
<label>Tidal Download Quality:</label>
<select id="tidal-download-quality" class="form-select">
<option value="low">Low (AAC 96kbps)</option>
<option value="high">High (AAC 320kbps)</option>
<option value="lossless">Lossless (FLAC 16-bit/44.1kHz)</option>
<option value="hires">HiRes (FLAC 24-bit/96kHz)</option>
</select>
<div class="setting-help-text">
Audio quality for Tidal downloads. HiRes requires a Tidal HiFi Plus subscription.
</div>
<label class="checkbox-inline" style="margin-top: 8px;">
<input type="checkbox" id="tidal-allow-fallback" checked>
Allow quality fallback
</label>
<div class="setting-help-text">
When disabled, only downloads at the exact quality selected. If unavailable, skips to the next source.
</div>
</div>
<div class="form-group">
<label>Tidal Download Auth:</label>
<div class="form-actions" style="margin-top: 4px;">
@ -4776,25 +4806,6 @@
<!-- Qobuz Settings (shown only when qobuz mode is selected) -->
<div id="qobuz-settings-container" style="display: none;">
<div class="form-group">
<label>Qobuz Download Quality:</label>
<select id="qobuz-quality" class="form-select">
<option value="mp3">MP3 320kbps</option>
<option value="lossless">Lossless (FLAC 16-bit/44.1kHz)</option>
<option value="hires">Hi-Res (FLAC 24-bit/96kHz)</option>
<option value="hires_max">Hi-Res Max (FLAC 24-bit/192kHz)</option>
</select>
<div class="setting-help-text">
Audio quality for Qobuz downloads. Hi-Res requires a Qobuz Studio or Sublime subscription.
</div>
<label class="checkbox-inline" style="margin-top: 8px;">
<input type="checkbox" id="qobuz-allow-fallback" checked>
Allow quality fallback
</label>
<div class="setting-help-text">
When disabled, only downloads at the exact quality selected. If unavailable, skips to the next source.
</div>
</div>
<div class="form-group">
<label>Qobuz Account:</label>
<div id="qobuz-auth-logged-in" style="display: none; margin-top: 4px;">
@ -4842,25 +4853,6 @@
<!-- HiFi Download Settings (shown only when hifi mode is selected) -->
<div id="hifi-download-settings-container" style="display: none;">
<div class="form-group">
<label>HiFi Download Quality:</label>
<select id="hifi-download-quality" class="form-select">
<option value="low">Low (AAC 96kbps)</option>
<option value="high">High (AAC 320kbps)</option>
<option value="lossless">Lossless (FLAC 16-bit/44.1kHz)</option>
<option value="hires">HiRes (FLAC 24-bit/96kHz)</option>
</select>
<div class="setting-help-text">
Audio quality for HiFi downloads. Uses public API instances — no account required.
</div>
<label class="checkbox-inline" style="margin-top: 8px;">
<input type="checkbox" id="hifi-allow-fallback" checked>
Allow quality fallback
</label>
<div class="setting-help-text">
When disabled, only downloads at the exact quality selected. If unavailable, skips to the next source.
</div>
</div>
<div class="form-group">
<label>HiFi Status:</label>
<div class="form-actions" style="margin-top: 4px;">
@ -4890,25 +4882,6 @@
<!-- Deezer Download Settings (shown only when deezer_dl mode is selected) -->
<div id="deezer-download-settings-container" style="display: none;">
<div class="form-group">
<label>Deezer Download Quality:</label>
<select id="deezer-download-quality" class="form-select">
<option value="mp3_128">MP3 128kbps (Free)</option>
<option value="mp3_320">MP3 320kbps (Premium)</option>
<option value="flac">FLAC Lossless (HiFi)</option>
</select>
<div class="setting-help-text">
Audio quality for Deezer downloads. FLAC requires a Deezer HiFi subscription.
MP3 320 requires Premium or higher.
</div>
<label class="checkbox-inline" style="margin-top: 8px;">
<input type="checkbox" id="deezer-allow-fallback" checked>
Allow quality fallback
</label>
<div class="setting-help-text">
When disabled, only downloads at the exact quality selected. If unavailable, skips to the next source.
</div>
</div>
<div class="form-group">
<label>Deezer ARL Token:</label>
<input type="password" id="deezer-download-arl" class="form-input"
@ -4932,25 +4905,6 @@
<!-- Amazon Music Download Settings (shown only when amazon mode is selected) -->
<div id="amazon-download-settings-container" style="display: none;">
<div class="form-group">
<label>Amazon Music Quality:</label>
<select id="amazon-quality" class="form-select">
<option value="flac">FLAC Lossless (24-bit/48kHz Hi-Res)</option>
<option value="opus">Opus (320kbps)</option>
<option value="eac3">EAC3 Dolby Atmos (768kbps 5.1)</option>
</select>
<div class="setting-help-text">
Preferred codec tier. FLAC is 24-bit/48kHz Hi-Res — no subscription required.
Downloads via T2Tunes proxy.
</div>
<label class="checkbox-inline" style="margin-top: 8px;">
<input type="checkbox" id="amazon-allow-fallback" checked>
Allow quality fallback
</label>
<div class="setting-help-text">
Fall back to the next codec tier if the preferred one is unavailable.
</div>
</div>
<div class="form-group">
<label>Connection:</label>
<div class="form-actions" style="margin-top: 4px;">
@ -5082,9 +5036,8 @@
</div>
<div class="settings-section-body collapsed" data-stg="downloads">
<!-- Quality Profile Settings (Soulseek only) -->
<!-- Quality Profile Settings (global — applies to all download sources) -->
<div class="settings-group" id="quality-profile-section" data-stg="downloads">
<h3>🎵 Quality Profile</h3>
<!-- Presets -->
<div class="quality-presets">
@ -5103,194 +5056,187 @@
💾 Space Saver
</button>
</div>
<div class="quality-presets-footer">
<span class="help-text" style="padding:0;border:0;background:none;margin:0;flex:1;">
Edits below are saved <em>per preset</em> — switch away and back and your
changes are still there.
</span>
<button type="button" class="preset-reset-btn" onclick="resetActiveQualityPreset()"
title="Restore the selected preset's factory settings">
<span class="preset-reset-icon"></span> Reset to defaults
</button>
</div>
</div>
<!-- FLAC Quality -->
<div class="quality-tier">
<div class="quality-tier-header">
<label class="checkbox-label">
<input type="checkbox" id="quality-flac-enabled" checked
onchange="toggleQuality('flac')">
<span class="quality-tier-name">FLAC (Lossless)</span>
</label>
<span class="quality-tier-priority" id="priority-flac">Priority: 1</span>
<!-- Ranked target priority list (v3) -->
<div class="ranked-targets-editor">
<div class="setting-row">
<label class="ranked-targets-label" style="margin:0;">Quality priority (drag to reorder — 1st = most preferred):</label>
<span class="info-icon" role="button" tabindex="0" title="What's this?"
onclick="toggleSettingHelp(this)" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();toggleSettingHelp(this);}">ⓘ</span>
</div>
<div class="quality-tier-sliders" id="sliders-flac">
<div class="slider-group">
<label>Bitrate Range:</label>
<div class="dual-slider-container">
<input type="range" class="range-slider range-slider-min" id="flac-min"
min="0" max="10000" value="500" step="100"
oninput="updateQualityRange('flac')">
<input type="range" class="range-slider range-slider-max" id="flac-max"
min="0" max="10000" value="10000" step="100"
oninput="updateQualityRange('flac')">
<div class="range-slider-track"></div>
</div>
<div class="slider-values">
<span id="flac-min-value">500 kbps</span>
<span>-</span>
<span id="flac-max-value">10000 kbps</span>
</div>
</div>
<div class="help-text setting-help-body" hidden>
<strong>How it works:</strong> Each download source is checked against this list
top-down. The first target a source can satisfy wins; a source that meets no target
is skipped for the next one (source priority still decides between sources that can).
For lossless, bit depth + sample rate decide the match. For MP3/AAC the bitrate is a
<em>minimum</em> threshold (≥), so VBR and mono files aren't falsely rejected. After
download the real file is verified against the same list. With fallback off, a track
is left missing rather than accepting a quality below every target.
<br><br>
<strong>Note:</strong> the <em>Downsample hi-res</em> option (under Lossy Copy) also
bypasses this gate — if off-list files keep slipping through with fallback off, check
that setting too.
</div>
<div class="flac-bit-depth-selector" id="flac-bit-depth-selector">
<label>Bit Depth:</label>
<div class="bit-depth-buttons">
<button class="bit-depth-btn active" data-value="any" onclick="setFlacBitDepth('any')">Any</button>
<button class="bit-depth-btn" data-value="16" onclick="setFlacBitDepth('16')">16-bit</button>
<button class="bit-depth-btn" data-value="24" onclick="setFlacBitDepth('24')">24-bit</button>
</div>
<div class="flac-fallback-toggle" id="flac-fallback-toggle" style="display: none; margin-top: 6px;">
<label style="display: flex; align-items: center; gap: 6px; cursor: pointer; font-size: 0.8em; color: #ccc;">
<input type="checkbox" id="flac-bit-depth-fallback" checked onchange="setFlacBitDepthFallback(this.checked)">
Accept other bit depths as fallback
<div id="ranked-targets-list" class="ranked-targets-list">
<!-- rows injected by renderRankedTargets() -->
</div>
<!-- Add a new target -->
<div class="ranked-target-add">
<select id="rt-add-format" onchange="onRtAddFormatChange()">
<optgroup label="Lossless">
<option value="group:lossless">All lossless</option>
<option value="flac">FLAC</option>
<option value="alac">ALAC</option>
<option value="wav">WAV / AIFF</option>
</optgroup>
<optgroup label="Lossy">
<option value="group:lossy">All lossy</option>
<option value="mp3">MP3</option>
<option value="aac">AAC</option>
<option value="ogg">OGG (Vorbis)</option>
<option value="opus">Opus</option>
<option value="wma">WMA</option>
</optgroup>
</select>
<span class="rt-lossless-fields">
<select id="rt-add-bitdepth" title="Minimum bit depth">
<option value="">Any bit depth</option>
<option value="16">16-bit</option>
<option value="24">24-bit</option>
</select>
<select id="rt-add-samplerate" title="Minimum sample rate">
<option value="">Any sample rate</option>
<option value="44100">≥ 44.1 kHz</option>
<option value="48000">≥ 48 kHz</option>
<option value="96000">≥ 96 kHz</option>
<option value="192000">≥ 192 kHz</option>
</select>
</span>
<span class="rt-lossy-fields" style="display:none;">
<select id="rt-add-bitrate" title="Minimum bitrate" onchange="onRtBitrateChange()">
<option value="">Any bitrate</option>
<option value="96">≥ 96 kbps</option>
<option value="128">≥ 128 kbps</option>
<option value="192">≥ 192 kbps</option>
<option value="256">≥ 256 kbps</option>
<option value="320" selected>≥ 320 kbps</option>
<option value="custom">Custom…</option>
</select>
<label class="rt-inline-label" id="rt-add-bitrate-custom-wrap" style="display:none;">
<input type="number" id="rt-add-bitrate-custom" min="0" max="5000" step="1" value="320" style="width:72px;"
autocomplete="off" data-bwignore="" data-1p-ignore="" data-lpignore="true"> kbps
</label>
<div style="color: #888; font-size: 0.75em; margin-top: 2px;">
When enabled, accepts any FLAC rather than rejecting on bit depth mismatch
</div>
</div>
</span>
<button type="button" class="rt-add-btn" onclick="addRankedTarget()">+ Add target</button>
</div>
</div>
<!-- AAC Quality (opt-in; Soulseek/torrents — off by default) -->
<div class="quality-tier">
<div class="quality-tier-header">
<label class="checkbox-label">
<input type="checkbox" id="quality-aac-enabled"
onchange="toggleQuality('aac')">
<span class="quality-tier-name">AAC <span style="opacity:.6;font-weight:400;">(.m4a/.aac — Soulseek)</span></span>
</label>
<span class="quality-tier-priority" id="priority-aac">Priority: 1.5</span>
<!-- Search strategy -->
<div class="form-group">
<div class="setting-row" style="margin-bottom:4px;">
<label for="quality-search-mode" style="font-weight:600;">Search strategy</label>
<span class="info-icon" role="button" tabindex="0" title="What's this?"
onclick="toggleSettingHelp(this)" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();toggleSettingHelp(this);}">ⓘ</span>
</div>
<div class="quality-tier-sliders" id="sliders-aac">
<div class="slider-group">
<label>Bitrate Range:</label>
<div class="dual-slider-container">
<input type="range" class="range-slider range-slider-min"
id="aac-min" min="0" max="400" value="128" step="10"
oninput="updateQualityRange('aac')">
<input type="range" class="range-slider range-slider-max"
id="aac-max" min="0" max="400" value="400" step="10"
oninput="updateQualityRange('aac')">
<div class="range-slider-track"></div>
</div>
<div class="slider-values">
<span id="aac-min-value">128 kbps</span>
<span>-</span>
<span id="aac-max-value">400 kbps</span>
</div>
</div>
<select id="quality-search-mode" style="width:100%;max-width:420px;" onchange="onSearchModeChange()">
<option value="priority">Source priority — fast (use the first source that has it)</option>
<option value="best_quality">Best quality — thorough (check every source, take the best)</option>
</select>
<div class="help-text setting-help-body" hidden>
<strong>Source priority</strong> goes through your sources in order and
downloads from the <em>first one</em> that has the track. Fastest, fewest
lookups.<br><br>
<strong>Best quality</strong> asks <em>every</em> source, then downloads the
highest-quality copy it found — even if a faster source also had a lower-quality
one. Slower and more API calls, but you never settle for a worse file when a
better one exists.<br><br>
Either way, the per-source retry limits stay the same.
</div>
</div>
<!-- MP3 320 Quality -->
<div class="quality-tier">
<div class="quality-tier-header">
<label class="checkbox-label">
<input type="checkbox" id="quality-mp3_320-enabled" checked
onchange="toggleQuality('mp3_320')">
<span class="quality-tier-name">MP3 320 kbps</span>
<!-- Rank candidates by quality (priority mode only) -->
<div class="form-group" id="quality-rank-candidates-group">
<div class="setting-row">
<label class="checkbox-label" style="margin:0;">
<input type="checkbox" id="quality-rank-candidates">
Rank-based download order
</label>
<span class="quality-tier-priority" id="priority-mp3_320">Priority: 2</span>
<span class="info-icon" role="button" tabindex="0" title="What's this?"
onclick="toggleSettingHelp(this)" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();toggleSettingHelp(this);}">ⓘ</span>
</div>
<div class="quality-tier-sliders" id="sliders-mp3_320">
<div class="slider-group">
<label>Bitrate Range:</label>
<div class="dual-slider-container">
<input type="range" class="range-slider range-slider-min"
id="mp3_320-min" min="0" max="500" value="280" step="10"
oninput="updateQualityRange('mp3_320')">
<input type="range" class="range-slider range-slider-max"
id="mp3_320-max" min="0" max="500" value="500" step="10"
oninput="updateQualityRange('mp3_320')">
<div class="range-slider-track"></div>
</div>
<div class="slider-values">
<span id="mp3_320-min-value">280 kbps</span>
<span>-</span>
<span id="mp3_320-max-value">500 kbps</span>
</div>
</div>
</div>
</div>
<!-- MP3 256 Quality -->
<div class="quality-tier">
<div class="quality-tier-header">
<label class="checkbox-label">
<input type="checkbox" id="quality-mp3_256-enabled" checked
onchange="toggleQuality('mp3_256')">
<span class="quality-tier-name">MP3 256 kbps</span>
</label>
<span class="quality-tier-priority" id="priority-mp3_256">Priority: 3</span>
</div>
<div class="quality-tier-sliders" id="sliders-mp3_256">
<div class="slider-group">
<label>Bitrate Range:</label>
<div class="dual-slider-container">
<input type="range" class="range-slider range-slider-min"
id="mp3_256-min" min="0" max="400" value="200" step="10"
oninput="updateQualityRange('mp3_256')">
<input type="range" class="range-slider range-slider-max"
id="mp3_256-max" min="0" max="400" value="400" step="10"
oninput="updateQualityRange('mp3_256')">
<div class="range-slider-track"></div>
</div>
<div class="slider-values">
<span id="mp3_256-min-value">200 kbps</span>
<span>-</span>
<span id="mp3_256-max-value">400 kbps</span>
</div>
</div>
</div>
</div>
<!-- MP3 192 Quality -->
<div class="quality-tier">
<div class="quality-tier-header">
<label class="checkbox-label">
<input type="checkbox" id="quality-mp3_192-enabled"
onchange="toggleQuality('mp3_192')">
<span class="quality-tier-name">MP3 192 kbps</span>
</label>
<span class="quality-tier-priority" id="priority-mp3_192">Priority: 4</span>
</div>
<div class="quality-tier-sliders disabled" id="sliders-mp3_192">
<div class="slider-group">
<label>Bitrate Range:</label>
<div class="dual-slider-container">
<input type="range" class="range-slider range-slider-min"
id="mp3_192-min" min="0" max="300" value="150" step="10"
oninput="updateQualityRange('mp3_192')">
<input type="range" class="range-slider range-slider-max"
id="mp3_192-max" min="0" max="300" value="300" step="10"
oninput="updateQualityRange('mp3_192')">
<div class="range-slider-track"></div>
</div>
<div class="slider-values">
<span id="mp3_192-min-value">150 kbps</span>
<span>-</span>
<span id="mp3_192-max-value">300 kbps</span>
</div>
</div>
<div class="help-text setting-help-body" hidden>
A single source often returns several copies of the same track.<br><br>
<strong>Off</strong> (default): the most likely-correct / fastest copy is
downloaded first — the original behaviour.<br><br>
<strong>On</strong>: the highest-quality copy (per your target list above) is
downloaded first instead; correctness and speed only break ties.<br><br>
Nothing is skipped and the source order doesn't change, so a track can't go
missing — it just prefers the better copy. (This is always how
<strong>Best quality</strong> works, which is why this option only appears for
<strong>Source priority</strong>.)
</div>
</div>
<!-- Fallback Option -->
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" id="quality-fallback-enabled" checked>
Allow fallback to any quality if preferred qualities unavailable
</label>
<div class="setting-row">
<label class="checkbox-label" style="margin:0;">
<input type="checkbox" id="quality-fallback-enabled" checked>
Accept off-list quality when nothing in the list is available
</label>
<span class="info-icon" role="button" tabindex="0" title="What's this?"
onclick="toggleSettingHelp(this)" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();toggleSettingHelp(this);}">ⓘ</span>
</div>
<div class="help-text setting-help-body" hidden>
This does <strong>not</strong> mean "walk down my list" — the list already
does that on its own.<br><br>
When <strong>on</strong>, a file that matches <em>none</em> of your targets
(e.g. a 16-bit FLAC or an MP3 when you only listed 24-bit) is
<strong>accepted anyway</strong> as a last resort.<br><br>
Turn it <strong>off</strong> to strictly enforce your list — anything off-list
is then quarantined instead of completed.
</div>
</div>
<div class="help-text">
<strong>How it works:</strong> Downloads try each enabled quality in priority order
(1 = highest).
MIN bitrate catches fake/transcoded files (e.g., FLAC below 500 kbps is likely a
re-encoded MP3). MAX bitrate limits hi-res files if you want to save space.
When track duration is unavailable, a generous file-size safety net is used instead.
<!-- Require hard AcoustID verification — only shown when AcoustID is enabled -->
<div class="form-group" id="acoustid-require-verified-group" style="display:none;">
<div class="setting-row">
<label class="checkbox-label" style="margin:0;">
<input type="checkbox" id="acoustid-require-verified">
Only import AcoustID-verified tracks (reject "could not confirm")
</label>
<span class="info-icon" role="button" tabindex="0" title="What's this?"
onclick="toggleSettingHelp(this)" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();toggleSettingHelp(this);}">ⓘ</span>
</div>
<div class="help-text setting-help-body" hidden>
When <strong>on</strong>, a track that AcoustID runs but <em>cannot confirm</em>
(no fingerprint match / cross-script metadata — the ⚠ "unverified" case) is
<strong>quarantined</strong> instead of imported. Downloads then try the next
candidate; imports just quarantine (no candidate to retry). Only a clean AcoustID
<strong>pass</strong> is kept.
<br><br>
⚠️ <strong>Use with care.</strong> AcoustID "could not confirm" is <em>common</em>
for perfectly good tracks — anything not in its fingerprint database: new/obscure
releases, classical, remixes, live cuts, remasters, non-Latin-script titles. With
this on, <strong>all of those get quarantined</strong>, so you may end up with many
legitimate files to review and approve by hand. Nothing is lost (quarantined files
can be approved), but expect manual cleanup. Leave this <strong>off</strong> to keep
the safer default: unconfirmed tracks import with the ⚠ "unverified" badge so you can
review them without anything being blocked. Transient lookup errors (rate-limit /
outage) always import, so an AcoustID outage never stalls you. Requires AcoustID enabled.
</div>
</div>
</div>
@ -5811,6 +5757,13 @@
<input type="number" id="duration-tolerance-seconds" min="0" max="60" step="0.5" value="0" style="width: 100px;">
<small class="settings-hint">Maximum drift between the file's actual length and the metadata source's expected length before the file is quarantined. <strong>0 = auto</strong> (3s normal, 5s for tracks &gt;10min). Raise this if tracks routinely quarantine for being a few seconds off (live recordings, alternate masterings, etc). Capped at 60s.</small>
</div>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" id="audio-completeness-check">
Verify real audio with ffmpeg (detect truncated / silent files)
</label>
<small class="settings-hint">Decodes every downloaded file with ffmpeg to catch fake 30s previews padded to full length and mostly-silent files — the kind whose header lies about the length so mutagen can't catch them. <strong>Off by default:</strong> this fully decodes each file, the most CPU-heavy post-processing step. Most sources (HiFi, Qobuz) already catch previews on their own, so only turn this on if you see padded/silent files slip through. Applies to all download sources.</small>
</div>
</div>
<!-- Tag Embedding — per-tag toggles grouped by source -->
@ -6425,10 +6378,32 @@
</div>
</div>
<!-- Import Quality Settings -->
<!-- Import Settings (collapsible tile) -->
<div class="settings-section-header collapsed" data-stg="library" onclick="this.classList.toggle('collapsed'); const b=this.nextElementSibling; b.classList.toggle('collapsed'); b.style.display=b.classList.contains('collapsed')?'none':''">
<span class="settings-section-arrow">&#9660;</span>
<h3>Import</h3>
<span class="settings-section-hint">Quality filter, replace rules, folder-artist</span>
</div>
<div class="settings-section-body collapsed" data-stg="library">
<div class="settings-group" data-stg="library">
<h3>📥 Import Settings</h3>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" id="import-quality-filter-enabled" checked>
Only import tracks that meet your quality profile
</label>
</div>
<div class="help-text">
On by default. SoulSync always probes each downloaded file's real audio quality; this
toggle decides what to do with the result. <strong>On</strong> — files that don't meet your
quality profile are quarantined instead of imported (the same gate the download pipeline
uses; fallback/downsample settings still apply). <strong>Off</strong> — everything is
imported regardless of quality. Either way, the library <em>Quality Upgrade Scanner</em>
still flags below-profile tracks so you can act on them later.
</div>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" id="import-replace-lower-quality">
@ -6473,6 +6448,8 @@
</div>
</div>
</div><!-- end Import body -->
<!-- M3U Export Settings -->
<div class="settings-group" data-stg="library">
@ -8262,9 +8239,6 @@
<button class="library-history-tab" data-tab="import" onclick="switchHistoryTab('import')">
Server Imports <span class="library-history-tab-count" id="history-import-count">0</span>
</button>
<button class="library-history-tab" data-tab="quarantine" onclick="switchHistoryTab('quarantine')">
Quarantine <span class="library-history-tab-count" id="history-quarantine-count">0</span>
</button>
</div>
<div class="history-source-bar" id="history-source-bar" style="display:none"></div>
<div class="library-history-list" id="library-history-list"></div>

View file

@ -10,6 +10,7 @@ import type {
ImportAutoImportResultsPayload,
ImportAutoImportSettingsPayload,
ImportAutoImportStatusPayload,
ImportOptionsPayload,
ImportProcessPayload,
ImportStagingFilesPayload,
ImportStagingGroupsPayload,
@ -25,7 +26,7 @@ export const IMPORT_QUERY_KEY = ['import'] as const;
// which left the progress bar stuck at 0 and showing "Failed" while files
// imported fine (#772). Give the import-process calls a generous bound so the
// responses actually arrive and the bar advances. Scoped to import only.
const IMPORT_REQUEST_TIMEOUT_MS = 300_000; // 5 min/track
const IMPORT_REQUEST_TIMEOUT_MS = 300_000; // 5 min/track
export async function fetchImportStagingFiles(): Promise<ImportStagingFilesPayload> {
return readJson<ImportStagingFilesPayload>(apiClient.get('import/staging/files'));
@ -225,6 +226,45 @@ export function autoImportResultsQueryOptions() {
});
}
// --- Import behaviour toggles (mirrors Settings → Import) ---
// Both live under the `import.*` config namespace. GET reads the whole settings
// blob; POST /api/settings partial-merges, so we only send the import keys.
type SettingsBlob = {
import?: { quality_filter_enabled?: boolean; folder_artist_override?: boolean };
};
export async function fetchImportOptions(): Promise<ImportOptionsPayload> {
const data = await readJson<SettingsBlob>(apiClient.get('settings'));
const imp = data.import ?? {};
return {
// Both default ON when the key is absent (matches the backend defaults).
qualityFilterEnabled: imp.quality_filter_enabled !== false,
folderArtistOverride: imp.folder_artist_override !== false,
};
}
export async function saveImportOptions(
options: ImportOptionsPayload,
): Promise<{ success: boolean; error?: string }> {
return readJson<{ success: boolean; error?: string }>(
apiClient.post('settings', {
json: {
import: {
quality_filter_enabled: options.qualityFilterEnabled,
folder_artist_override: options.folderArtistOverride,
},
},
}),
);
}
export function importOptionsQueryOptions() {
return queryOptions({
queryKey: [...IMPORT_QUERY_KEY, 'import-options'],
queryFn: fetchImportOptions,
});
}
export function invalidateImportQueries(queryClient: QueryClient) {
return queryClient.invalidateQueries({ queryKey: IMPORT_QUERY_KEY });
}

View file

@ -241,3 +241,10 @@ export interface ImportSinglesQueueJob {
}
export type ImportQueueJob = ImportAlbumQueueJob | ImportSinglesQueueJob;
// The two import behaviour toggles also shown in Settings → Import, mirrored
// onto the Import page for visibility. Both map to `import.*` config keys.
export interface ImportOptionsPayload {
qualityFilterEnabled: boolean;
folderArtistOverride: boolean;
}

View file

@ -56,6 +56,25 @@
color: rgba(255, 255, 255, 0.5);
}
.importOptionsRow {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 22px;
padding: 10px 16px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 10px;
font-size: 13px;
color: rgba(255, 255, 255, 0.7);
}
.importOption {
display: flex;
align-items: center;
gap: 8px;
}
.importStagingPath {
flex: 1;
overflow: hidden;

View file

@ -1,12 +1,14 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Link, Outlet } from '@tanstack/react-router';
import clsx from 'clsx';
import { Button } from '@/components/form/form';
import { Button, Switch } from '@/components/form/form';
import { Show } from '@/components/primitives';
import { useReactPageShell } from '@/platform/shell/route-controllers';
import type { ImportQueueEntry } from '../-import.types';
import type { ImportOptionsPayload, ImportQueueEntry } from '../-import.types';
import { importOptionsQueryOptions, IMPORT_QUERY_KEY, saveImportOptions } from '../-import.api';
import {
getQueueProgressPercent,
getQueueStatusText,
@ -36,6 +38,7 @@ export function ImportPage() {
lastRefreshedAt={lastRefreshedAt}
onRefresh={refreshStaging}
/>
<ImportOptions />
<ImportProcessingQueue />
<ImportTabNav />
<section className={clsx(styles.importPageTabContent, styles.active)}>
@ -54,6 +57,75 @@ function formatShortTime(timestamp: number) {
});
}
// The two import behaviour toggles, also in Settings → Import, surfaced here so
// they're visible right where you import. Each writes its `import.*` config key
// on change (POST /api/settings partial-merges, so the rest of config is safe).
function ImportOptions() {
const queryClient = useQueryClient();
const optionsQuery = useQuery(importOptionsQueryOptions());
const saveMutation = useMutation({
mutationFn: saveImportOptions,
onSuccess: () => {
window.showToast?.('Import options saved', 'success');
},
onError: (error: unknown) => {
window.showToast?.(
error instanceof Error ? error.message : 'Could not save import options',
'error',
);
// Re-sync the toggles to the server's actual state after a failed save.
void queryClient.invalidateQueries({
queryKey: [...IMPORT_QUERY_KEY, 'import-options'],
});
},
});
const opts = optionsQuery.data;
if (!opts) return null;
const update = (patch: Partial<ImportOptionsPayload>) => {
const next = { ...opts, ...patch };
// Optimistic: reflect the toggle immediately, the mutation persists it.
queryClient.setQueryData([...IMPORT_QUERY_KEY, 'import-options'], next);
saveMutation.mutate(next);
};
return (
<section className={styles.importOptionsRow} data-testid="import-options">
<div className={styles.importOption}>
<Switch
id="import-quality-filter"
checked={opts.qualityFilterEnabled}
disabled={saveMutation.isPending}
aria-labelledby="import-quality-filter-label"
onCheckedChange={(checked) => update({ qualityFilterEnabled: checked })}
/>
<span
id="import-quality-filter-label"
title="Only import tracks that meet your quality profile; otherwise import everything regardless of quality."
>
Quality check on import
</span>
</div>
<div className={styles.importOption}>
<Switch
id="import-folder-artist"
checked={opts.folderArtistOverride}
disabled={saveMutation.isPending}
aria-labelledby="import-folder-artist-label"
onCheckedChange={(checked) => update({ folderArtistOverride: checked })}
/>
<span
id="import-folder-artist-label"
title="Use the top Staging folder as the album artist (good for mixtapes; turn off for mixed piles of songs)."
>
Use folder as artist
</span>
</div>
</section>
);
}
function ImportHeader({
error,
fileCountText,

View file

@ -73,6 +73,17 @@ function autoSyncIntervalLabel(hours) {
return `Every ${hours} hour${hours === 1 ? '' : 's'}`;
}
// Short cadence word for the lane badge (under the interval number).
function autoSyncLaneCadence(hours) {
if (hours === 1) return 'Hourly';
if (hours === 12) return 'Twice a day';
if (hours === 24) return 'Daily';
if (hours === 168) return 'Weekly';
if (hours < 24) return `Every ${hours}h`;
const days = hours / 24;
return `Every ${days} days`;
}
// Browser-detected default tz for new schedules. Used when the user
// creates a weekly schedule and hasn't picked an explicit tz — falls
// back to UTC on browsers where Intl is unavailable (very old ones).
@ -354,6 +365,12 @@ function renderAutoSyncScheduleModal() {
const overlay = document.getElementById('auto-sync-schedule-modal');
if (!overlay) return;
// Preserve the visible lane board's scroll across the full re-render so dropping /
// removing a playlist doesn't snap it back to the top (the user used to have to scroll
// back). Targets the ACTIVE tab so it works for both the hourly + weekly boards.
const _prevLanes = overlay.querySelector('.auto-sync-tab-panel.active .auto-sync-lanes');
const _prevScroll = _prevLanes ? _prevLanes.scrollTop : null;
const { playlists, playlistSchedules, weeklySchedules, automationPipelines, runHistory, runHistoryTotal } = _autoSyncScheduleState;
const scheduledCount = Object.keys(playlistSchedules).length + Object.keys(weeklySchedules || {}).length;
const enabledCount = Object.values(playlistSchedules).filter(s => s.enabled).length
@ -408,6 +425,11 @@ function renderAutoSyncScheduleModal() {
`;
populateAutoSyncHistoryList(overlay);
bindAutoSyncHistoryCardInteractions(overlay);
if (_prevScroll != null) {
const nl = overlay.querySelector('.auto-sync-tab-panel.active .auto-sync-lanes');
if (nl) nl.scrollTop = _prevScroll;
}
}
function setAutoSyncTab(tab) {
@ -479,17 +501,23 @@ function renderAutoSyncSchedulePanel(playlists, playlistSchedules) {
.map(s => parseInt(s?.hours, 10))
.filter(h => Number.isFinite(h) && h > 0 && !AUTO_SYNC_BUCKETS.includes(h));
const allBuckets = [...new Set([...AUTO_SYNC_BUCKETS, ...customHours])].sort((a, b) => a - b);
// Concept 1 — interval LANES (horizontal rows) instead of columns: no side-scroll,
// empty intervals collapse to thin strips, busy ones grow. Same drag handlers + card.
const bucketHtml = allBuckets.map(hours => {
const assigned = schedulablePlaylists.filter(p => playlistSchedules[p.id]?.hours === hours);
const isCustom = !AUTO_SYNC_BUCKETS.includes(hours);
const filled = assigned.length > 0;
return `
<div class="auto-sync-column ${isCustom ? 'custom' : ''}" data-hours="${hours}" ondragover="autoSyncDragOver(event)" ondragleave="autoSyncDragLeave(event)" ondrop="autoSyncDrop(event, ${hours})">
<div class="auto-sync-column-head">
<span>${autoSyncBucketLabel(hours)}${isCustom ? ' <em>custom</em>' : ''}</span>
<small>${assigned.length} playlist${assigned.length === 1 ? '' : 's'}</small>
<div class="auto-sync-lane ${filled ? 'filled' : 'empty'} ${isCustom ? 'custom' : ''}" data-hours="${hours}" ondragover="autoSyncDragOver(event)" ondragleave="autoSyncDragLeave(event)" ondrop="autoSyncDrop(event, ${hours})">
<div class="auto-sync-lane-badge">
<b>${autoSyncBucketLabel(hours)}</b>
<span>${_esc(autoSyncLaneCadence(hours))}${isCustom ? ' · custom' : ''}</span>
${filled ? `<em class="auto-sync-lane-count">${assigned.length}</em>` : ''}
</div>
<div class="auto-sync-column-list">
${assigned.length ? assigned.map(p => autoSyncScheduledCardHtml(p, playlistSchedules[p.id])).join('') : '<div class="auto-sync-drop-hint"><strong>Drop here</strong><span>Schedule playlists at this interval</span></div>'}
<div class="auto-sync-lane-track">
${filled
? assigned.map(p => autoSyncScheduledCardHtml(p, playlistSchedules[p.id])).join('')
: `<div class="auto-sync-lane-hint"><span class="auto-sync-lane-hint-ic">+</span> Drag a playlist here to sync ${_esc(autoSyncIntervalLabel(hours).toLowerCase())}</div>`}
</div>
</div>
`;
@ -514,7 +542,7 @@ function renderAutoSyncSchedulePanel(playlists, playlistSchedules) {
</div>
<div class="auto-sync-source-list">${sidebarHtml}${unavailableHtml}</div>
</aside>
<main class="auto-sync-board">${bucketHtml}</main>
<main class="auto-sync-lanes">${bucketHtml}</main>
</div>
`;
}
@ -598,21 +626,24 @@ function renderAutoSyncWeeklyPanel(playlists, playlistSchedules) {
});
});
// Day LANES (horizontal rows, MonSun) — mirrors the hourly board's lane layout.
const dayColumnsHtml = AUTO_SYNC_WEEKDAYS.map(day => {
const cards = cardsByDay[day];
const cardHtml = cards.length
const filled = cards.length > 0;
const cardHtml = filled
? cards.map(({ playlist, schedule }) => autoSyncWeeklyCardHtml(playlist, schedule)).join('')
: '<div class="auto-sync-drop-hint"><strong>Drop here</strong><span>Schedule playlists on this day</span></div>';
: `<div class="auto-sync-lane-hint"><span class="auto-sync-lane-hint-ic">+</span> Drag a playlist here to sync every ${_esc(AUTO_SYNC_WEEKDAY_LABELS[day])}</div>`;
return `
<div class="auto-sync-column auto-sync-weekly-column" data-day="${day}"
<div class="auto-sync-lane ${filled ? 'filled' : 'empty'}" data-day="${day}"
ondragover="autoSyncWeeklyDragOver(event)"
ondragleave="autoSyncWeeklyDragLeave(event)"
ondrop="autoSyncWeeklyDrop(event, '${day}')">
<div class="auto-sync-column-head">
<span>${AUTO_SYNC_WEEKDAY_LABELS[day]}</span>
<small>${cards.length} playlist${cards.length === 1 ? '' : 's'}</small>
<div class="auto-sync-lane-badge">
<b>${_esc(AUTO_SYNC_WEEKDAY_LABELS[day])}</b>
<span>Weekly</span>
${filled ? `<em class="auto-sync-lane-count">${cards.length}</em>` : ''}
</div>
<div class="auto-sync-column-list">${cardHtml}</div>
<div class="auto-sync-lane-track">${cardHtml}</div>
</div>
`;
}).join('');
@ -637,7 +668,7 @@ function renderAutoSyncWeeklyPanel(playlists, playlistSchedules) {
</div>
<div class="auto-sync-source-list">${sidebarHtml}${unavailableHtml}</div>
</aside>
<main class="auto-sync-board auto-sync-weekly-board">${dayColumnsHtml}</main>
<main class="auto-sync-lanes auto-sync-weekly-lanes">${dayColumnsHtml}</main>
</div>
${editorHtml}
`;

View file

@ -19,6 +19,7 @@ let personalizedPopularPicks = [];
let personalizedHiddenGems = [];
let personalizedDailyMixes = [];
let personalizedDiscoveryShuffle = [];
let personalizedListeningMix = []; // #913: the "Your Listening Mix" track playlist
let buildPlaylistSelectedArtists = [];
async function loadDiscoverPage() {
@ -27,6 +28,8 @@ async function loadDiscoverPage() {
// Load all sections
await Promise.all([
loadDiscoverHero(),
loadListeningRecommendations(), // #913: play-weighted, consensus-ranked picks
loadPersonalizedListeningMix(), // #913: playable track mix from those picks
loadRecommendedArtistsSection(),
loadYourArtists(),
loadYourAlbums(),
@ -676,10 +679,28 @@ async function addAllRecommendedToWatchlist(btn) {
// machinery, so the inline carousel and the "View All" modal stay in sync.
let _recommendedSectionCtrl = null;
function _renderRecommendedMini(artist, source) {
// "Because you listen to X, Y" — the listening-driven (#913) variant of the reason line.
function _listeningRecommendationReason(artist) {
const names = (artist && artist.because) || [];
if (names.length === 1) return `Because you listen to ${escapeHtml(names[0])}`;
if (names.length === 2) return `Because you listen to ${escapeHtml(names[0])} & ${escapeHtml(names[1])}`;
if (names.length >= 3) {
const shown = names.slice(0, 2).map(escapeHtml).join(', ');
return `Because you listen to ${shown} +${names.length - 2} more`;
}
return 'From artists you play often';
}
function _listeningRecommendationReasonTitle(artist) {
const names = (artist && artist.because) || [];
return names.length ? `You listen to: ${names.join(', ')}` : '';
}
function _renderRecommendedMini(artist, source, opts) {
const reasonFn = (opts && opts.reasonFn) || _recommendationReason;
const titleFn = (opts && opts.titleFn) || _recommendationReasonTitle;
const artistSource = artist.source || source || '';
const reason = _recommendationReason(artist);
const reasonTitle = _recommendationReasonTitle(artist);
const reason = reasonFn(artist);
const reasonTitle = titleFn(artist);
const genreTags = (artist.genres || []).slice(0, 2).map(g =>
`<span class="recommended-card-genre">${escapeHtml(g)}</span>`
).join('');
@ -712,7 +733,7 @@ function _renderRecommendedMini(artist, source) {
// Progressively fill in images for the cards we actually rendered (the API
// returns cached images only; the rest are fetched on demand — same endpoint
// the modal uses).
async function _enrichRecommendedCarouselCards(items, source) {
async function _enrichRecommendedCarouselCards(items, source, carouselId) {
const idKey = source === 'spotify' ? 'spotify_artist_id'
: source === 'deezer' ? 'deezer_artist_id'
: 'itunes_artist_id';
@ -726,7 +747,7 @@ async function _enrichRecommendedCarouselCards(items, source) {
});
const data = await resp.json();
if (!data.success || !data.artists) return;
const carousel = document.getElementById('recommended-artists-carousel');
const carousel = document.getElementById(carouselId || 'recommended-artists-carousel');
if (!carousel) return;
for (const [aid, info] of Object.entries(data.artists)) {
if (!info.image_url) continue;
@ -778,6 +799,48 @@ async function loadRecommendedArtistsSection() {
return _recommendedSectionCtrl.load();
}
// #913: "Based On Your Listening" — play-weighted, consensus-ranked recommendations.
// Mirrors loadRecommendedArtistsSection but reads the listening-driven endpoint and
// renders a "Because you listen to X" reason. Hides itself when empty (no scan yet).
let _listeningRecsCtrl = null;
async function loadListeningRecommendations() {
if (!_listeningRecsCtrl) {
_listeningRecsCtrl = createDiscoverSectionController({
id: 'listening-recs',
sectionEl: '#listening-recs-section',
contentEl: '#listening-recs-carousel',
fetchUrl: '/api/discover/listening-recommendations',
extractItems: (data) => data.artists || [],
isEmpty: (items) => items.length === 0,
hideWhenEmpty: true,
renderItems: (items, data) => {
const source = data.source || 'spotify';
const shown = items.slice(0, 18);
return shown.map(a => _renderRecommendedMini(a, source, {
reasonFn: _listeningRecommendationReason,
titleFn: _listeningRecommendationReasonTitle,
})).join('');
},
onRendered: ({ data }) => {
const carousel = document.getElementById('listening-recs-carousel');
if (carousel && !carousel._recoWired) {
carousel._recoWired = true;
carousel.addEventListener('click', function (e) {
const btn = e.target.closest('.recommended-card-watchlist-btn');
if (btn) { e.preventDefault(); e.stopPropagation(); toggleRecommendedWatchlist(btn); }
});
}
const source = (data && data.source) || 'spotify';
_enrichRecommendedCarouselCards((data && data.artists || []).slice(0, 18), source, 'listening-recs-carousel');
},
loadingMessage: 'Reading your listening...',
emptyMessage: 'Play more music and run a watchlist scan to see picks based on your listening',
errorMessage: 'Failed to load listening recommendations',
});
}
return _listeningRecsCtrl.load();
}
function closeRecommendedArtistsModal() {
const modal = document.getElementById('recommended-artists-modal');
if (modal) modal.style.display = 'none';
@ -4246,6 +4309,32 @@ async function loadPersonalizedHiddenGems() {
}
}
// #913: "Your Listening Mix" — a playable track playlist from the artists matched to your
// listening. Mirrors loadPersonalizedHiddenGems; tracks come pre-shaped from the scan so the
// row renders + syncs like the others. Hides when empty (no scan / no listening data yet).
async function loadPersonalizedListeningMix() {
try {
const container = document.getElementById('personalized-listening-mix');
if (!container) return;
const response = await fetch('/api/discover/personalized/listening-mix');
if (!response.ok) return;
const data = await response.json();
if (!data.success || !data.tracks || data.tracks.length === 0) {
container.closest('.discover-section').style.display = 'none';
return;
}
personalizedListeningMix = data.tracks;
renderCompactPlaylist(container, data.tracks);
container.closest('.discover-section').style.display = 'block';
} catch (error) {
console.error('Error loading listening mix:', error);
}
}
async function loadPersonalizedDailyMixes() {
try {
const container = document.getElementById('daily-mixes-grid');
@ -4300,7 +4389,6 @@ function renderCompactPlaylist(container, tracks) {
const durationMin = Math.floor(track.duration_ms / 60000);
const durationSec = Math.floor((track.duration_ms % 60000) / 1000);
const duration = `${durationMin}:${durationSec.toString().padStart(2, '0')}`;
const artistEsc = (track.artist_name || '').replace(/'/g, "\\'").replace(/"/g, '&quot;');
html += `
<div class="discover-playlist-track-compact" data-track-index="${index}">
@ -4314,7 +4402,6 @@ function renderCompactPlaylist(container, tracks) {
</div>
<div class="track-compact-album">${track.album_name}</div>
<div class="track-compact-duration">${duration}</div>
<button class="track-compact-block" onclick="event.stopPropagation(); blockDiscoveryArtist('${artistEsc}')" title="Block ${artistEsc} from discovery"></button>
</div>
`;
});
@ -8646,6 +8733,8 @@ async function openDownloadModalForDiscoverPlaylist(playlistType, playlistName)
tracks = personalizedHiddenGems;
} else if (playlistType === 'discovery_shuffle') {
tracks = personalizedDiscoveryShuffle;
} else if (playlistType === 'listening_mix') {
tracks = personalizedListeningMix;
} else if (playlistType === 'build_playlist') {
tracks = buildPlaylistTracks;
}
@ -8765,6 +8854,8 @@ async function startDiscoverPlaylistSync(playlistType, playlistName) {
tracks = personalizedHiddenGems;
} else if (playlistType === 'discovery_shuffle') {
tracks = personalizedDiscoveryShuffle;
} else if (playlistType === 'listening_mix') {
tracks = personalizedListeningMix;
} else if (playlistType === 'build_playlist') {
tracks = buildPlaylistTracks;
}

View file

@ -3825,7 +3825,7 @@ function processModalStatusUpdate(playlistId, data) {
// Distinguish quarantine outcomes from generic
// failures — the file is recoverable, not lost.
const _em = (task.error_message || '').toLowerCase();
if (_em.includes('integrity check failed') || _em.includes('bit depth filter') || _em.includes('verification failed') || _em.includes('quarantin')) {
if (_em.includes('integrity check failed') || _em.includes('bit depth filter') || _em.includes('verification failed') || _em.includes('quality filter') || _em.includes('audio guard') || _em.includes('silence guard') || _em.includes('quarantin')) {
isQuarantinedTask = true;
statusText = '🛡️ Quarantined';
} else {

Some files were not shown because too many files have changed in this diff Show more