Files
shelfmark/tests/prowlarr/test_source.py
Alex 3be99effe4 Base url additions and bug fixes (#519)
- Base URL option in settings for reverse proxy setups
- Fix NZB downloads not deleting on completion
- Fix handling for audiobook files over 100+ parts
- Fix prowlarr search timeout 
- Fix prowlarr categorisation for expanded searches
2026-01-23 13:03:02 +00:00

434 lines
16 KiB
Python

"""
Tests for the Prowlarr source module.
Tests the utility functions for parsing release metadata.
"""
import pytest
# Import the functions to test
from shelfmark.release_sources.prowlarr.source import (
ProwlarrSource,
_parse_size,
_extract_format,
_extract_language,
_detect_content_type_from_categories,
)
from shelfmark.release_sources.prowlarr.utils import get_protocol_display, sanitize_download_url
from shelfmark.metadata_providers import BookMetadata
class TestParseSize:
"""Tests for the _parse_size function."""
def test_parse_size_bytes(self):
"""Test parsing small byte sizes."""
assert _parse_size(100) == "100 B"
assert _parse_size(512) == "512 B"
def test_parse_size_kilobytes(self):
"""Test parsing kilobyte sizes."""
assert _parse_size(1024) == "1.0 KB"
assert _parse_size(2048) == "2.0 KB"
assert _parse_size(1536) == "1.5 KB"
def test_parse_size_megabytes(self):
"""Test parsing megabyte sizes."""
assert _parse_size(1048576) == "1.0 MB"
assert _parse_size(5242880) == "5.0 MB"
assert _parse_size(1572864) == "1.5 MB"
def test_parse_size_gigabytes(self):
"""Test parsing gigabyte sizes."""
assert _parse_size(1073741824) == "1.0 GB"
assert _parse_size(2147483648) == "2.0 GB"
def test_parse_size_terabytes(self):
"""Test parsing terabyte sizes."""
assert _parse_size(1099511627776) == "1.0 TB"
def test_parse_size_none(self):
"""Test that None returns None."""
assert _parse_size(None) is None
def test_parse_size_zero(self):
"""Test that zero returns None."""
assert _parse_size(0) is None
def test_parse_size_negative(self):
"""Test that negative values return None."""
assert _parse_size(-100) is None
class TestExtractFormat:
"""Tests for the _extract_format function."""
def test_extract_format_from_extension(self):
"""Test extracting format from file extension."""
assert _extract_format("The Book.epub") == "epub"
assert _extract_format("The Book.mobi") == "mobi"
assert _extract_format("The Book.pdf") == "pdf"
assert _extract_format("The Book.azw3") == "azw3"
def test_extract_format_from_brackets(self):
"""Test extracting format from brackets."""
assert _extract_format("The Book [EPUB]") == "epub"
assert _extract_format("The Book (PDF)") == "pdf"
assert _extract_format("The Book {MOBI}") == "mobi"
def test_extract_format_from_word(self):
"""Test extracting format as standalone word."""
assert _extract_format("The Book epub version") == "epub"
assert _extract_format("mobi edition of the book") == "mobi"
def test_extract_format_priority_extension_over_bracket(self):
"""Test that file extension takes priority over brackets."""
# Extension is more reliable
assert _extract_format("The Book [PDF].epub") == "epub"
def test_extract_format_case_insensitive(self):
"""Test that format extraction is case insensitive."""
assert _extract_format("The Book.EPUB") == "epub"
assert _extract_format("The Book [PDF]") == "pdf"
assert _extract_format("The Book.Mobi") == "mobi"
def test_extract_format_none_when_no_format(self):
"""Test that None is returned when no format found."""
assert _extract_format("The Book by Author") is None
assert _extract_format("") is None
def test_extract_format_cbz_cbr(self):
"""Test comic book formats."""
assert _extract_format("Comic Issue 1.cbz") == "cbz"
assert _extract_format("Comic Issue 2.cbr") == "cbr"
def test_extract_format_fb2(self):
"""Test FB2 format (common in Russian ebooks)."""
assert _extract_format("Russian Book.fb2") == "fb2"
assert _extract_format("Book [FB2]") == "fb2"
def test_extract_format_djvu(self):
"""Test DjVu format."""
assert _extract_format("Scanned Book.djvu") == "djvu"
def test_extract_format_avoids_false_positives(self):
"""Test that format extraction doesn't match partial words."""
# "republic" should not match "pdf" or other formats
assert _extract_format("The Republic by Plato") is None
# "literal" should not match "lit"
assert _extract_format("Literal Translation") is None
class TestGetProtocolDisplay:
"""Tests for the get_protocol_display function."""
def test_get_protocol_from_protocol_field_torrent(self):
"""Test extracting torrent protocol from protocol field."""
result = {"protocol": "torrent", "downloadUrl": "https://example.com"}
assert get_protocol_display(result) == "torrent"
def test_get_protocol_from_protocol_field_usenet(self):
"""Test extracting usenet protocol from protocol field."""
result = {"protocol": "usenet", "downloadUrl": "https://example.com"}
assert get_protocol_display(result) == "nzb"
def test_get_protocol_from_magnet_url(self):
"""Test inferring torrent from magnet URL."""
result = {"downloadUrl": "magnet:?xt=urn:btih:abc123"}
assert get_protocol_display(result) == "torrent"
def test_get_protocol_from_torrent_url(self):
"""Test inferring torrent from .torrent URL."""
result = {"downloadUrl": "https://example.com/file.torrent"}
assert get_protocol_display(result) == "torrent"
def test_get_protocol_from_nzb_url(self):
"""Test inferring NZB from .nzb URL."""
result = {"downloadUrl": "https://example.com/file.nzb"}
assert get_protocol_display(result) == "nzb"
def test_get_protocol_fallback_to_magnet_url(self):
"""Test fallback to magnetUrl field."""
result = {"magnetUrl": "magnet:?xt=urn:btih:abc123"}
assert get_protocol_display(result) == "torrent"
def test_get_protocol_unknown(self):
"""Test unknown protocol for unclear URLs."""
result = {"downloadUrl": "https://example.com/download"}
assert get_protocol_display(result) == "unknown"
def test_get_protocol_case_insensitive(self):
"""Test protocol detection is case insensitive."""
result = {"protocol": "TORRENT"}
assert get_protocol_display(result) == "torrent"
result = {"protocol": "Usenet"}
assert get_protocol_display(result) == "nzb"
class TestSanitizeDownloadUrl:
"""Tests for the sanitize_download_url helper."""
def test_sanitizes_apikey_whitespace(self):
"""Strip whitespace around apikey separators."""
url = "http://prowlarr:9696/5/download?apikey = 12345"
assert sanitize_download_url(url) == "http://prowlarr:9696/5/download?apikey=12345"
def test_sanitizes_multiple_query_params(self):
"""Sanitize all query pairs while keeping params."""
url = "http://prowlarr:9696/5/download?apikey = 12345&indexer = 7"
assert sanitize_download_url(url) == "http://prowlarr:9696/5/download?apikey=12345&indexer=7"
def test_leaves_non_http_urls_untouched(self):
"""Do not modify magnet or other non-http URLs."""
url = "magnet:?xt=urn:btih:abc123"
assert sanitize_download_url(url) == url
def test_leaves_clean_urls_untouched(self):
"""Return clean URLs as-is."""
url = "https://prowlarr:9696/5/download?apikey=12345"
assert sanitize_download_url(url) == url
class TestExtractLanguage:
"""Tests for the _extract_language function."""
def test_extract_language_english(self):
"""Test extracting English language."""
assert _extract_language("The Book [English]") == "en"
assert _extract_language("Book (eng)") == "en"
assert _extract_language("Book [EN]") == "en"
def test_extract_language_german(self):
"""Test extracting German language."""
assert _extract_language("Das Buch [German]") == "de"
assert _extract_language("Buch (Deutsch)") == "de"
assert _extract_language("Buch [DE]") == "de"
def test_extract_language_french(self):
"""Test extracting French language."""
assert _extract_language("Le Livre [French]") == "fr"
assert _extract_language("Livre (Français)") == "fr"
assert _extract_language("Livre [FR]") == "fr"
def test_extract_language_spanish(self):
"""Test extracting Spanish language."""
assert _extract_language("El Libro [Spanish]") == "es"
assert _extract_language("Libro (Español)") == "es"
assert _extract_language("Libro [ES]") == "es"
def test_extract_language_italian(self):
"""Test extracting Italian language."""
assert _extract_language("Il Libro [Italian]") == "it"
assert _extract_language("Libro (Italiano)") == "it"
def test_extract_language_russian(self):
"""Test extracting Russian language."""
assert _extract_language("Book [Russian]") == "ru"
assert _extract_language("Book [RU]") == "ru"
def test_extract_language_japanese(self):
"""Test extracting Japanese language."""
assert _extract_language("Book [Japanese]") == "ja"
assert _extract_language("Book [JA]") == "ja"
def test_extract_language_chinese(self):
"""Test extracting Chinese language."""
assert _extract_language("Book [Chinese]") == "zh"
assert _extract_language("Book [ZH]") == "zh"
def test_extract_language_none_when_not_found(self):
"""Test that None is returned when no language found."""
assert _extract_language("The Book by Author") is None
assert _extract_language("") is None
def test_extract_language_case_insensitive(self):
"""Test that language extraction is case insensitive."""
assert _extract_language("Book [GERMAN]") == "de"
assert _extract_language("Book [german]") == "de"
assert _extract_language("Book [German]") == "de"
class TestDetectContentType:
"""Tests for the _detect_content_type_from_categories function."""
def test_fallback_without_categories(self):
assert _detect_content_type_from_categories([], "ebook") == "book"
assert _detect_content_type_from_categories([], "audiobook") == "audiobook"
def test_audiobook_categories(self):
assert _detect_content_type_from_categories([{"id": 3030}], "ebook") == "audiobook"
assert _detect_content_type_from_categories([3000], "ebook") == "audiobook"
def test_book_category_range(self):
assert _detect_content_type_from_categories([{"id": 7000}], "ebook") == "book"
assert _detect_content_type_from_categories([7020], "audiobook") == "book"
assert _detect_content_type_from_categories([7030], "ebook") == "book"
def test_non_book_categories_return_other(self):
assert _detect_content_type_from_categories([{"id": 2000}], "ebook") == "other"
class TestProwlarrLocalizedQueries:
def test_manual_query_still_applies_content_type_categories(self, monkeypatch):
class FakeClient:
def __init__(self):
self.calls: list[tuple[str, object]] = []
def search(self, query: str, indexer_ids=None, categories=None):
self.calls.append((query, categories))
return []
import shelfmark.release_sources.prowlarr.source as prowlarr_source
def fake_get(key: str, default=None):
values = {
"PROWLARR_INDEXERS": "",
"PROWLARR_AUTO_EXPAND": False,
}
return values.get(key, default)
monkeypatch.setattr(prowlarr_source.config, "get", fake_get)
fake_client = FakeClient()
source = ProwlarrSource()
monkeypatch.setattr(source, "_get_client", lambda: fake_client)
book = BookMetadata(
provider="hardcover",
provider_id="123",
title="Anything",
authors=["Someone"],
)
from shelfmark.core.search_plan import build_release_search_plan
plan = build_release_search_plan(book, languages=["en"], manual_query="my custom")
source.search(book, plan, content_type="audiobook")
assert fake_client.calls == [("my custom", [3030])]
def test_manual_query_expand_removes_categories(self, monkeypatch):
class FakeClient:
def __init__(self):
self.calls: list[tuple[str, object]] = []
def search(self, query: str, indexer_ids=None, categories=None):
self.calls.append((query, categories))
return []
import shelfmark.release_sources.prowlarr.source as prowlarr_source
def fake_get(key: str, default=None):
values = {
"PROWLARR_INDEXERS": "",
"PROWLARR_AUTO_EXPAND": False,
}
return values.get(key, default)
monkeypatch.setattr(prowlarr_source.config, "get", fake_get)
fake_client = FakeClient()
source = ProwlarrSource()
monkeypatch.setattr(source, "_get_client", lambda: fake_client)
book = BookMetadata(
provider="hardcover",
provider_id="123",
title="Anything",
authors=["Someone"],
)
from shelfmark.core.search_plan import build_release_search_plan
plan = build_release_search_plan(book, languages=["en"], manual_query="my custom")
source.search(book, plan, expand_search=True, content_type="audiobook")
assert fake_client.calls == [("my custom", None)]
def test_search_uses_localized_titles_when_available(self, monkeypatch):
class FakeClient:
def __init__(self):
self.queries: list[str] = []
def search(self, query: str, indexer_ids=None, categories=None):
self.queries.append(query)
return []
import shelfmark.release_sources.prowlarr.source as prowlarr_source
def fake_get(key: str, default=None):
values = {
"PROWLARR_INDEXERS": "",
"PROWLARR_AUTO_EXPAND": False,
}
return values.get(key, default)
monkeypatch.setattr(prowlarr_source.config, "get", fake_get)
fake_client = FakeClient()
source = ProwlarrSource()
monkeypatch.setattr(source, "_get_client", lambda: fake_client)
book = BookMetadata(
provider="hardcover",
provider_id="219252",
title="The Lightning Thief",
authors=["Rick Riordan"],
titles_by_language={"hu": "A villámtolvaj"},
)
from shelfmark.core.search_plan import build_release_search_plan
plan = build_release_search_plan(book, languages=["en", "hu"])
source.search(book, plan, content_type="ebook")
assert "The Lightning Thief" in fake_client.queries
assert "A villámtolvaj" in fake_client.queries
assert len(fake_client.queries) == 2
def test_search_does_not_override_search_title_for_english(self, monkeypatch):
class FakeClient:
def __init__(self):
self.queries: list[str] = []
def search(self, query: str, indexer_ids=None, categories=None):
self.queries.append(query)
return []
import shelfmark.release_sources.prowlarr.source as prowlarr_source
def fake_get(key: str, default=None):
values = {
"PROWLARR_INDEXERS": "",
"PROWLARR_AUTO_EXPAND": False,
}
return values.get(key, default)
monkeypatch.setattr(prowlarr_source.config, "get", fake_get)
fake_client = FakeClient()
source = ProwlarrSource()
monkeypatch.setattr(source, "_get_client", lambda: fake_client)
book = BookMetadata(
provider="hardcover",
provider_id="123",
title="Mistborn: The Final Empire",
search_title="The Final Empire",
search_author="Brandon Sanderson",
authors=["Brandon Sanderson"],
titles_by_language={
"en": "Mistborn: The Final Empire",
"hu": "A végső birodalom",
},
)
from shelfmark.core.search_plan import build_release_search_plan
plan = build_release_search_plan(book, languages=["en", "hu"])
source.search(book, plan, content_type="ebook")
assert "The Final Empire" in fake_client.queries
assert "A végső birodalom" in fake_client.queries
assert "Mistborn: The Final Empire" not in fake_client.queries