commit
c8accf4e28
110 changed files with 7868 additions and 1679 deletions
4
.github/workflows/docker-publish.yml
vendored
4
.github/workflows/docker-publish.yml
vendored
|
|
@ -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:
|
||||
|
|
|
|||
74
DISCOVER_BEST_IN_CLASS_PLAN.md
Normal file
74
DISCOVER_BEST_IN_CLASS_PLAN.md
Normal 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
13
RELEASE_2.7.9_discord.md
Normal 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! 🎶
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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, []
|
||||
|
|
|
|||
|
|
@ -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 0–1 weight.
|
||||
|
||||
SoulSync stores each ``(seed → similar)`` edge with a 1–10 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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
best→worst 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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"""
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'] = (
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
(best→worst) 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 []
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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})"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 "",
|
||||
}
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
277
core/imports/silence.py
Normal 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)
|
||||
|
|
@ -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"])
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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'))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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'))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
68
core/personalized/generators/listening_mix.py
Normal file
68
core/personalized/generators/listening_mix.py
Normal 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)
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
0
core/quality/__init__.py
Normal file
284
core/quality/model.py
Normal file
284
core/quality/model.py
Normal 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
148
core/quality/selection.py
Normal 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 best→worst 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
203
core/quality/source_map.py
Normal 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
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
416
core/repair_jobs/quality_upgrade_scanner.py
Normal file
416
core/repair_jobs/quality_upgrade_scanner.py
Normal 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
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 seed→similar), 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 id→name
|
||||
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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 5–10 tracks because future-dated albums ate the candidate budget.
|
||||
- the **SoulSync Discovery** tab on the Sync page now lists *every* playlist kind (incl. the Listening Mix) so you can mirror + auto-sync them.
|
||||
|
||||
### 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
|
||||
---
|
||||
|
|
|
|||
79
tests/discovery/test_listening_mix_generator.py
Normal file
79
tests/discovery/test_listening_mix_generator.py
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
57
tests/discovery/test_recent_albums_future_filter.py
Normal file
57
tests/discovery/test_recent_albums_future_filter.py
Normal 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
|
||||
69
tests/downloads/test_candidate_ordering.py
Normal file
69
tests/downloads/test_candidate_ordering.py
Normal 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
|
||||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
66
tests/downloads/test_orchestrator_search_mode.py
Normal file
66
tests/downloads/test_orchestrator_search_mode.py
Normal 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']
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
47
tests/downloads/test_worker_quality_ordering.py
Normal file
47
tests/downloads/test_worker_quality_ordering.py
Normal 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)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
127
tests/imports/test_quality_guard.py
Normal file
127
tests/imports/test_quality_guard.py
Normal 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')
|
||||
|
|
@ -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():
|
||||
|
|
|
|||
104
tests/imports/test_silence_guard.py
Normal file
104
tests/imports/test_silence_guard.py
Normal 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
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
127
tests/quality/test_engine_fallback.py
Normal file
127
tests/quality/test_engine_fallback.py
Normal 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
130
tests/quality/test_model.py
Normal 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
|
||||
60
tests/quality/test_quality_meets_profile.py
Normal file
60
tests/quality/test_quality_meets_profile.py
Normal 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)
|
||||
81
tests/quality/test_quality_presets.py
Normal file
81
tests/quality/test_quality_presets.py
Normal 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
|
||||
113
tests/quality/test_search_all_sources.py
Normal file
113
tests/quality/test_search_all_sources.py
Normal 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 == []
|
||||
64
tests/quality/test_search_mode.py
Normal file
64
tests/quality/test_search_mode.py
Normal 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 best→worst 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
|
||||
64
tests/quality/test_selection.py
Normal file
64
tests/quality/test_selection.py
Normal 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 == []
|
||||
147
tests/quality/test_source_map.py
Normal file
147
tests/quality/test_source_map.py
Normal 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
|
||||
62
tests/quality/test_source_tier.py
Normal file
62
tests/quality/test_source_tier.py
Normal 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'
|
||||
39
tests/quality/test_track_result_quality.py
Normal file
39
tests/quality/test_track_result_quality.py
Normal 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
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
70
tests/test_hifi_preview_detection.py
Normal file
70
tests/test_hifi_preview_detection.py
Normal 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
|
||||
|
|
@ -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 = []
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
68
tests/test_media_track_disc_number.py
Normal file
68
tests/test_media_track_disc_number.py
Normal 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)
|
||||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
65
tests/test_playlist_sync_status.py
Normal file
65
tests/test_playlist_sync_status.py
Normal 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'
|
||||
37
tests/test_popularity_thresholds.py
Normal file
37
tests/test_popularity_thresholds.py
Normal 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)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
83
tests/test_wing_it_pool.py
Normal file
83
tests/test_wing_it_pool.py
Normal 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}
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
383
web_server.py
383
web_server.py
|
|
@ -40,7 +40,7 @@ logger = setup_logging(_log_level, _log_path)
|
|||
|
||||
# App version — single source of truth for backup metadata, system-info, update check, etc.
|
||||
# Semver: MAJOR.MINOR.PATCH. Bump at each dev→main release.
|
||||
_SOULSYNC_BASE_VERSION = "2.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',
|
||||
|
|
|
|||
512
webui/index.html
512
webui/index.html
|
|
@ -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 >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">▼</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>
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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, Mon–Sun) — 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}
|
||||
`;
|
||||
|
|
|
|||
|
|
@ -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, '"');
|
||||
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in a new issue