chore: drop internal planning docs/specs from the PR branch

Remove PLAN.md and the design specs — internal planning notes, not part
of the shipped feature. Keeps them out of the PR diff.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
dev 2026-06-20 22:10:22 +02:00
parent f9dfbd8fd5
commit 75f3b0aa71
3 changed files with 0 additions and 508 deletions

127
PLAN.md
View file

@ -1,127 +0,0 @@
# Plan: Global Quality System — `feature/global-quality-system`
## Was bereits implementiert ist (dieser Commit)
### 1. `core/quality/model.py` — Das Herzstück
Source-agnostisches Datenmodell. **Jede** Quelle mappt ihre Ergebnisse auf `AudioQuality`, der gleiche Ranker läuft für alle.
```
AudioQuality(format, bitrate, sample_rate, bit_depth)
QualityTarget(format, bit_depth, min_sample_rate, min_bitrate, label)
filter_and_rank(candidates, targets) ← eine Funktion für alle Quellen
```
### 2. Soulseek — echte Werte statt Heuristik
slskd `attributes` werden jetzt ausgelesen:
- `type 4` = sample_rate (Hz)
- `type 5` = bit_depth (bits)
Vorher: kbps-Schwellwert-Hack (1450 kbps = "wahrscheinlich 24-bit"). Jetzt: echte Werte direkt aus dem Protokoll.
### 3. Quality Profile v3
Statt `qualities: {flac: {enabled, priority, bit_depth}}` jetzt eine **geordnete Liste**:
```json
"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/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},
...
]
```
v2-Profile werden automatisch migriert.
### 4. Post-Download Verifikation (alle Quellen)
`probe_audio_quality()` liest die **echte heruntergeladene Datei** mit mutagen:
- FLAC: sample_rate + bit_depth + bitrate
- MP3: bitrate + sample_rate
- M4A/AAC/OGG/OPUS/WAV: bitrate + sample_rate
`check_quality_target()` prüft gegen die `ranked_targets`:
- Match → akzeptiert
- Kein Match + `fallback_enabled=False` → Quarantäne
- Nach Quarantäne → **Retry mit nächstem Kandidaten** (wie AcoustID)
---
## 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)
Diese Quellen geben keine echten Werte, haben aber definierte Tier-Strings die wir mappen:
| Quelle | Tier-String | AudioQuality |
|--------|------------|--------------|
| Tidal | `'hires'` | `flac, 24bit, 96kHz` |
| Tidal | `'lossless'` | `flac, 16bit, 44.1kHz` |
| HiFi | gleich wie Tidal | — |
| Deezer | `'flac'` | `flac, 16bit, 44.1kHz` |
**Wo**: `core/quality/model.py``TIDAL_TIER_MAP`, `DEEZER_TIER_MAP` hinzufügen
**Warum**: Damit `filter_and_rank()` auch für diese Quellen entscheiden kann ob eine Quelle das Ziel erfüllt (z.B. "ich will 24-bit, Tidal lossless reicht nicht")
**Qobuz ist Sonderfall** — liefert echte `maximum_sampling_rate` + `maximum_bit_depth` aus der API, diese direkt in `AudioQuality` befüllen.
### B) UI — Ranked Targets als editierbare Liste
Aktuell zeigt das Settings-UI noch die alte `qualities`-Ansicht (FLAC on/off, MP3 320 on/off).
Neu: Drag & Drop Prioritätsliste in den Settings:
```
[↕] FLAC 24-bit/192kHz [🗑]
[↕] FLAC 24-bit/96kHz [🗑]
[↕] FLAC 24-bit/44.1kHz [🗑]
[↕] FLAC 16-bit [🗑]
[↕] MP3 320kbps [🗑]
[+ Ziel hinzufügen]
```
**Wo**: `webui/static/settings.js` + `webui/index.html`
**API**: `GET/POST /api/quality-profile` existiert bereits, liefert jetzt v3
### C) Quarantäne-UI — Grund anzeigen
Wenn eine Datei wegen Quality quarantiniert wird, sollte das UI den Grund klar anzeigen:
- Welches Target wurde gesucht
- Was die Datei tatsächlich hat
- "Approve" Button setzt `_skip_quarantine_check = 'quality'`
### D) Tests
- `tests/quality/test_model.py``AudioQuality.matches_target()`, `filter_and_rank()`, Migration
- `tests/imports/test_quality_guard.py``check_quality_target()` mit verschiedenen Szenarien
---
## Branch auf neuem PC holen
```bash
git clone https://github.com/nick2000713/SoulSync.git
cd SoulSync
git fetch origin
git checkout feature/global-quality-system
```
Oder wenn der Fork schon geklont ist:
```bash
git fetch origin
git checkout feature/global-quality-system
```

View file

