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:
parent
f9dfbd8fd5
commit
75f3b0aa71
3 changed files with 0 additions and 508 deletions
127
PLAN.md
127
PLAN.md
|
|
@ -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
|
|
||||||
```
|
|
||||||
|
|
@ -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:529–593, 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.
|
|
||||||
|
|
@ -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).
|
|
||||||
Loading…
Reference in a new issue