docs: global quality system design spec + Monochrome 30s bug note
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>
This commit is contained in:
parent
4cc2401332
commit
97242cbd8d
2 changed files with 201 additions and 0 deletions
21
PLAN.md
21
PLAN.md
|
|
@ -45,6 +45,27 @@ v2-Profile werden automatisch migriert.
|
|||
|
||||
---
|
||||
|
||||
## Bekannte Bugs / offene Fragen
|
||||
|
||||
### BUG: HiFi/Monochrome liefert nur 30s Audio — wird nicht erkannt
|
||||
|
||||
**Symptom:** Jeder Download von Monochrome-Instanzen enthält nur ~30 Sekunden echtes Audio. Der Rest der Datei ist Stille.
|
||||
|
||||
**Warum es durchrutscht:**
|
||||
- Monochrome liefert einen HLS-Stream, aber nur die ersten ~30s der Segmente haben Audiodaten
|
||||
- ffmpeg muxed diese Segmente in einen Container mit der **vollen Laufzeit** (z.B. 3:30) — stille Segmente = Stille in der Datei
|
||||
- `mutagen` liest die Container-Länge (3:30 ✓) → Duration-Check besteht
|
||||
- `probe_audio_quality()` prüft Format/Bitrate/Sample-Rate → alles OK
|
||||
- Niemand prüft den **tatsächlichen Audioinhalt** — die Stille bleibt unerkannt
|
||||
|
||||
**Qobuz hat bereits einen Fix** (`duration_s < 35` in `qobuz_client.py:1315`) — aber der greift nur wenn die Datei selbst kurz ist, nicht beim Silence-Padding.
|
||||
|
||||
**Geplanter Fix:** `ffmpeg silencedetect` nach dem Download. Wenn die letzten X% der Datei unter -50dB → Preview → Quarantäne/Retry.
|
||||
|
||||
**Offene Frage (erst klären!):** Ist das ein bekanntes Monochrome-Problem (Preview-Limitation der API), oder ein Bug im HLS-Downloader von SoulSync?
|
||||
|
||||
---
|
||||
|
||||
## Was noch fehlt — nächste Schritte
|
||||
|
||||
### A) Andere Quellen anbinden (Source-Mapper)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,180 @@
|
|||
# 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).
|
||||
Loading…
Reference in a new issue