Merge pull request #928 from nick2000713/fix/post-processing-race-and-followups

Fix import-vs-quarantine race + opt-in rank-based download order + quality-settings UI cleanup
This commit is contained in:
BoulderBadgeDad 2026-06-25 14:52:53 -07:00 committed by GitHub
commit 3c33e31985
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 469 additions and 95 deletions

View file

@ -171,6 +171,21 @@ def run_post_processing_worker(task_id: str, batch_id: str, deps: PostProcessDep
logger.info(f"[Post-Processing] Task {task_id} already completed by stream processor, skipping verification")
return
# RACE GUARD: the monitor sets status -> 'post_processing' immediately
# before submitting this worker. If the status is now anything else, the
# browser-poll post-processor already took ownership of this task — e.g.
# it quarantined the file and requeued the next-best candidate (status
# -> 'searching', source identity cleared). Bail WITHOUT marking failed
# or notifying batch completion: otherwise we clobber that in-flight
# retry with a bogus "missing file or source information" failure while a
# parallel attempt is importing the song.
if task['status'] != 'post_processing':
logger.info(
f"[Post-Processing] Task {task_id} no longer in 'post_processing' "
f"(now '{task['status']}') — another path took over, skipping"
)
return
# Extract file information for verification
track_info = task.get('track_info', {})
task_filename = task.get('filename') or track_info.get('filename')

View file

@ -54,21 +54,34 @@ def _cand_user_file(candidate):
return getattr(candidate, 'username', None), getattr(candidate, 'filename', None)
def _best_quality_ordering():
"""Return ``(quality_first, targets)`` for the active search mode.
def _candidate_ordering():
"""Return ``(quality_first, targets)`` for the active search mode + toggle.
In best-quality mode the candidate walk is ordered by the user's profile
quality rank (bestworst) instead of confidence-first. Fails closed to
priority-mode ordering on any error so a profile/DB hiccup never blocks a
download. See docs/superpowers/specs/2026-06-14-best-quality-search-mode-design.md.
The candidate walk is ordered by the user's profile quality rank
(bestworst) instead of confidence-first when EITHER:
- best-quality search mode is active (always quality-first), OR
- priority mode and the ``rank_candidates_by_quality`` toggle is on
(opt-in; default off keeps the byte-for-byte confidence-first walk).
Quality-first ordering also makes the version-mismatch force-import pick
the highest-quality candidate, because that fallback accepts the
first-tried (= best-ordered) quarantined entry.
Fails closed to confidence-first ordering on any error so a profile/DB
hiccup never blocks a download. See
docs/superpowers/specs/2026-06-14-best-quality-search-mode-design.md.
"""
try:
from core.quality.selection import load_search_mode, load_profile_targets
if load_search_mode() == 'best_quality':
from core.quality.selection import (
load_search_mode,
load_profile_targets,
load_rank_candidates_by_quality,
)
if load_search_mode() == 'best_quality' or load_rank_candidates_by_quality():
targets, _ = load_profile_targets()
return True, targets
except Exception as exc:
logger.debug("[Modal Worker] best-quality ordering unavailable: %s", exc)
logger.debug("[Modal Worker] quality ordering unavailable: %s", exc)
return False, None
@ -109,7 +122,7 @@ def _try_cached_candidates(task_id, batch_id, track, deps):
f"[Modal Worker] Quarantine retry: trying {len(remaining)} cached "
f"candidate(s) before re-searching (task {task_id})"
)
_qf, _qt = _best_quality_ordering()
_qf, _qt = _candidate_ordering()
return deps.attempt_download_with_candidates(
task_id, remaining, track, batch_id, quality_first=_qf, quality_targets=_qt,
)
@ -393,7 +406,7 @@ def download_track_worker(task_id: str, batch_id: Optional[str], deps: TaskWorke
# Best-quality search mode: the orchestrator already pooled candidates
# across every source for each query, so order the candidate walk by the
# user's profile quality rank (best→worst). Computed once per task.
_best_quality, _quality_targets = _best_quality_ordering()
_best_quality, _quality_targets = _candidate_ordering()
# 2. Sequential Query Search (matches GUI's start_search_worker_parallel logic)
search_diagnostics = [] # Track what happened per query for detailed error messages

View file

@ -103,9 +103,12 @@ def select_version_mismatch_fallback(
# don't guess which the user wants.
return None
# First tried = oldest = highest-confidence (the retry walks candidates
# best-first). The id is a "<date>_<time>_<name>" timestamp prefix, so the
# lexicographically smallest id is the earliest attempt.
# First tried = oldest = best (the retry walks candidates best-first; what
# "best" means follows the active ordering — confidence-first by default, or
# ranked-target quality when best_quality mode / the rank_candidates_by_quality
# toggle is on, so this naturally accepts the highest-quality candidate then).
# The id is a "<date>_<time>_<name>" timestamp prefix, so the lexicographically
# smallest id is the earliest attempt.
return min((e for _, e in candidates), key=lambda e: e["id"])

View file

@ -119,6 +119,26 @@ def load_search_mode() -> str:
return mode if mode in _VALID_SEARCH_MODES else "priority"
def load_rank_candidates_by_quality() -> bool:
"""Opt-in: order the priority-mode download walk (and thus the
version-mismatch force-import pick, which takes the first-tried = best)
by ranked-target quality instead of confidence-first.
Best-quality search mode is always quality-first regardless of this flag;
this toggle only affects *priority* mode. Default ``False`` keeps the
byte-for-byte old behaviour (confidence/peer-speed first), so existing
installs are unaffected unless they opt in. Any missing value or DB error
resolves to ``False``.
"""
from database.music_database import MusicDatabase
try:
profile = MusicDatabase().get_quality_profile()
return bool(profile.get("rank_candidates_by_quality", False))
except Exception:
return False
def rank_for_profile(candidates: list) -> Tuple[list, bool]:
"""Load the user's quality profile and rank *candidates* against it.

View file

@ -66,6 +66,39 @@ def add_activity_item(icon, title, subtitle, time_ago="Now", show_toast=True):
return activity_item
def claim_for_post_processing(task_id: str) -> bool:
"""Atomically claim a download task for post-processing.
The browser-poll status endpoint AND the background download monitor both
watch the same slskd/streaming transfers and each tries to post-process a
completed file. Without a single claim, BOTH run the verification pipeline
on the same download double imports, and a nasty race where one path
quarantines + requeues the next-best candidate (clearing the source identity
and resetting status to ``searching``) while the other, mid-flight, then
reports a bogus "missing file or source information" failure that clobbers
the in-flight retry.
The claim is the ``downloading``/``queued`` -> ``post_processing`` status
transition, done under ``tasks_lock``. Exactly one caller wins:
- Returns ``True`` (and flips the status) for the caller that claimed it.
- Returns ``False`` if the task is gone, already ``post_processing`` (owned
by the other path), requeued (``searching``), or terminal the caller
must then NOT process the file.
Acquires ``tasks_lock`` itself, so callers must NOT already hold it.
"""
with tasks_lock:
task = download_tasks.get(task_id)
if not task:
return False
if task.get("status") in ("downloading", "queued"):
task["status"] = "post_processing"
task["status_change_time"] = time.time()
return True
return False
@caller_must_hold_tasks_lock
def mark_task_completed(task_id: str, track_info: Optional[Dict[str, Any]] = None) -> bool:
"""Mark a download task as completed.

View file

@ -8824,6 +8824,7 @@ class MusicDatabase:
"preset": "balanced",
"fallback_enabled": True,
"search_mode": "priority",
"rank_candidates_by_quality": False,
"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},

View file

@ -117,6 +117,31 @@ def test_stream_processed_task_returns_early():
assert rec.calls == []
def test_requeued_task_bails_without_marking_failed():
"""RACE GUARD: the monitor sets status -> 'post_processing' and submits this
worker. If, before the worker runs, the browser-poll post-processor
quarantines the file and requeues the next-best candidate (status ->
'searching', username/filename cleared), this worker must bail WITHOUT
marking failed or notifying batch completion. Otherwise it clobbers the
in-flight retry with a false 'missing file or source information' failure
while a parallel attempt imports the song."""
download_tasks['t1'] = {'status': 'searching', 'track_info': {}}
deps, rec = _build_deps()
pp.run_post_processing_worker('t1', 'b1', deps)
assert download_tasks['t1']['status'] == 'searching' # untouched
assert 'error_message' not in download_tasks['t1']
assert not any(c[0] == 'on_complete' for c in rec.calls)
def test_queued_task_bails_without_marking_failed():
"""Same race guard for a task another path reset to 'queued'."""
download_tasks['t1'] = {'status': 'queued', 'track_info': {}}
deps, rec = _build_deps()
pp.run_post_processing_worker('t1', 'b1', deps)
assert download_tasks['t1']['status'] == 'queued'
assert not any(c[0] == 'on_complete' for c in rec.calls)
def test_missing_filename_marks_failed_and_calls_on_complete():
download_tasks['t1'] = {'status': 'post_processing', 'username': 'u1', 'track_info': {}}
deps, rec = _build_deps()

View file

@ -0,0 +1,47 @@
"""core.downloads.task_worker._candidate_ordering — decides whether the
download walk is ordered quality-first or confidence-first.
Quality-first applies when:
- best_quality search mode is active (always), OR
- priority mode AND the rank_candidates_by_quality toggle is on.
Default (priority mode, toggle off) confidence-first, the byte-for-byte old
behaviour. Any error fails closed to confidence-first so a DB hiccup never
blocks a download.
"""
import core.quality.selection as selection
import core.downloads.task_worker as task_worker
_TARGETS = ["t1", "t2"]
def _patch(monkeypatch, *, mode, rank_toggle):
monkeypatch.setattr(selection, "load_search_mode", lambda: mode)
monkeypatch.setattr(
selection, "load_rank_candidates_by_quality", lambda: rank_toggle
)
monkeypatch.setattr(selection, "load_profile_targets", lambda: (_TARGETS, True))
def test_priority_mode_toggle_off_is_confidence_first(monkeypatch):
_patch(monkeypatch, mode="priority", rank_toggle=False)
assert task_worker._candidate_ordering() == (False, None)
def test_priority_mode_toggle_on_is_quality_first(monkeypatch):
_patch(monkeypatch, mode="priority", rank_toggle=True)
assert task_worker._candidate_ordering() == (True, _TARGETS)
def test_best_quality_mode_is_quality_first_regardless_of_toggle(monkeypatch):
_patch(monkeypatch, mode="best_quality", rank_toggle=False)
assert task_worker._candidate_ordering() == (True, _TARGETS)
def test_fails_closed_to_confidence_first_on_error(monkeypatch):
def _boom():
raise RuntimeError("db down")
monkeypatch.setattr(selection, "load_search_mode", _boom)
assert task_worker._candidate_ordering() == (False, None)

View file

@ -33,3 +33,32 @@ def test_returns_best_quality_when_set(monkeypatch):
def test_unknown_value_falls_back_to_priority(monkeypatch):
_patch_profile(monkeypatch, {"version": 3, "search_mode": "nonsense"})
assert selection.load_search_mode() == "priority"
# ── rank_candidates_by_quality toggle ───────────────────────────────────────
# Opt-in: order the priority-mode download walk by ranked-target quality
# instead of confidence-first. Default OFF = byte-for-byte old behaviour.
def test_rank_by_quality_defaults_false_when_key_absent(monkeypatch):
_patch_profile(monkeypatch, {"version": 3, "ranked_targets": []})
assert selection.load_rank_candidates_by_quality() is False
def test_rank_by_quality_true_when_enabled(monkeypatch):
_patch_profile(monkeypatch, {"version": 3, "rank_candidates_by_quality": True})
assert selection.load_rank_candidates_by_quality() is True
def test_rank_by_quality_false_when_disabled(monkeypatch):
_patch_profile(monkeypatch, {"version": 3, "rank_candidates_by_quality": False})
assert selection.load_rank_candidates_by_quality() is False
def test_rank_by_quality_false_on_db_error(monkeypatch):
class _BoomDB:
def get_quality_profile(self):
raise RuntimeError("db down")
monkeypatch.setattr(music_database, "MusicDatabase", _BoomDB)
assert selection.load_rank_candidates_by_quality() is False

View file

@ -30,3 +30,34 @@ def test_mark_task_completed_succeeds_when_lock_held():
finally:
runtime_state.download_tasks.clear()
runtime_state.download_tasks.update(original_tasks)
@pytest.fixture
def _isolated_tasks():
original_tasks = dict(runtime_state.download_tasks)
runtime_state.download_tasks.clear()
yield runtime_state.download_tasks
runtime_state.download_tasks.clear()
runtime_state.download_tasks.update(original_tasks)
@pytest.mark.parametrize("start_status", ["downloading", "queued"])
def test_claim_for_post_processing_wins_from_active_status(_isolated_tasks, start_status):
_isolated_tasks["t1"] = {"status": start_status}
assert runtime_state.claim_for_post_processing("t1") is True
assert _isolated_tasks["t1"]["status"] == "post_processing"
@pytest.mark.parametrize("start_status", ["post_processing", "searching", "completed", "failed", "cancelled"])
def test_claim_for_post_processing_loses_when_already_owned(_isolated_tasks, start_status):
"""A task already being post-processed by the monitor (post_processing),
requeued by a quarantine retry (searching), or already terminal must NOT be
re-claimed that is the double-processing race that produced the bogus
'missing file or source information' failure."""
_isolated_tasks["t1"] = {"status": start_status}
assert runtime_state.claim_for_post_processing("t1") is False
assert _isolated_tasks["t1"]["status"] == start_status # untouched
def test_claim_for_post_processing_missing_task_returns_false(_isolated_tasks):
assert runtime_state.claim_for_post_processing("absent") is False

View file

@ -192,6 +192,7 @@ from core.runtime_state import (
activity_feed,
activity_feed_lock,
add_activity_item,
claim_for_post_processing,
download_batches,
download_tasks,
matched_context_lock,
@ -4549,9 +4550,12 @@ def apply_quality_preset(preset_name):
current = db.get_quality_profile()
preset = dict(db.get_quality_preset(preset_name))
# search_mode is a global search strategy, not a per-preset audio setting —
# carry the user's current choice across preset switches.
# search_mode + rank_candidates_by_quality are global search/ordering
# strategies, not per-preset audio settings — carry the user's current
# choices across preset switches.
preset['search_mode'] = current.get('search_mode', preset.get('search_mode', 'priority'))
preset['rank_candidates_by_quality'] = current.get(
'rank_candidates_by_quality', preset.get('rank_candidates_by_quality', False))
success = db.set_quality_profile(preset)
if success:
@ -4579,6 +4583,8 @@ def reset_quality_preset(preset_name):
current = db.get_quality_profile()
preset = dict(db.reset_quality_preset(preset_name))
preset['search_mode'] = current.get('search_mode', preset.get('search_mode', 'priority'))
preset['rank_candidates_by_quality'] = current.get(
'rank_candidates_by_quality', preset.get('rank_candidates_by_quality', False))
success = db.set_quality_profile(preset)
if success:
@ -6796,6 +6802,19 @@ def get_download_status():
_pp_task_id = context.get('task_id')
_pp_batch_id = context.get('batch_id')
if _pp_task_id and _pp_batch_id:
# Atomic claim: the download monitor ALSO watches slskd
# transfers and submits its own post-processing worker for
# this task. Losing the claim means the monitor (or a
# parallel poll) already owns it — skip to avoid a double
# import and the quarantine-requeue race that produced the
# bogus "missing file or source information" failure.
if not claim_for_post_processing(_pp_task_id):
logger.info(
f"Task {_pp_task_id} already claimed for post-processing "
f"by another path — skipping duplicate for {context_key}"
)
processed_download_ids.add(context_key)
continue
_pp_target = _post_process_matched_download_with_verification
_pp_args = (context_key, context, found_path, _pp_task_id, _pp_batch_id)
else:
@ -6817,6 +6836,15 @@ def get_download_status():
logger.error(f"Error starting post-processing thread for {context_key}: {e}")
# Don't add to processed set if thread failed to start
logger.warning(f"Will retry {context_key} on next check")
# Release the post-processing claim so a later poll (or the
# monitor) can retry — otherwise the task is stuck in
# 'post_processing' with no worker running.
_failed_task_id = context.get('task_id')
if _failed_task_id:
with tasks_lock:
_ft = download_tasks.get(_failed_task_id)
if _ft and _ft.get('status') == 'post_processing':
_ft['status'] = 'downloading'
# Start a single thread to manage the launching of all processing threads
processing_thread = threading.Thread(target=process_completed_downloads)
@ -6874,6 +6902,16 @@ def get_download_status():
_st_task_id = _ctx.get('task_id')
_st_batch_id = _ctx.get('batch_id')
if _st_task_id and _st_batch_id:
# Atomic claim — the download monitor watches
# these (non-soulseek) transfers too and would
# otherwise double-process this same task.
if not claim_for_post_processing(_st_task_id):
logger.info(
f"[{_label}] Task {_st_task_id} already claimed "
f"for post-processing — skipping duplicate for {_ctx_key}"
)
processed_download_ids.add(_ctx_key)
return
_st_target = _post_process_matched_download_with_verification
_st_args = (_ctx_key, _ctx, _path, _st_task_id, _st_batch_id)
else:
@ -6886,6 +6924,13 @@ def get_download_status():
logger.info(f"[{_label}] Marked as processed: {_ctx_key}")
except Exception as e:
logger.error(f"[{_label}] Error starting post-processing thread for {_ctx_key}: {e}")
# Release the claim so a later poll / the monitor retries.
_st_failed_id = _ctx.get('task_id')
if _st_failed_id:
with tasks_lock:
_stf = download_tasks.get(_st_failed_id)
if _stf and _stf.get('status') == 'post_processing':
_stf['status'] = 'downloading'
processing_thread = threading.Thread(target=process_streaming_download)
processing_thread.daemon = True

View file

@ -4791,12 +4791,6 @@
<!-- Tidal Download Settings (shown only when tidal mode is selected) -->
<div id="tidal-download-settings-container" style="display: none;">
<div class="form-group">
<label>Tidal Download Quality:</label>
<div class="setting-help-text">
Quality is set globally in <strong>Quality Profile</strong> (ranked targets) — Tidal fetches the highest tier your profile accepts.
</div>
</div>
<div class="form-group">
<label>Tidal Download Auth:</label>
<div class="form-actions" style="margin-top: 4px;">
@ -4812,12 +4806,6 @@
<!-- Qobuz Settings (shown only when qobuz mode is selected) -->
<div id="qobuz-settings-container" style="display: none;">
<div class="form-group">
<label>Qobuz Download Quality:</label>
<div class="setting-help-text">
Quality is set globally in <strong>Quality Profile</strong> (ranked targets) — Qobuz fetches the highest tier your profile accepts. Hi-Res requires a Qobuz Studio or Sublime subscription.
</div>
</div>
<div class="form-group">
<label>Qobuz Account:</label>
<div id="qobuz-auth-logged-in" style="display: none; margin-top: 4px;">
@ -4865,12 +4853,6 @@
<!-- HiFi Download Settings (shown only when hifi mode is selected) -->
<div id="hifi-download-settings-container" style="display: none;">
<div class="form-group">
<label>HiFi Download Quality:</label>
<div class="setting-help-text">
Quality is set globally in <strong>Quality Profile</strong> (ranked targets) — HiFi fetches the highest tier your profile accepts. Uses public API instances — no account required.
</div>
</div>
<div class="form-group">
<label>HiFi Status:</label>
<div class="form-actions" style="margin-top: 4px;">
@ -4900,12 +4882,6 @@
<!-- Deezer Download Settings (shown only when deezer_dl mode is selected) -->
<div id="deezer-download-settings-container" style="display: none;">
<div class="form-group">
<label>Deezer Download Quality:</label>
<div class="setting-help-text">
Quality is set globally in <strong>Quality Profile</strong> (ranked targets) — Deezer fetches the highest tier your profile accepts. FLAC requires a Deezer HiFi subscription.
</div>
</div>
<div class="form-group">
<label>Deezer ARL Token:</label>
<input type="password" id="deezer-download-arl" class="form-input"
@ -4929,12 +4905,6 @@
<!-- Amazon Music Download Settings (shown only when amazon mode is selected) -->
<div id="amazon-download-settings-container" style="display: none;">
<div class="form-group">
<label>Amazon Music Quality:</label>
<div class="setting-help-text">
Quality is set globally in <strong>Quality Profile</strong> (ranked targets) — Amazon fetches FLAC (24-bit/48kHz Hi-Res) when your profile wants lossless, otherwise a lossy tier. Downloads via T2Tunes proxy.
</div>
</div>
<div class="form-group">
<label>Connection:</label>
<div class="form-actions" style="margin-top: 4px;">
@ -5068,7 +5038,6 @@
<!-- Quality Profile Settings (global — applies to all download sources) -->
<div class="settings-group" id="quality-profile-section" data-stg="downloads">
<h3>🎵 Quality Profile</h3>
<!-- Presets -->
<div class="quality-presets">
@ -5087,17 +5056,38 @@
💾 Space Saver
</button>
</div>
<div class="help-text" style="margin-top:6px">
Edits you make below are saved <em>per preset</em> — switch away and back and
your changes are still there. Use
<a href="#" onclick="resetActiveQualityPreset(); return false;">Reset to defaults</a>
to restore the selected preset's factory settings.
<div class="quality-presets-footer">
<span class="help-text" style="padding:0;border:0;background:none;margin:0;flex:1;">
Edits below are saved <em>per preset</em> — switch away and back and your
changes are still there.
</span>
<button type="button" class="preset-reset-btn" onclick="resetActiveQualityPreset()"
title="Restore the selected preset's factory settings">
<span class="preset-reset-icon"></span> Reset to defaults
</button>
</div>
</div>
<!-- Ranked target priority list (v3) -->
<div class="ranked-targets-editor">
<label class="ranked-targets-label">Quality priority (drag to reorder — 1st = most preferred):</label>
<div class="setting-row">
<label class="ranked-targets-label" style="margin:0;">Quality priority (drag to reorder — 1st = most preferred):</label>
<span class="info-icon" role="button" tabindex="0" title="What's this?"
onclick="toggleSettingHelp(this)" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();toggleSettingHelp(this);}">ⓘ</span>
</div>
<div class="help-text setting-help-body" hidden>
<strong>How it works:</strong> Each download source is checked against this list
top-down. The first target a source can satisfy wins; a source that meets no target
is skipped for the next one (source priority still decides between sources that can).
For lossless, bit depth + sample rate decide the match. For MP3/AAC the bitrate is a
<em>minimum</em> threshold (≥), so VBR and mono files aren't falsely rejected. After
download the real file is verified against the same list. With fallback off, a track
is left missing rather than accepting a quality below every target.
<br><br>
<strong>Note:</strong> the <em>Downsample hi-res</em> option (under Lossy Copy) also
bypasses this gate — if off-list files keep slipping through with fallback off, check
that setting too.
</div>
<div id="ranked-targets-list" class="ranked-targets-list">
<!-- rows injected by renderRankedTargets() -->
</div>
@ -5155,45 +5145,82 @@
<!-- Search strategy -->
<div class="form-group">
<label for="quality-search-mode" style="display:block;margin-bottom:4px;font-weight:600;">Search strategy</label>
<select id="quality-search-mode" style="width:100%;max-width:420px;">
<option value="priority">Source priority — fastest, stops at first good source</option>
<option value="best_quality">Best quality — search all sources, pick the highest</option>
<div class="setting-row" style="margin-bottom:4px;">
<label for="quality-search-mode" style="font-weight:600;">Search strategy</label>
<span class="info-icon" role="button" tabindex="0" title="What's this?"
onclick="toggleSettingHelp(this)" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();toggleSettingHelp(this);}">ⓘ</span>
</div>
<select id="quality-search-mode" style="width:100%;max-width:420px;" onchange="onSearchModeChange()">
<option value="priority">Source priority — fast (use the first source that has it)</option>
<option value="best_quality">Best quality — thorough (check every source, take the best)</option>
</select>
<div class="help-text" style="margin-top:4px">
<strong>Source priority</strong> takes the first source in your chain that meets
a target — fast, fewer lookups. <strong>Best quality</strong> searches
<em>every</em> source for each track, pools the results, and downloads them
best→worst by actual audio quality (source order only breaks ties). Slower and
more API calls, but it won't settle for a passable Soulseek file when HiFi/Qobuz
has a higher-resolution version. Retry budgets are unchanged: a source that spends
its budget is dropped from the whole pool.
<div class="help-text setting-help-body" hidden>
<strong>Source priority</strong> goes through your sources in order and
downloads from the <em>first one</em> that has the track. Fastest, fewest
lookups.<br><br>
<strong>Best quality</strong> asks <em>every</em> source, then downloads the
highest-quality copy it found — even if a faster source also had a lower-quality
one. Slower and more API calls, but you never settle for a worse file when a
better one exists.<br><br>
Either way, the per-source retry limits stay the same.
</div>
</div>
<!-- Rank candidates by quality (priority mode only) -->
<div class="form-group" id="quality-rank-candidates-group">
<div class="setting-row">
<label class="checkbox-label" style="margin:0;">
<input type="checkbox" id="quality-rank-candidates">
Rank-based download order
</label>
<span class="info-icon" role="button" tabindex="0" title="What's this?"
onclick="toggleSettingHelp(this)" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();toggleSettingHelp(this);}">ⓘ</span>
</div>
<div class="help-text setting-help-body" hidden>
A single source often returns several copies of the same track.<br><br>
<strong>Off</strong> (default): the most likely-correct / fastest copy is
downloaded first — the original behaviour.<br><br>
<strong>On</strong>: the highest-quality copy (per your target list above) is
downloaded first instead; correctness and speed only break ties.<br><br>
Nothing is skipped and the source order doesn't change, so a track can't go
missing — it just prefers the better copy. (This is always how
<strong>Best quality</strong> works, which is why this option only appears for
<strong>Source priority</strong>.)
</div>
</div>
<!-- Fallback Option -->
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" id="quality-fallback-enabled" checked>
Accept off-list quality when nothing in the list is available
</label>
<div class="help-text" style="margin-top:4px">
⚠️ This does <strong>not</strong> mean "walk down my list" — the list already
does that on its own. When <strong>on</strong>, a file that matches
<em>none</em> of your targets (e.g. a 16-bit FLAC or MP3 when you only listed
24-bit) is <strong>accepted anyway</strong> as a last resort. Turn it
<strong>off</strong> to strictly enforce your list — anything off-list is then
quarantined instead of completed.
<div class="setting-row">
<label class="checkbox-label" style="margin:0;">
<input type="checkbox" id="quality-fallback-enabled" checked>
Accept off-list quality when nothing in the list is available
</label>
<span class="info-icon" role="button" tabindex="0" title="What's this?"
onclick="toggleSettingHelp(this)" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();toggleSettingHelp(this);}">ⓘ</span>
</div>
<div class="help-text setting-help-body" hidden>
This does <strong>not</strong> mean "walk down my list" — the list already
does that on its own.<br><br>
When <strong>on</strong>, a file that matches <em>none</em> of your targets
(e.g. a 16-bit FLAC or an MP3 when you only listed 24-bit) is
<strong>accepted anyway</strong> as a last resort.<br><br>
Turn it <strong>off</strong> to strictly enforce your list — anything off-list
is then quarantined instead of completed.
</div>
</div>
<!-- Require hard AcoustID verification — only shown when AcoustID is enabled -->
<div class="form-group" id="acoustid-require-verified-group" style="display:none;">
<label class="checkbox-label">
<input type="checkbox" id="acoustid-require-verified">
Only import AcoustID-verified tracks (reject "could not confirm")
</label>
<div class="help-text" style="margin-top:4px">
<div class="setting-row">
<label class="checkbox-label" style="margin:0;">
<input type="checkbox" id="acoustid-require-verified">
Only import AcoustID-verified tracks (reject "could not confirm")
</label>
<span class="info-icon" role="button" tabindex="0" title="What's this?"
onclick="toggleSettingHelp(this)" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();toggleSettingHelp(this);}">ⓘ</span>
</div>
<div class="help-text setting-help-body" hidden>
When <strong>on</strong>, a track that AcoustID runs but <em>cannot confirm</em>
(no fingerprint match / cross-script metadata — the ⚠ "unverified" case) is
<strong>quarantined</strong> instead of imported. Downloads then try the next
@ -5211,20 +5238,6 @@
outage) always import, so an AcoustID outage never stalls you. Requires AcoustID enabled.
</div>
</div>
<div class="help-text">
<strong>How it works:</strong> Each download source is checked against this list
top-down. The first target a source can satisfy wins; a source that meets no target
is skipped for the next one (source priority still decides between sources that can).
For lossless, bit depth + sample rate decide the match. For MP3/AAC the bitrate is a
<em>minimum</em> threshold (≥), so VBR and mono files aren't falsely rejected. After
download the real file is verified against the same list. With fallback off, a track
is left missing rather than accepting a quality below every target.
<br><br>
<strong>Note:</strong> the <em>Downsample hi-res</em> option (under Lossy Copy) also
bypasses this gate — if off-list files keep slipping through with fallback off, check
that setting too.
</div>
</div>
</div><!-- end Quality Profile body -->

View file

@ -2001,6 +2001,32 @@ function populateQualityProfileUI(profile) {
const searchModeSelect = document.getElementById('quality-search-mode');
if (searchModeSelect) searchModeSelect.value = profile.search_mode === 'best_quality' ? 'best_quality' : 'priority';
const rankCandidatesCheckbox = document.getElementById('quality-rank-candidates');
if (rankCandidatesCheckbox) rankCandidatesCheckbox.checked = profile.rank_candidates_by_quality === true;
onSearchModeChange();
}
// Hide the "rank-based download order" toggle when Best quality is active —
// that mode always ranks by quality, so the toggle would be meaningless there.
function onSearchModeChange() {
const mode = document.getElementById('quality-search-mode')?.value;
const group = document.getElementById('quality-rank-candidates-group');
if (group) group.style.display = mode === 'best_quality' ? 'none' : '';
}
// Toggle the collapsible help text below a setting's ⓘ icon. Walks forward from
// the icon's row to the next .setting-help-body sibling, so it works whether the
// body is the immediate next element or sits after a control (e.g. a <select>),
// and regardless of any wrapping container.
function toggleSettingHelp(iconEl) {
const row = iconEl.closest('.setting-row') || iconEl;
let el = row.nextElementSibling;
while (el && !el.classList.contains('setting-help-body')) {
el = el.nextElementSibling;
}
if (el) el.hidden = !el.hidden;
}
function renderRankedTargets() {
@ -2211,6 +2237,7 @@ function collectQualityProfileFromUI() {
preset: (currentQualityProfile && currentQualityProfile.preset) || 'custom',
fallback_enabled: document.getElementById('quality-fallback-enabled')?.checked ?? true,
search_mode: document.getElementById('quality-search-mode')?.value === 'best_quality' ? 'best_quality' : 'priority',
rank_candidates_by_quality: document.getElementById('quality-rank-candidates')?.checked ?? false,
ranked_targets,
};
}

View file

@ -3921,6 +3921,41 @@ body.helper-mode-active #dashboard-activity-feed:hover {
border: 1px solid rgba(255, 255, 255, 0.04);
}
/* Setting label + inline info icon. The icon sits on the (fixed) label row;
clicking it only toggles the help text below, so the trigger never moves. */
.setting-row {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
/* Kill the default .form-group label margin-bottom so the label centers on the
icon row instead of sitting high. */
.setting-row > label { margin: 0; }
/* Field-heading labels (e.g. "Search strategy") sit next to a 12px control and
the brighter checkbox labels match that weight/brightness so they don't look
dim or undersized. */
.setting-row > label:not(.checkbox-label) {
font-size: 12px;
color: rgba(255, 255, 255, 0.85);
}
.info-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
flex: 0 0 auto;
font-size: 12px;
line-height: 1;
color: rgba(var(--accent-rgb), 0.85);
cursor: pointer;
user-select: none;
transition: color 0.15s ease;
}
.info-icon:hover { color: rgba(var(--accent-rgb), 1); }
.setting-help-body { margin-top: 4px; }
/* Supported Formats */
.supported-formats {
color: rgba(255, 255, 255, 0.7);
@ -4109,6 +4144,39 @@ body.helper-mode-active #dashboard-activity-feed:hover {
box-shadow: 0 4px 12px rgba(var(--accent-rgb), 0.15);
}
/* Presets footer: explanation on the left, a tidy Reset button on the right */
.quality-presets-footer {
display: flex;
align-items: center;
gap: 12px;
margin-top: 10px;
}
.preset-reset-btn {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 12px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
color: rgba(255, 255, 255, 0.7);
font-size: 11px;
font-weight: 500;
cursor: pointer;
white-space: nowrap;
transition: all 0.2s ease;
}
.preset-reset-btn:hover {
background: rgba(var(--accent-rgb), 0.1);
border-color: rgba(var(--accent-rgb), 0.35);
color: rgb(var(--accent-light-rgb));
}
.preset-reset-icon {
font-size: 13px;
line-height: 1;
}
/* Quality tier cards - upgraded inner cards */
/* ── Ranked-targets editor (v3 quality profile) ───────────────────────── */
.ranked-targets-editor {
@ -4123,6 +4191,10 @@ body.helper-mode-active #dashboard-activity-feed:hover {
margin-bottom: 8px;
}
/* The label now sits in a .setting-row (alongside the ), whose label margin is
zeroed restore a healthy gap before the draggable target list below. */
.ranked-targets-editor > .setting-row { margin-bottom: 10px; }
.ranked-targets-list {
display: flex;
flex-direction: column;