From 95d6ad4bc9555adf28e06d42912d7c29eb0d575e Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Mon, 1 Jun 2026 09:55:59 -0700 Subject: [PATCH] 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. --- core/download_plugins/torrent.py | 7 ++++++ core/download_plugins/usenet.py | 5 ++++ tests/test_torrent_usenet_plugins.py | 36 ++++++++++++++++++++++++++++ 3 files changed, 48 insertions(+) diff --git a/core/download_plugins/torrent.py b/core/download_plugins/torrent.py index e97a2b5a..dbebcf95 100644 --- a/core/download_plugins/torrent.py +++ b/core/download_plugins/torrent.py @@ -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( diff --git a/core/download_plugins/usenet.py b/core/download_plugins/usenet.py index 2b5bca62..2bf14512 100644 --- a/core/download_plugins/usenet.py +++ b/core/download_plugins/usenet.py @@ -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( diff --git a/tests/test_torrent_usenet_plugins.py b/tests/test_torrent_usenet_plugins.py index 8b53c6f9..1becacea 100644 --- a/tests/test_torrent_usenet_plugins.py +++ b/tests/test_torrent_usenet_plugins.py @@ -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