Design for source-binding + quality-aware fall-through ranking (per-source population, source-priority-king), ranked-targets UI, quarantine-reason surfacing, and tests. Locks the constraints that quality quarantine reuses the trigger='quality' retry path and never sets force_imported (reserved for AcoustID mismatches). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
180 lines
8.6 KiB
Markdown
180 lines
8.6 KiB
Markdown
# Global Quality System — Source Binding, Ranking & UI
|
|
|
|
**Branch:** `feature/global-quality-system`
|
|
**Date:** 2026-06-14
|
|
**Status:** Approved design — ready for implementation plan
|
|
|
|
## Problem
|
|
|
|
The quality model (`core/quality/model.py`) is complete and Soulseek already
|
|
uses it, but the system has no cross-cutting quality behaviour:
|
|
|
|
1. **Streaming sources don't populate real quality.** `SearchResult.audio_quality`
|
|
is a derived property over `format/bitrate/sample_rate/bit_depth`, but Tidal,
|
|
HiFi, Deezer, Qobuz, Amazon, YouTube never fill `sample_rate`/`bit_depth` (and
|
|
sometimes not even the right `format`). Their `audio_quality` therefore falls
|
|
back to crude kbps heuristics.
|
|
2. **Ranking is inconsistent.** Only the Soulseek path runs
|
|
`filter_results_by_quality_preference`. Streaming results are ordered by
|
|
match-confidence only — quality is ignored.
|
|
3. **No quality-aware source fall-through.** `search_with_fallback` is
|
|
"first non-empty source wins": it never escalates to the next source when the
|
|
current source can't deliver the wanted quality.
|
|
4. **No UI for the v3 ranked-target list.** The profile model is v3 (ordered
|
|
target list) but there is no editor for it.
|
|
|
|
## Decisions (locked)
|
|
|
|
- **Per-Source Population, not cross-source pooling.** Source priority (the
|
|
hybrid chain order) stays king. Each source populates an accurate
|
|
`audio_quality`; the chain fall-through becomes quality-aware so a source that
|
|
cannot meet *any* target is skipped in favour of the next source.
|
|
- **Full scope:** source mappers (A) + ranking wiring + streaming-path unify,
|
|
UI ranked-target editor (B), quarantine-reason surfacing (C), tests (D).
|
|
- **Bitrate is a settable minimum threshold (a range "≥ X"), never an exact
|
|
match.** Lossless (FLAC/WAV) is matched on `bit_depth`/`sample_rate`; bitrate is
|
|
only a fallback heuristic when those are absent. This is already how
|
|
`AudioQuality.matches_target` behaves — the work is to expose it correctly in
|
|
the UI and add a small VBR tolerance for lossy presets.
|
|
|
|
## Hard constraints (must not regress)
|
|
|
|
These are already correct in the codebase and the new work must preserve them:
|
|
|
|
- **Retry harmony.** A quality reject already flows through the same retry path as
|
|
AcoustID: `check_quality_target` → `move_to_quarantine(trigger='quality')` →
|
|
`requeue_quarantined_task_for_retry(..., 'quality')` (pipeline.py:584-620). The
|
|
worker walks the next-best candidate using the per-source retry budget. New code
|
|
must keep `check_quality_target` returning a reason string that feeds this path.
|
|
- **force_import isolation.** `force_imported` status is set ONLY by the AcoustID
|
|
version-mismatch fallback (`core/imports/version_mismatch_fallback.py`). The
|
|
quality guard sets NO verification status — it quarantines with
|
|
`trigger='quality'` and the bypass flag `_skip_quarantine_check='quality'`. A
|
|
quality mismatch must NEVER become `force_imported`. force_import stays reserved
|
|
for AcoustID mismatches.
|
|
|
|
## A — Source mappers
|
|
|
|
New module **`core/quality/source_map.py`** centralises each source's tier
|
|
knowledge (kept out of `model.py` to avoid bloat). Each download client populates
|
|
the four quality fields (`quality`, `bitrate`, `sample_rate`, `bit_depth`) of its
|
|
`TrackResult` via these helpers; `audio_quality` then derives automatically.
|
|
|
|
Each tier value is a **claim**, verified post-download by `check_quality_target`
|
|
reading the real file. Ranking uses the claim to pick; the guard catches lies.
|
|
|
|
| Source | Source of values | Mapping |
|
|
|--------|------------------|---------|
|
|
| **Soulseek** | slskd attrs type 4 (sample_rate) / 5 (bit_depth) — real | already done (`AudioQuality.from_slskd_file`) |
|
|
| **Qobuz** | API `maximum_sampling_rate` (kHz) + `maximum_bit_depth` — real | `sample_rate = rate*1000`, `bit_depth`, `format='flac'` |
|
|
| **Deezer** | config code `flac`/`mp3_320`/`mp3_128` | flac→16-bit/44.1kHz · mp3_320→320kbps · mp3_128→128kbps |
|
|
| **Tidal** | track `audioQuality` tier | HI_RES_LOSSLESS/HI_RES→flac 24/96 · LOSSLESS→flac 16/44.1 · HIGH→aac 320 · LOW→aac 96 |
|
|
| **HiFi / Monochrome** | Tidal-backed; config quality key | same map as Tidal |
|
|
| **Amazon** | real `sampleRate` when present, else tier | UHD→24/96 · HD→16/44.1; prefer real sampleRate |
|
|
| **YouTube / SoundCloud** | yt-dlp / stream `format` + `abr` | lossy: format + bitrate, no bit_depth |
|
|
|
|
Helper shape:
|
|
|
|
```python
|
|
# core/quality/source_map.py
|
|
TIDAL_TIER_MAP: dict[str, AudioQuality]
|
|
AMAZON_TIER_MAP: dict[str, AudioQuality]
|
|
|
|
def quality_from_tidal_tier(tier: str) -> AudioQuality: ...
|
|
def quality_from_qobuz(sampling_rate_khz: float, bit_depth: int) -> AudioQuality: ...
|
|
def quality_from_deezer(code: str) -> AudioQuality: ...
|
|
def quality_from_amazon(stream_info: dict) -> AudioQuality: ...
|
|
```
|
|
|
|
Clients copy the result onto the `TrackResult` fields (small helper
|
|
`TrackResult.set_quality(aq)` to avoid four-line repetition at each call site).
|
|
|
|
## Wiring — quality-aware fall-through
|
|
|
|
New helper **`core/quality/selection.py`**:
|
|
|
|
```python
|
|
def rank_for_profile(candidates) -> tuple[list, bool]:
|
|
"""Return (ranked_candidates, satisfied_a_target).
|
|
|
|
satisfied = filter_and_rank(fallback_enabled=False) produced anything.
|
|
"""
|
|
```
|
|
|
|
`search_with_fallback` (`core/download_engine/engine.py`) changes from
|
|
"first non-empty wins" to:
|
|
|
|
```
|
|
best_fallback = []
|
|
for source in chain:
|
|
tracks, albums = source.search(query)
|
|
if not tracks: continue
|
|
ranked, satisfied = rank_for_profile(tracks)
|
|
if satisfied:
|
|
return ranked, albums # this source meets a target → done
|
|
best_fallback = best_fallback or (ranked, albums)
|
|
# chain exhausted, nothing satisfied a target
|
|
return best_fallback if fallback_enabled else ([], [])
|
|
```
|
|
|
|
**Key behavioural note:** with source-priority-king plus a long target list
|
|
(down to MP3 192), fall-through only triggers when a source meets *no* target at
|
|
all (below the floor) — e.g. user wants only FLAC and a source has only MP3.
|
|
Otherwise the first source that returns acceptable results wins, exactly as today
|
|
but now quality-ranked within.
|
|
|
|
## Streaming-path unification
|
|
|
|
In `download_orchestrator.search_and_download_best`, both paths converge on:
|
|
**match-filter first (right track), then quality-rank (best version).** The
|
|
streaming branch keeps its confidence filter (≥0.55) and then applies
|
|
`rank_for_profile` to the survivors; the Soulseek branch keeps its quality ranking
|
|
and is unchanged in spirit. No path is left quality-blind.
|
|
|
|
## B — Ranked-targets UI
|
|
|
|
A draggable, ordered list in the quality settings panel:
|
|
|
|
- Each row: drag handle, label, format, the relevant constraint
|
|
(bit_depth + min_sample_rate for lossless; **min_bitrate as a settable "≥ X
|
|
kbps" field** for lossy), delete button.
|
|
- "Add target" control with format/bit_depth/sample_rate/bitrate inputs.
|
|
- `fallback_enabled` toggle.
|
|
- Persists via the existing `GET/POST /api/quality-profile` (already v3-shaped).
|
|
|
|
Bitrate field is explicitly a **minimum threshold**, defaulting to small VBR
|
|
headroom for the common presets (e.g. a "320" preset stores `min_bitrate≈315` so
|
|
VBR/mono files near 320 still match). Lossless rows hide the bitrate field since
|
|
they match on bit_depth/sample_rate.
|
|
|
|
## C — Quarantine reason
|
|
|
|
Mostly wiring — the UI already carries `quarantineReason` dataset and an approve
|
|
flow (`webui/static/downloads.js`), and `check_quality_target` already returns a
|
|
"file is X, wanted Y" string.
|
|
|
|
- Ensure the quality rejection reason is threaded into the quarantine record's
|
|
reason field and rendered in the track-detail modal (what was wanted vs what the
|
|
file actually is).
|
|
- "Approve anyway" sets `_skip_quarantine_check='quality'` (the bypass already
|
|
honoured at pipeline.py:584-585).
|
|
|
|
## D — Tests
|
|
|
|
- `tests/quality/test_source_map.py` — each mapper produces the expected
|
|
`AudioQuality` (Tidal tiers, Qobuz kHz→Hz, Deezer codes, Amazon real-vs-tier,
|
|
lossy no bit_depth).
|
|
- `tests/quality/test_selection.py` — `rank_for_profile` satisfied/unsatisfied;
|
|
fall-through: source A below floor → engine escalates to source B; nothing
|
|
matches with `fallback_enabled` on (best returned) vs off (empty).
|
|
- Extend `tests/quality/test_model.py` — `matches_target` bitrate-as-minimum,
|
|
FLAC matched on bit_depth/sample_rate not bitrate, v2→v3 migration.
|
|
- `tests/imports/test_quality_guard.py` — `check_quality_target` quarantine
|
|
reason scenarios **plus a regression test asserting a quality mismatch uses
|
|
`trigger='quality'` and never sets `force_imported`.**
|
|
|
|
## Out of scope
|
|
|
|
- Cross-source candidate pooling (rejected in favour of per-source population).
|
|
- The Monochrome/HiFi 30-second-silence bug (tracked separately in `PLAN.md`;
|
|
needs ffmpeg `silencedetect`, not tier mapping).
|