fix(auto-download): match Usenet ebooks by the format in their name (#1704)

Automatic downloads reject Usenet ebook releases whose format is written
as `(epub)` or `[EPUB]` rather than `.epub`. A Hardcover ebook request
whose match is something like `Rick Rubin - The Creative Act (retail)
(epub)`, from Newznab or from a Prowlarr indexer other than
MyAnonamouse, stays pending with `no strict match`.

For an ebook request, `_format_match` wants a detected format, content
type `ebook`, or a supported format after a dot in the title, and these
releases have none of the three. `_prowlarr_result_to_release` only
detects a format for MyAnonamouse, `_newznab_result_to_release` never
sets one, `_detect_content_type_from_categories` maps every 7000–7999
category to `book`, and the names put the format in brackets. The rule
came in with ebook auto-download in #1533.

The title check now looks for a supported format among the title's
words, using the `_tokens` helper that title and author matching already
use. `Dune.epub`, `(epub)`, `[EPUB]`, `{MOBI}` and `- azw3` all count,
and a word that only contains a format, like "Mobile" or "Republished",
doesn't. It replaces the `.<format>` check, which it covers. The
audiobook check is unchanged, and `docs/hardcover-sync.md` now describes
the rule as it works.

I didn't make content type `book` count instead. Magazines, comics and
results with no category get it too, so it would drop the format
requirement for nearly every Newznab and Prowlarr release. One side
effect: with the default formats, a bracketed `(cbr)` or `(cbz)` now
counts where before only `.cbr` and `.cbz` did.

New tests in `TestContentTypeAwareMatching` use a release shaped like
the Usenet ones (no format, content type `book`, NZB). The Creative Act
title matches, the bracketed and dashed forms count, `(m4b)`, `(epub)
(Unabridged)` and a title with no format still fail, and Mobile,
Republished and epubs don't count. The five positive cases fail on main.
`make python-checks` and the auto-download tests pass, and the full
suite has no new failures against main.



<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

<!-- coderabbit-ai-generated:v1 -->

## Summary by CodeRabbit

* **Bug Fixes**
* Ebook releases can now match supported formats such as EPUB, AZW3, and
MOBI when the format appears as a whole word in the release title,
including in parentheses or brackets.
* Titles containing a format name only as part of a longer word, such as
“Mobile,” won’t be mistaken for that format.
* **Documentation**
* Clarified how format matching works for sources that don’t report an
ebook format.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
codyw912 authored and GitHub committed 2026-11-13 00:47:13 -05:00
1 parent 44e1c6a660
commit 2fbe1dedc2
3 files changed
+68 -3

No files matched your search

+1 -1
View File
@@ -30,7 +30,7 @@ A release is a strict match only when all of the following hold:
- the release title doesn't name a different book that contains the requested one. A "Dune" request won't take *Dune Messiah* or *Children of Dune*, and a bare number after the title (*Mistborn 2*, *Mistborn Two*, *Dune Chronicles Book II*) names a sequel. A title with a number of its own (*Ready Player One*) takes no other number after it, 1 included. Only the parts of the release name that hold the title are judged, so narrators, uploader tags and the book's own series, author and subtitle words don't count against it; a part that is just the series name (*Alex Cross 25*) does not vouch for a one-word title, so a "Cross" request won't take *Alex Cross: Cross Justice*. Two slash-separated names that both hold the title (*Dune / Dune Messiah*) are two books;
- the release is not a derivative of the work: *summary*, *analysis*, *study guide*, *workbook*, *abridged*, *dramatized*, *adaptation* or *graphic novel* anywhere in the name disqualifies it, unless the requested title itself carries the word. *Unabridged* is a full recording and is fine;
- the release is not a multi-book pack: ranges like `1-24` or `Books 1 to 17`, or words like *complete*, *collection*, *box set*, *omnibus*, *trilogy*, *megapack*, *+ short stories*, unless the requested title itself carries them. "Complete and Unabridged" describes one recording and doesn't count. The library check uses the same word list;
- the format fits the request — an audiobook request needs an audiobook signal (`m4b`/`mp3`, a release from an audiobook-only source, or "audiobook"/"unabridged" in the title) and is never an ebook-only file; an ebook request needs one of your **Supported Book Formats** (or an `.epub`-style marker in the title) and no audiobook signal;
- the format fits the request — an audiobook request needs an audiobook signal (`m4b`/`mp3`, a release from an audiobook-only source, or "audiobook"/"unabridged" in the title) and is never an ebook-only file; an ebook request needs one of your **Supported Book Formats**, either reported by the source or named as a whole word in the release title (`Dune.epub`, `(epub)`, `[EPUB]`; *Mobile* is not `mobi`), and no audiobook signal. Usenet and most Prowlarr indexers report no format, so for them the title is what counts;
- for torrents that report a seeder count, it meets **Minimum seeders** — sources that report none (AudiobookBay) are not judged on seeders.
AudiobookBay requires every word of a query, and its posts for one volume of a multi-part book often spell the part differently from the request ("Part Two" for "Part II"), so a query carrying the part finds only the packs that list it. When no row a query returns carries the requested title, the source tries the next query, ending with the title minus its trailing *, Part N* or *Volume N*. The strict match above still decides which part was found.
+5 -2
View File
@@ -181,11 +181,14 @@ def _format_match(
# Reject things that are clearly an ebook and nothing else.
return _audiobook_signal(release, audiobook_formats) and fmt not in EBOOK_FORMAT_MARKERS
title_l = (release.title or "").lower()
# A format named as a whole word anywhere in the name ("Dune.epub", "(epub)", "[EPUB]")
# counts: Newznab releases, and Prowlarr ones outside MyAnonamouse, carry no format and
# only the generic "book" content type, so the name is their only signal. Whole words
# only, so "Mobile" is not "mobi".
has_ebook_signal = (
fmt in ebook_formats
or (release.content_type or "").lower() == "ebook"
or any(f".{ef}" in title_l for ef in ebook_formats)
or not ebook_formats.isdisjoint(_tokens(release.title))
)
return has_ebook_signal and not _audiobook_signal(release, audiobook_formats)
+62
View File
@@ -1196,6 +1196,68 @@ class TestContentTypeAwareMatching:
assert not auto_download.strict_match(release, _book(), content_type="ebook")
# Newznab releases, and Prowlarr ones outside MyAnonamouse, carry no format and the
# generic "book" content type, so the format named in the name is the only signal.
@staticmethod
def _usenet(title):
return _release(
title=title,
format=None,
content_type="book",
protocol=ReleaseProtocol.NZB,
seeders=None,
)
@staticmethod
def _ebook_format_match(release):
return auto_download._format_match(
release, "ebook", AUDIOBOOK_FORMATS, {"epub", "azw3", "mobi"}
)
def test_a_usenet_release_naming_its_format_matches_an_ebook_request(self):
release = self._usenet("Rick Rubin - The Creative Act (retail) (epub)")
book = _book(title="The Creative Act", authors=["Rick Rubin"])
assert auto_download.strict_match(release, book, content_type="ebook")
@pytest.mark.parametrize(
"title",
[
"Matt Dinniman - Dungeon Crawler Carl (retail) (epub)",
"Dungeon Crawler Carl [EPUB] - Matt Dinniman",
"Matt Dinniman - Dungeon Crawler Carl - azw3",
"Matt Dinniman - Dungeon Crawler Carl {MOBI}",
],
)
def test_a_format_word_in_the_name_is_an_ebook_signal(self, title):
assert self._ebook_format_match(self._usenet(title))
@pytest.mark.parametrize(
"title",
[
"Matt Dinniman - Dungeon Crawler Carl (m4b)",
"Matt Dinniman - Dungeon Crawler Carl (epub) (Unabridged)",
],
)
def test_an_audiobook_signal_still_fails_an_ebook_request(self, title):
assert not self._ebook_format_match(self._usenet(title))
def test_a_name_without_a_format_still_fails_an_ebook_request(self):
assert not self._ebook_format_match(
self._usenet("Matt Dinniman - Dungeon Crawler Carl (retail)")
)
@pytest.mark.parametrize(
"title",
[
"Matt Dinniman - Dungeon Crawler Carl (Mobile Edition)",
"Matt Dinniman - Dungeon Crawler Carl (Republished)",
"Matt Dinniman - Dungeon Crawler Carl (epubs)",
],
)
def test_a_word_containing_a_format_is_not_an_ebook_signal(self, title):
assert not self._ebook_format_match(self._usenet(title))
def test_ebook_ranking_prefers_epub_then_azw3_then_mobi(self):
mobi = _release(format="mobi", seeders=50)
azw3 = _release(format="azw3", seeders=1)