Swigs: 'No audio files found in /data/usenet/incomplete/….#2141' — SoulSync
imported a usenet album from NZBGet's intermediate working dir, which is emptied
after the move (files were in /data/soulseek/…). Two causes, both fixed to match
the already-correct SAB adapter:
- _parse_history mapped save_path=DestDir, but the authoritative final location
after a post-processing move is FinalDir. Prefer FinalDir, fall back to DestDir,
empty/whitespace -> None.
- _parse_group exposed the queue group's DestDir (the in-progress '….#NZBID' dir)
as save_path, so a PP_FINISHED group (which maps to 'completed') could finalize
on the incomplete folder before the move. A queue group now reports no save_path
-> finalisation always comes from the history entry (real FinalDir/DestDir),
bridged by the existing 120s completed-no-path window.
6 regression tests (FinalDir preferred, DestDir fallback, empty->None, queue/
PP_FINISHED never offer the incomplete path).
The earlier #721 fix tolerated a ~10s "completed but no save_path"
window, but the real production stall sits upstream of that: SABnzbd
removes a finished download from the queue and runs par2 verify /
repair / unpack *in History*, exposing the live stage in the slot
`status` ('Verifying' / 'Repairing' / 'Extracting' / 'Moving' / ...)
with `storage` empty until the final move. `_parse_history_slot` mapped
EVERY non-'Failed' status to 'completed', so a still-extracting 1.7 GB
FLAC album looked "completed with no save_path" the instant download hit
100%. The poll burned its completed-no-path budget mid-PP and bailed,
freezing the UI on the last download emit (the stuck-at-99%/100%
signature). SAB then finished fine — which is why the job shows
Completed in History but SoulSync never staged it.
Root fix
- `_parse_history_slot` routes `status` through `_map_state`, so PP
stages stay NON-terminal: the poll keeps waiting (as 'downloading')
for as long as post-processing takes and only a real 'Completed'
flips to terminal success. `save_path` is trusted only on true
completion (mid-PP path fields may point at the incomplete dir).
Supporting / defensive
- `UsenetStatus.incomplete_path`: surfaced separately from save_path
(SAB `incomplete_path`) and used by the poll loops as a LAST RESORT
after the completed-no-path window, to recover the case where
`storage` never lands but the files are physically on disk.
- `poll_album_download`: dedicated, configurable completed-no-path
window (~120s via `download_source.album_bundle_completed_no_path_seconds`)
decoupled from the ~10s transient-miss window; incomplete_path
fallback; a 30s heartbeat log so the previously-silent poll loop is
diagnosable.
- `usenet.py` `_download_thread`: per-track parity — it was erroring
immediately on the first completed-no-path read.
- `album_bundle_dispatch.py` / `status.py` / `monitor.py`: use the
project `get_logger` so download-flow logs land in app.log under the
`soulsync.*` namespace (they were console-only before, which hid the
`[Album Bundle] flow failed` line during triage).
Tests
- PP-history state mapping; end-to-end Hunky Dory PP regression
(download -> Verifying/Extracting in History past both budgets ->
Completed+storage -> success); completed-no-path window +
incomplete_path fallback; per-track thread parity. ruff + compileall +
pytest all green (the only local failures are environmental: missing
tzdata + local tools/ffmpeg.exe, neither present on CI).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Follow-up to the 2.6.3 queue→history handoff fix (#706). User
@IamGroot60 reported in #721 that on 2.6.3 the bundle still gets
stuck mid-flight: SoulSync UI sits on "Usenet downloading release
61%" forever, SAB History shows the job as Completed 2+ minutes
ago, files are physically present in the slskd downloads folder
but never copied into ``storage/album_bundle_staging/<batch>/``.
Root cause: a second-stage gap in the SAB pipeline. SAB flips a
job's ``status`` to ``Completed`` in History as soon as par2 +
unrar finish, but its post-processing pipeline writes the final
``storage`` field a few seconds LATER (the move-to-final step).
``poll_album_download`` saw the first ``Completed`` read with
``save_path=None`` and bailed:
if status.state in complete_states:
return last_save_path # ← None at this point
``download_album_to_staging`` got ``save_path=None``, set
``result['error']`` and returned. The bundle was marked failed but
the LAST progress emit before the failure was ``downloading
progress=0.61``, so the UI froze on "61%" — the terminal ``failed``
emit never registered on the user's screen because the renderer
holds the last-known progress.
Fix
- ``poll_album_download`` now tracks a separate transient counter
for "complete state seen, save_path not yet set." Up to
``transient_miss_threshold`` (default 5) consecutive reads in
that state are tolerated before the poll bails. SAB writes the
``storage`` field within 2-10 seconds of the History flip in
practice — the default 5 × 2s = 10s window covers it.
- When save_path eventually lands, return it normally.
- When the threshold is exhausted with save_path still empty,
emit terminal ``failed`` with an explicit message pointing at
the missing save_path field — no more 6-hour silent spin.
- Earlier ``downloading`` reads with a non-empty ``save_path``
(qBit / Transmission set this from the start of the download)
remain "sticky" — if the eventual ``completed`` read has empty
save_path, the cached one applies. So torrent flows aren't
affected by the retry path.
SAB adapter (``_parse_history_slot``)
- Widened the save_path field fallback chain:
storage → path → download_path → dirname → incomplete_path
Covers SAB version differences (older builds populated ``path``)
and forks that expose ``download_path`` or ``dirname``.
``incomplete_path`` is the last resort — SAB's in-progress dir
before the final move — so the bundle plugin at least has a
path to scan when nothing else lands.
- Whitespace-only values are skipped.
- Loud debug log when none of the known fields land — users on
SAB versions / forks with novel field names need to see this in
logs so we can grow ``_HISTORY_SAVE_PATH_KEYS``.
Tests
- ``test_album_bundle.py`` (3 new):
- tolerates_completed_with_late_save_path_arrival — the #721
scenario; first Completed read has no save_path, third has
it; poll returns the path normally
- gives_up_when_completed_with_no_save_path_persists — past
the threshold the poll fails loudly instead of silent-spinning
- uses_save_path_from_earlier_downloading_emit_if_completed_lacks_one
— sticky save_path keeps torrent flows working
- ``test_usenet_client_adapters.py`` (6 new):
- falls back to ``path`` when ``storage`` empty
- falls back to ``download_path``
- prefers ``storage`` when multiple fields present
- returns ``None`` when all fields empty (the #721 gap window)
- ignores whitespace-only values
- uses ``incomplete_path`` as last resort
132 album-bundle + usenet tests pass.
Branch is on dev parented at 2.6.3 — user @IamGroot60 offered
to test on dev, so this is a candidate cherry-pick for either
a 2.6.4 hotfix or merge straight into dev for the next release.
Follow-up to f13d3395. Five gaps called out on self-review:
1. Per-track inline transient tolerance was duplicated between
usenet.py and torrent.py (~12 lines each, identical) and wasn't
directly tested. Extracted into ``TransientMissCounter`` in
``album_bundle.py`` — small class with ``record_miss()`` returning
True at threshold and ``reset()`` for successful reads. Both
per-track flows AND the lifted ``poll_album_download`` now use
the same counter, so the rule is in one place.
2. Threshold is now config-driven via
``download_source.album_bundle_transient_miss_threshold``
(default 5). Same defensive pattern as ``get_poll_interval`` /
``get_poll_timeout`` — non-positive / non-numeric falls back to
the default. Users with very slow servers (huge multi-disc box
sets, slow disks) can extend the tolerance window without
touching code.
3. SAB state map verified against the canonical Status enum in
``sabnzbd/constants.py`` (sabnzbd/constants.py:~95-118). Dropped
six entries I'd guessed at and couldn't verify in source
(``trying``, ``prop_paused``, ``prop_failed``, ``unpacking``,
``pp``, ``postprocessing``). Kept the verified ``deleted`` (lower-
cased from SAB's ``Deleted``) and added the one real state I'd
missed: ``Propagating`` (SAB's pre-download delay state — maps to
``queued`` since we're waiting on the NZB to be available, not
actively downloading).
4. SAB integration test exercising the queue→history gap end-to-end
through the real adapter HTTP layer. Mocks SAB's queue + history
endpoints with the exact response shapes SAB emits, runs three
gap polls (both endpoints empty), then a recovery poll where the
slot appears in history as Completed. Confirms the TransientMissCounter
absorbs the gap and ``poll_album_download`` returns the save_path
without emitting terminal failure. This was the path I had only
tested at the helper layer before — now pinned end-to-end through
the adapter.
5. SAB state mapping has new tests: every Status value from SAB's
canonical enum must map to a known adapter state (not the 'error'
default fallback), Propagating routes to queued, Deleted routes
to failed. Future SAB state additions that we miss will surface
as 'error' default → transient-miss tolerance → terminal failure
with a clear log line, but the explicit assertion list here means
we'll catch the omission in CI before users do.
Test count after: 537 download-suite tests pass; 21 new
(``TransientMissCounter`` ×4, ``get_transient_miss_threshold`` ×3,
SAB state-coverage ×3, SAB direct ``nzo_ids`` lookup ×5, SAB
queue→history integration ×1, plus the existing helper-layer
coverage from the parent commit). Ruff clean.
54 mocked unit tests pinning the parse + dispatch behavior of the
new indexer and downloader plumbing. No live services required —
HTTP is mocked at the requests-library boundary, RPC is mocked at
the _rpc_sync helper.
Coverage:
- core/prowlarr_client.py: parse_indexer / parse_result with
category-shape variants, search query encodes repeated
``categories=`` and ``indexerIds=`` keys, check_connection hits
the right endpoint with the right header.
- core/torrent_clients/qbittorrent.py: login sends the Referer
CSRF header, login failure surfaces, parse_status normalises
field names, eta <= 0 becomes None.
- core/torrent_clients/transmission.py: bare host URL is rewritten
to /transmission/rpc, 409 + X-Transmission-Session-Id is
renegotiated and the retry carries the new id, torrent-add
surfaces torrent-duplicate hashes, eta -1 becomes None.
- core/torrent_clients/deluge.py: requires password to be configured,
magnet vs HTTP URL hit different RPC methods, progress is
normalised from 0-100 to 0-1.
- core/usenet_clients/sabnzbd.py: parse_timeleft handles HH:MM:SS
and the MM:SS fallback, queue + history merge into a single
get_all, addurl vs addfile are dispatched on the input type.
- core/usenet_clients/nzbget.py: requires URL + username + password,
mb_value prefers the 64-bit size split over the legacy MB field,
add_nzb base64-encodes raw bytes, GroupFinalDelete vs GroupDelete
is picked by the delete_files flag, non-numeric job IDs fail fast.
- state mapping tables for all five adapters get explicit assertions
so future refactors can't silently lose a native state value.
WHATS_NEW entry covers the test addition; no VERSION_MODAL_SECTIONS
entry — internal infrastructure, not user-facing.