Usenet album poll: tolerate SAB queue→history handoff, emit terminal failure (#706)

User reported usenet album downloads getting stuck on "downloading
release" while SABnzbd reported the job as complete. Container restart
did not help; reproducible on every usenet album download.

Three independent issues all causing the same symptom — the download
modal freezes mid-flow with no error surfaced to the user:

1. SAB queue → history transition window
   SAB removes a slot from its queue BEFORE adding it to the history,
   and on a busy server (par2 verify, unrar, multi-file move) that
   window can span several poll iterations. The poll treated a single
   None status as terminal failure ("disappeared from client") and
   gave up. Now the poll tolerates up to ~10s of consecutive misses
   (5 polls at the default 2s interval) before declaring the job gone.

2. SAB queue states like `Pp` were unmapped
   `_SAB_QUEUE_STATE_MAP` didn't cover SAB's `Pp` (post-processing
   summary), `Unpacking`, `Trying`, `Deleted`, or the `Prop_paused`
   / `Prop_failed` variants. Unmapped states fell through to the
   default-'error' fallback, and the poll loop only treated explicit
   'failed' / 'completed' as terminal — 'error' was neither, so the
   loop spun until the 6-hour timeout. Map now covers every Status
   value from SAB's `sabnzbd/api.py`, and the poll treats the default-
   'error' fallback as a transient miss (warn-logged, retry within
   the same tolerance window) so a brand-new unmapped state can't
   infinite-loop the way `Pp` did here.

3. No terminal failure emit
   The poll only logged on failure / timeout / disappeared — never
   called the progress callback with 'failed', so the download modal
   stayed at the last 'downloading' emit forever. Plumb a 'failed'
   emit through every failure exit path so the UI flips out of the
   downloading state when the poll gives up.

Plus:

4. SAB direct nzo_ids lookup instead of paging all-history
   `_get_status_sync` was fetching the latest 50 history entries on
   every poll and iterating to find the target nzo_id. On busy
   servers (many recent downloads), the target job could roll past
   the 50-entry window and look like a "disappeared" job. Replaced
   with a targeted `mode=queue&nzo_ids=<id>` → `mode=history&nzo_ids=<id>`
   chain. Falls back to the bulk path for SAB versions that pre-date
   the nzo_ids filter — the transient-miss tolerance covers any
   short-lived gap there too.

Implementation:

Lifted the album-bundle poll loop out of `usenet.py` and `torrent.py`
into `core/download_plugins/album_bundle.py:poll_album_download` —
near-duplicate implementations are now a single function with deps
injected so it's testable in isolation (kettui's extract-don't-AST-parse
standard; can't unit-test a `time.sleep` loop inside a plugin method).
The lifted helper takes:
- `get_status` callable bound to job_id, so the same loop works for
  usenet UsenetStatus and torrent TorrentStatus shapes
- `complete_states` set so torrent's `{'seeding', 'completed'}` and
  usenet's `{'completed'}` both Just Work
- `failed_states` set so torrent's `{'error'}` is terminal while
  usenet's default-'error' fallback is transient
- `transient_miss_threshold` (default 5 ≈ 10s at 2s poll)
- `sleep` / `monotonic` injectables for deterministic tests

Per-track flows in both plugins gained the same transient-miss
tolerance inline — they don't use the emit pattern (update an
`active_downloads[id]` row dict via lock instead), so reusing the
helper would have required threading a no-op emit through. Inline
fix is small enough.

Tests:
- 11 new tests in `tests/test_album_bundle.py:poll_album_download`
  cover the happy path, transient-miss tolerance with recovery,
  hard-failure threshold, explicit-failed surface, timeout-emit,
  default-'error' transient treatment, shutdown clean exit,
  torrent's `seeding`-counts-as-complete, save_path captured across
  iterations, and adapter-exception treated as transient miss.
- 521 download-suite tests pass (33 in test_album_bundle, others
  pin existing torrent + usenet contracts).
- Ruff clean.

Closes #706.
This commit is contained in:
Broque Thomas 2026-05-27 09:42:51 -07:00
parent 1d6ced286b
commit f13d339584
6 changed files with 561 additions and 83 deletions

View file

