"""Unit tests for the Newznab release source.""" from dataclasses import replace from unittest.mock import MagicMock import pytest from shelfmark.core.search_plan import ReleaseSearchPlan, ReleaseSearchVariant from shelfmark.metadata_providers import BookMetadata from shelfmark.release_sources import ReleaseProtocol from shelfmark.release_sources.newznab.source import ( NewznabSource, _newznab_result_to_release, _parse_category_ids, _parse_indexer_rows, ) # ── fixtures / helpers ───────────────────────────────────────────────────────── class _AvailableSource: display_name = "Newznab" def is_available(self): return True @pytest.fixture(autouse=True) def source_available_by_default(monkeypatch): import shelfmark.download.orchestrator as orchestrator monkeypatch.setattr(orchestrator, "get_source", lambda _source: _AvailableSource()) def _make_book(**kwargs) -> BookMetadata: defaults = { "provider": "hardcover", "provider_id": "1", "title": "Dune", "authors": ["Frank Herbert"], } defaults.update(kwargs) return BookMetadata(**defaults) def _make_result(**kwargs) -> dict: """Minimal Newznab-like result dict.""" base = { "title": "Dune (2024) [EPUB]", "guid": "https://indexer.example.com/nzb/42", "downloadUrl": "https://indexer.example.com/nzb/42?apikey=secret", "protocol": "usenet", "size": 2097152, "indexer": "MyIndexer", "categories": [7000], "indexerFlags": [], "publishDate": "2024-01-01", } base.update(kwargs) return base def _make_plan(book: BookMetadata, *, manual_query: str | None = None): from shelfmark.core.search_plan import build_release_search_plan return build_release_search_plan(book, languages=["en"], manual_query=manual_query) # ── _newznab_result_to_release ───────────────────────────────────────────────── class TestResultToRelease: def test_basic_usenet_result(self): r = _newznab_result_to_release(_make_result()) assert r.source == "newznab" assert r.title == "Dune (2024) [EPUB]" assert r.protocol == ReleaseProtocol.NZB assert r.size == "2.0 MB" assert r.size_bytes == 2097152 assert r.indexer == "MyIndexer" assert r.source_id == "https://indexer.example.com/nzb/42" assert r.download_url is None def test_torrent_result_has_torrent_protocol(self): r = _newznab_result_to_release( _make_result( protocol="torrent", magnetUrl="magnet:?xt=urn:btih:abc", categories=[3030], ) ) assert r.protocol == ReleaseProtocol.TORRENT def test_audiobook_category_detected(self): r = _newznab_result_to_release(_make_result(categories=[3030]), "ebook") assert r.content_type == "audiobook" def test_book_category_detected(self): r = _newznab_result_to_release(_make_result(categories=[7000])) assert r.content_type == "book" def test_no_categories_uses_content_type_fallback(self): r = _newznab_result_to_release(_make_result(categories=[]), "audiobook") assert r.content_type == "audiobook" def test_custom_searched_category_treated_as_book(self): r = _newznab_result_to_release(_make_result(categories=[8010]), "ebook", [8010]) assert r.content_type == "book" def test_custom_searched_category_treated_as_audiobook(self): r = _newznab_result_to_release(_make_result(categories=[{"id": 3040}]), "audiobook", [3040]) assert r.content_type == "audiobook" def test_unsearched_out_of_range_category_stays_other(self): r = _newznab_result_to_release(_make_result(categories=[2000]), "ebook", [8010]) assert r.content_type == "other" def test_freeleech_flag_detected_via_download_volume(self): r = _newznab_result_to_release(_make_result(downloadVolumeFactor=0.0)) assert r.extra["freeleech"] is True assert "FreeLeech" in r.extra["indexer_flags"] def test_freeleech_flag_detected_via_indexer_flags(self): r = _newznab_result_to_release(_make_result(indexerFlags=["freeleech"])) assert r.extra["freeleech"] is True def test_vip_detected_from_title(self): r = _newznab_result_to_release(_make_result(title="Dune [VIP] [EPUB]")) assert r.extra["vip"] is True assert "VIP" in r.extra["indexer_flags"] def test_duplicate_flags_deduplicated(self): r = _newznab_result_to_release(_make_result(indexerFlags=["FreeLeech", "freeleech", "FL"])) lower_flags = [f.lower() for f in r.extra["indexer_flags"]] assert lower_flags.count("freeleech") == 1 def test_seeders_only_set_for_torrents(self): usenet = _newznab_result_to_release(_make_result(protocol="usenet", seeders=10)) assert usenet.seeders is None torrent = _newznab_result_to_release( _make_result(protocol="torrent", seeders=10, leechers=2) ) assert torrent.seeders == 10 assert torrent.peers == "10 / 2" def test_fallback_source_id_when_no_guid(self): r = _newznab_result_to_release(_make_result(guid=None)) assert r.source_id.startswith("newznab:") def test_none_size_returns_none(self): r = _newznab_result_to_release(_make_result(size=None)) assert r.size is None assert r.size_bytes is None def test_extra_fields_preserved(self): r = _newznab_result_to_release( _make_result( author="Frank Herbert", bookTitle="Dune", infoHash="abc123", ) ) assert r.extra["author"] == "Frank Herbert" assert r.extra["book_title"] == "Dune" assert r.extra["info_hash"] == "abc123" def test_redacted_result_still_builds_private_retry_payload(self, monkeypatch): import shelfmark.download.orchestrator as orchestrator secret_download_url = "https://indexer.example.com/nzb/42?apikey=secret" release = _newznab_result_to_release(_make_result(downloadUrl=secret_download_url)) assert release.download_url is None captured_tasks = [] def fake_add(task): captured_tasks.append(task) return True monkeypatch.setattr(orchestrator.book_queue, "add", fake_add) monkeypatch.setattr( orchestrator.config, "get", lambda key, default=None, user_id=None: default ) success, error = orchestrator.queue_release(release.__dict__) assert success is True assert error is None assert captured_tasks[0].retry_download_url == secret_download_url assert captured_tasks[0].retry_download_protocol == "usenet" assert "retry_download_url" not in orchestrator._task_to_dict(captured_tasks[0]) # ── NewznabSource.is_available ───────────────────────────────────────────────── class TestIsAvailable: def _config(self, **overrides): values = { "NEWZNAB_ENABLED": True, "NEWZNAB_URL": "http://nzbhydra:5076", } values.update(overrides) return lambda k, default=None: values.get(k, default) def test_available_when_enabled_and_url_set(self, monkeypatch): import shelfmark.release_sources.newznab.source as mod monkeypatch.setattr(mod.config, "get", self._config()) assert NewznabSource().is_available() is True def test_unavailable_when_disabled(self, monkeypatch): import shelfmark.release_sources.newznab.source as mod monkeypatch.setattr(mod.config, "get", self._config(NEWZNAB_ENABLED=False)) assert NewznabSource().is_available() is False def test_unavailable_when_no_url(self, monkeypatch): import shelfmark.release_sources.newznab.source as mod monkeypatch.setattr(mod.config, "get", self._config(NEWZNAB_URL="")) assert NewznabSource().is_available() is False def test_available_with_named_indexer_and_no_legacy_url(self, monkeypatch): import shelfmark.release_sources.newznab.source as mod monkeypatch.setattr( mod.config, "get", self._config( NEWZNAB_URL="", NEWZNAB_INDEXERS=[{"name": "NZBGeek", "url": "https://geek.example"}], ), ) assert NewznabSource().is_available() is True # ── category parsing ─────────────────────────────────────────────────────────── class TestParseCategoryIds: def test_parses_list_of_strings(self): assert _parse_category_ids(["7100", "7120"]) == [7100, 7120] def test_parses_comma_separated_string(self): assert _parse_category_ids("7100, 7120") == [7100, 7120] def test_parses_comma_separated_entry_inside_list(self): assert _parse_category_ids(["7100,7120", "8010"]) == [7100, 7120, 8010] def test_drops_duplicates_and_keeps_order(self): assert _parse_category_ids(["7120", "7100", "7120"]) == [7120, 7100] def test_skips_non_numeric_and_non_positive_values(self): assert _parse_category_ids(["books", "0", "-7000", "7100"]) == [7100] def test_returns_empty_for_unset_or_blank(self): assert _parse_category_ids(None) == [] assert _parse_category_ids([]) == [] assert _parse_category_ids(" ") == [] class TestParseIndexerRows: def test_parses_named_connections(self): assert _parse_indexer_rows( [ { "name": "NZBGeek", "url": "https://api.nzbgeek.info/", "api_key": "geek-key", }, { "name": "DrunkenSlug", "url": "drunkenslug.com", "api_key": "slug-key", }, ] ) == [ ("NZBGeek", "https://api.nzbgeek.info", "geek-key"), ("DrunkenSlug", "http://drunkenslug.com", "slug-key"), ] def test_uses_hostname_when_name_is_blank(self): assert _parse_indexer_rows([{"url": "https://indexer.example.com"}]) == [ ("indexer.example.com", "https://indexer.example.com", "") ] def test_ignores_invalid_and_duplicate_connections(self): assert _parse_indexer_rows( [ None, {"name": "Incomplete", "url": ""}, {"name": "First", "url": "https://indexer.example.com", "api_key": "key"}, {"name": "Duplicate", "url": "https://indexer.example.com", "api_key": "key"}, ] ) == [("First", "https://indexer.example.com", "key")] # ── NewznabSource.search ─────────────────────────────────────────────────────── class TestSearch: def _fake_config(self, **overrides): values = { "NEWZNAB_AUTO_EXPAND": False, } values.update(overrides) return lambda k, default=None: values.get(k, default) def _patched_source(self, monkeypatch, client, config_overrides=None): import shelfmark.release_sources.newznab.source as mod monkeypatch.setattr(mod.config, "get", self._fake_config(**(config_overrides or {}))) src = NewznabSource() monkeypatch.setattr(src, "_get_client", lambda: client) return src def test_returns_empty_when_no_client(self, monkeypatch): import shelfmark.release_sources.newznab.source as mod monkeypatch.setattr(mod.config, "get", self._fake_config()) src = NewznabSource() monkeypatch.setattr(src, "_get_client", lambda: None) book = _make_book() results = src.search(book, _make_plan(book)) assert results == [] def test_returns_empty_with_no_queries(self, monkeypatch): client = MagicMock() client.search.return_value = [] src = self._patched_source(monkeypatch, client) book = BookMetadata(provider="test", provider_id="1", title="", authors=[]) from shelfmark.core.search_plan import ReleaseSearchPlan empty_plan = ReleaseSearchPlan( languages=["en"], isbn_candidates=[], author="", title_variants=[], grouped_title_variants=[], manual_query=None, indexers=[], ) results = src.search(book, empty_plan) assert results == [] client.search.assert_not_called() def test_searches_with_ebook_category(self, monkeypatch): client = MagicMock() client.search.return_value = [] src = self._patched_source(monkeypatch, client) book = _make_book() src.search(book, _make_plan(book), content_type="ebook") _, kwargs = client.search.call_args assert kwargs["categories"] == [7000] def test_searches_with_audiobook_category(self, monkeypatch): client = MagicMock() client.search.return_value = [] src = self._patched_source(monkeypatch, client) book = _make_book() src.search(book, _make_plan(book), content_type="audiobook") _, kwargs = client.search.call_args assert kwargs["categories"] == [3030] def test_searches_with_configured_ebook_categories(self, monkeypatch): client = MagicMock() client.search.return_value = [] src = self._patched_source( monkeypatch, client, {"NEWZNAB_EBOOK_CATEGORIES": ["7100", "7120"]} ) book = _make_book() src.search(book, _make_plan(book), content_type="ebook") _, kwargs = client.search.call_args assert kwargs["categories"] == [7100, 7120] def test_searches_with_configured_audiobook_categories(self, monkeypatch): client = MagicMock() client.search.return_value = [] src = self._patched_source(monkeypatch, client, {"NEWZNAB_AUDIOBOOK_CATEGORIES": "3040"}) book = _make_book() src.search(book, _make_plan(book), content_type="audiobook") _, kwargs = client.search.call_args assert kwargs["categories"] == [3040] def test_falls_back_to_default_when_categories_cleared(self, monkeypatch): client = MagicMock() client.search.return_value = [] src = self._patched_source(monkeypatch, client, {"NEWZNAB_EBOOK_CATEGORIES": []}) book = _make_book() src.search(book, _make_plan(book), content_type="ebook") _, kwargs = client.search.call_args assert kwargs["categories"] == [7000] def test_ebook_categories_do_not_affect_audiobook_search(self, monkeypatch): client = MagicMock() client.search.return_value = [] src = self._patched_source(monkeypatch, client, {"NEWZNAB_EBOOK_CATEGORIES": ["7100"]}) book = _make_book() src.search(book, _make_plan(book), content_type="audiobook") _, kwargs = client.search.call_args assert kwargs["categories"] == [3030] def test_expand_search_removes_categories(self, monkeypatch): client = MagicMock() client.search.return_value = [] src = self._patched_source(monkeypatch, client) book = _make_book() src.search(book, _make_plan(book), expand_search=True, content_type="ebook") _, kwargs = client.search.call_args assert kwargs["categories"] is None def test_deduplicates_results_by_guid(self, monkeypatch): dup = _make_result(guid="same-guid") client = MagicMock() client.search.side_effect = [[dup], [dup]] # two queries, same result each book = _make_book() src = self._patched_source(monkeypatch, client) plan_two = ReleaseSearchPlan( languages=["en"], isbn_candidates=[], author="Frank Herbert", title_variants=[ ReleaseSearchVariant("Dune", "Frank Herbert"), ReleaseSearchVariant("Düne", "Frank Herbert"), ], grouped_title_variants=[], manual_query=None, indexers=[], ) results = src.search(book, plan_two) assert len(results) == 1 def test_auto_expand_retries_without_categories(self, monkeypatch): calls: list = [] def fake_search(query, categories=None): calls.append(categories) return [] if categories else [_make_result()] client = MagicMock() client.search.side_effect = fake_search src = self._patched_source(monkeypatch, client, {"NEWZNAB_AUTO_EXPAND": True}) book = _make_book() results = src.search(book, _make_plan(book), content_type="ebook") assert [7000] in calls # first call with category assert None in calls # auto-expanded call without assert len(results) == 1 def test_no_auto_expand_when_disabled(self, monkeypatch): client = MagicMock() client.search.return_value = [] src = self._patched_source(monkeypatch, client, {"NEWZNAB_AUTO_EXPAND": False}) book = _make_book() src.search(book, _make_plan(book)) # Only one call per query, no retry assert client.search.call_count == 1 def test_converts_results_to_releases(self, monkeypatch): client = MagicMock() client.search.return_value = [_make_result()] src = self._patched_source(monkeypatch, client) book = _make_book() results = src.search(book, _make_plan(book)) assert len(results) == 1 r = results[0] assert r.source == "newznab" assert r.protocol == ReleaseProtocol.NZB def test_manual_query_overrides_title_variants(self, monkeypatch): client = MagicMock() client.search.return_value = [] src = self._patched_source(monkeypatch, client) book = _make_book() plan = _make_plan(book, manual_query="custom search term") src.search(book, plan) call_kwargs = client.search.call_args assert call_kwargs[1]["query"] == "custom search term" def test_isbn_used_when_no_title_variants(self, monkeypatch): client = MagicMock() client.search.return_value = [] src = self._patched_source(monkeypatch, client) book = _make_book() from shelfmark.core.search_plan import ReleaseSearchPlan isbn_plan = ReleaseSearchPlan( languages=["en"], isbn_candidates=["9780441013593"], author="Frank Herbert", title_variants=[], grouped_title_variants=[], manual_query=None, indexers=[], ) src.search(book, isbn_plan) call_kwargs = client.search.call_args assert call_kwargs[1]["query"] == "9780441013593" def test_searches_all_named_indexers_and_labels_plain_feed_results(self, monkeypatch): import shelfmark.release_sources.newznab.source as mod rows = [ {"name": "NZBGeek", "url": "https://geek.example", "api_key": "one"}, {"name": "DrunkenSlug", "url": "https://slug.example", "api_key": "two"}, ] monkeypatch.setattr( mod.config, "get", self._fake_config(NEWZNAB_INDEXERS=rows), ) clients = {} def client_factory(url, api_key): client = MagicMock() client.search.return_value = [ _make_result( guid="shared-guid", downloadUrl=f"{url}/download?apikey={api_key}", indexer=None, ) ] clients[url] = client return client monkeypatch.setattr(mod, "NewznabClient", client_factory) results = NewznabSource().search(_make_book(), _make_plan(_make_book())) assert {release.indexer for release in results} == {"NZBGeek", "DrunkenSlug"} assert len({release.source_id for release in results}) == 2 assert all(release.source_id.startswith("newznab:") for release in results) assert set(clients) == {"https://geek.example", "https://slug.example"} def test_indexer_filter_is_applied_to_named_results(self, monkeypatch): import shelfmark.release_sources.newznab.source as mod rows = [ {"name": "NZBGeek", "url": "https://geek.example"}, {"name": "DrunkenSlug", "url": "https://slug.example"}, ] monkeypatch.setattr(mod.config, "get", self._fake_config(NEWZNAB_INDEXERS=rows)) def client_factory(url, _api_key): client = MagicMock() client.search.return_value = [_make_result(guid=f"{url}/guid", indexer=None)] return client monkeypatch.setattr(mod, "NewznabClient", client_factory) book = _make_book() plan = replace(_make_plan(book), indexers=["DrunkenSlug"]) results = NewznabSource().search(book, plan) assert [release.indexer for release in results] == ["DrunkenSlug"] def test_one_named_indexer_failure_does_not_hide_other_results(self, monkeypatch): import shelfmark.release_sources.newznab.source as mod rows = [ {"name": "Unavailable", "url": "https://down.example"}, {"name": "Working", "url": "https://working.example"}, ] monkeypatch.setattr(mod.config, "get", self._fake_config(NEWZNAB_INDEXERS=rows)) def client_factory(url, _api_key): client = MagicMock() if "down" in url: client.search.side_effect = RuntimeError("offline") else: client.search.return_value = [_make_result(indexer=None)] return client monkeypatch.setattr(mod, "NewznabClient", client_factory) results = NewznabSource().search(_make_book(), _make_plan(_make_book())) assert [release.indexer for release in results] == ["Working"] def test_exception_in_client_returns_empty(self, monkeypatch): client = MagicMock() client.search.side_effect = RuntimeError("boom") src = self._patched_source(monkeypatch, client) book = _make_book() results = src.search(book, _make_plan(book)) assert results == []