@ -1,201 +0,0 @@
# Best-Quality Search Mode — Design
**Date:** 2026-06-14
**Branch:** (new, off current `fix/import-folder-artist-override-optin`)
**Status:** Approved approach (Option A), pending spec review
## Goal
Add a user-toggleable download strategy. Today hybrid search is **priority-first**:
`engine.search_with_fallback` walks the source chain in priority order and accepts
the **first** source that meets a quality target — so a passable Soulseek 16-bit FLAC
"wins" even when HiFi/Qobuz/Tidal could deliver a 24-bit version of the same track.
The new **best-quality** mode instead searches **all** configured sources, pools their
candidates, and works them **best→worst by actual audio quality**. Source priority
becomes only a tiebreaker between equally-good candidates.
## Hard constraints (from the user)
1. **Two independent toggles.** The new `search_mode` and the existing
`post_processing.retry_exhaustive` are orthogonal. The feature must behave
correctly in **all four** combinations (priority/best × default/exhaustive budget).
2. **Budget semantics preserved 1:1.** No change to how retries are counted:
- Default mode: single global cap `MAX_QUARANTINE_RETRIES = 5` across all sources.
- Exhaustive mode: per-source budget `query_count × retries_per_query`.
When a source **completely spends** its budget, **all of that source's candidates
are removed from the entire pooled list** (not just skipped once).
3. **Do not touch the query generator.** `matching_engine.generate_download_queries`
and the legacy query-building block in `download_track_worker` stay exactly as-is.
4. **`force_import` never fires on quality** (unchanged existing invariant — only
AcoustID mismatches can force-import).
## Key realization: the budget-removal mechanism already exists
When a source spends its budget, `monitor.requeue_quarantined_task_for_retry`
already adds it to the task's **`exhausted_download_sources`** set, and the worker
already passes that set as `exclude_sources` to its searches. Constraint #2's
"remove the source from the whole list" is therefore **not new logic** — best-quality
mode simply consults the *same* set when assembling/filtering its pooled candidate
list. No new budget bookkeeping is introduced.
## Architecture
### The one genuinely new piece: quality-dominant candidate ordering
`attempt_download_with_candidates` (core/downloads/candidates.py:71) sorts candidates
**confidence-first, then `quality_score`**. That is correct for priority mode (never
download a high-quality *wrong* file). For best-quality mode we keep the
"correct-first" guarantee — `get_valid_candidates` already drops candidates below the
match threshold, so **every candidate in the list is correctly matched** — but among
those valid candidates we want **profile quality rank to dominate**, with confidence as
the tiebreaker.
Add an opt-in parameter:
```python
attempt_download_with_candidates(task_id, candidates, track, batch_id=None,
deps=None, *, quality_first=False)
```
- `quality_first=False` (default): today's sort, byte-for-byte unchanged.
- `quality_first=True`: sort key becomes
`(profile_quality_rank, confidence, upload_speed, -queue_length, free_slots, size)`,
i.e. profile quality dominates, all existing signals become tiebreakers.
`profile_quality_rank` is derived from the candidate's stamped `AudioQuality` against
the user's `ranked_targets` (reusing `core.quality.model` / `filter_and_rank` ordering).
Candidates with no usable quality info sort last (rank = worst), never dropped.
### New engine method: `search_all_sources`
Mirror of `search_with_fallback`, but it does **not** stop at the first satisfying
source. It iterates every configured, non-excluded source in the chain, runs each
source's `search`, and returns the **combined** raw track list (each track already
quality-stamped by its client's `set_quality`, as today). Per-source exceptions are
swallowed exactly like the existing method. Excluded (`exhausted`) sources are skipped.
```python
async def search_all_sources(self, query, source_chain, timeout=None,
progress_callback=None, exclude_sources=None)
-> Tuple[list, list] # (combined_tracks, combined_albums)
```
Quality ranking is **not** done here — the orchestrator/worker owns final ranking, same
division of labour as today (engine returns raw, orchestrator filters/ranks).
### Orchestrator wiring
`download_orchestrator.search(...)` gains awareness of `search_mode`:
- `priority` (default) → unchanged: `search_with_fallback`.
- `best_quality` **and** `mode == 'hybrid'``search_all_sources`.
- Single-source mode → unchanged regardless of `search_mode` (only one source exists;
its candidates are still quality-ranked downstream, so the toggle is a no-op there).
A thin helper `load_search_mode() -> str` reads the quality profile (see Config).
### Worker integration (the per-query loop stays intact)
In `download_track_worker`'s existing query loop (core/downloads/task_worker.py:398),
when best-quality mode is active:
1. The loop and the query generator are untouched.
2. `orchestrator.search(query, exclude_sources=_exclude_sources)` now returns
**pooled** results from all non-exhausted sources (via `search_all_sources`).
3. `candidates = get_valid_candidates(pooled, track, query)` — unchanged.
4. `ranked, _ = rank_for_profile(candidates)` — orders best→worst by profile quality.
5. `attempt_download_with_candidates(task_id, ranked, track, batch_id, quality_first=True)`.
Because the pool already came from every source, the **hybrid-fallback block**
(task_worker.py:529593, which re-tries `hybrid_order[1:]` individually) is
**redundant in best-quality mode** and is skipped — it would just re-search sources the
pool already covered. In priority mode it runs exactly as today.
The `exhausted_download_sources` set is already folded into `_exclude_sources`
(task_worker.py:446), so a budget-spent source is excluded from `search_all_sources`'s
pool automatically — satisfying constraint #2 with no new code. A belt-and-suspenders
filter drops any straggler exhausted-source candidates before step 5.
## Config / persistence
`search_mode` belongs to the quality profile (it *is* a quality-driven policy). Stored
in the v3 quality-profile JSON (`database.music_database.get_quality_profile`):
```json
{ "version": 3, "search_mode": "priority", ... }
```
- Default: `"priority"` (preserves today's behavior for every existing install).
- Accessor: extend `core/quality/selection.py` with `load_search_mode() -> str`
returning `'priority'` unless the profile says `'best_quality'`.
- No migration needed: a profile lacking the key reads as `'priority'`.
## UI
A single toggle in the existing **Quality Profile** tile (webui/index.html +
webui/static/settings.js), e.g. a labelled switch:
> **Search strategy:** ( ) Source priority — fastest, stops at first good source
> (•) Best quality — searches all sources, picks the highest quality
With a short note that best-quality searches **all** sources every track (slower / more
API calls). `collectQualityProfileFromUI` emits `search_mode`; `populateQualityProfileUI`
reads it. No change to the ranked-targets editor or the fallback toggle.
## Behaviour matrix (all four combinations must hold)
| search_mode | retry_exhaustive | Behaviour |
|---|---|---|
| priority | off | **Unchanged today.** First satisfying source wins; 5 global retries. |
| priority | on | **Unchanged today.** First source wins; per-source budgets. |
| best_quality | off | Pool all sources, best→worst; 5 **global** retries total. A source can't be "removed from the pool" because there's no per-source budget — the global cap fires first. (Documented: combine best-quality with exhaustive for per-source removal.) |
| best_quality | on | Pool all sources, best→worst; each source has `query_count × retries_per_query`; on spend → source removed from the whole pool via `exhausted_download_sources`. **This is the user's target configuration.** |
## Error handling
- `search_all_sources`: fail-open per source (swallow exceptions, keep going), identical
to `search_with_fallback`. If the whole pool is empty, the worker falls through to its
existing not-found handling.
- Quality-rank computation: any candidate that can't be ranked sorts last, never crashes
the walk (mirrors the engine's existing fail-open ranking).
## Testing (TDD)
New tests under `tests/quality/` and `tests/downloads/`:
1. **`load_search_mode`** — defaults to `'priority'`; returns `'best_quality'` when set;
unknown value falls back to `'priority'`.
2. **`search_all_sources`** — returns combined candidates from multiple fake sources;
skips excluded/exhausted sources; swallows a raising source; empty when all empty.
3. **quality-first ordering**`attempt_download_with_candidates(quality_first=True)`
tries a 24-bit candidate before a higher-confidence 16-bit one; `quality_first=False`
reproduces today's confidence-first order exactly (regression lock).
4. **budget-removal in pool** — given an exhausted source, its candidates never appear in
the walked list, while other sources' candidates remain (exhaustive mode).
5. **toggle independence** — orchestrator chooses `search_all_sources` only when
`search_mode == best_quality and mode == hybrid`; single-source and priority paths
call `search_with_fallback` (or single-client search) unchanged.
Each test written first, watched fail, then minimal code to green. No production code
before a failing test.
## Files touched
- `core/quality/selection.py` — add `load_search_mode()`; small quality-rank helper for the sort key (or reuse model).
- `core/download_engine/engine.py` — add `search_all_sources`.
- `core/download_orchestrator.py` — branch `search()` on `search_mode`.
- `core/downloads/candidates.py` — add `quality_first` param + alternate sort key.
- `core/downloads/task_worker.py` — pass pooled results + `quality_first=True`; skip the redundant hybrid-fallback block in best-quality mode. (Query generator block untouched.)
- `database/music_database.py` — include `search_mode` default in the v3 profile.
- `webui/index.html`, `webui/static/settings.js` — the toggle.
- `tests/quality/`, `tests/downloads/` — the tests above.
## Explicitly out of scope
- No change to the query generator.
- No change to the budget counters / `MAX_*` constants / `requeue_quarantined_task_for_retry`.
- No change to AcoustID / force_import behavior.
- No cross-query pooling: best-quality pools across **sources** within each query of the
existing loop. The primary query already covers all sources, so this captures the best
candidate without restructuring the loop. Later queries remain per-source fallbacks for
the empty-result case.

View file

@ -1,180 +0,0 @@
# 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).