Commit graph

2 commits

Author SHA1 Message Date
Broque Thomas
f13d339584 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.
2026-05-27 09:42:51 -07:00
Broque Thomas
670a2db95e refactor(downloads): extract album_bundle shared helpers + atomic copy
Per code review: the album-bundle helpers (release picker + staging
collision suffix) were defined as private symbols in torrent.py and
imported by usenet.py through ``from core.download_plugins.torrent
import _pick_best_album_release, _unique_staging_path``. Sibling
plugins shouldn't reach into each other's private surface — leaky
module boundary, and the underscore prefix says don't import.

Also addressed two latent issues at the same time:

- The Auto-Import sweep race: my plugin copied audio files into
  staging via plain ``shutil.copy2``, which exposes a partial file
  at the audio extension for the duration of the copy. The Auto-
  Import worker filters by audio extension when scanning Staging
  (AUDIO_EXTENSIONS in core/auto_import_worker.py), so a mid-flight
  scan could pick up a truncated file. Fix: copy to a
  ``.tmp.<random>`` sidecar first, then atomically rename via
  ``Path.replace`` (which is ``os.replace`` — atomic on the same
  filesystem). Auto-Import sees the file either at its final name
  or not at all.

- The 6-hour poll timeout was a hard-coded magic constant. Users
  with slow private trackers or large box sets would silently time
  out after 6h. Both the timeout and the poll interval are now
  read from config (``download_source.album_bundle_timeout_seconds``
  / ``..._poll_interval_seconds``) with safe fallback to the
  existing defaults when unset / non-numeric.

- core/download_plugins/album_bundle.py: new module owns the
  shared surface — ``pick_best_album_release`` (with quality_guess
  passed in as a parameter to avoid the circular import that would
  result from this module trying to know about torrent.py's title
  parser), ``unique_staging_path``, ``atomic_copy_to_staging``,
  ``copy_audio_files_atomically``, ``get_poll_interval``,
  ``get_poll_timeout``. Module-level size constants and quality
  weights live here too. Usenet's grabs-as-popularity-proxy is
  built into the picker so both plugins get the right behavior
  without divergent local logic.
- core/download_plugins/torrent.py: drops the local helpers + the
  hard-coded poll constants, imports from album_bundle. Per-track
  download flow still uses module-level ``_POLL_TIMEOUT_SECONDS``
  / ``_POLL_INTERVAL_SECONDS`` aliases (read from config once at
  import time, same as before from a per-track perspective).
- core/download_plugins/usenet.py: drops the imports of the
  torrent.py private helpers; everything goes through album_bundle
  now. Stops the cross-plugin private-import leak that started
  this whole refactor.
- tests/test_album_bundle.py: 23 new tests covering the picker
  heuristic (empty input, singleton drop, FLAC preference, grabs
  fallback for usenet, size-floor / ceiling boundaries), the
  collision-suffix logic, the atomic-copy invariant (concurrent
  scanner thread asserts it never observes a partial audio file
  during five sequential copies), the failure-skip behavior of the
  batch copier, and the config-driven poll cadence including
  garbage-input fallback.
- tests/test_torrent_usenet_plugins.py: existing picker tests
  updated to call the new module-level helpers instead of the
  former torrent.py privates.
2026-05-20 20:26:30 -07:00