Fix: torrent/usenet album bundle hard-fails on 'no results' instead of falling back

A torrent-first (or usenet-first) hybrid download would freeze at
"Torrent searching for release 0%" and never move to the next source when
Prowlarr returned no results for the album. Reported by Cezar:
  [Album Bundle] torrent flow failed for '...': No torrent results found

Cause: the album-bundle dispatch only returns to the per-track flow (which,
in hybrid mode, tries the next configured source) when the plugin's failure
outcome carries fallback=True; otherwise it marks the batch failed and stops.
Both plugins set fallback=True on their 'results found but none matched the
album' branch, but the adjacent 'no results at all' branch set only an error
and no fallback flag -- so zero results hard-failed while wrong results fell
back. Backwards, and soulseek's plugin already defaults fallback=True for
exactly this reason.

Fix: set fallback=True on the no-results branch in torrent.py and usenet.py.
The dispatch's fallback handling (return False -> per-track flow) was already
correct and is unchanged.

The only consumer of download_album_to_staging is the dispatch, which reads
the result via .get('fallback'), so the change is additive and locally
contained.

Tests: new test_torrent_album_to_staging_no_results_flags_fallback and
test_usenet_album_to_staging_no_results_flags_fallback assert the plugins now
emit fallback=True on an empty search; the existing torrent no-results test is
extended with the same assertion. Existing dispatch tests already pin
fallback=True -> per-track flow. Full downloads/plugins/adapters sweep: 690
passed.
pull/761/head
BoulderBadgeDad 2 weeks ago
parent 6e7948b642
commit 95d6ad4bc9

@ -494,7 +494,14 @@ class TorrentDownloadPlugin(DownloadSourcePlugin):
candidates = [r for r in search_results
if r.protocol == 'torrent' and (r.magnet_uri or r.download_url)]
if not candidates:
# Album isn't available on this source. Mark the failure as
# fallback-eligible so the dispatch returns to the per-track flow
# instead of hard-failing the batch — in hybrid mode that lets the
# next configured source take over. Without this flag a torrent-first
# hybrid would get stuck at "searching" forever when Prowlarr
# returns nothing, never trying the other sources.
result['error'] = f'No torrent results found for "{query}"'
result['fallback'] = True
return result
picked = pick_best_album_release(

@ -465,7 +465,12 @@ class UsenetDownloadPlugin(DownloadSourcePlugin):
candidates = [r for r in search_results
if r.protocol == 'usenet' and r.download_url]
if not candidates:
# Album isn't available on this source — fall back to the per-track
# flow (next configured source in hybrid mode) rather than hard-
# failing the whole batch. Mirrors the torrent plugin + soulseek's
# default fallback contract.
result['error'] = f'No usenet results found for "{query}"'
result['fallback'] = True
return result
picked = pick_best_album_release(

@ -589,9 +589,45 @@ def test_torrent_album_to_staging_ignores_candidates_without_download_url(tmp_pa
assert outcome['success'] is False
assert 'No torrent results' in outcome['error']
# Regression (Cezar): "no results" must be fallback-eligible so a
# torrent-first hybrid returns to the per-track flow (next source)
# instead of the dispatch marking the batch failed and freezing at
# "Torrent searching for release 0%".
assert outcome.get('fallback') is True
fake_adapter.add_torrent.assert_not_called()
def test_torrent_album_to_staging_no_results_flags_fallback(tmp_path: Path) -> None:
"""Empty Prowlarr search → fallback-eligible failure, not terminal."""
plugin = TorrentDownloadPlugin()
fake_adapter = MagicMock()
fake_adapter.is_configured.return_value = True
with patch.object(plugin, 'is_configured', return_value=True), \
patch.object(plugin._prowlarr, 'search', new=AsyncMock(return_value=[])), \
patch('core.download_plugins.torrent.get_active_torrent_adapter', return_value=fake_adapter):
outcome = plugin.download_album_to_staging('GNX', 'Kendrick Lamar', str(tmp_path))
assert outcome['success'] is False
assert 'No torrent results' in outcome['error']
assert outcome.get('fallback') is True
fake_adapter.add_torrent.assert_not_called()
def test_usenet_album_to_staging_no_results_flags_fallback(tmp_path: Path) -> None:
"""Same contract for usenet: an empty search must fall back to the
per-track flow rather than hard-failing the album batch."""
plugin = UsenetDownloadPlugin()
fake_adapter = MagicMock()
fake_adapter.is_configured.return_value = True
with patch.object(plugin, 'is_configured', return_value=True), \
patch.object(plugin._prowlarr, 'search', new=AsyncMock(return_value=[])), \
patch('core.download_plugins.usenet.get_active_usenet_adapter', return_value=fake_adapter):
outcome = plugin.download_album_to_staging('GNX', 'Kendrick Lamar', str(tmp_path))
assert outcome['success'] is False
assert 'No usenet results' in outcome['error']
assert outcome.get('fallback') is True
fake_adapter.add_nzb.assert_not_called()
def test_registry_includes_torrent_and_usenet() -> None:
"""The registry decides what shows up in the orchestrator's
iteration helpers. If we forget to register a new plugin the

Loading…
Cancel
Save