diff --git a/PLAN.md b/PLAN.md index 60a86c28..01a464f1 100644 --- a/PLAN.md +++ b/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) diff --git a/docs/superpowers/specs/2026-06-14-global-quality-system-design.md b/docs/superpowers/specs/2026-06-14-global-quality-system-design.md new file mode 100644 index 00000000..df7dbbfa --- /dev/null +++ b/docs/superpowers/specs/2026-06-14-global-quality-system-design.md @@ -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).