Files
shelfmark/tests/audiobookbay/test_source.py
T
CaliBrain bd21ec1257 fix(audiobookbay): search the ASCII punctuation ABB actually stores (#1242)
WordPress texturizes punctuation on output only, so a post stored as
"The
Stranger's Wife" renders as "The Stranger’s Wife". ABB's search matches
the
stored value and ANDs its terms, so one typographic character in the
query
empties the entire result set rather than merely ranking worse. Book
metadata
and mobile keyboards both hand us those characters.

Map curly quotes, dashes and ellipses to ASCII before a query goes out,
and on
both sides of the relevance comparison, since scraped titles carry the
rendered
forms. Release titles are still stored and displayed exactly as ABB
renders
them; only matching normalizes.

Also percent-encode the search query properly. The hand-rolled encoder
only
escaped double quotes and spaces, so a bare "&" started a new query
parameter
and silently truncated the search: "detective dan riley books 1 & 2
weatherley"
reached ABB as "detective dan riley books 1" and returned six
confident-looking
results without the requested book among them. "%" and "+" were mangled
too.
2026-08-20 19:00:27 -04:00

632 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Tests for AudiobookBay release source.
"""
from unittest.mock import patch
import pytest
from shelfmark.core.search_plan import ReleaseSearchPlan, ReleaseSearchVariant
from shelfmark.metadata_providers import BookMetadata
from shelfmark.release_sources.audiobookbay.source import (
AudiobookBaySource,
_generate_source_id,
_map_language,
_parse_bitrate_to_kbps,
_split_title_and_author,
)
class TestMapLanguage:
"""Tests for the _map_language function."""
def test_map_language_english(self):
"""Test mapping English language."""
assert _map_language("English") == "en"
assert _map_language("english") == "en"
assert _map_language("ENGLISH") == "en"
def test_map_language_spanish(self):
"""Test mapping Spanish language."""
assert _map_language("Spanish") == "es"
assert _map_language("spanish") == "es"
def test_map_language_french(self):
"""Test mapping French language."""
assert _map_language("French") == "fr"
assert _map_language("french") == "fr"
def test_map_language_german(self):
"""Test mapping German language."""
assert _map_language("German") == "de"
assert _map_language("german") == "de"
def test_map_language_unknown(self):
"""Test mapping unknown language returns lowercase."""
assert _map_language("UnknownLanguage") == "unknownlanguage"
assert _map_language("Klingon") == "klingon"
def test_map_language_empty(self):
"""Test empty language returns None."""
assert _map_language("") is None
assert _map_language(None) is None
def test_map_language_with_whitespace(self):
"""Test language with whitespace is trimmed."""
assert _map_language(" English ") == "en"
assert _map_language("\tFrench\n") == "fr"
class TestParseBitrate:
"""Tests for the _parse_bitrate_to_kbps function."""
def test_parse_bitrate_to_kbps(self):
"""Test normal bitrate parsing from ABB string values."""
assert _parse_bitrate_to_kbps("128 Kbps") == 128
assert _parse_bitrate_to_kbps("192kbps") == 192
def test_parse_bitrate_to_kbps_invalid(self):
"""Test invalid bitrate values return None."""
assert _parse_bitrate_to_kbps(None) is None
assert _parse_bitrate_to_kbps("") is None
assert _parse_bitrate_to_kbps("Unknown") is None
class TestGenerateSourceId:
"""Tests for the _generate_source_id function."""
def test_generate_source_id_consistent(self):
"""Test that same URL generates same ID."""
url = "https://audiobookbay.lu/abss/test-book/"
id1 = _generate_source_id(url)
id2 = _generate_source_id(url)
assert id1 == id2
assert len(id1) == 32 # MD5 hex digest length
def test_generate_source_id_different_urls(self):
"""Test that different URLs generate different IDs."""
url1 = "https://audiobookbay.lu/abss/book1/"
url2 = "https://audiobookbay.lu/abss/book2/"
id1 = _generate_source_id(url1)
id2 = _generate_source_id(url2)
assert id1 != id2
class TestAudiobookBaySource:
"""Tests for the AudiobookBaySource class."""
@pytest.fixture(autouse=True)
def configure_hostname(self, monkeypatch):
"""Provide a default ABB hostname for source.search tests."""
import shelfmark.release_sources.audiobookbay.source as source_module
original_get = source_module.config.get
def mock_get(key, default=None):
if key == "ABB_HOSTNAME":
return "audiobookbay.lu"
return original_get(key, default)
monkeypatch.setattr(source_module.config, "get", mock_get)
def test_is_available_enabled(self, monkeypatch):
"""Test is_available when enabled."""
def mock_get(key, default=False):
if key == "ABB_ENABLED":
return True
if key == "ABB_HOSTNAME":
return "audiobookbay.lu"
return default
import shelfmark.release_sources.audiobookbay.source as source_module
monkeypatch.setattr(source_module.config, "get", mock_get)
source = AudiobookBaySource()
assert source.is_available() is True
def test_is_available_disabled(self, monkeypatch):
"""Test is_available when disabled."""
def mock_get(key, default=False):
if key == "ABB_ENABLED":
return False
return default
import shelfmark.release_sources.audiobookbay.source as source_module
monkeypatch.setattr(source_module.config, "get", mock_get)
source = AudiobookBaySource()
assert source.is_available() is False
def test_search_non_audiobook_content_type(self):
"""Test that non-audiobook content types return empty."""
source = AudiobookBaySource()
book = BookMetadata(
provider="test",
provider_id="123",
title="Test Book",
authors=["Test Author"],
)
plan = ReleaseSearchPlan(
languages=["en"],
isbn_candidates=[],
author="Test Author",
title_variants=[ReleaseSearchVariant(title="Test Book", author="Test Author")],
grouped_title_variants=[],
)
results = source.search(book, plan, content_type="ebook")
assert results == []
@patch("shelfmark.release_sources.audiobookbay.source.scraper.search_audiobookbay")
def test_search_query_generation_manual(self, mock_search):
"""Test search with manual query."""
mock_search.return_value = []
source = AudiobookBaySource()
book = BookMetadata(
provider="test",
provider_id="123",
title="Test Book",
authors=["Test Author"],
)
plan = ReleaseSearchPlan(
languages=["en"],
isbn_candidates=[],
author="Test Author",
title_variants=[ReleaseSearchVariant(title="Test Book", author="Test Author")],
grouped_title_variants=[],
manual_query="custom search query",
)
source.search(book, plan, content_type="audiobook")
mock_search.assert_called_once()
call_args = mock_search.call_args
assert call_args.kwargs["query"] == "custom search query"
@patch("shelfmark.release_sources.audiobookbay.source.scraper.search_audiobookbay")
def test_search_query_generation_from_variants(self, mock_search):
"""Test search query generation from title variants with title-only retry."""
mock_search.return_value = []
source = AudiobookBaySource()
book = BookMetadata(
provider="test",
provider_id="123",
title="Test Book",
authors=["Test Author"],
)
plan = ReleaseSearchPlan(
languages=["en"],
isbn_candidates=[],
author="Test Author",
title_variants=[ReleaseSearchVariant(title="Test Book", author="Test Author")],
grouped_title_variants=[],
)
source.search(book, plan, content_type="audiobook")
assert mock_search.call_count == 2
first_call = mock_search.call_args_list[0]
second_call = mock_search.call_args_list[1]
assert first_call.kwargs["query"] == "test book test author"
assert second_call.kwargs["query"] == "test book"
@patch("shelfmark.release_sources.audiobookbay.source.scraper.search_audiobookbay")
def test_search_query_generation_from_title_only(self, mock_search):
"""Test search query generation when only title available."""
mock_search.return_value = []
source = AudiobookBaySource()
book = BookMetadata(
provider="test",
provider_id="123",
title="Test Book",
authors=[],
)
plan = ReleaseSearchPlan(
languages=["en"],
isbn_candidates=[],
author="",
title_variants=[],
grouped_title_variants=[],
)
source.search(book, plan, content_type="audiobook")
mock_search.assert_called_once()
call_args = mock_search.call_args
assert call_args.kwargs["query"] == "test book"
@patch("shelfmark.release_sources.audiobookbay.source.scraper.search_audiobookbay")
def test_search_empty_query(self, mock_search):
"""Test search with empty query returns empty."""
source = AudiobookBaySource()
book = BookMetadata(
provider="test",
provider_id="123",
title="",
authors=[],
)
plan = ReleaseSearchPlan(
languages=["en"],
isbn_candidates=[],
author="",
title_variants=[],
grouped_title_variants=[],
)
results = source.search(book, plan, content_type="audiobook")
assert results == []
mock_search.assert_not_called()
@patch("shelfmark.release_sources.audiobookbay.source.scraper.search_audiobookbay")
def test_search_relevance_filtering(self, mock_search):
"""Test that irrelevant results are filtered out."""
mock_search.return_value = [
{
"title": "Test Book by Test Author",
"link": "https://audiobookbay.lu/abss/test-book/",
"format": "M4B",
"size": "500 MB",
"language": "English",
},
{
"title": "Something Completely Different",
"link": "https://audiobookbay.lu/abss/unrelated/",
"format": "MP3",
"size": "1 GB",
"language": "English",
},
]
source = AudiobookBaySource()
book = BookMetadata(
provider="test",
provider_id="123",
title="Test Book",
authors=["Test Author"],
)
plan = ReleaseSearchPlan(
languages=["en"],
isbn_candidates=[],
author="Test Author",
title_variants=[ReleaseSearchVariant(title="Test Book", author="Test Author")],
grouped_title_variants=[],
)
results = source.search(book, plan, content_type="audiobook")
# Should filter out "Unrelated Book Title" as it doesn't contain query words
assert len(results) == 1
assert results[0].title == "Test Book by Test Author"
@patch("shelfmark.release_sources.audiobookbay.source.scraper.search_audiobookbay")
def test_search_relevance_filtering_spans_typographic_punctuation(self, mock_search):
"""Test an ASCII query still matches the typographic title ABB renders."""
mock_search.return_value = [
{
"title": "The Stranger’s Wife — Anna‑Lou Weatherley",
"link": "https://audiobookbay.lu/abss/the-strangers-wife/",
"format": "M4B",
"size": "259 MB",
"language": "English",
},
]
source = AudiobookBaySource()
book = BookMetadata(
provider="test",
provider_id="123",
title="Stranger's",
authors=["Anna-Lou Weatherley"],
)
# Every query word carries punctuation, so the result survives only when
# both sides of the comparison are normalized.
plan = ReleaseSearchPlan(
languages=["en"],
isbn_candidates=[],
author="",
title_variants=[ReleaseSearchVariant(title="Stranger's", author="")],
grouped_title_variants=[],
)
results = source.search(book, plan, content_type="audiobook")
assert len(results) == 1
# The release keeps the title as ABB rendered it; only matching normalizes.
assert results[0].title == "The Stranger’s Wife — Anna‑Lou Weatherley"
@patch("shelfmark.release_sources.audiobookbay.source.scraper.search_audiobookbay")
def test_search_result_mapping(self, mock_search):
"""Test conversion of scraper results to Release objects."""
mock_search.return_value = [
{
"title": "Test Book - Test Author",
"link": "https://audiobookbay.lu/abss/test-book/",
"format": "M4B",
"size": "500.00 MBs",
"language": "English",
"bitrate": "128 Kbps",
"posted_date": "01 Jan 2024",
"cover": "https://example.com/cover.jpg",
},
]
source = AudiobookBaySource()
book = BookMetadata(
provider="test",
provider_id="123",
title="Test Book",
authors=["Test Author"],
)
plan = ReleaseSearchPlan(
languages=["en"],
isbn_candidates=[],
author="Test Author",
title_variants=[ReleaseSearchVariant(title="Test Book", author="Test Author")],
grouped_title_variants=[],
)
results = source.search(book, plan, content_type="audiobook")
assert len(results) == 1
release = results[0]
assert release.source == "audiobookbay"
assert release.title == "Test Book"
assert release.format == "m4b"
assert release.language == "en"
assert release.size == "500.00 MBs"
assert release.download_url == "https://audiobookbay.lu/abss/test-book/"
assert release.info_url == "https://audiobookbay.lu/abss/test-book/"
assert release.protocol.value == "torrent"
assert release.indexer == "AudiobookBay"
assert release.content_type == "audiobook"
assert release.extra["preview"] == "https://example.com/cover.jpg"
assert release.extra["detail_url"] == "https://audiobookbay.lu/abss/test-book/"
assert release.extra["bitrate"] == "128 Kbps"
assert release.extra["bitrate_value"] == 128
assert release.extra["posted_date"] == "01 Jan 2024"
assert release.extra["title_raw"] == "Test Book - Test Author"
assert release.extra["author"] == "Test Author"
assert release.extra["language_raw"] == "English"
def test_split_title_and_author(self):
"""Test title/author parsing from ABB title patterns."""
assert _split_title_and_author("Book Title - Author Name") == ("Book Title", "Author Name")
assert _split_title_and_author("Book Title - Author Name - Narrator") == (
"Book Title - Author Name",
"Narrator",
)
assert _split_title_and_author("Book Title") == ("Book Title", None)
assert _split_title_and_author(" Book Title - Author Name ") == (
"Book Title",
"Author Name",
)
@patch("shelfmark.release_sources.audiobookbay.source.scraper.search_audiobookbay")
def test_search_handles_scraper_exception(self, mock_search):
"""Test that scraper exceptions are handled gracefully."""
mock_search.side_effect = Exception("Scraper error")
source = AudiobookBaySource()
book = BookMetadata(
provider="test",
provider_id="123",
title="Test Book",
authors=["Test Author"],
)
plan = ReleaseSearchPlan(
languages=["en"],
isbn_candidates=[],
author="Test Author",
title_variants=[ReleaseSearchVariant(title="Test Book", author="Test Author")],
grouped_title_variants=[],
)
results = source.search(book, plan, content_type="audiobook")
assert results == []
@patch("shelfmark.release_sources.audiobookbay.source.scraper.search_audiobookbay")
def test_search_handles_invalid_result(self, mock_search):
"""Test that invalid results are skipped."""
mock_search.return_value = [
{
"title": "Relevant Book",
"link": "https://audiobookbay.lu/abss/valid/",
"format": "M4B",
},
{
# Missing required fields
"title": "Relevant But Invalid",
},
]
source = AudiobookBaySource()
book = BookMetadata(
provider="test",
provider_id="123",
title="Relevant",
authors=["Author"],
)
plan = ReleaseSearchPlan(
languages=["en"],
isbn_candidates=[],
author="Author",
title_variants=[ReleaseSearchVariant(title="Relevant", author="Author")],
grouped_title_variants=[],
)
results = source.search(book, plan, content_type="audiobook")
# Should only include valid result
assert len(results) == 1
assert results[0].title == "Relevant Book"
@patch("shelfmark.release_sources.audiobookbay.source.scraper.search_audiobookbay")
def test_search_config_hostname(self, mock_search, monkeypatch):
"""Test that custom hostname is used from config."""
mock_search.return_value = []
def mock_get(key, default=None):
if key == "ABB_HOSTNAME":
return "audiobookbay.is"
if key == "ABB_PAGE_LIMIT":
return 3
return default
import shelfmark.release_sources.audiobookbay.source as source_module
monkeypatch.setattr(source_module.config, "get", mock_get)
source = AudiobookBaySource()
book = BookMetadata(
provider="test",
provider_id="123",
title="Test Book",
authors=["Test Author"],
)
plan = ReleaseSearchPlan(
languages=["en"],
isbn_candidates=[],
author="Test Author",
title_variants=[ReleaseSearchVariant(title="Test Book", author="Test Author")],
grouped_title_variants=[],
)
source.search(book, plan, content_type="audiobook")
call_args = mock_search.call_args
assert call_args.kwargs["hostname"] == "audiobookbay.is"
assert call_args.kwargs["max_pages"] == 3
@patch("shelfmark.release_sources.audiobookbay.source.scraper.search_audiobookbay")
def test_search_exact_phrase_setting_forwarded(self, mock_search, monkeypatch):
"""Test that exact phrase setting is forwarded to scraper search."""
mock_search.return_value = [
{
"title": "Test Book - Test Author",
"link": "https://audiobookbay.lu/abss/test-book/",
"cover": None,
"language": "English",
"format": "M4B",
"bitrate": "128 Kbps",
"size": "500 MB",
"posted_date": "01 Jan 2024",
}
]
def mock_get(key, default=None):
if key == "ABB_HOSTNAME":
return "audiobookbay.lu"
if key == "ABB_EXACT_PHRASE":
return True
return default
import shelfmark.release_sources.audiobookbay.source as source_module
monkeypatch.setattr(source_module.config, "get", mock_get)
source = AudiobookBaySource()
book = BookMetadata(
provider="test",
provider_id="123",
title="Test Book",
authors=["Test Author"],
)
plan = ReleaseSearchPlan(
languages=["en"],
isbn_candidates=[],
author="Test Author",
title_variants=[ReleaseSearchVariant(title="Test Book", author="Test Author")],
grouped_title_variants=[],
)
source.search(book, plan, content_type="audiobook")
call_args = mock_search.call_args_list[0]
assert call_args.kwargs["exact_phrase"] is True
@patch("shelfmark.release_sources.audiobookbay.source.scraper.search_audiobookbay")
def test_search_falls_back_to_broad_when_exact_finds_no_results(self, mock_search, monkeypatch):
"""Test fallback to broad search when exact phrase search has no results."""
mock_search.side_effect = [
[],
[
{
"title": "Test Book - Test Author",
"link": "https://audiobookbay.lu/abss/test-book/",
"cover": None,
"language": "English",
"format": "M4B",
"bitrate": "128 Kbps",
"size": "500 MB",
"posted_date": "01 Jan 2024",
}
],
]
def mock_get(key, default=None):
if key == "ABB_HOSTNAME":
return "audiobookbay.lu"
if key == "ABB_EXACT_PHRASE":
return True
return default
import shelfmark.release_sources.audiobookbay.source as source_module
monkeypatch.setattr(source_module.config, "get", mock_get)
source = AudiobookBaySource()
book = BookMetadata(
provider="test",
provider_id="123",
title="Test Book",
authors=["Test Author"],
)
plan = ReleaseSearchPlan(
languages=["en"],
isbn_candidates=[],
author="Test Author",
title_variants=[ReleaseSearchVariant(title="Test Book", author="Test Author")],
grouped_title_variants=[],
)
results = source.search(book, plan, content_type="audiobook")
assert len(results) == 1
assert mock_search.call_count == 2
first_call = mock_search.call_args_list[0].kwargs
second_call = mock_search.call_args_list[1].kwargs
assert first_call["exact_phrase"] is True
assert second_call["exact_phrase"] is False
def test_get_column_config(self):
"""Test column configuration."""
source = AudiobookBaySource()
config = source.get_column_config()
assert config is not None
assert len(config.columns) == 4
column_keys = [col.key for col in config.columns]
assert "language" in column_keys
assert "format" in column_keys
assert "extra.bitrate" in column_keys
assert "size" in column_keys
assert "seeders" not in column_keys # ABB doesn't show seeders
assert config.supported_filters == ["format", "language"]
bitrate_col = next(col for col in config.columns if col.key == "extra.bitrate")
assert bitrate_col.sortable is True
assert bitrate_col.sort_key == "extra.bitrate_value"
size_col = next(col for col in config.columns if col.key == "size")
assert size_col.sortable is True
assert size_col.sort_key == "size_bytes"