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