Add min delay between slskd searches (Bell Canada anti-abuse fix)
- new soulseek.search_min_delay_seconds knob forces a gap between consecutive searches; smooths the burst pattern that trips ISP anti-abuse (Reddit report: Bell Canada cuts the WAN after rapid peer-connection spikes) even when the existing 35/220 sliding-window cap isn't hit - throttle math lifted to a pure compute_search_wait_seconds helper so the gate logic is testable independent of asyncio.sleep + the singleton client - new field on settings → connections → soulseek; default 0 = disabled so existing users see no change 15 helper-boundary tests pin defaults / no-throttle, sliding-window cap (legacy), min-delay (the new burst-smoother), max-of-both gates, and defensive paths.
This commit is contained in:
parent
e0bf595a35
commit
b9feed1a67
6 changed files with 378 additions and 21 deletions
|
|
@ -466,7 +466,14 @@ class ConfigManager:
|
|||
"download_path": "./downloads",
|
||||
"transfer_path": "./Transfer",
|
||||
"max_peer_queue": 0,
|
||||
"download_timeout": 600
|
||||
"download_timeout": 600,
|
||||
# Reddit report (YeloMelo95, Bell Canada): the existing
|
||||
# 35-per-220s sliding-window cap allows all 35 searches in
|
||||
# rapid succession before throttling — that burst trips ISP
|
||||
# anti-abuse. This knob forces a min gap between consecutive
|
||||
# searches even when the window cap isn't hit. 0 = disabled
|
||||
# (preserves prior behavior).
|
||||
"search_min_delay_seconds": 0,
|
||||
},
|
||||
"download_source": {
|
||||
"mode": "soulseek", # Options: "soulseek", "youtube", "tidal", "qobuz", "hifi", "hybrid"
|
||||
|
|
|
|||
|
|
@ -48,18 +48,87 @@ _SLSKD_DEFAULT_TIMEOUT = aiohttp.ClientTimeout(
|
|||
)
|
||||
|
||||
|
||||
# Search-rate-limit defaults. Pre-fix these were hardcoded magic numbers
|
||||
# inside `SoulseekClient.__init__`. Lifted to module level so they're
|
||||
# greppable + bumpable in one place, and so the reddit-reported case
|
||||
# (Bell Canada anti-abuse trips on slskd peer-connection bursts) can
|
||||
# tune them via `soulseek.search_*` config without touching code.
|
||||
_DEFAULT_MAX_PER_WINDOW = 35
|
||||
_DEFAULT_WINDOW_SECONDS = 220
|
||||
_DEFAULT_MIN_DELAY_SECONDS = 0 # 0 = disabled (preserves prior behavior)
|
||||
|
||||
|
||||
def compute_search_wait_seconds(
|
||||
timestamps: List[float],
|
||||
last_search_at: float,
|
||||
now: float,
|
||||
*,
|
||||
max_per_window: int,
|
||||
window_seconds: float,
|
||||
min_delay_seconds: float,
|
||||
) -> float:
|
||||
"""Pure scheduler for the slskd search throttle.
|
||||
|
||||
Returns how many seconds the caller should sleep before issuing
|
||||
the next search. ``timestamps`` is the list of recent search
|
||||
timestamps already pruned to the current window (caller's job).
|
||||
``last_search_at`` is the timestamp of the most recent search
|
||||
(0.0 if there hasn't been one). ``now`` is the current monotonic /
|
||||
wall-clock time (caller chooses — pure function only does math).
|
||||
|
||||
Two independent gates, return the larger:
|
||||
|
||||
1. **Sliding-window cap** — when ``len(timestamps) >= max_per_window``,
|
||||
sleep until the oldest timestamp ages out of the window. Same
|
||||
semantics as the pre-fix hardcoded behavior.
|
||||
|
||||
2. **Min-delay between searches** — when ``min_delay_seconds > 0``,
|
||||
sleep until at least that many seconds have passed since
|
||||
``last_search_at``. Smooths bursts even when the window isn't
|
||||
full — this is the actual fix for the Reddit-reported case where
|
||||
Bell Canada's anti-abuse trips on the rapid peer-connection
|
||||
bursts that 35 back-to-back searches generate.
|
||||
|
||||
Returns 0.0 (no wait) when ``min_delay_seconds`` is 0 / negative
|
||||
AND the window isn't full. Pure: no I/O, no side effects, no
|
||||
mutation of the inputs.
|
||||
"""
|
||||
window_wait = 0.0
|
||||
if max_per_window > 0 and len(timestamps) >= max_per_window:
|
||||
oldest = timestamps[0]
|
||||
window_wait = max(0.0, oldest + window_seconds - now)
|
||||
|
||||
delay_wait = 0.0
|
||||
if min_delay_seconds > 0 and last_search_at > 0:
|
||||
elapsed = now - last_search_at
|
||||
delay_wait = max(0.0, min_delay_seconds - elapsed)
|
||||
|
||||
return max(window_wait, delay_wait)
|
||||
|
||||
|
||||
class SoulseekClient(DownloadSourcePlugin):
|
||||
def __init__(self):
|
||||
self.base_url: Optional[str] = None
|
||||
self.api_key: Optional[str] = None
|
||||
self.download_path: Path = Path("./downloads")
|
||||
self.active_searches: Dict[str, bool] = {} # search_id -> still_active
|
||||
|
||||
# Rate limiting for searches
|
||||
self.search_timestamps: List[float] = [] # Track search timestamps
|
||||
self.max_searches_per_window = 35 # Conservative limit to prevent Soulseek bans
|
||||
self.rate_limit_window = 220 # seconds (3 minutes 40 seconds)
|
||||
|
||||
|
||||
# Rate limiting for searches. Cap + window stay hardcoded —
|
||||
# nobody has reported issues with the 35/220 defaults. The
|
||||
# min-delay knob is the actual fix for the Reddit-reported
|
||||
# case (Bell Canada anti-abuse cuts the WAN after rapid
|
||||
# peer-connection bursts) — smooths bursts even when the
|
||||
# sliding-window cap isn't hit. 0 = disabled (preserves prior
|
||||
# behavior).
|
||||
self.search_timestamps: List[float] = []
|
||||
self._last_search_at: float = 0.0
|
||||
self.max_searches_per_window = _DEFAULT_MAX_PER_WINDOW
|
||||
self.rate_limit_window = _DEFAULT_WINDOW_SECONDS
|
||||
self.search_min_delay_seconds = float(
|
||||
config_manager.get('soulseek.search_min_delay_seconds', _DEFAULT_MIN_DELAY_SECONDS)
|
||||
or _DEFAULT_MIN_DELAY_SECONDS
|
||||
)
|
||||
|
||||
self._setup_client()
|
||||
|
||||
def _setup_client(self):
|
||||
|
|
@ -103,22 +172,36 @@ class SoulseekClient(DownloadSourcePlugin):
|
|||
self.search_timestamps = [ts for ts in self.search_timestamps if ts > cutoff_time]
|
||||
|
||||
async def _wait_for_rate_limit(self):
|
||||
"""Wait if necessary to respect rate limiting"""
|
||||
"""Wait if necessary to respect search rate limits.
|
||||
|
||||
Delegates the wait math to ``compute_search_wait_seconds`` so
|
||||
the throttle logic is testable independently of asyncio.sleep
|
||||
and the singleton client. Two gates apply (max wins): sliding-
|
||||
window cap on searches per N seconds, plus optional min-delay
|
||||
between consecutive searches (the burst-smoother).
|
||||
"""
|
||||
self._clean_old_timestamps()
|
||||
|
||||
if len(self.search_timestamps) >= self.max_searches_per_window:
|
||||
# Calculate how long to wait
|
||||
oldest_timestamp = self.search_timestamps[0]
|
||||
wait_time = oldest_timestamp + self.rate_limit_window - time.time()
|
||||
|
||||
if wait_time > 0:
|
||||
logger.info(f"Rate limit reached ({len(self.search_timestamps)}/{self.max_searches_per_window} searches). Waiting {wait_time:.1f} seconds...")
|
||||
await asyncio.sleep(wait_time)
|
||||
# Clean up again after waiting
|
||||
self._clean_old_timestamps()
|
||||
|
||||
wait_time = compute_search_wait_seconds(
|
||||
self.search_timestamps,
|
||||
self._last_search_at,
|
||||
time.time(),
|
||||
max_per_window=self.max_searches_per_window,
|
||||
window_seconds=self.rate_limit_window,
|
||||
min_delay_seconds=self.search_min_delay_seconds,
|
||||
)
|
||||
if wait_time > 0:
|
||||
logger.info(
|
||||
f"Search rate limit: waiting {wait_time:.1f}s "
|
||||
f"({len(self.search_timestamps)}/{self.max_searches_per_window} in window, "
|
||||
f"min_delay={self.search_min_delay_seconds:.1f}s)"
|
||||
)
|
||||
await asyncio.sleep(wait_time)
|
||||
self._clean_old_timestamps()
|
||||
|
||||
# Record this search attempt
|
||||
self.search_timestamps.append(time.time())
|
||||
now = time.time()
|
||||
self.search_timestamps.append(now)
|
||||
self._last_search_at = now
|
||||
|
||||
def get_rate_limit_status(self) -> Dict[str, Any]:
|
||||
"""Get current rate limiting status"""
|
||||
|
|
|
|||
254
tests/test_soulseek_search_throttle.py
Normal file
254
tests/test_soulseek_search_throttle.py
Normal file
|
|
@ -0,0 +1,254 @@
|
|||
"""Pin `compute_search_wait_seconds` — the pure scheduler behind the
|
||||
slskd search throttle.
|
||||
|
||||
Reddit report (YeloMelo95, Bell Canada): ISP anti-abuse cuts the user's
|
||||
WAN connection after a burst of slskd searches. The pre-fix throttle
|
||||
was hardcoded to 35 searches per 220s sliding window, which allowed all
|
||||
35 in rapid succession and only blocked once the cap was hit. That's
|
||||
fine for soulseek-side bans but doesn't smooth bursts at the ISP layer.
|
||||
|
||||
Fix lifts the cap + window to config and adds a new `min_delay_seconds`
|
||||
knob. The pure helper takes the throttle inputs and returns how long to
|
||||
sleep — easy to test independently of asyncio.sleep / the singleton
|
||||
client / wall-clock time.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from core.soulseek_client import compute_search_wait_seconds
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Defaults / no-throttle path
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestNoThrottleNeeded:
|
||||
def test_empty_state_returns_zero(self):
|
||||
"""First search ever → no timestamps, no last-search → no wait."""
|
||||
assert compute_search_wait_seconds(
|
||||
timestamps=[],
|
||||
last_search_at=0.0,
|
||||
now=100.0,
|
||||
max_per_window=35,
|
||||
window_seconds=220,
|
||||
min_delay_seconds=0,
|
||||
) == 0.0
|
||||
|
||||
def test_below_window_cap_returns_zero(self):
|
||||
"""When timestamps haven't filled the window cap and min-delay
|
||||
is disabled, no wait. Preserves prior behavior for existing
|
||||
users who don't tune the new knob."""
|
||||
assert compute_search_wait_seconds(
|
||||
timestamps=[10.0, 20.0, 30.0],
|
||||
last_search_at=30.0,
|
||||
now=100.0,
|
||||
max_per_window=35,
|
||||
window_seconds=220,
|
||||
min_delay_seconds=0,
|
||||
) == 0.0
|
||||
|
||||
def test_min_delay_zero_is_disabled(self):
|
||||
"""Explicit zero (the default) means no min-delay enforcement
|
||||
even when the last search was a millisecond ago. Confirms
|
||||
backwards compat — existing users see no new wait."""
|
||||
assert compute_search_wait_seconds(
|
||||
timestamps=[],
|
||||
last_search_at=99.99,
|
||||
now=100.0,
|
||||
max_per_window=35,
|
||||
window_seconds=220,
|
||||
min_delay_seconds=0,
|
||||
) == 0.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sliding-window cap (legacy behavior preserved)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSlidingWindowCap:
|
||||
def test_window_full_waits_for_oldest_to_age_out(self):
|
||||
"""35 timestamps in window → wait until oldest ages out.
|
||||
Same semantics as the pre-fix hardcoded behavior."""
|
||||
timestamps = [10.0 + i for i in range(35)] # 10..44
|
||||
# now = 50, window = 220, oldest = 10 → ages out at 230 → wait 180
|
||||
wait = compute_search_wait_seconds(
|
||||
timestamps=timestamps,
|
||||
last_search_at=44.0,
|
||||
now=50.0,
|
||||
max_per_window=35,
|
||||
window_seconds=220,
|
||||
min_delay_seconds=0,
|
||||
)
|
||||
assert wait == pytest.approx(180.0, abs=1e-9)
|
||||
|
||||
def test_window_full_but_oldest_already_aged_out_returns_zero(self):
|
||||
"""If now is past oldest+window, the negative is clamped to 0
|
||||
(the caller is expected to prune timestamps before passing —
|
||||
this is just defense-in-depth)."""
|
||||
wait = compute_search_wait_seconds(
|
||||
timestamps=[10.0] * 35,
|
||||
last_search_at=10.0,
|
||||
now=400.0,
|
||||
max_per_window=35,
|
||||
window_seconds=220,
|
||||
min_delay_seconds=0,
|
||||
)
|
||||
assert wait == 0.0
|
||||
|
||||
def test_custom_max_per_window_honored(self):
|
||||
"""User dials max down to 10 (paranoia mode for ISP anti-abuse).
|
||||
Cap kicks in at 10, not 35."""
|
||||
timestamps = [10.0 + i for i in range(10)]
|
||||
wait = compute_search_wait_seconds(
|
||||
timestamps=timestamps,
|
||||
last_search_at=19.0,
|
||||
now=20.0,
|
||||
max_per_window=10,
|
||||
window_seconds=60,
|
||||
min_delay_seconds=0,
|
||||
)
|
||||
# oldest = 10, ages out at 70, now = 20 → wait 50
|
||||
assert wait == pytest.approx(50.0, abs=1e-9)
|
||||
|
||||
def test_max_per_window_zero_disables_window_cap(self):
|
||||
"""Defensive: max=0 means no cap (don't divide by zero, don't
|
||||
block forever). Min-delay still applies if set."""
|
||||
wait = compute_search_wait_seconds(
|
||||
timestamps=[10.0] * 100,
|
||||
last_search_at=50.0,
|
||||
now=51.0,
|
||||
max_per_window=0,
|
||||
window_seconds=220,
|
||||
min_delay_seconds=0,
|
||||
)
|
||||
assert wait == 0.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Min-delay between searches (the new knob — Bell Canada fix)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMinDelayBetweenSearches:
|
||||
def test_recent_last_search_blocks_for_remaining_delay(self):
|
||||
"""User sets min_delay=5s. Last search 2s ago → wait 3s.
|
||||
Smooths the burst pattern that trips Bell's anti-abuse even
|
||||
when the sliding window isn't full."""
|
||||
wait = compute_search_wait_seconds(
|
||||
timestamps=[100.0, 102.0],
|
||||
last_search_at=102.0,
|
||||
now=104.0,
|
||||
max_per_window=35,
|
||||
window_seconds=220,
|
||||
min_delay_seconds=5,
|
||||
)
|
||||
assert wait == pytest.approx(3.0, abs=1e-9)
|
||||
|
||||
def test_min_delay_already_elapsed_returns_zero(self):
|
||||
"""Last search 10s ago, min-delay 5s → already cleared, no wait."""
|
||||
wait = compute_search_wait_seconds(
|
||||
timestamps=[100.0],
|
||||
last_search_at=100.0,
|
||||
now=110.0,
|
||||
max_per_window=35,
|
||||
window_seconds=220,
|
||||
min_delay_seconds=5,
|
||||
)
|
||||
assert wait == 0.0
|
||||
|
||||
def test_min_delay_skipped_on_very_first_search(self):
|
||||
"""`last_search_at == 0` means there's never been a search.
|
||||
Don't gate the very first one — that would force an arbitrary
|
||||
startup delay for no reason."""
|
||||
wait = compute_search_wait_seconds(
|
||||
timestamps=[],
|
||||
last_search_at=0.0,
|
||||
now=100.0,
|
||||
max_per_window=35,
|
||||
window_seconds=220,
|
||||
min_delay_seconds=10,
|
||||
)
|
||||
assert wait == 0.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Both gates active — max wins
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMaxOfBothGates:
|
||||
def test_returns_window_wait_when_window_wait_is_larger(self):
|
||||
"""Window says wait 100s, min-delay says wait 5s → return 100s."""
|
||||
timestamps = [0.0 + i for i in range(35)] # 0..34
|
||||
# now = 5, window = 220, oldest = 0 → ages out at 220 → wait 215
|
||||
# min_delay = 5, last = 4, now = 5 → wait 4
|
||||
wait = compute_search_wait_seconds(
|
||||
timestamps=timestamps,
|
||||
last_search_at=4.0,
|
||||
now=5.0,
|
||||
max_per_window=35,
|
||||
window_seconds=220,
|
||||
min_delay_seconds=5,
|
||||
)
|
||||
assert wait == pytest.approx(215.0, abs=1e-9)
|
||||
|
||||
def test_returns_min_delay_wait_when_min_delay_is_larger(self):
|
||||
"""Window not full → window wait = 0. Min-delay 30s, last 5s
|
||||
ago → wait 25s. Min-delay drives it."""
|
||||
wait = compute_search_wait_seconds(
|
||||
timestamps=[100.0, 105.0],
|
||||
last_search_at=105.0,
|
||||
now=110.0,
|
||||
max_per_window=35,
|
||||
window_seconds=220,
|
||||
min_delay_seconds=30,
|
||||
)
|
||||
assert wait == pytest.approx(25.0, abs=1e-9)
|
||||
|
||||
def test_both_zero_returns_zero(self):
|
||||
"""Window not full + min-delay clear → zero. Sanity."""
|
||||
wait = compute_search_wait_seconds(
|
||||
timestamps=[100.0],
|
||||
last_search_at=50.0,
|
||||
now=200.0,
|
||||
max_per_window=35,
|
||||
window_seconds=220,
|
||||
min_delay_seconds=10,
|
||||
)
|
||||
assert wait == 0.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Defensive — input shape variations
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDefensive:
|
||||
def test_negative_min_delay_treated_as_disabled(self):
|
||||
"""Defensive: a negative min-delay (somehow) shouldn't return
|
||||
a negative wait or trigger weird behavior. Treat as disabled."""
|
||||
wait = compute_search_wait_seconds(
|
||||
timestamps=[],
|
||||
last_search_at=99.0,
|
||||
now=100.0,
|
||||
max_per_window=35,
|
||||
window_seconds=220,
|
||||
min_delay_seconds=-5,
|
||||
)
|
||||
assert wait == 0.0
|
||||
|
||||
def test_returns_float(self):
|
||||
"""Caller passes to asyncio.sleep which wants a float. Pin shape."""
|
||||
wait = compute_search_wait_seconds(
|
||||
timestamps=[],
|
||||
last_search_at=0.0,
|
||||
now=100.0,
|
||||
max_per_window=35,
|
||||
window_seconds=220,
|
||||
min_delay_seconds=0,
|
||||
)
|
||||
assert isinstance(wait, float)
|
||||
|
|
@ -4329,6 +4329,16 @@
|
|||
<small class="settings-hint">Extra time to wait
|
||||
for late results (5-60 seconds)</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Minimum Delay Between Searches (seconds):</label>
|
||||
<input type="number" id="soulseek-search-min-delay-seconds" placeholder="0"
|
||||
min="0" max="60" value="0">
|
||||
<small class="settings-hint">Forces a gap between
|
||||
consecutive searches. Smooths burst patterns
|
||||
that trip ISP anti-abuse (e.g. Bell Canada
|
||||
cuts the WAN after rapid peer-connection
|
||||
spikes). 0 disables.</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Minimum Peer Upload Speed:</label>
|
||||
<select id="soulseek-min-peer-speed">
|
||||
|
|
|
|||
|
|
@ -3416,6 +3416,7 @@ const WHATS_NEW = {
|
|||
'2.5.1': [
|
||||
// --- post-release patch work on the 2.5.1 line — entries hidden by _getLatestWhatsNewVersion until the build version bumps ---
|
||||
{ date: 'Unreleased — 2.5.1 patch work' },
|
||||
{ title: 'Soulseek: Min Delay Between Searches (Fixes ISP Anti-Abuse Trips)', desc: 'reddit report (yelomelo95, bell canada): isp anti-abuse cuts the wan after a burst of slskd searches. soulsync\'s sliding-window cap (35 searches per 220s) prevented soulseek-side bans but allowed all 35 in rapid succession — which is exactly the connection-burst pattern that trips isp throttling. new knob on settings → connections → soulseek: minimum delay between searches (default 0 = disabled, preserves prior behavior). set it to 5-10 seconds if your isp throttles peer-connection spikes. throttle math lifted to a pure `compute_search_wait_seconds` helper so the gate logic is testable independent of asyncio.sleep + the singleton client. 15 new tests pin: defaults / no-throttle, sliding-window cap (legacy), min-delay (the new burst-smoother), max-of-both gates, defensive paths.', page: 'tools' },
|
||||
{ title: 'Help & Docs: Copy Debug Info Now Reports The Right Music Source + Lists All Services', desc: 'the music_source field always rendered as "unknown" because the code read `_status_cache.get(\'spotify\', {})` — but the cache only has \'media_server\' and \'soulseek\' keys, so the lookup always fell through. same silent miss for spotify_connected and spotify_rate_limited. fix routes those reads through the canonical accessors: `get_primary_source()` for music source (which already accounts for the spotify→deezer auth fallback), `get_spotify_status()` for connection + rate-limit state. also added hydrabase_connected (was missing entirely), youtube_available (always true — yt-dlp + url-based, no auth), hifi_instance_count (separate from connection because each instance is its own endpoint with its own auth), and an always_available_metadata_sources list (deezer / itunes / musicbrainz — public apis, no auth) so the dump reflects the full metadata surface. while in there: removed a local `from core.metadata.status import get_spotify_status` re-import that was making python 3.12 treat the name as a function-scoped local, breaking the new lambda above it (NameError on free variable). 11 new tests at the endpoint boundary pin music_source, spotify_*, hydrabase_*, youtube_available, always_available_metadata_sources, hifi_instance_count, and the defensive paths when each lookup raises.', page: 'tools' },
|
||||
{ title: 'Download Discography: Skips Tracks Already In Your Library', desc: 'discord report (skowl): clicking download discography on the same artist twice re-queued every track instead of skipping the half already on disk. trace: the endpoint added each track via `add_to_wishlist`, which dedups against the wishlist itself but never checks the library — once a downloaded track leaves the wishlist the next click re-inserts it. fix: same library-ownership check the discography backfill repair job already runs (`db.check_track_exists` at confidence ≥ 0.7). format-agnostic — name + artist + album, no extension comparison — so blasphemy mode (flac → mp3 with original deleted) doesn\'t false-miss. exception during the check returns "not owned" so a transient db hiccup doesn\'t silently nuke the discography fetch (a redundant wishlist add is cheap, a missed track isn\'t). per-album response carries a new `tracks_skipped_owned` counter alongside the artist / content / wishlist skips. 10 new tests at the helper boundary.', page: 'discover' },
|
||||
{ title: 'Download Discography: No More Cross-Artist Tracks Or Unwanted Remixes', desc: 'issue #559: download discography pulled in tracks from compilations / appears-on albums where the artist was only featured on one or two tracks — every other track on those albums got added too. also ignored your watchlist "include remixes / live / acoustic / instrumental" settings, so one-off discography downloads kept stuffing your wishlist with remix ladders. fix: per-track filter at the endpoint. drops tracks where the requested artist isn\'t named in the track\'s artists list (keeps features, drops unrelated compilation entries). honors `watchlist.global_include_*` settings the same way the discography backfill repair job already does. per-album response carries new skip counts so the ui can show how much got filtered. 21 new tests at the helper boundary.', page: 'discover' },
|
||||
|
|
|
|||
|
|
@ -856,6 +856,7 @@ async function loadSettingsData() {
|
|||
document.getElementById('soulseek-api-key').value = settings.soulseek?.api_key || '';
|
||||
document.getElementById('soulseek-search-timeout').value = settings.soulseek?.search_timeout || 60;
|
||||
document.getElementById('soulseek-search-timeout-buffer').value = settings.soulseek?.search_timeout_buffer || 15;
|
||||
document.getElementById('soulseek-search-min-delay-seconds').value = settings.soulseek?.search_min_delay_seconds ?? 0;
|
||||
document.getElementById('soulseek-min-peer-speed').value = settings.soulseek?.min_peer_upload_speed || 0;
|
||||
document.getElementById('soulseek-max-peer-queue').value = settings.soulseek?.max_peer_queue || 0;
|
||||
document.getElementById('soulseek-download-timeout').value = Math.round((settings.soulseek?.download_timeout || 600) / 60);
|
||||
|
|
@ -2605,6 +2606,7 @@ async function saveSettings(quiet = false) {
|
|||
transfer_path: document.getElementById('transfer-path').value,
|
||||
search_timeout: parseInt(document.getElementById('soulseek-search-timeout').value) || 60,
|
||||
search_timeout_buffer: parseInt(document.getElementById('soulseek-search-timeout-buffer').value) || 15,
|
||||
search_min_delay_seconds: parseInt(document.getElementById('soulseek-search-min-delay-seconds').value) || 0,
|
||||
min_peer_upload_speed: parseInt(document.getElementById('soulseek-min-peer-speed').value) || 0,
|
||||
max_peer_queue: parseInt(document.getElementById('soulseek-max-peer-queue').value) || 0,
|
||||
download_timeout: (parseInt(document.getElementById('soulseek-download-timeout').value) || 10) * 60,
|
||||
|
|
|
|||
Loading…
Reference in a new issue