@ -25,7 +25,7 @@ import shutil
import time
import uuid
from pathlib import Path
from typing import Iterable, Optional
from typing import Any, Callable, Iterable, Optional
from config.settings import config_manager
from utils.logging_config import get_logger
@ -177,6 +177,139 @@ def atomic_copy_to_staging(src: Path, dest: Path) -> bool:
raise
# Number of consecutive None-status reads tolerated before treating the
# job as gone. Sized for the SAB queue→history transition window: SAB
# removes the slot from the queue before adding it to history, and on a
# busy server (par2 verify + unrar) that window can be several poll
# intervals. At the default 2s interval, 5 retries = ~10s of tolerance
# before we give up and emit a terminal failure.
DEFAULT_TRANSIENT_MISS_THRESHOLD = 5
def poll_album_download(
*,
get_status: Callable[[], Optional[Any]],
title: str,
emit: Callable[..., None],
complete_states: frozenset,
failed_states: frozenset = frozenset(['failed']),
is_shutdown: Optional[Callable[[], bool]] = None,
transient_miss_threshold: int = DEFAULT_TRANSIENT_MISS_THRESHOLD,
poll_interval: Optional[float] = None,
timeout: Optional[float] = None,
sleep: Callable[[float], None] = time.sleep,
monotonic: Callable[[], float] = time.monotonic,
log_prefix: str = '[album_bundle]',
) -> Optional[str]:
"""Drive the per-poll status loop for an album-bundle download.
Lifted out of ``UsenetDownloadPlugin._poll_album_download`` and the
sibling torrent method so the loop is testable in isolation and so
both plugins share the same exit semantics.
Contract:
- ``get_status()`` returns the adapter status object for the bound
job, ``None`` when the client doesn't know about the job
currently (transient or terminal disambiguated by retry count).
- ``emit(state, **fields)`` is the plugin's progress callback —
this function calls it on EVERY successful poll with
``state='downloading'`` and ALWAYS calls it once more with
``state='failed'`` before returning ``None`` on any failure path,
so the UI doesn't freeze on the last 'downloading' emit.
- ``complete_states`` is the adapter's terminal-success set
('completed' alone for usenet; 'seeding' + 'completed' for
torrent because seeding-but-files-on-disk also counts).
- ``failed_states`` is the explicit-failure set. The adapter-level
'error' (unmapped state default) is intentionally NOT in here
that's treated as a transient miss because a real SAB / NZBGet
/ qBit never returns a literal 'error' state on a healthy job;
it's only our default fallback for unknown queue strings. Real
example: SAB's 'Pp' post-processing state was unmapped → became
'error' poll infinite-looped until the 6-hour timeout.
- ``transient_miss_threshold`` is the number of consecutive None /
'error' reads tolerated before declaring the job gone. Sized for
the SAB queuehistory gap window.
Returns the adapter's reported save_path on terminal success, or
``None`` on any failure (timeout / disappeared / explicit failed
/ shutdown). On every failure path emits ``'failed'`` once with an
``error`` field describing why.
"""
interval = poll_interval if poll_interval is not None else get_poll_interval()
deadline = monotonic() + (timeout if timeout is not None else get_poll_timeout())
last_save_path: Optional[str] = None
transient_misses = 0
def _fail(reason: str) -> None:
try:
emit('failed', release=title, error=reason)
except Exception as cb_exc:
logger.debug("%s terminal emit failed: %s", log_prefix, cb_exc)
while monotonic() < deadline:
if is_shutdown and is_shutdown():
# Shutdown is a clean exit — don't paint failure on the UI;
# the app is going away anyway.
return None
try:
status = get_status()
except Exception as e:
logger.warning("%s Poll error: %s", log_prefix, e)
status = None
if status is None:
transient_misses += 1
if transient_misses >= transient_miss_threshold:
logger.error(
"%s '%s' missing from client for %d consecutive polls — giving up",
log_prefix, title, transient_misses,
)
_fail('Disappeared from client (no status after retries)')
return None
sleep(interval)
continue
# Reset the miss counter only when the adapter returned a state
# we actually recognise. The default-fallback 'error' is treated
# as a continuing transient miss below, so it must NOT reset
# here — otherwise a persistently-unmapped state loops forever.
if status.state != 'error':
transient_misses = 0
emit('downloading', progress=status.progress, downloaded=status.downloaded,
speed=status.download_speed)
if status.save_path:
last_save_path = status.save_path
if status.state in complete_states:
return last_save_path
if status.state in failed_states:
error = getattr(status, 'error', None) or 'Client reported failure'
logger.error("%s '%s' failed: %s", log_prefix, title, error)
_fail(error)
return None
if status.state == 'error':
# Unmapped adapter state — see contract docstring. Warn so
# we hear about new states the adapter map needs to grow
# without breaking the user's download. The miss counter
# was intentionally NOT reset above for this branch.
logger.warning(
"%s '%s' returned unmapped state — treating as transient",
log_prefix, title,
)
transient_misses += 1
if transient_misses >= transient_miss_threshold:
_fail('Client returned unmapped state repeatedly')
return None
sleep(interval)
logger.error("%s '%s' timed out", log_prefix, title)
_fail('Download timed out')
return None
def copy_audio_files_atomically(
sources: Iterable[Path], staging_dir: Path,
) -> list:
@ -206,11 +339,13 @@ __all__ = [
"ALBUM_PICK_MAX_BYTES",
"DEFAULT_POLL_INTERVAL_SECONDS",
"DEFAULT_POLL_TIMEOUT_SECONDS",
"DEFAULT_TRANSIENT_MISS_THRESHOLD",
"atomic_copy_to_staging",
"copy_audio_files_atomically",
"get_poll_interval",
"get_poll_timeout",
"pick_best_album_release",
"poll_album_download",
"quality_score",
"time",
"unique_staging_path",

View file

@ -59,10 +59,12 @@ from typing import Any, Dict, List, Optional, Tuple
from config.settings import config_manager
from core.archive_pipeline import collect_audio_after_extraction
from core.download_plugins.album_bundle import (
DEFAULT_TRANSIENT_MISS_THRESHOLD,
copy_audio_files_atomically,
get_poll_interval,
get_poll_timeout,
pick_best_album_release,
poll_album_download,
)
from core.download_plugins.base import DownloadSourcePlugin
from core.download_plugins.types import AlbumResult, DownloadStatus, TrackResult
@ -297,6 +299,12 @@ class TorrentDownloadPlugin(DownloadSourcePlugin):
deadline = time.monotonic() + _POLL_TIMEOUT_SECONDS
last_save_path: Optional[str] = None
# Tolerate transient None reads — covers network blips. Torrent
# adapters don't have an SAB-style queue→history transition,
# but the same tolerance keeps a one-off connection failure
# from killing an otherwise-healthy download.
transient_misses = 0
miss_threshold = DEFAULT_TRANSIENT_MISS_THRESHOLD
while time.monotonic() < deadline:
if self.shutdown_check and self.shutdown_check():
return
@ -307,9 +315,17 @@ class TorrentDownloadPlugin(DownloadSourcePlugin):
status = None
if status is None:
# Adapter forgot about the torrent — probably user-removed.
self._mark_error(download_id, "Torrent disappeared from client")
return
transient_misses += 1
if transient_misses >= miss_threshold:
self._mark_error(
download_id,
f"Torrent disappeared from client (no status after {miss_threshold} polls)",
)
return
time.sleep(_POLL_INTERVAL_SECONDS)
continue
transient_misses = 0
with self._lock:
row = self.active_downloads.get(download_id)
@ -494,10 +510,32 @@ class TorrentDownloadPlugin(DownloadSourcePlugin):
result['error'] = 'Torrent client refused the release'
return result
# Phase 3: poll until complete.
# Phase 3: poll until complete. The lifted helper handles
# transient missing windows (uncommon for torrents — adapters
# don't have a queue→history transition like SAB — but the
# same path also catches network blips that would otherwise
# take down the whole download) and always emits a terminal
# 'failed' state on failure paths so the UI doesn't freeze on
# the last 'downloading' emit.
_emit('downloading', release=picked.title)
save_path = self._poll_album_download(adapter, torrent_id, picked.title, _emit)
save_path = poll_album_download(
get_status=lambda: run_async(adapter.get_status(torrent_id)),
title=picked.title,
emit=_emit,
# Torrent adapters flip to 'seeding' on completion (files
# on disk, share-ratio progress) — both states count as
# terminal success.
complete_states=frozenset(['seeding', 'completed']),
# qBit / Transmission / Deluge surface a real 'error'
# state when the torrent itself errors (tracker, missing
# files, etc.). That's distinct from the unmapped-state
# default-'error' fallback the helper treats as transient.
failed_states=frozenset(['error']),
is_shutdown=self.shutdown_check,
log_prefix='[Torrent album]',
)
if save_path is None:
# poll_album_download already emitted terminal 'failed'.
result['error'] = 'Torrent download failed or timed out'
return result
@ -522,34 +560,6 @@ class TorrentDownloadPlugin(DownloadSourcePlugin):
result['files'] = copied
return result
def _poll_album_download(self, adapter, torrent_id, title, emit) -> Optional[str]:
"""Poll the adapter until the torrent is complete. Returns
the save path or ``None`` on timeout / failure."""
deadline = time.monotonic() + _POLL_TIMEOUT_SECONDS
last_save_path: Optional[str] = None
while time.monotonic() < deadline:
if self.shutdown_check and self.shutdown_check():
return None
try:
status = run_async(adapter.get_status(torrent_id))
except Exception as e:
logger.warning("[Torrent album] Poll error: %s", e)
status = None
if status is None:
logger.error("[Torrent album] '%s' disappeared from client", title)
return None
emit('downloading', progress=status.progress, downloaded=status.downloaded,
speed=status.download_speed)
if status.save_path:
last_save_path = status.save_path
if status.state in _COMPLETE_STATES:
return last_save_path
if status.state == 'error':
logger.error("[Torrent album] '%s' errored: %s", title, status.error)
return None
time.sleep(_POLL_INTERVAL_SECONDS)
logger.error("[Torrent album] '%s' timed out", title)
return None
# ---------------------------------------------------------------------------

View file

@ -22,8 +22,10 @@ from typing import Any, Dict, List, Optional, Tuple
from core.archive_pipeline import collect_audio_after_extraction
from core.download_plugins.album_bundle import (
DEFAULT_TRANSIENT_MISS_THRESHOLD,
copy_audio_files_atomically,
pick_best_album_release,
poll_album_download,
)
from core.download_plugins.base import DownloadSourcePlugin
from core.download_plugins.torrent import (
@ -227,6 +229,14 @@ class UsenetDownloadPlugin(DownloadSourcePlugin):
deadline = time.monotonic() + _POLL_TIMEOUT_SECONDS
last_save_path: Optional[str] = None
# Tolerate transient None / 'error' (unmapped state) reads —
# SAB removes a job from the queue before adding it to history,
# and on a busy server that gap can span several polls. Same
# bug class as the album-bundle path; see
# ``core/download_plugins/album_bundle.py:poll_album_download``
# docstring for the longer rationale.
transient_misses = 0
miss_threshold = DEFAULT_TRANSIENT_MISS_THRESHOLD
while time.monotonic() < deadline:
if self.shutdown_check and self.shutdown_check():
return
@ -237,8 +247,18 @@ class UsenetDownloadPlugin(DownloadSourcePlugin):
status = None
if status is None:
self._mark_error(download_id, "Usenet job disappeared from client")
return
transient_misses += 1
if transient_misses >= miss_threshold:
self._mark_error(
download_id,
f"Usenet job disappeared from client (no status after {miss_threshold} polls)",
)
return
time.sleep(_POLL_INTERVAL_SECONDS)
continue
if status.state != 'error':
transient_misses = 0
with self._lock:
row = self.active_downloads.get(download_id)
@ -258,6 +278,18 @@ class UsenetDownloadPlugin(DownloadSourcePlugin):
if status.state == 'failed':
self._mark_error(download_id, status.error or "Usenet client reported failure")
return
if status.state == 'error':
logger.warning(
"Usenet poll: '%s' returned unmapped state — treating as transient",
job_id,
)
transient_misses += 1
if transient_misses >= miss_threshold:
self._mark_error(
download_id,
"Usenet client returned unmapped state repeatedly",
)
return
time.sleep(_POLL_INTERVAL_SECONDS)
@ -408,8 +440,21 @@ class UsenetDownloadPlugin(DownloadSourcePlugin):
return result
_emit('downloading', release=picked.title)
save_path = self._poll_album_download(adapter, job_id, picked.title, _emit)
save_path = poll_album_download(
get_status=lambda: run_async(adapter.get_status(job_id)),
title=picked.title,
emit=_emit,
# Usenet completes into history as 'completed'; no 'seeding'
# equivalent. Failed is explicit on history failures.
complete_states=frozenset(['completed']),
failed_states=frozenset(['failed']),
is_shutdown=self.shutdown_check,
log_prefix='[Usenet album]',
)
if save_path is None:
# poll_album_download already emitted the terminal 'failed'
# state on every failure path (timeout / disappeared /
# explicit failure / unmapped). UI is unstuck either way.
result['error'] = 'Usenet download failed or timed out'
return result
@ -433,29 +478,3 @@ class UsenetDownloadPlugin(DownloadSourcePlugin):
result['files'] = copied
return result
def _poll_album_download(self, adapter, job_id, title, emit) -> Optional[str]:
deadline = time.monotonic() + _POLL_TIMEOUT_SECONDS
last_save_path: Optional[str] = None
while time.monotonic() < deadline:
if self.shutdown_check and self.shutdown_check():
return None
try:
status = run_async(adapter.get_status(job_id))
except Exception as e:
logger.warning("[Usenet album] Poll error: %s", e)
status = None
if status is None:
logger.error("[Usenet album] '%s' disappeared from client", title)
return None
emit('downloading', progress=status.progress, downloaded=status.downloaded,
speed=status.download_speed)
if status.save_path:
last_save_path = status.save_path
if status.state in _COMPLETE_STATES:
return last_save_path
if status.state == 'failed':
logger.error("[Usenet album] '%s' failed: %s", title, status.error)
return None
time.sleep(_POLL_INTERVAL_SECONDS)
logger.error("[Usenet album] '%s' timed out", title)
return None

View file

@ -21,26 +21,37 @@ from utils.logging_config import get_logger
logger = get_logger("usenet.sabnzbd")
# SAB queue states + history states → adapter-uniform set.
# Queue: Idle, Paused, Downloading, Grabbing, Queued, Checking,
# QuickCheck, Verifying, Repairing, Fetching, Extracting, Moving,
# Running, Completed, Failed.
# SAB queue states + history states → adapter-uniform set. Covers
# every Status value from SAB's ``sabnzbd/api.py`` plus the legacy
# short-form codes ("pp" for post-processing, "trying" for retry) and
# the prop_* variants returned for items that bounce between paused
# and failed during retry. Anything unmapped lands on "error" via
# ``_map_state``'s default — the album-bundle poll helper treats that
# default as a transient miss so a brand-new unmapped state can't
# infinite-loop the poll the way "pp" used to.
_SAB_QUEUE_STATE_MAP = {
"idle": "queued",
"queued": "queued",
"grabbing": "queued",
"fetching": "downloading",
"downloading": "downloading",
"paused": "paused",
"checking": "verifying",
"quickcheck": "verifying",
"verifying": "verifying",
"repairing": "repairing",
"extracting": "extracting",
"moving": "extracting",
"running": "extracting",
"completed": "completed",
"failed": "failed",
"idle": "queued",
"queued": "queued",
"grabbing": "queued",
"fetching": "downloading",
"downloading": "downloading",
"trying": "downloading",
"paused": "paused",
"prop_paused": "paused",
"checking": "verifying",
"quickcheck": "verifying",
"verifying": "verifying",
"repairing": "repairing",
"extracting": "extracting",
"unpacking": "extracting",
"moving": "extracting",
"running": "extracting",
"pp": "extracting",
"postprocessing": "extracting",
"completed": "completed",
"failed": "failed",
"prop_failed": "failed",
"deleted": "failed",
}
@ -156,7 +167,28 @@ class SABnzbdAdapter:
return await loop.run_in_executor(None, self._get_status_sync, job_id)
def _get_status_sync(self, job_id: str) -> Optional[UsenetStatus]:
# Check active queue first; if not found, fall back to history.
# Direct nzo_ids lookup against queue, then history. Falls back
# to the bulk fetch for SAB versions that don't honour the
# nzo_ids filter (very old SAB), but the direct path is the hot
# path because the bulk history fetch was limited to 50 entries
# — on a busy SAB server a recently-completed job would roll
# past the window and the poll would log "disappeared".
if not job_id:
return None
queue = self._call_sync('queue', nzo_ids=job_id)
if queue and isinstance(queue.get('queue'), dict):
for slot in queue['queue'].get('slots', []) or []:
if str(slot.get('nzo_id') or '') == job_id:
return self._parse_queue_slot(slot)
history = self._call_sync('history', nzo_ids=job_id)
if history and isinstance(history.get('history'), dict):
for slot in history['history'].get('slots', []) or []:
if str(slot.get('nzo_id') or '') == job_id:
return self._parse_history_slot(slot)
# Fallback: SAB version pre-dating nzo_ids filter support. The
# bulk path is still limit=50; the helper's transient-miss
# tolerance will cover the gap if the entry briefly rolls out
# of the window.
for status in self._get_all_sync():
if status.id == job_id:
return status

View file

@ -299,3 +299,284 @@ def test_get_poll_timeout_falls_back_on_garbage() -> None:
assert get_poll_timeout() == DEFAULT_POLL_TIMEOUT_SECONDS
cm.get.return_value = 0
assert get_poll_timeout() == DEFAULT_POLL_TIMEOUT_SECONDS
# ---------------------------------------------------------------------------
# poll_album_download — lifted poll loop for both torrent + usenet plugins.
# ---------------------------------------------------------------------------
from core.download_plugins.album_bundle import poll_album_download
@dataclass
class _Status:
"""Duck-typed sibling of UsenetStatus / TorrentStatus — only the
fields poll_album_download reads."""
state: str
save_path: Optional[str] = None
progress: float = 0.0
downloaded: int = 0
download_speed: int = 0
error: Optional[str] = None
class _ScriptedClock:
"""Deterministic monotonic-time + sleep stand-in for poll tests.
Each call to ``sleep(n)`` advances ``now`` by ``n`` seconds with
no real wall-clock delay. Lets us run multi-iteration poll
scenarios in milliseconds and assert on the exact iteration count
each branch took."""
def __init__(self) -> None:
self.now = 0.0
self.sleep_calls = 0
def monotonic(self) -> float:
return self.now
def sleep(self, seconds: float) -> None:
self.now += seconds
self.sleep_calls += 1
def _make_emit_recorder():
"""Collects (state, kwargs) tuples so tests can assert on the
emit sequence the UI would see."""
calls = []
def _emit(state: str, **fields) -> None:
calls.append((state, fields))
return _emit, calls
def test_poll_returns_save_path_on_completed_state() -> None:
"""Happy path — adapter says 'completed' with a save_path on the
first poll; function returns the path and emits a single
'downloading' (NOT a terminal 'failed') so the caller can chain
'staging' / 'staged' next."""
clock = _ScriptedClock()
emit, calls = _make_emit_recorder()
status = _Status(state='completed', save_path='/dl/album', progress=1.0)
result = poll_album_download(
get_status=lambda: status,
title='Album X',
emit=emit,
complete_states=frozenset(['completed']),
sleep=clock.sleep, monotonic=clock.monotonic,
poll_interval=2.0, timeout=60.0,
)
assert result == '/dl/album'
states = [c[0] for c in calls]
assert 'failed' not in states
assert 'downloading' in states
def test_poll_tolerates_transient_missing_during_sab_handoff() -> None:
"""SAB removes a job from the queue before adding it to history.
Pre-fix: one None read = give up + log 'disappeared from client'
even though SAB was healthy and just mid-handoff. Now we tolerate
up to ``transient_miss_threshold`` consecutive None reads before
declaring the job gone. Recovery to a real status MUST reset the
miss counter."""
clock = _ScriptedClock()
emit, calls = _make_emit_recorder()
sequence = iter([None, None, None,
_Status(state='completed', save_path='/sab/done')])
result = poll_album_download(
get_status=lambda: next(sequence),
title='Album X', emit=emit,
complete_states=frozenset(['completed']),
transient_miss_threshold=5,
sleep=clock.sleep, monotonic=clock.monotonic,
poll_interval=2.0, timeout=60.0,
)
assert result == '/sab/done'
assert 'failed' not in [c[0] for c in calls]
def test_poll_gives_up_after_threshold_consecutive_misses() -> None:
"""When the job genuinely is gone (user deleted it from SAB), the
transient tolerance still has a floor after N misses, fail
explicitly and emit a terminal 'failed' so the UI doesn't freeze."""
clock = _ScriptedClock()
emit, calls = _make_emit_recorder()
result = poll_album_download(
get_status=lambda: None,
title='Album X', emit=emit,
complete_states=frozenset(['completed']),
transient_miss_threshold=3,
sleep=clock.sleep, monotonic=clock.monotonic,
poll_interval=2.0, timeout=600.0,
)
assert result is None
failed_calls = [c for c in calls if c[0] == 'failed']
assert len(failed_calls) == 1
assert 'Disappeared' in failed_calls[0][1].get('error', '')
def test_poll_emits_terminal_failed_on_explicit_failed_state() -> None:
"""Adapter says 'failed' (real failure, not transient). Function
returns None AND emits 'failed' with the adapter's error message."""
clock = _ScriptedClock()
emit, calls = _make_emit_recorder()
status = _Status(state='failed', error='par2 unrecoverable')
result = poll_album_download(
get_status=lambda: status,
title='Album X', emit=emit,
complete_states=frozenset(['completed']),
sleep=clock.sleep, monotonic=clock.monotonic,
poll_interval=2.0, timeout=60.0,
)
assert result is None
failed_calls = [c for c in calls if c[0] == 'failed']
assert len(failed_calls) == 1
assert failed_calls[0][1].get('error') == 'par2 unrecoverable'
def test_poll_emits_terminal_failed_on_timeout() -> None:
"""When the deadline passes without success or explicit failure,
emit 'failed' once so the UI exits the 'downloading' state."""
clock = _ScriptedClock()
emit, calls = _make_emit_recorder()
status = _Status(state='downloading', progress=0.5)
result = poll_album_download(
get_status=lambda: status,
title='Album X', emit=emit,
complete_states=frozenset(['completed']),
sleep=clock.sleep, monotonic=clock.monotonic,
poll_interval=2.0, timeout=10.0,
)
assert result is None
failed_calls = [c for c in calls if c[0] == 'failed']
assert len(failed_calls) == 1
assert 'timed out' in failed_calls[0][1].get('error', '').lower()
def test_poll_treats_default_error_state_as_transient_not_terminal() -> None:
"""The adapter state-map's default-fallback for unmapped strings
is 'error' (real-world: SAB's 'Pp' state used to land here and
cause the poll to infinite-loop because 'error' wasn't in the
failed set and wasn't in the complete set). Now: treat as a
transient miss so the poll recovers when the unmapped state
transitions to a known one. If it stays unmapped for the threshold
of consecutive polls, emit terminal failed."""
clock = _ScriptedClock()
emit, calls = _make_emit_recorder()
sequence = iter([
_Status(state='error'),
_Status(state='error'),
_Status(state='completed', save_path='/done'),
])
result = poll_album_download(
get_status=lambda: next(sequence),
title='Album X', emit=emit,
complete_states=frozenset(['completed']),
transient_miss_threshold=5,
sleep=clock.sleep, monotonic=clock.monotonic,
poll_interval=2.0, timeout=60.0,
)
assert result == '/done'
assert 'failed' not in [c[0] for c in calls]
def test_poll_gives_up_when_default_error_state_persists() -> None:
"""If the adapter keeps returning the unmapped 'error' state past
the threshold, fail rather than burning the full 6-hour timeout."""
clock = _ScriptedClock()
emit, calls = _make_emit_recorder()
result = poll_album_download(
get_status=lambda: _Status(state='error'),
title='Album X', emit=emit,
complete_states=frozenset(['completed']),
transient_miss_threshold=3,
sleep=clock.sleep, monotonic=clock.monotonic,
poll_interval=2.0, timeout=600.0,
)
assert result is None
failed_calls = [c for c in calls if c[0] == 'failed']
assert len(failed_calls) == 1
assert 'unmapped' in failed_calls[0][1].get('error', '').lower()
def test_poll_shutdown_returns_none_without_terminal_emit() -> None:
"""Process shutdown is a clean exit — don't paint failure on the
UI; the app is going away anyway."""
clock = _ScriptedClock()
emit, calls = _make_emit_recorder()
result = poll_album_download(
get_status=lambda: _Status(state='downloading', progress=0.5),
title='Album X', emit=emit,
complete_states=frozenset(['completed']),
is_shutdown=lambda: True,
sleep=clock.sleep, monotonic=clock.monotonic,
poll_interval=2.0, timeout=60.0,
)
assert result is None
assert 'failed' not in [c[0] for c in calls]
def test_poll_torrent_seeding_counts_as_complete() -> None:
"""Torrent plugin passes ``complete_states={'seeding', 'completed'}``
because qBit / Transmission flip the torrent to 'seeding' on
completion (files already on disk + share ratio progress). Same
poll function must accept either state as terminal success."""
clock = _ScriptedClock()
emit, calls = _make_emit_recorder()
status = _Status(state='seeding', save_path='/dl/album.torrent')
result = poll_album_download(
get_status=lambda: status,
title='Album X', emit=emit,
complete_states=frozenset(['seeding', 'completed']),
sleep=clock.sleep, monotonic=clock.monotonic,
poll_interval=2.0, timeout=60.0,
)
assert result == '/dl/album.torrent'
def test_poll_save_path_captured_across_iterations() -> None:
"""save_path can appear mid-poll (e.g. once SAB moves the slot
out of the queue and into history). The last non-empty save_path
seen during the run is what we return on terminal success even
if the final status read happens to have it cleared."""
clock = _ScriptedClock()
emit, calls = _make_emit_recorder()
sequence = iter([
_Status(state='downloading', progress=0.4),
_Status(state='downloading', save_path='/late/path', progress=0.9),
_Status(state='completed', progress=1.0),
])
result = poll_album_download(
get_status=lambda: next(sequence),
title='Album X', emit=emit,
complete_states=frozenset(['completed']),
sleep=clock.sleep, monotonic=clock.monotonic,
poll_interval=2.0, timeout=60.0,
)
assert result == '/late/path'
def test_poll_get_status_exception_treated_as_transient_miss() -> None:
"""Adapter raising (network blip, JSON decode error) shouldn't
blow up the poll thread caught, logged, counted as a transient
miss alongside None returns."""
clock = _ScriptedClock()
emit, calls = _make_emit_recorder()
counter = {'n': 0}
def _raising_then_success():
counter['n'] += 1
if counter['n'] <= 2:
raise RuntimeError('network blip')
return _Status(state='completed', save_path='/recovered')
result = poll_album_download(
get_status=_raising_then_success,
title='Album X', emit=emit,
complete_states=frozenset(['completed']),
transient_miss_threshold=5,
sleep=clock.sleep, monotonic=clock.monotonic,
poll_interval=2.0, timeout=60.0,
)
assert result == '/recovered'
assert 'failed' not in [c[0] for c in calls]

View file

@ -3415,6 +3415,7 @@ function closeHelperSearch() {
const WHATS_NEW = {
'2.6.3': [
{ unreleased: true },
{ title: 'Fix: usenet album downloads stuck on "downloading release" even after SAB finished', desc: 'when SAB moves a job from its queue into history (post-processing handoff — verify + unrar window), there\'s a brief gap where the job is in neither. SoulSync\'s poll treated one missing read as terminal failure and gave up silently, leaving the download modal frozen on "downloading release" forever. SAB queue states like `Pp` (post-processing) also weren\'t in the state map, hit the default-error fallback, and hung the poll the same way. now the poll tolerates ~10 seconds of transient misses, treats unmapped queue states as transient instead of silently looping, fires a terminal failed event on every failure path so the modal exits the downloading state, and queries SAB by nzo_id directly instead of paging the last-50-entries history (avoids losing the job on busy servers). same tolerance applies to torrent downloads — covers network blips that used to kill an otherwise healthy download.' },
{ title: 'Discogs: strip artist disambiguation suffixes everywhere', desc: 'Discogs marks duplicate-named artists with either `(N)` numeric suffixes ("Bullet (2)") or trailing asterisks ("John Smith*"). Only the `(N)` form was stripped, and only on the collection-import path — so artist + album search results in the UI surfaced raw "Foo*" or "Bar (2)" rows, and worse, import path inherited those characters into folder names on disk. centralized the cleanup into a single helper applied at every site a Discogs payload becomes a name string (artist search, album search, track lookups, get_artist_albums feature filtering). closes #634.' },
{ title: 'Library: Enhanced / Standard view toggle now sticks per browser', desc: 'the artist detail page used to revert to Standard view every time you clicked into a new artist — flipping to Enhanced was a per-click thing with no memory. now the choice is persisted in localStorage and reapplied automatically on every artist open. survives page reloads too. non-admins (who can\'t toggle Enhanced anyway) are unaffected; admins who never touched the Enhanced view stay on Standard since the saved value defaults to it.' },
{ title: 'Fix popup: manual matches survive Playlist Pipeline runs', desc: 'manually mapping a mirrored-playlist track via the Fix popup (search or MBID) saved correctly and the download went through, but the next Playlist Pipeline run kept reverting the match — the track flipped back to "Provider Changed" and you had to map it again. three things were ganging up on it: the manual-fix save always stamped `provider: \'spotify\'` even when the match came from MusicBrainz / iTunes / Deezer, so prepare-discovery saw a provider mismatch on the next run; the discovery worker also re-queued any matched_data that lacked `track_number` / `album.id` / `release_date` for re-discovery, and Fix-popup saves never carry those fields by design (search results don\'t include track_number, MBID lookups don\'t include album.id) — so manual fixes always looked "incomplete" and got overwritten; and the prepare-discovery provider check didn\'t honour the `manual_match` flag at all. all three patched — manual matches now stamp the actual source, the worker short-circuits on `manual_match` before the incomplete-data branch, and prepare-discovery treats manual fixes as cached regardless of provider drift.' },