Route primary Soulseek album downloads through the album-bundle staging flow, reusing preflight-selected folders when available. In hybrid mode, only the first configured source can claim whole-album bundle behavior so later Soulseek fallback keeps the existing per-track/source-reuse path.
Allow Soulseek album bundles to stage completed tracks when some same-source transfers fail or time out, and keep partial bundles from blocking per-track fallback. Add coverage for dispatcher gating, master flow ordering, task-worker staged-miss behavior, and Soulseek bundle polling.
Route torrent and Usenet album bundles through private per-batch staging so Auto-Import cannot race public staging or duplicate imports.
Expose album-bundle progress in batch status and render it on the Downloads page while the external client is still downloading.
Tighten release handoff safety by rejecting archive path traversal, ignoring torrent candidates without a usable URL, and skipping Soulseek source reuse for torrent/Usenet batches.
Tests: .venv/bin/python -m pytest tests/downloads/test_downloads_status.py tests/test_album_bundle_dispatch.py tests/downloads/test_downloads_staging.py tests/test_torrent_usenet_plugins.py
Per code review: ~90 lines of inline gate logic in
``run_full_missing_tracks_process`` was inflating an already-580-
line worker function and was non-testable in isolation. Lifted to
``core/downloads/album_bundle_dispatch.py`` with two entry points:
- ``is_eligible(mode, is_album, album_name, artist_name)`` — pure
predicate, no side effects, easy to assert against. Splits the
gate decision from the resolution + run step so tests can pin
the gate semantics without standing up a plugin.
- ``try_dispatch(...)`` — full flow. Returns True iff the master
worker should stop (gate fired + failed); False = engaged-and-
succeeded OR didn't engage, both fall through to per-track.
State access is now decoupled from ``runtime_state``:
- New ``BatchStateAccess`` Protocol with two methods
(``update_fields``, ``mark_failed``).
- Concrete impl ``_BatchStateAccessImpl`` lives in master.py and
wraps the tasks_lock + dict ops the original inline code did.
- Injected via parameter so the dispatch module never imports
``download_batches`` / ``tasks_lock`` directly.
Same goes for the plugin resolver and config getter — both
injected, so the dispatcher works against any orchestrator /
config implementation (including the in-test fakes).
Behavior unchanged. The master worker call site is now 11 lines
of boilerplate instead of 90 lines of inline conditional. Plugin
contract (``download_album_to_staging`` return dict shape)
unchanged.
- core/downloads/album_bundle_dispatch.py: new module owning the
gate + execution. ~150 lines including docstrings and the
Protocol definition.
- core/downloads/master.py: gate call site shrunk to a single
``if _album_bundle_dispatch.try_dispatch(...): return``. New
``_BatchStateAccessImpl`` class implements the Protocol against
the existing ``download_batches`` dict + ``tasks_lock`` so the
dispatcher gets injected access instead of importing them.
- tests/test_album_bundle_dispatch.py: 16 new tests covering the
pure predicate (album-required, mode allowlist, name validation,
case insensitivity), the resolver-failure fall-through
(plugin missing, plugin lacks method, resolver raises), the
success path returning False so per-track flows, the failure
path returning True with state.mark_failed called, plugin-raise
treated as a normal failure, whitespace stripping on names,
and progress-callback mirroring lifecycle events into batch
state.