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:
commit
3c33e31985
14 changed files with 469 additions and 95 deletions
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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 (best→worst) 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
|
||||
(best→worst) 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
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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},
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
47
tests/downloads/test_worker_quality_ordering.py
Normal file
47
tests/downloads/test_worker_quality_ordering.py
Normal 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)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
171
webui/index.html
171
webui/index.html
|
|
@ -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 -->
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue