From 2fbe1dedc2364cbf4838bb8a5db038d403164472 Mon Sep 17 00:00:00 2001 From: codyw912 <32690983+codyw912@users.noreply.github.com> Date: Fri, 13 Nov 2026 00:47:13 -0500 Subject: [PATCH] fix(auto-download): match Usenet ebooks by the format in their name (#1704) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 `.` 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. ## 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. --- docs/hardcover-sync.md | 2 +- shelfmark/core/auto_download.py | 7 ++-- tests/core/test_auto_download.py | 62 ++++++++++++++++++++++++++++++++ 3 files changed, 68 insertions(+), 3 deletions(-) diff --git a/docs/hardcover-sync.md b/docs/hardcover-sync.md index 2ff463a2..8aba4023 100644 --- a/docs/hardcover-sync.md +++ b/docs/hardcover-sync.md @@ -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. diff --git a/shelfmark/core/auto_download.py b/shelfmark/core/auto_download.py index f4bf120e..73cf8ee3 100644 --- a/shelfmark/core/auto_download.py +++ b/shelfmark/core/auto_download.py @@ -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) diff --git a/tests/core/test_auto_download.py b/tests/core/test_auto_download.py index eab4d356..73083e09 100644 --- a/tests/core/test_auto_download.py +++ b/tests/core/test_auto_download.py @@ -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)