Files
shelfmark/tests/irc/test_parser.py
T
CaliBrain d0e008adde Stop dropping audiobook releases that are not m4b or mp3 (#1199)
An IRC audiobook search returned nothing while OpenBooks, reading the
same @search answer from the same channel, listed results. Three
separate defects were discarding them.

The audiobook format list was maintained by hand in four places and had
drifted. The settings UI offered only m4b/mp3/m4a/zip/rar, and that list
is the only one a user's config can be built from, so flac, opus, ogg,
aac, wav and wma were unreachable everywhere — even though the IRC
parser recognized them, the IRC sorter ranked them (dead code that could
never fire), archive extraction knew them and Prowlarr searched for
them. A FLAC audiobook was invisible in search and, if it arrived
anyway, rejected after download as "format not supported".
AUDIOBOOK_FORMATS and ARCHIVE_FORMATS now live once in core.utils and
every layer derives from them, which also restored the missing .opus in
the post-download scan's trackable extensions.

Widening the default alone would not have reached anyone already
affected: initialize_default_configs() writes field defaults only when a
tab has no config file yet, so an existing install keeps its persisted
m4b/mp3 list forever. migrate_audiobook_formats rewrites a list that
still matches the old default exactly and leaves every other value
alone — re-enabling formats someone had deliberately turned off would be
worse than leaving them narrow.

The IRC parser filtered by file extension alone. Multi-file audiobooks
ship as a .rar or .zip of MP3s, which matched neither SUPPORTED_FORMATS
nor SUPPORTED_AUDIOBOOK_FORMATS, so they fell out of the ebook bucket
and the audiobook bucket both. Results are now classified before the
format filter is applied: an audio extension means audiobook, an ebook
extension means ebook, and for a container — where the extension says
nothing about the contents — the release name decides. An ebook archive
stays out of audiobook results.

RESULT_LINE_REGEX matched \w+ after any dot, so a line carrying no file
extension parsed as format "5mb" out of "::INFO:: 620.5MB", taking the
title and the size down with it and guaranteeing every downstream filter
dropped it. Any decimal size did this. The extension is now matched
against the known formats, so such a line falls through to the simple
pattern and comes back as "unknown", which the rest of the parser
already handles. ALL_RECOGNIZED_FORMATS became an ordered tuple in the
process: it was a set, so which extension won for a line naming two of
them depended on set iteration order and could vary between restarts.

Refs #1129
2026-08-13 13:51:40 -04:00

134 lines
5.1 KiB
Python

import pytest
from shelfmark.core.utils import ARCHIVE_FORMATS, AUDIOBOOK_FORMATS
from shelfmark.release_sources.irc import parser
# What a stock install actually filters with, so these tests fail if the defaults regress.
_DEFAULT_CONFIG = {
"SUPPORTED_FORMATS": ["epub", "mobi", "azw3", "fb2", "djvu", "cbz", "cbr"],
"SUPPORTED_AUDIOBOOK_FORMATS": [*AUDIOBOOK_FORMATS, *ARCHIVE_FORMATS],
}
@pytest.fixture
def default_formats(monkeypatch):
"""Filter with the shipped default format lists."""
monkeypatch.setattr(
parser.config, "get", lambda key, default=None: _DEFAULT_CONFIG.get(key, default)
)
def test_audiobook_archives_are_found_with_default_settings(default_formats):
"""Regression for #1129: audiobooks ship as .rar/.zip and were dropped by both buckets.
A single "@search" answers with one file holding every format. These are the lines an
audiobook actually occupies in it - the extension is a container, and the release name
is the only thing saying what is inside.
"""
content = "\n".join(
[
"!Oatmeal Andy Weir - Project Hail Mary (Audiobook) [MP3 64kbps].rar ::INFO:: 620.5MB",
"!DV8 Andy Weir - Project Hail Mary - Audiobook.zip ::INFO:: 700MB",
"!Horla Andy Weir - Project Hail Mary [Unabridged].m4b ::INFO:: 850.1MB",
]
)
results = parser.parse_results_file(content, content_type="audiobook")
assert [result.format for result in results] == ["rar", "zip", "m4b"]
def test_ebook_archive_does_not_leak_into_audiobook_results(default_formats):
"""An ebook .rar must not be offered as an audiobook just because it is an archive."""
content = "!bald Andy Weir - Project Hail Mary (retail).rar ::INFO:: 2.1MB"
assert parser.parse_results_file(content, content_type="audiobook") == []
def test_flac_audiobook_is_reachable_with_default_settings(default_formats):
"""FLAC was recognized by the parser and ranked by the sorter, but never selectable."""
content = "!Ook Andy Weir - Project Hail Mary.flac ::INFO:: 1.1GB"
results = parser.parse_results_file(content, content_type="audiobook")
assert [result.format for result in results] == ["flac"]
def test_decimal_size_is_not_mistaken_for_a_file_extension():
"""A line with no extension used to parse as format="5mb" out of "::INFO:: 620.5MB"."""
line = "!Ook Andy Weir - Project Hail Mary (2021) Audiobook ::INFO:: 620.5MB"
result = parser.parse_result_line(line)
assert result.format == "unknown"
assert result.title == "Project Hail Mary (2021) Audiobook"
assert result.size == "620.5MB"
@pytest.mark.parametrize(
("line", "expected"),
[
("!s A - T.epub ::INFO:: 1MB", "ebook"),
("!s A - T.mp3 ::INFO:: 1MB", "audiobook"),
("!s A - T.flac ::INFO:: 1MB", "audiobook"),
# Archives carry no format information, so the name has to decide.
("!s A - T (Audiobook).rar ::INFO:: 1MB", "audiobook"),
("!s A - T [Unabridged].zip ::INFO:: 1MB", "audiobook"),
("!s A - T (Narrated by Someone).rar ::INFO:: 1MB", "audiobook"),
("!s A - T [64kbps].zip ::INFO:: 1MB", "audiobook"),
("!s A - T (retail).rar ::INFO:: 1MB", "ebook"),
("!s A - T.zip ::INFO:: 1MB", "ebook"),
],
)
def test_detect_content_type(line, expected):
assert parser.detect_content_type(parser.parse_result_line(line)) == expected
def test_recognized_formats_order_is_deterministic():
"""This was a set, so which format won for a multi-extension line varied per restart."""
assert parser.ALL_RECOGNIZED_FORMATS == tuple(parser.ALL_RECOGNIZED_FORMATS)
# Longest-first, so ".azw3" cannot be truncated to "azw" (nor ".docx" to "doc").
lengths = [len(fmt) for fmt in parser.ALL_RECOGNIZED_FORMATS]
assert lengths == sorted(lengths, reverse=True)
def test_parse_results_file_uses_audiobook_format_settings(monkeypatch):
values = {
"SUPPORTED_FORMATS": ["epub"],
"SUPPORTED_AUDIOBOOK_FORMATS": ["zip", "mp3"],
}
monkeypatch.setattr(parser.config, "get", lambda key, default=None: values.get(key, default))
content = "\n".join(
[
"!AudioBot Author Name - Great Audio Book.zip ::INFO:: 1.2GB",
"!AudioBot Author Name - Great Audio Book.mp3 ::INFO:: 1.1GB",
"!AudioBot Author Name - Great Audio Book.epub ::INFO:: 5MB",
]
)
results = parser.parse_results_file(content, content_type="audiobook")
assert [result.format for result in results] == ["zip", "mp3"]
def test_parse_results_file_uses_book_format_settings_for_ebooks(monkeypatch):
values = {
"SUPPORTED_FORMATS": ["epub"],
"SUPPORTED_AUDIOBOOK_FORMATS": ["zip", "mp3"],
}
monkeypatch.setattr(parser.config, "get", lambda key, default=None: values.get(key, default))
content = "\n".join(
[
"!BookBot Author Name - Great Book.zip ::INFO:: 50MB",
"!BookBot Author Name - Great Book.epub ::INFO:: 5MB",
]
)
results = parser.parse_results_file(content, content_type="ebook")
assert [result.format for result in results] == ["epub"]