From ea69db3f8e8d88d2bb0a42623914860d4d723306 Mon Sep 17 00:00:00 2001 From: splitsec2 <35583321+splitsec2@users.noreply.github.com> Date: Fri, 23 Oct 2026 00:21:40 -0600 Subject: [PATCH] feat(library): Audiobookshelf library check, and the sync fails closed when a library cannot be read (#1519) Adds an Audiobookshelf provider for the library check, next to the Calibre one. It reads the server over its REST API (title, author, ISBN and ASIN per item), answers for audiobooks, and has its settings in General with a test button that uses the unsaved form values. Read only. The Hardcover sync and automatic downloads now fail closed when an enabled library cannot be read: the sync skips its cycle and an automatic download holds the request. A library that listed items at the last index and now lists none counts as a failed read. Badges still fail open. --- docs/hardcover-sync.md | 4 +- docs/library-check.md | 36 +- shelfmark/config/settings.py | 42 +++ shelfmark/core/auto_download.py | 14 +- shelfmark/core/hardcover_sync.py | 12 +- shelfmark/core/library_index.py | 74 +++- shelfmark/core/library_providers/__init__.py | 3 +- .../core/library_providers/audiobookshelf.py | 159 +++++++++ tests/config/test_general_settings.py | 30 ++ tests/core/test_auto_download.py | 32 +- tests/core/test_hardcover_sync.py | 22 +- tests/core/test_library_index.py | 105 ++++++ .../test_library_provider_audiobookshelf.py | 330 ++++++++++++++++++ 13 files changed, 835 insertions(+), 28 deletions(-) create mode 100644 shelfmark/core/library_providers/audiobookshelf.py create mode 100644 tests/core/test_library_provider_audiobookshelf.py diff --git a/docs/hardcover-sync.md b/docs/hardcover-sync.md index 3a42bf4c..ffbf482a 100644 --- a/docs/hardcover-sync.md +++ b/docs/hardcover-sync.md @@ -34,7 +34,7 @@ Among matching releases from the winning source, `m4b` outranks `mp3`, then more Both shelf sync and automatic downloads ask the libraries you have enabled under [Library Check](library-check.md) whether the book is already there, for the content type being requested, and skip it if so. There is no separate switch on this tab. A library that is not enabled, or that does not hold the requested content type, never causes a skip. -The check **fails open**: a library that cannot be read is logged and the book is treated as not owned, so processing continues. +The check **fails closed** here: if an enabled library cannot be read and has not been read before, the sync cycle is skipped and an automatic download is held, and the request stays pending until the next cycle. Skipping is the safe side, because carrying on would request or download books the library already holds. Badges in search results still fail open. ## Settings @@ -74,6 +74,6 @@ Only one run happens at a time. If a scheduled cycle is in progress, **Sync now* **Requests appear but nothing downloads** — check **Enable automatic downloads** is on, that **Source priority** contains at least one configured source that supports audiobooks, and look for `no strict match` lines in the log. Lowering **Minimum seeders** helps on quiet trackers; loosening the title or author match is deliberately not an option. -**Books you own keep being added** — enable the library check for the format you request (see [Library Check](library-check.md)) and use its test button. Because the check fails open, a library that cannot be read only produces a log warning rather than blocking the sync. +**Books you own keep being added** — enable the library check for the format you request (see [Library Check](library-check.md)) and use its test button. A library that cannot be read shows up in the log as a skipped cycle. **Too many requests at once** — the first sync of a large shelf creates one request per book, and the per-user pending limit from **Users & Requests** does not apply to synced requests. Start with a shelf you are happy to see in the queue in full. diff --git a/docs/library-check.md b/docs/library-check.md index 33a8f573..8413c9ac 100644 --- a/docs/library-check.md +++ b/docs/library-check.md @@ -21,6 +21,26 @@ database directly. No HTTP call, no API token, and nothing is ever written back. A result that matches the library then carries an **In library** badge in the card, list and compact views, and in the details dialog. +## Audiobookshelf + +Shelfmark asks the Audiobookshelf server over its REST API for the title, author, ISBN and +ASIN of every item, and nothing is written back. + +1. In **Settings, General**, turn on **Mark audiobooks already in your Audiobookshelf + library**. +2. Set **Audiobookshelf URL** to an address the Shelfmark container can reach (often the + host's LAN IP, not the public URL), for example `http://10.0.0.91:13378`. +3. Paste an **Audiobookshelf API Token** (Audiobookshelf **Settings, Users, your user, API + Token**). Read access is enough. +4. Leave **Audiobookshelf Library IDs** blank to check every book library, or list the IDs + to check (comma separated). +5. Press **Test Audiobookshelf library**. It reports how many items it indexed, using the + values on the form, so it works before you save. + +Audiobookshelf titles are often raw folder names such as `Alex Cross 02: Kiss the Girls`. +When the server reports a series for the item, the leading label and number are read as the +series, not as a different book, so a search for `Kiss the Girls` still matches. + ## How a match is decided In order of confidence: @@ -32,12 +52,18 @@ In order of confidence: ## Behaviour worth knowing -- **It fails open.** If the database cannot be read, Shelfmark logs a warning, reuses the - last successful read if it has one, and otherwise treats the book as not owned. A broken - path degrades the badge, it never blocks a search. +- **It fails open for badges.** If a library cannot be read, Shelfmark logs a warning, reuses + the last successful read if it has one, and otherwise treats the book as not owned. A + broken path degrades the badge, it never blocks a search. +- **It fails closed for anything that acts on the answer.** The Hardcover sync and automatic + downloads would otherwise request or download books the library already holds, so when an + enabled library cannot be read and has not been read before, the sync cycle is skipped and + the request stays pending until the next one. A library that listed items a moment ago and + now lists none is treated as unreadable too, not as an emptied shelf. - **Results are cached** for ten minutes, and refreshed early when the database file changes, so a large library costs one read rather than one per search. -- **Ebooks only.** A Calibre library holds ebooks, so the badge answers for ebooks. The - provider interface in `shelfmark/core/library_providers/` takes more libraries: add a +- **Each library answers for its own format.** Calibre holds ebooks and Audiobookshelf holds + audiobooks, so an ebook result is only checked against Calibre and an audiobook against + Audiobookshelf. The provider interface in `shelfmark/core/library_providers/` takes more libraries: add a module with the `LibraryProvider` shape and list it in `all_providers()`. Nothing above that function knows which libraries exist. diff --git a/shelfmark/config/settings.py b/shelfmark/config/settings.py index be173ee5..e5c105cc 100644 --- a/shelfmark/config/settings.py +++ b/shelfmark/config/settings.py @@ -396,6 +396,13 @@ def _test_calibre_library(current_values: dict[str, Any] | None = None) -> dict[ return library_index.test_connection("calibre", current_values) +def _test_audiobookshelf_library(current_values: dict[str, Any] | None = None) -> dict[str, Any]: + """Action-button callback: read the Audiobookshelf libraries and count the items.""" + from shelfmark.core import library_index + + return library_index.test_connection("audiobookshelf", current_values) + + @register_settings("general", "General", icon="settings", order=0) def general_settings() -> list[SettingsField]: """Core application settings.""" @@ -444,6 +451,41 @@ def general_settings() -> list[SettingsField]: description="Check that Shelfmark can read the Calibre database and count the books.", callback=_test_calibre_library, ), + CheckboxField( + key="LIBRARY_CHECK_AUDIOBOOKSHELF_ENABLED", + label="Mark audiobooks already in your Audiobookshelf library", + description=( + "Ask your Audiobookshelf server which audiobooks you already own, so synced " + "and automatic downloads skip them. Read only, nothing is written to Audiobookshelf." + ), + default=False, + ), + TextField( + key="AUDIOBOOKSHELF_URL", + label="Audiobookshelf URL", + description=( + "Base URL reachable from the Shelfmark container (not the public URL). Often the " + "host LAN IP, e.g. http://10.0.0.91:13378." + ), + placeholder="http://10.0.0.91:13378", + ), + PasswordField( + key="AUDIOBOOKSHELF_TOKEN", + label="Audiobookshelf API Token", + description="API token from Audiobookshelf (Settings > Users > your user > API Token).", + ), + TextField( + key="AUDIOBOOKSHELF_LIBRARY_IDS", + label="Audiobookshelf Library IDs (optional)", + description="Comma-separated library IDs to check. Leave blank for all book libraries.", + placeholder="Blank = all book libraries", + ), + ActionButton( + key="test_audiobookshelf_library", + label="Test Audiobookshelf library", + description="Check that Shelfmark can reach Audiobookshelf and count the items.", + callback=_test_audiobookshelf_library, + ), HeadingField( key="search_defaults_heading", title="Default Search Filters", diff --git a/shelfmark/core/auto_download.py b/shelfmark/core/auto_download.py index 5c8fd69d..d80038ff 100644 --- a/shelfmark/core/auto_download.py +++ b/shelfmark/core/auto_download.py @@ -147,7 +147,7 @@ def strict_match( """Return True only if the release confidently matches the requested audiobook.""" formats = audiobook_formats if audiobook_formats is not None else _audiobook_formats() return ( - _title_match(book, release.title) + _title_match(book, release.title or "") and _author_match(book, release) and _format_match(release, formats) and _seeders_ok(release, min_seeders) @@ -278,9 +278,17 @@ def auto_download_request( return AutoDownloadOutcome(request_id, "skipped", "book not found in provider") # Final guard: skip if the book is already in a library that is checked for this content type. - from shelfmark.core.library_index import is_in_library + from shelfmark.core import library_index - if is_in_library(book, content_type): + try: + in_library = library_index.is_in_library(book, content_type, strict=True) + except library_index.LibraryUnavailableError as exc: + # Unattended: do not download blind. The request stays pending for the next cycle. + logger.warning( + "auto-download: request %s held, library check unavailable: %s", request_id, exc + ) + return AutoDownloadOutcome(request_id, "error", f"library check unavailable: {exc}") + if in_library: logger.info( "auto-download: request %s (%s) already in library; skipping", request_id, diff --git a/shelfmark/core/hardcover_sync.py b/shelfmark/core/hardcover_sync.py index e7a4cfc7..e901de19 100644 --- a/shelfmark/core/hardcover_sync.py +++ b/shelfmark/core/hardcover_sync.py @@ -172,9 +172,17 @@ def sync_wishlist( return summary content_type = str(app_config.get("HARDCOVER_SYNC_CONTENT_TYPE", "audiobook") or "audiobook") + from shelfmark.core import library_index + + try: + library_index.require_ready(content_type) + except library_index.LibraryUnavailableError as exc: + # Requesting now would queue books the library may already hold. + logger.warning("hardcover-sync: %s; skipping this sync", exc) + summary["errors"] += 1 + return summary known_provider_ids = _existing_provider_ids(user_db) - from shelfmark.core.library_index import is_in_library from shelfmark.core.requests_service import RequestServiceError, create_request for status_id in _configured_statuses(): @@ -188,7 +196,7 @@ def sync_wishlist( if _already_downloaded(db_path, book.title, author): summary["skipped"] += 1 continue - if is_in_library(book, content_type): + if library_index.is_in_library(book, content_type): summary["in_library"] += 1 logger.info("hardcover-sync: '%s' already in your library; skipping", book.title) continue diff --git a/shelfmark/core/library_index.py b/shelfmark/core/library_index.py index 970ee9ab..542f899b 100644 --- a/shelfmark/core/library_index.py +++ b/shelfmark/core/library_index.py @@ -69,14 +69,31 @@ def _store(provider_name: str, entries: list[LibraryEntry], fingerprint: object slot.failed_at = None -def _entries_for(provider: LibraryProvider) -> list[LibraryEntry]: - """Cached entries for one provider, re-indexed past the TTL or when the library changed.""" +class LibraryUnavailableError(RuntimeError): + """An enabled library could not be read and there is no earlier index to fall back on.""" + + +def _entries_for(provider: LibraryProvider, *, strict: bool = False) -> list[LibraryEntry]: + """Cached entries for one provider, re-indexed past the TTL or when the library changed. + + A library that cannot be read answers from its stale cache when there is one. With + nothing cached the default is to fail open (no entries, so nothing counts as owned), + which suits badges. ``strict`` raises instead, for callers that act on the answer. + """ slot = _slot(provider.name) with _lock: - if slot.failed_at is not None and time.monotonic() - slot.failed_at < ( - _FAILURE_BACKOFF_SECONDS - ): - return slot.entries or [] + backing_off = slot.failed_at is not None and ( + time.monotonic() - slot.failed_at < _FAILURE_BACKOFF_SECONDS + ) + stale = slot.entries + if backing_off: + # Same answer the failure gave: the stale index, or (strict) a refusal to guess. + if stale is not None: + return stale + if strict: + msg = f"{provider.describe()} is unavailable (retrying after a short back-off)" + raise LibraryUnavailableError(msg) + return [] try: fingerprint = provider.fingerprint() with _lock: @@ -86,11 +103,30 @@ def _entries_for(provider: LibraryProvider) -> list[LibraryEntry]: if cached is not None and fresh and unchanged: return cached entries = provider.fetch_entries() - except Exception as exc: # noqa: BLE001 - any failure must fail open - logger.warning("library check: %s unavailable (%s); failing open", provider.describe(), exc) + with _lock: + had_entries = bool(slot.entries) + if had_entries and not entries: + # A library that held books a moment ago and now lists none is far more likely a + # changed answer from the server than an emptied shelf. Indexing it would make + # every owned book look missing, so it is treated like any other failed read + # (the earlier index keeps answering, strict callers hold). A library that was + # really emptied clears on the next restart. + msg = "it listed no items although it held some at the last index" + raise ValueError(msg) # noqa: TRY301 - handled by the failure path below + except Exception as exc: with _lock: slot.failed_at = time.monotonic() - return slot.entries or [] # Use the stale cache if we have one. + stale = slot.entries + if stale is not None: + logger.warning( + "library check: %s unavailable (%s); using stale index", provider.describe(), exc + ) + return stale + if strict: + msg = f"{provider.describe()} is unavailable: {exc}" + raise LibraryUnavailableError(msg) from exc + logger.warning("library check: %s unavailable (%s); failing open", provider.describe(), exc) + return [] _store(provider.name, entries, fingerprint) logger.info("library check: indexed %d %s item(s)", len(entries), provider.display_name) @@ -153,13 +189,25 @@ def book_matches_entries(book: BookMetadata, entries: list[LibraryEntry]) -> boo return match_entries(book, entries) is not None -def is_in_library(book: BookMetadata, content_type: str | None = None) -> bool: - """True if an enabled library holding ``content_type`` already has ``book`` (fail-open). +def require_ready(content_type: str | None = None) -> None: + """Raise LibraryUnavailableError unless every enabled library can be read (or is cached). - ``content_type`` None consults every enabled provider. + ``content_type`` limits the check to the libraries holding that type. + """ + for provider in _enabled_providers(content_type): + _entries_for(provider, strict=True) + + +def is_in_library( + book: BookMetadata, content_type: str | None = None, *, strict: bool = False +) -> bool: + """True if an enabled library holding ``content_type`` already has ``book``. + + ``content_type`` None consults every enabled provider. A library that cannot be read + counts as not holding the book, unless ``strict``, which raises LibraryUnavailableError. """ return any( - book_matches_entries(book, _entries_for(provider)) + book_matches_entries(book, _entries_for(provider, strict=strict)) for provider in _enabled_providers(content_type) ) diff --git a/shelfmark/core/library_providers/__init__.py b/shelfmark/core/library_providers/__init__.py index 16a12789..23794618 100644 --- a/shelfmark/core/library_providers/__init__.py +++ b/shelfmark/core/library_providers/__init__.py @@ -68,6 +68,7 @@ def all_providers(overrides: Mapping[str, Any] | None = None) -> list[LibraryPro Adding a library means writing a module with the :class:`LibraryProvider` shape and listing it here. Nothing above this function knows which libraries exist. """ + from shelfmark.core.library_providers.audiobookshelf import AudiobookshelfLibrary from shelfmark.core.library_providers.calibre import CalibreLibrary - return [CalibreLibrary(overrides)] + return [CalibreLibrary(overrides), AudiobookshelfLibrary(overrides)] diff --git a/shelfmark/core/library_providers/audiobookshelf.py b/shelfmark/core/library_providers/audiobookshelf.py new file mode 100644 index 00000000..cad67527 --- /dev/null +++ b/shelfmark/core/library_providers/audiobookshelf.py @@ -0,0 +1,159 @@ +"""Audiobookshelf library provider. + +Fetches the libraries of an Audiobookshelf (ABS) server over its REST API, with title, +author, ISBN and ASIN per item. ABS metadata is often messy (titles are raw folder +names, the author is frequently empty), so each item is tokenized from title, author and +folder path. +""" + +from __future__ import annotations + +import re +from typing import TYPE_CHECKING, Any + +import requests + +from shelfmark.core.config import config as app_config +from shelfmark.core.library_providers import LibraryEntry, setting +from shelfmark.core.text_match import isbn_variants, tokens +from shelfmark.core.utils import normalize_http_url + +if TYPE_CHECKING: + from collections.abc import Mapping + +_REQUEST_TIMEOUT = 15 +_ITEMS_PAGE_LIMIT = 500 + +# "Alex Cross 02: ", "DCC 1: ", "Reacher 19.5: ": a short label, a number and a colon. +_SERIES_PREFIX = re.compile(r"^(?P