feat(hardcover): owned books, simpler queries, dead sources and failed releases (#1535)

A request whose book turns out to be in the library is closed with the note `[auto] Already in the library.` instead of sitting in Needs Review. A request an admin handled by hand while a pass was running is skipped. A series label in front of a Calibre title no longer hides an owned book, and a Calibre title marked as another edition (full cast, dramatized, GraphicAudio) is not the book.

When no source matches, up to three simpler forms of the title are tried on each source under the same strict match. Optionally (off by default), an unmatched audiobook request is looked up on Audible US and UK through Audiobookshelf's metadata search and the request says what the store answered. It never declines anything.

A Soulseek peer whose recent transfers all failed, and an AudiobookBay post whose comments say it has no seeders, are tried after the other matches and never excluded. A failed or stalled release is kept out for AUTO_DOWNLOAD_RETRY_FAILED_RELEASE_DAYS and then tried again, and a torrent that died for one request is not offered to another. A request stuck on queued or downloading is settled from the download history. Optionally (off by default) a failed download is retried with another release within minutes.
This commit is contained in:
splitsec2 authored and GitHub committed 2026-10-23 01:08:00 -06:00
1 parent 9d5b47d17b
commit 86fff32297
25 files changed
+3948 -87

No files matched your search

+23
View File
@@ -33,11 +33,30 @@ A release is a strict match only when all of the following hold:
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.
When no source yields a strict match for the request's own search title, up to three simpler titles are tried in turn, each across the sources in priority order and all within the request's search budget: the title without a subtitle after `:` or ` - `, the title without an alternate title (*The Hobbit, or There and Back Again* becomes *The Hobbit*), and the title with its numbers written as digits (*Part II* becomes *Part 2*). The first one that yields a strict match wins, and the log names it. A simpler title is matched by exactly the rules above, so a release has to name the shorter title, the right author, and no sequel, edition, derivative or pack. The subtitle is only dropped when the book's metadata says it is one (a series name is known, or the book's subtitle is what follows the separator), and never when what is left is only the series or author name: *Star Wars: Thrawn* is not searched as *Star Wars*.
Among matching releases from the winning source, format ranks first (`m4b` over `mp3` for audiobooks; `epub` over `azw3` over `mobi` for ebooks), then the copy other people chose — more downloads (direct-download sources report them), then more seeders — then larger size. If no source produces a confident match the request stays **pending** for manual review; nothing is guessed.
A request whose book turns out to be in your library already is closed, not left pending: it is marked fulfilled with the admin note `[auto] Already in the library.`, so it stops being looked up every pass. Before this it sat in Needs Review indefinitely. **Clear Completed** clears these too.
Hardcover allows a short burst of lookups and then answers 429. Each pass paces its book lookups and backs off when told to, following `Retry-After`. A lookup that still fails counts as an error for that request, not as "book not found", and the request is tried again next pass.
A download that fails, or is interrupted (a restart while it was queued, say), does not stay that way forever. Once the request has sat there for **Retry failed downloads after (days)** (7 by default), it goes back to pending and the next pass searches again. Each pass reopens at most five, oldest first, so a backlog is spread out instead of hitting your sources at once. An interrupted download may pick the same release again, since a torrent already in your client is joined instead of added twice. A download that itself failed, or that Shelfmark's stall timer cancelled, has its release remembered and skipped next time, and the request is given up on after three failed retries. Requests you or an admin cancelled are never retried, and neither are rejected ones: both are decisions. Set the value to 0 to turn retries off.
A request that no source has a match for is not searched every cycle for ever. After three passes in a row with no match it is searched once a day, then every two days, and no further apart than **Longest wait between searches for an unmatched request (days)** (3 by default, 0 searches every unmatched request every cycle). A match, a search that failed (which says nothing about the book) or **Sync now** starts it over.
**Trying another release sooner.** Set **Try another release after a failed download (minutes)** (off by default) and a download that fails, or that is given up on (stalled, or no seeders when **Give Up With No Seeders** is on), does not wait for the days above. Between sync cycles Shelfmark looks for such requests, reopens them after that many minutes and searches again, for those requests only. The release that failed is skipped, and a request is still given up on after three failed attempts.
**Sources known to be dead are tried last.** When choosing among matching releases, a Soulseek peer whose transfers failed in the last three days and who delivered nothing in that time comes after the others. AudiobookBay shows no seed counts, so the comments on its five best posts are read, and a post where comments say it has no seeders comes after the others. Nothing is excluded: if a dead-looking release is the only match, it is still tried.
### Audiobooks no store sells
Some requested books were never recorded, so no source will ever match them and the request waits in Needs Review for good. With **Mark audiobooks no store sells** on, an audiobook request that has gone **Look the book up after (unmatched passes)** consecutive passes (3 by default) without a strict match is looked up on Audible's US and UK stores, through the metadata search of the Audiobookshelf server set up for the library check (its URL and token are needed; the library check itself can stay off).
- **On neither store:** the request stays pending and its failure reason reads `Not found on Audible (US/UK) after <N> searches, <date>`. It is not declined: Audible search misses real recordings (novellas sold only in a collection, books retitled before release, older or fan recordings), so the call is left to you. The store is asked again a week later.
- **On a store:** the request stays pending and its failure reason reads `Exists on Audible (<ASIN>), not on our sources yet`. The store is asked again a week later, not every pass.
- **Lookup failed:** nothing changes and the next pass asks again. A failed lookup never marks a request. Audiobookshelf answers a failed Audible search with an empty list, which looks like "no such book", so before marking a request as not found Shelfmark also looks up a book both stores are known to sell (*The Hobbit*). If that comes back missing too, the answer is treated as a failure.
A store result counts as the book when its title matches the request's once both are normalised (accents, punctuation, "Part II"/"Part Two"/"Part 2", a subtitle after `:` or `, or`, bracketed notes like "(Unabridged)" and a leading series label are all ignored, and a title that begins the other with at least two words also counts) and the request author's surname is in its author field. The matching leans towards "found": a wrong "found" only leaves the request pending. The pass counter is reset when a source matches the book and when the request is rejected, so a reopened request counts afresh. Store lookups come out of the request's search time; when too little is left, the lookup waits for the next pass. Ebook requests are not checked.
### Library check
@@ -61,6 +80,10 @@ The check **fails closed** here: if an enabled library cannot be read and has no
| `AUTO_DOWNLOAD_MIN_SEEDERS` | Minimum seeders (torrents) | 1 | Torrent releases below this are ignored |
| `AUTO_DOWNLOAD_RETRY_DAYS` | Retry failed downloads after (days) | 7 | A failed or interrupted download goes back to pending after this long. 0 turns retries off |
| `AUTO_DOWNLOAD_BACKOFF_MAX_DAYS` | Longest wait between searches for an unmatched request (days) | 3 | 0 searches every unmatched request every cycle |
| `AUTO_DOWNLOAD_FAILURE_RETRY_MINUTES` | Try another release after a failed download (minutes) | 0 (off) | Reopens failed or given-up downloads and picks another release this long after, between sync cycles |
| `AUTO_DOWNLOAD_RETRY_FAILED_RELEASE_DAYS` | Try a failed release again after (days) | 7 | A release that failed or stalled is kept out of the search for this long, then may be picked again; a request is still given up on after three failed attempts |
| `AUTO_DOWNLOAD_EXISTENCE_CHECK_ENABLED` | Mark audiobooks no store sells | Off | See [Audiobooks no store sells](#audiobooks-no-store-sells). Needs the Audiobookshelf URL and token |
| `AUTO_DOWNLOAD_EXISTENCE_CHECK_AFTER_PASSES` | Look the book up after (unmatched passes) | 3 | Consecutive passes without a strict match before the stores are asked |
One action button sits at the bottom of the tab:
+2
View File
@@ -52,6 +52,8 @@ In order of confidence:
## Behaviour worth knowing
Sequels and other near-miss titles fail the title rule; the same title by a different author fails the surname rule. A series label in front of a library title ("Alex Cross 02: Kiss the Girls", "HHG 5: Mostly Harmless") is read as the series, not as part of the title, when the entry is in a series, so renaming a shelf to show reading order doesn't make its books look unowned. A shelf title whose bracketed words name another edition ("Full Cast Edition", "Dramatized Adaptation") is not the book either: owning the adaptation is not owning the recording asked for. Printing words ("Illustrated Edition", "Unabridged") change nothing, and in a Calibre title a numbered parenthetical ("(Alex Cross Series #11)") is read as the series, not as an edition.
- **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.
@@ -199,6 +199,33 @@ def hardcover_sync_settings() -> list[SettingsField]:
min_value=0,
max_value=365,
),
NumberField(
key="AUTO_DOWNLOAD_FAILURE_RETRY_MINUTES",
label="Try another release after a failed download (minutes)",
description=(
"A download that failed, or that was given up on (stalled, no seeders), goes "
"back to pending this many minutes later and another release is chosen, "
"without waiting for the retry days above or the next sync cycle. The release "
"that failed is not picked again, and a request is given up on after three "
"failed attempts. 0 turns this off."
),
default=0,
min_value=0,
max_value=1440,
),
NumberField(
key="AUTO_DOWNLOAD_RETRY_FAILED_RELEASE_DAYS",
label="Try a failed release again after (days)",
description=(
"A release that failed or stalled is kept out of the search for this many "
"days, then it may be picked again. A torrent that stalled can be revived "
"by new seeds, so a failure should not shut a book out for good. A request "
"is still given up on after three failed attempts."
),
default=7,
min_value=1,
max_value=365,
),
NumberField(
key="AUTO_DOWNLOAD_BACKOFF_MAX_DAYS",
label="Longest wait between searches for an unmatched request (days)",
@@ -212,6 +239,30 @@ def hardcover_sync_settings() -> list[SettingsField]:
min_value=0,
max_value=30,
),
CheckboxField(
key="AUTO_DOWNLOAD_EXISTENCE_CHECK_ENABLED",
label="Mark audiobooks no store sells",
description=(
"An audiobook request that stays unmatched is looked up on Audible (US and "
"UK) through your Audiobookshelf server. Either way it stays pending and its "
"reason says what the store answered (with the ASIN when listed), for you to "
"decline or keep; it is checked again after a week. A lookup that "
"fails decides nothing. Needs the Audiobookshelf URL and token below."
),
default=False,
),
NumberField(
key="AUTO_DOWNLOAD_EXISTENCE_CHECK_AFTER_PASSES",
label="Look the book up after (unmatched passes)",
description=(
"How many consecutive passes with no strict match a request goes through "
"before the store is asked. A pass that matched, or a reopen, starts the "
"count over."
),
default=3,
min_value=1,
max_value=100,
),
NumberField(
key="AUTO_DOWNLOAD_AUDIOBOOK_MIN_SIZE_PERCENT",
label="Smallest acceptable audiobook copy (%)",
+664 -79
View File
@@ -15,12 +15,14 @@ Design goals:
from __future__ import annotations
import json
import math
import re
import sqlite3
import statistics
import time
import unicodedata
from dataclasses import dataclass
from dataclasses import dataclass, replace
from datetime import UTC, datetime, timedelta
from typing import TYPE_CHECKING, Any
@@ -39,6 +41,9 @@ from shelfmark.core.text_match import (
author_surname,
extra_work_tokens,
is_bundle_title,
normalize_numerals,
significant_tokens,
strip_alternate_title,
title_tokens_match,
)
from shelfmark.core.text_match import (
@@ -59,6 +64,10 @@ if TYPE_CHECKING:
logger = setup_logger(__name__)
# Admin note on a pending request auto-download closed because the library already has it.
# "[auto]" marks an admin action no person took; the note is all a request records about why.
IN_LIBRARY_NOTE = "[auto] Already in the library."
# Fraction of significant book-title tokens that must appear in the release title.
TITLE_MATCH_THRESHOLD = DEFAULT_TITLE_MATCH_THRESHOLD
@@ -636,6 +645,196 @@ def _demote_undersized(ranked: list[Release], content_type: str) -> list[Release
return [r for r in ranked if r not in thin] + thin
# A Soulseek peer whose recent downloads all failed is tried after the others, not before.
DEAD_PEER_DAYS = 3
# What a Soulseek task records when the peer is the problem: a transfer that errored or timed
# out, or a peer that did not answer slskd's queue request (it answers 500, and the download is
# tried twice before giving up).
_PEER_FAILURE_MESSAGES = ("peer transfer failed", "failed to add to slskd")
# AudiobookBay shows no seed counts, so its best few candidates have their comments read for
# "0 seeders" and the like. A page costs a fetch (cached), so only this many are looked at.
MAX_COMPLAINT_CHECKS = 5
def recent_failed_peers(
db_path: str | None, *, now: datetime | None = None, days: int = DEAD_PEER_DAYS
) -> frozenset[str]:
"""Soulseek peers that failed a transfer in the last ``days`` and delivered nothing in it."""
if not db_path:
return frozenset()
since = ((now or datetime.now(UTC)) - timedelta(days=days)).isoformat(timespec="seconds")
try:
conn = sqlite3.connect(db_path)
try:
rows = conn.execute(
"SELECT final_status, status_message, retry_payload FROM download_history "
"WHERE source = 'slskd' AND queued_at >= ?",
(since,),
).fetchall()
finally:
conn.close()
except sqlite3.Error as exc:
logger.debug("auto-download: could not read recent Soulseek failures: %s", exc)
return frozenset()
failed: set[str] = set()
delivered: set[str] = set()
for final_status, message, payload in rows:
try:
context = (json.loads(payload) if payload else {}).get("retry_source_context") or {}
except ValueError, AttributeError:
continue
peer = str(context.get("username") or "").strip() if isinstance(context, dict) else ""
if not peer:
continue
if final_status == "complete":
delivered.add(peer)
elif final_status == "error" and str(message or "").lower().startswith(
_PEER_FAILURE_MESSAGES
):
failed.add(peer)
return frozenset(failed - delivered)
# A release that failed is not offered again for this long, then it is tried once more: a torrent
# that stalled can be revived by new seeds, and a failure may simply have been a bad hour.
FAILED_RELEASE_RETRY_DAYS_SETTING = "AUTO_DOWNLOAD_RETRY_FAILED_RELEASE_DAYS"
DEFAULT_FAILED_RELEASE_RETRY_DAYS = 7
# A torrent release that stalled or had no seeders is not offered to ANY request in that time.
# Soulseek failures are about a peer, not a release, and are handled by the dead-peer rule.
_DEAD_RELEASE_MESSAGES = ("download stalled", "no seeders")
# Rows that say nothing about the release: the process stopped, or a later download replaced it.
_NOT_THE_RELEASES_FAULT = ("interrupted", "superseded")
def failed_release_retry_days() -> int:
"""Days a failed release is kept out before it may be picked again (at least 1)."""
return max(
coerce_int(
app_config.get(FAILED_RELEASE_RETRY_DAYS_SETTING, DEFAULT_FAILED_RELEASE_RETRY_DAYS),
DEFAULT_FAILED_RELEASE_RETRY_DAYS,
),
1,
)
def recent_failed_releases(
db_path: str | None, *, now: datetime | None = None, days: int | None = None
) -> frozenset[tuple[str, str]]:
"""Releases (source, source_id) whose latest download failed within the last ``days``.
The history row of a release is the one place its failure time is recorded, so a failure
older than that no longer counts against the release, whatever a request remembers. A release
that completed in the window is not failed.
"""
if not db_path:
return frozenset()
window = failed_release_retry_days() if days is None else days
since = ((now or datetime.now(UTC)) - timedelta(days=window)).isoformat(timespec="seconds")
try:
conn = sqlite3.connect(db_path)
try:
rows = conn.execute(
"SELECT task_id, source, final_status, status_message FROM download_history "
"WHERE COALESCE(terminal_at, queued_at) >= ?",
(since,),
).fetchall()
finally:
conn.close()
except sqlite3.Error as exc:
logger.debug("auto-download: could not read recent failed releases: %s", exc)
return frozenset()
failed: set[tuple[str, str]] = set()
delivered: set[tuple[str, str]] = set()
for task_id, source, final_status, message in rows:
key = (str(source), str(task_id))
if final_status == "complete":
delivered.add(key)
elif final_status in ("cancelled", "error") and not str(message or "").lower().startswith(
_NOT_THE_RELEASES_FAULT
):
failed.add(key)
return frozenset(failed - delivered)
def recent_dead_releases(
db_path: str | None, *, now: datetime | None = None, days: int | None = None
) -> frozenset[tuple[str, str]]:
"""Releases (source, source_id) that stalled or had no seeders in the last ``days``.
Another request's failure counts: a request remembers only the releases that failed for it,
so a torrent that died for one book was picked again for the next. A release that also
completed in the window is not dead.
"""
if not db_path:
return frozenset()
window = failed_release_retry_days() if days is None else days
since = ((now or datetime.now(UTC)) - timedelta(days=window)).isoformat(timespec="seconds")
try:
conn = sqlite3.connect(db_path)
try:
rows = conn.execute(
"SELECT task_id, source, final_status, status_message FROM download_history "
"WHERE source != 'slskd' AND COALESCE(terminal_at, queued_at) >= ?",
(since,),
).fetchall()
finally:
conn.close()
except sqlite3.Error as exc:
logger.debug("auto-download: could not read recent dead releases: %s", exc)
return frozenset()
dead: set[tuple[str, str]] = set()
delivered: set[tuple[str, str]] = set()
for task_id, source, final_status, message in rows:
key = (str(source), str(task_id))
if final_status == "complete":
delivered.add(key)
elif final_status in ("cancelled", "error") and str(message or "").lower().startswith(
_DEAD_RELEASE_MESSAGES
):
dead.add(key)
return frozenset(dead - delivered)
def _dead_source_reason(
release: Release, dead_peers: frozenset[str], complaint_checks: list[int]
) -> str | None:
extra = release.extra if isinstance(release.extra, dict) else {}
if release.source == "slskd":
peer = str(extra.get("username") or "").strip()
return f"peer {peer} failed recently" if peer and peer in dead_peers else None
if release.source == "audiobookbay" and complaint_checks[0] < MAX_COMPLAINT_CHECKS:
if search_deadline.expired():
return None
complaint_checks[0] += 1
from shelfmark.release_sources import get_handler
try:
complaints = get_handler(release.source).seed_complaints(
{"source": release.source, "download_url": release.download_url}
)
except Exception as exc: # noqa: BLE001 - a page that cannot be read just is not judged
logger.debug("auto-download: no seed check for %s: %s", release.title, exc)
return None
if complaints:
return f"{complaints} comment(s) say it has no seeders"
return None
def _demote_dead_sources(ranked: list[Release], dead_peers: frozenset[str]) -> list[Release]:
"""Move releases from sources known to be dead behind the rest, keeping order within each."""
live: list[Release] = []
dead: list[Release] = []
complaint_checks = [0]
for release in ranked:
reason = _dead_source_reason(release, dead_peers, complaint_checks)
if reason is None:
live.append(release)
continue
logger.info("auto-download: trying %s last, %s", release.title, reason)
dead.append(release)
return live + dead
def pick_best_release(releases: list[Release], content_type: str = "audiobook") -> Release | None:
if not releases:
return None
@@ -797,7 +996,11 @@ def _last_delivery_change(row: dict[str, Any]) -> datetime | None:
def _latest_history(db_path: str, request_ids: list[int]) -> dict[int, tuple[str, str | None]]:
"""The newest (status, message) in download_history per request id; none means absent."""
"""The newest (status, message) in download_history per request id; none means absent.
Newest by when it was queued: a reused release keeps its old row id, so id order is not
chronological.
"""
latest: dict[int, tuple[str, str | None]] = {}
if not request_ids:
return latest
@@ -808,7 +1011,7 @@ def _latest_history(db_path: str, request_ids: list[int]) -> dict[int, tuple[str
marks = ",".join("?" * len(chunk))
rows = conn.execute(
"SELECT request_id, final_status, status_message FROM download_history " # noqa: S608 - placeholders only
f"WHERE request_id IN ({marks}) ORDER BY id",
f"WHERE request_id IN ({marks}) ORDER BY queued_at, id",
chunk,
)
for request_id, final_status, status_message in rows:
@@ -818,6 +1021,15 @@ def _latest_history(db_path: str, request_ids: list[int]) -> dict[int, tuple[str
return latest
# What a download records when its client could not be reached. The release is not to blame.
_CLIENT_UNREACHABLE = (
"failed to connect",
"cannot connect",
"connection refused",
"connection aborted",
)
def _failure_kind(latest: tuple[str, str | None] | None) -> str | None:
"""Why a request whose delivery never completed never arrived, from its latest history row.
@@ -832,7 +1044,9 @@ def _failure_kind(latest: tuple[str, str | None] | None) -> str | None:
if final_status == "active":
return _INTERRUPTED
if final_status == "error":
if (message or "").strip().lower() == _INTERRUPTED_MESSAGE:
lowered = (message or "").strip().lower()
if lowered == _INTERRUPTED_MESSAGE or any(m in lowered for m in _CLIENT_UNREACHABLE):
# Nothing was wrong with the release: the process stopped, or its client was down.
return _INTERRUPTED
return _FAILED
if final_status == "cancelled" and (message or "").strip().lower().startswith(
@@ -861,6 +1075,89 @@ def _failed_release_keys(book_data: object) -> set[tuple[str, str]]:
return keys
# Delivery states that say a task is alive. Only the status poll moves a request off them, and a
# finished task leaves the queue an hour after it ends, so with nobody watching they can stay
# for good.
_LIVE_DELIVERY_STATES = frozenset({QueueStatus.QUEUED.value, QueueStatus.DOWNLOADING.value})
_SETTLED_STATUSES = frozenset(
{QueueStatus.COMPLETE.value, QueueStatus.ERROR.value, QueueStatus.CANCELLED.value}
)
def _parse_moment(value: object) -> datetime | None:
try:
moment = datetime.fromisoformat(str(value))
except ValueError:
return None
return moment if moment.tzinfo else moment.replace(tzinfo=UTC)
def settle_delivery_states_from_history(user_db: UserDB, *, db_path: str | None) -> int:
"""Record how a download ended for requests that still say queued or downloading.
The status poll does this while someone watches. Here the history decides: the newest row of
the request, if it ended after the request last changed, is the outcome, and the request is
dated from when it ended so the retry cooldown counts from then. A row still
"active", or one that ended before the request was fulfilled again, says nothing about the
current attempt and is left alone. Returns how many requests were updated.
"""
if not db_path:
return 0
candidates = [
row
for row in user_db.list_requests(status=RequestStatus.FULFILLED)
if str(row.get("delivery_state") or "none").lower() in _LIVE_DELIVERY_STATES
]
if not candidates:
return 0
ids = [int(row["id"]) for row in candidates]
latest: dict[int, tuple[str, object]] = {}
try:
conn = sqlite3.connect(db_path)
try:
for start in range(0, len(ids), 500):
chunk = ids[start : start + 500]
marks = ",".join("?" * len(chunk))
for request_id, final_status, terminal_at in conn.execute(
"SELECT request_id, final_status, terminal_at FROM download_history " # noqa: S608 - placeholders only
f"WHERE request_id IN ({marks}) ORDER BY queued_at, id",
chunk,
):
latest[int(request_id)] = (str(final_status), terminal_at)
finally:
conn.close()
except sqlite3.Error as exc:
logger.warning("auto-download: could not settle delivery states from history: %s", exc)
return 0
settled = 0
for row in candidates:
request_id = int(row["id"])
entry = latest.get(request_id)
if entry is None or entry[0] not in _SETTLED_STATUSES:
continue
ended = _parse_moment(entry[1])
changed = _last_delivery_change(row)
if ended is None or (changed is not None and ended <= changed):
continue
try:
user_db.update_request(
request_id,
delivery_state=entry[0],
delivery_updated_at=ended.isoformat(timespec="seconds"),
)
except sqlite3.Error as exc:
logger.warning("auto-download: could not settle request %s: %s", request_id, exc)
continue
logger.info(
"auto-download: request %s said %s but its download ended (%s); recorded",
request_id,
row.get("delivery_state"),
entry[0],
)
settled += 1
return settled
def reopen_stale_failures(
user_db: UserDB,
*,
@@ -869,9 +1166,17 @@ def reopen_stale_failures(
provider_filter: str | None = "hardcover",
max_reopen: int = MAX_RETRIES_PER_PASS,
now: datetime | None = None,
failure_cooldown: timedelta | None = None,
only_kinds: frozenset[str] | None = None,
reopened_ids: list[int] | None = None,
) -> int:
"""Return synced requests whose download never arrived to pending once the cooldown passes.
``failure_cooldown`` shortens the wait for a download that itself failed or that the stall
timer ended (a dead release says nothing about when the book will turn up, so another
release can be tried soon); interrupted downloads keep the ``cooldown_days`` wait.
``only_kinds`` limits which of those are reopened, and ``reopened_ids`` collects the ids.
Nothing else retries them: the sync treats a request in any status as handled, and the
auto-download pass only acts on pending ones. Reopening goes through
``UserDB.reopen_failed_request`` (upstream's own path for a failed request), which clears
@@ -884,9 +1189,14 @@ def reopen_stale_failures(
and rejected ones are decisions and are never touched. Returns how many were reopened; ``cooldown_days`` of 0 or less turns retries off,
and without readable history nothing is retried, since guessing would be worse.
"""
if cooldown_days <= 0:
if cooldown_days <= 0 and failure_cooldown is None:
return 0
cutoff = (now or datetime.now(UTC)) - timedelta(days=cooldown_days)
settle_delivery_states_from_history(user_db, db_path=db_path)
moment = now or datetime.now(UTC)
cutoff = moment - timedelta(days=cooldown_days) if cooldown_days > 0 else None
failure_cutoff = moment - failure_cooldown if failure_cooldown is not None else None
# A row can only be reopened once it is past the shorter of the two waits.
earliest = max(c for c in (cutoff, failure_cutoff) if c is not None)
stale: list[tuple[datetime, dict[str, Any], str]] = []
for row in user_db.list_requests(status=RequestStatus.FULFILLED):
@@ -899,7 +1209,7 @@ def reopen_stale_failures(
):
continue
changed = _last_delivery_change(row)
if changed is None or changed > cutoff:
if changed is None or changed > earliest:
continue
stale.append((changed, row, state))
if not stale or not db_path:
@@ -920,6 +1230,13 @@ def reopen_stale_failures(
kind = _failure_kind(history.get(request_id))
if kind is None or (state == _CANCELLED and kind != _STALLED):
continue
if only_kinds is not None and kind not in only_kinds:
continue
threshold = (
failure_cutoff if kind in (_FAILED, _STALLED) and failure_cutoff is not None else cutoff
)
if threshold is None or _changed > threshold:
continue
try:
if kind in (_FAILED, _STALLED):
book_data = dict(row["book_data"])
@@ -933,13 +1250,20 @@ def reopen_stale_failures(
) not in _failed_release_keys(book_data):
book_data["failed_releases"] = [*tried, key]
user_db.update_request(request_id, book_data=book_data)
reason = f"Retrying after a {cooldown_days}-day cooldown ({kind}; delivery was {state})"
wait = (
f"{int(failure_cooldown.total_seconds() // 60)}-minute"
if failure_cooldown is not None and threshold is failure_cutoff
else f"{cooldown_days}-day"
)
reason = f"Retrying after a {wait} cooldown ({kind}; delivery was {state})"
result = user_db.reopen_failed_request(request_id, failure_reason=reason)
except sqlite3.Error as exc:
logger.warning("auto-download: could not reopen request %s: %s", request_id, exc)
continue
if result is not None:
reopened += 1
if reopened_ids is not None:
reopened_ids.append(request_id)
return reopened
@@ -991,6 +1315,87 @@ def _configured_source_priority(content_type: str) -> list[str]:
return [src["name"] for src in available if src.get("enabled")]
def _acted_on_since_listed(user_db: UserDB, request_id: int) -> bool:
"""True only when the request is read back and is positively no longer waiting.
A pass lists its pending requests once and can run for minutes, so an admin may have
fulfilled, rejected or cancelled one since. Anything doubtful (the read fails, the row
is missing, an unexpected value) answers False, so the request is searched exactly as
it was before this check existed.
"""
try:
current = user_db.get_request(request_id)
except Exception: # noqa: BLE001 - a failed read must never stop a download
return False
if not isinstance(current, dict):
return False
status = str(current.get("status") or "").lower()
delivery = str(current.get("delivery_state") or "none").lower()
closed = {RequestStatus.FULFILLED, RequestStatus.REJECTED, RequestStatus.CANCELLED}
return status in closed or (status == RequestStatus.PENDING and delivery not in {"none", ""})
# Simpler queries tried, in order, when a request's own query finds no strict match.
MAX_QUERY_VARIANTS = 3
# "Find Your Why: A Practical Guide..." and "Dune - Deluxe Edition" both name the book
# before the separator.
_SUBTITLE_SPLIT = re.compile(r"^(?P<main>.+?)(?:\s*:\s+|\s+-\s+)\S")
def _without_subtitle(title: str, book: BookMetadata) -> str:
"""The title before a ':' or ' - ' subtitle; the title itself when that would not do.
A prefix that is only series or author words ("Star Wars: Thrawn", "Alex Cross:
Kiss the Girls") names a whole series rather than this book, so it is not taken;
nor is one followed by only series words (the "Thrawn" series' "Star Wars:
Thrawn"), where the prefix is a franchise.
Nor is it when the metadata cannot say so: without a series name, or a subtitle
the part after the separator holds, "Star Wars: Thrawn" would be searched and
matched as "Star Wars".
"""
match = _SUBTITLE_SPLIT.match(title)
if not match:
return title
main = match.group("main").strip()
words = set(significant_tokens(main))
known = set(significant_tokens(" ".join([book.series_name or "", *(book.authors or [])])))
rest = set(significant_tokens(title[len(main) :]))
series = set(significant_tokens(book.series_name))
if not words or words <= known or (rest and rest <= series):
return title
subtitle = set(significant_tokens(book.subtitle))
confirmed = bool(subtitle) and subtitle <= rest
if not (book.series_name or confirmed):
return title
return main
def query_variants(book: BookMetadata) -> list[tuple[str, str]]:
"""Simpler titles to search when the book's own search title finds no strict match.
Returns ``(label, title)`` pairs, at most ``MAX_QUERY_VARIANTS``, each different
from the title already searched and from the ones before it: the title without a
subtitle, without an alternate title (", or There and Back Again"), and with its
numbers written as digits. Each is searched and matched as the book's search title,
so every strict-match rule still applies to it.
"""
searched = book.search_title or book.title or ""
candidates = [
("without subtitle", _without_subtitle(searched, book)),
("without alternate title", strip_alternate_title(searched)),
("numerals as digits", normalize_numerals(searched)),
]
seen = {" ".join(searched.lower().split())}
variants: list[tuple[str, str]] = []
for label, title in candidates:
key = " ".join(title.lower().split())
if not key or key in seen or not significant_tokens(title):
continue
seen.add(key)
variants.append((label, title.strip()))
return variants[:MAX_QUERY_VARIANTS]
def auto_download_request(
user_db: UserDB,
request_row: dict[str, Any],
@@ -1001,6 +1406,9 @@ def auto_download_request(
queue_release: Callable[..., tuple[bool, str | None]],
admin_user_id: int,
providers: dict[str, Any] | None = None,
dead_peers: frozenset[str] = frozenset(),
dead_releases: frozenset[tuple[str, str]] = frozenset(),
recent_failures: frozenset[tuple[str, str]] | None = None,
) -> AutoDownloadOutcome:
"""Search, strict-match, and queue a single pending request.
@@ -1015,6 +1423,9 @@ def auto_download_request(
)
request_id = int(request_row["id"])
if _acted_on_since_listed(user_db, request_id):
logger.info("auto-download: request %s was handled since this pass listed it", request_id)
return AutoDownloadOutcome(request_id, "skipped", "no longer pending")
book_data = request_row.get("book_data") or {}
if not isinstance(book_data, dict):
return AutoDownloadOutcome(request_id, "skipped", "no book_data")
@@ -1050,89 +1461,146 @@ def auto_download_request(
)
return AutoDownloadOutcome(request_id, "error", f"library check unavailable: {exc}")
if in_library:
# Close it, or it sits in Needs Review and is looked up again every pass.
# Same end state as an admin approving it with no release.
try:
user_db.update_request(
request_id,
expected_current_status=RequestStatus.PENDING,
status=RequestStatus.FULFILLED,
delivery_state=QueueStatus.COMPLETE,
delivery_updated_at=datetime.now(UTC).isoformat(timespec="seconds"),
admin_note=IN_LIBRARY_NOTE,
reviewed_by=admin_user_id,
reviewed_at=datetime.now(UTC).isoformat(timespec="seconds"),
)
except ValueError as exc:
# Changed under us (an admin acted on it mid-pass); leave it as it now is.
logger.info(
"auto-download: request %s already in library, not closed: %s", request_id, exc
)
return AutoDownloadOutcome(request_id, "in_library", "already in library")
logger.info(
"auto-download: request %s (%s) already in library; skipping",
"auto-download: request %s (%s) already in library; closed",
request_id,
book.title,
)
return AutoDownloadOutcome(request_id, "in_library", "already in library")
return AutoDownloadOutcome(request_id, "in_library", "already in library; closed")
audiobook_formats = _audiobook_formats()
ebook_formats = _ebook_formats()
languages = _wanted_languages(coerce_int(request_row.get("user_id"), 0) or None)
skipped_pack = False
# Releases that already failed for this request, so a retry does not pick the same one.
failed_releases = _failed_release_keys(book_data)
# Releases that already failed for this request, so a retry does not pick the same one. Only
# those whose failure is recent: an older one is given another chance (None keeps all).
remembered = _failed_release_keys(book_data)
if recent_failures is not None:
remembered &= recent_failures
failed_releases = remembered | dead_releases
source_errors: list[str] = []
skipped_pack = False
# Walk sources in priority order; take the first source with a strict match.
for source_name in sources:
# The requester's id lets the search plan apply their default languages.
_source, releases, search_error = search_source_releases(
source_name,
book,
expand_search=True,
content_type=content_type,
user_id=coerce_int(request_row.get("user_id"), 0) or None,
)
if search_error:
source_errors.append(f"{source_name}: {search_error}")
match_kwargs: dict[str, Any] = {
"content_type": content_type,
"min_seeders": min_seeders,
"audiobook_formats": audiobook_formats,
"ebook_formats": ebook_formats,
"languages": languages,
}
releases = [r for r in releases if (r.source, r.source_id) not in failed_releases]
if not any(strict_match(release, book, **match_kwargs) for release in releases):
_fill_missing_authors(releases, book, match_kwargs)
candidates = [
release for release in releases if strict_match(release, book, **match_kwargs)
]
chosen = None
release_data: dict[str, Any] = {}
ranked = sorted(candidates, key=lambda r: _release_sort_key(r, content_type), reverse=True)
thin = _undersized_copies(ranked, content_type)
ranked = _demote_undersized(ranked, content_type)
for index, candidate in enumerate(ranked):
if index >= MAX_PACK_CHECKS:
break # the best few were all packs; don't queue an uninspected tail
data = build_release_data(candidate, book, content_type)
is_pack, single_file = _inspect_candidate(data)
if is_pack:
logger.info("auto-download: skipping multi-book pack %s", candidate.title)
skipped_pack = True
continue
if single_file and candidate not in thin:
# One m4b/m4a beats a set of chapter files: no merge step afterwards.
chosen, release_data = candidate, data
break
if chosen is None:
chosen, release_data = candidate, data # best-ranked fallback
if content_type != "audiobook" or _audiobook_format(candidate) == "m4b":
break # already as good as it gets; don't inspect the rest
if chosen is None:
continue
try:
fulfil_request(
user_db,
request_id=request_id,
admin_user_id=admin_user_id,
queue_release=queue_release,
release_data=release_data,
def _walk(searched_book: BookMetadata) -> AutoDownloadOutcome | None:
"""Search the sources in priority order; the first with a strict match wins."""
nonlocal skipped_pack
for source_name in sources:
# The requester's id lets the search plan apply their default languages.
_source, releases, search_error = search_source_releases(
source_name,
searched_book,
expand_search=True,
content_type=content_type,
user_id=coerce_int(request_row.get("user_id"), 0) or None,
)
except RequestServiceError as exc:
logger.warning("auto-download: fulfil failed for request %s: %s", request_id, exc)
return AutoDownloadOutcome(request_id, "error", str(exc), source=source_name)
if search_error:
source_errors.append(f"{source_name}: {search_error}")
match_kwargs: dict[str, Any] = {
"content_type": content_type,
"min_seeders": min_seeders,
"audiobook_formats": audiobook_formats,
"ebook_formats": ebook_formats,
"languages": languages,
}
releases = [r for r in releases if (r.source, r.source_id) not in failed_releases]
if not any(
strict_match(release, searched_book, **match_kwargs) for release in releases
):
_fill_missing_authors(releases, searched_book, match_kwargs)
candidates = [
release
for release in releases
if strict_match(release, searched_book, **match_kwargs)
]
chosen = None
release_data: dict[str, Any] = {}
ranked = sorted(
candidates, key=lambda r: _release_sort_key(r, content_type), reverse=True
)
thin = _undersized_copies(ranked, content_type)
ranked = _demote_undersized(ranked, content_type)
ranked = _demote_dead_sources(ranked, dead_peers)
for index, candidate in enumerate(ranked):
if index >= MAX_PACK_CHECKS:
break # the best few were all packs; don't queue an uninspected tail
data = build_release_data(candidate, searched_book, content_type)
is_pack, single_file = _inspect_candidate(data)
if is_pack:
logger.info("auto-download: skipping multi-book pack %s", candidate.title)
skipped_pack = True
continue
if single_file and candidate not in thin:
# One m4b/m4a beats a set of chapter files: no merge step afterwards.
chosen, release_data = candidate, data
break
if chosen is None:
chosen, release_data = candidate, data # best-ranked fallback
if content_type != "audiobook" or _audiobook_format(candidate) == "m4b":
break # already as good as it gets; don't inspect the rest
if chosen is None:
continue
try:
fulfil_request(
user_db,
request_id=request_id,
admin_user_id=admin_user_id,
queue_release=queue_release,
release_data=release_data,
)
except RequestServiceError as exc:
logger.warning("auto-download: fulfil failed for request %s: %s", request_id, exc)
return AutoDownloadOutcome(request_id, "error", str(exc), source=source_name)
logger.info(
"auto-download: queued request %s from %s (%s)",
request_id,
source_name,
chosen.title,
)
return AutoDownloadOutcome(request_id, "queued", chosen.title, source=source_name)
return None
outcome = _walk(book)
if outcome is not None:
return outcome
# Nothing matched the request's own query. Try a few simpler ones, stopping at the
# first that yields a strict match; the match rules are the same for each.
for label, title in query_variants(book):
logger.info(
"auto-download: queued request %s from %s (%s)",
"auto-download: request %s (%s): no strict match, trying %s: %r",
request_id,
source_name,
chosen.title,
book.title,
label,
title,
)
return AutoDownloadOutcome(request_id, "queued", chosen.title, source=source_name)
outcome = _walk(replace(book, search_title=title))
if outcome is not None:
if outcome.status == "queued":
logger.info(
"auto-download: request %s matched on the query %s: %r",
request_id,
label,
title,
)
return outcome
if source_errors:
# A source that failed has not said the book is missing.
@@ -1147,6 +1615,61 @@ def auto_download_request(
return AutoDownloadOutcome(request_id, "no_match", "no strict match across sources")
FAILURE_RETRY_MINUTES_SETTING = "AUTO_DOWNLOAD_FAILURE_RETRY_MINUTES"
def retry_failed_downloads(
user_db: UserDB,
*,
queue_release: Callable[..., tuple[bool, str | None]],
provider_filter: str | None = "hardcover",
admin_user_id: int | None = None,
db_path: str | None = None,
) -> dict[str, int] | None:
"""Pick another release for requests whose download just failed or stalled.
Runs between full passes. Only the requests it reopens are searched, so it costs nothing
when no download has failed. The release that failed is noted on the request first, and a
request is given up on after ``MAX_FAILURE_RETRIES`` attempts. None when switched off.
"""
minutes = coerce_int(app_config.get(FAILURE_RETRY_MINUTES_SETTING, 0), 0)
if minutes <= 0 or not bool(app_config.get("AUTO_DOWNLOAD_ENABLED", False)):
return None
reopened_ids: list[int] = []
reopen_stale_failures(
user_db,
cooldown_days=0,
db_path=db_path,
provider_filter=provider_filter,
failure_cooldown=timedelta(minutes=minutes),
only_kinds=frozenset({_FAILED, _STALLED}),
reopened_ids=reopened_ids,
)
# A failed download the status poll already reopened is pending with no release; those
# are searched here as well, so they are not left for the next full pass.
pending_after_failure = {
int(row["id"])
for row in user_db.list_requests(status="pending")
if row.get("last_failure_reason")
and str(row.get("delivery_state") or "none").lower() in {"none", ""}
and isinstance(row.get("book_data"), dict)
and row["book_data"].get("failed_releases")
and len(row["book_data"]["failed_releases"]) < MAX_FAILURE_RETRIES
}
ids = frozenset(reopened_ids) | pending_after_failure
if not ids:
return None
logger.info("auto-download: trying another release for %d request(s)", len(ids))
return auto_download_pending(
user_db,
queue_release=queue_release,
provider_filter=provider_filter,
admin_user_id=admin_user_id,
db_path=db_path,
only_request_ids=ids,
)
def auto_download_pending(
user_db: UserDB,
*,
@@ -1155,9 +1678,13 @@ def auto_download_pending(
admin_user_id: int | None = None,
db_path: str | None = None,
ignore_backoff: bool = False,
only_request_ids: frozenset[int] | None = None,
) -> dict[str, int]:
"""Run the auto-download pass over all eligible pending requests.
``only_request_ids`` limits the pass to those requests (the quick retry after a failed
download), and skips the 7-day reopen.
``ignore_backoff`` (a manual "Sync now") searches every request, however many passes in a
row found nothing for it.
@@ -1181,8 +1708,12 @@ def auto_download_pending(
retry_days = coerce_int(
app_config.get(RETRY_DAYS_SETTING, DEFAULT_RETRY_DAYS), DEFAULT_RETRY_DAYS
)
reopened = reopen_stale_failures(
user_db, cooldown_days=retry_days, db_path=db_path, provider_filter=provider_filter
reopened = (
0
if only_request_ids is not None
else reopen_stale_failures(
user_db, cooldown_days=retry_days, db_path=db_path, provider_filter=provider_filter
)
)
if reopened:
logger.info(
@@ -1203,7 +1734,13 @@ def auto_download_pending(
else coerce_int(app_config.get(BACKOFF_SETTING, DEFAULT_BACKOFF_MAX_DAYS), 0)
)
dead_peers = recent_failed_peers(db_path)
dead_releases = recent_dead_releases(db_path)
# Without a history to read there is no way to tell how old a failure is, so all count.
recent_failures = recent_failed_releases(db_path) if db_path else None
for row in pending:
if only_request_ids is not None and int(row["id"]) not in only_request_ids:
continue
book_data = row.get("book_data") or {}
if provider_filter and (
not isinstance(book_data, dict) or book_data.get("provider") != provider_filter
@@ -1235,6 +1772,7 @@ def auto_download_pending(
summary["skipped"] += 1
continue
started = time.monotonic()
try:
outcome = auto_download_request(
user_db,
@@ -1245,6 +1783,9 @@ def auto_download_pending(
queue_release=queue_release,
admin_user_id=admin_user_id,
providers=providers,
dead_peers=dead_peers,
dead_releases=dead_releases,
recent_failures=recent_failures,
)
except Exception:
# One bad request must not take the rest of the pass with it.
@@ -1252,7 +1793,51 @@ def auto_download_pending(
summary["error"] += 1
continue
summary[outcome.status] = summary.get(outcome.status, 0) + 1
_existence_check(
user_db,
row,
outcome,
admin_user_id=admin_user_id,
content_type=content_type,
# What is left of this request's search budget; the store lookups spend it.
budget_seconds=max(
0.0, search_deadline.budget_seconds() - (time.monotonic() - started)
),
summary=summary,
)
_record_search_outcome(user_db, int(row["id"]), outcome.status, datetime.now(UTC))
logger.info("auto-download pass complete: %s", summary)
return summary
def _existence_check(
user_db: UserDB,
row: dict[str, Any],
outcome: AutoDownloadOutcome,
*,
admin_user_id: int,
content_type: str,
budget_seconds: float,
summary: dict[str, int],
) -> None:
"""Hand a request's outcome to the audiobook existence check (see ``existence_check``).
Its failure must not take the pass with it, so it is logged and the pass moves on.
"""
from shelfmark.core import existence_check
try:
verdict = existence_check.after_request(
user_db,
row,
outcome,
admin_user_id=admin_user_id,
content_type=content_type,
budget_seconds=budget_seconds,
)
except Exception:
logger.exception("auto-download: existence check for request %s failed", row.get("id"))
return
if verdict == "not_listed":
summary["not_listed"] = summary.get("not_listed", 0) + 1
+286
View File
@@ -0,0 +1,286 @@
"""Mark audiobook requests for books that no store sells as an audiobook.
A pending audiobook request that no source ever matches sits in Needs Review for good,
and an admin ends up looking it up by hand to learn that there is no recording to find.
This does that lookup: once a request has gone a configured number of consecutive
auto-download passes without a strict match, Audible's US and UK catalogues are asked,
through the Audiobookshelf server Shelfmark already talks to, whether the book exists as
an audiobook at all.
- Not listed on either store: the request stays pending and its failure reason says so,
for an admin to decline or keep. Audible search misses real books (collection-only
novellas, retitled releases), so the check never declines on its own.
- Listed: the request stays pending and its failure reason says so, with the ASIN, so
the admin sees why it waits. Either way the store is not asked again for a week.
- The store or the server failed, or the request's search budget was spent: nothing is
decided. An error is not "no audiobook". Audiobookshelf answers a failed Audible search
with an empty list rather than an error, so before a "not listed" counts, a book both
stores are known to sell (``CANARY``) is looked up too; if that is not found either,
the answer is "unknown".
The pass counter lives in the request's ``book_data`` beside the retry bookkeeping. A
pass that matched the book clears it, and so does any rejection (``requests_service``
drops it), so a request an admin reopens starts counting afresh. Only audiobook requests are checked: ebooks have no store
that could vouch for their absence.
"""
from __future__ import annotations
import time
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from typing import TYPE_CHECKING, Any
import requests
from shelfmark.core.config import config as app_config
from shelfmark.core.existence_match import StoreMatch, find_match, search_title, us_spelling
from shelfmark.core.library_providers import audiobookshelf
from shelfmark.core.logger import setup_logger
from shelfmark.core.request_helpers import coerce_int
from shelfmark.core.request_validation import RequestStatus
if TYPE_CHECKING:
from shelfmark.core.auto_download import AutoDownloadOutcome
from shelfmark.core.user_db import UserDB
logger = setup_logger(__name__)
ENABLED_SETTING = "AUTO_DOWNLOAD_EXISTENCE_CHECK_ENABLED"
AFTER_PASSES_SETTING = "AUTO_DOWNLOAD_EXISTENCE_CHECK_AFTER_PASSES"
DEFAULT_AFTER_PASSES = 3
# A book the store listed is asked about again after this long, not every pass.
RECHECK_DAYS = 7
# Audiobookshelf's provider ids for the two Audible stores asked, in order.
STORES: tuple[tuple[str, str], ...] = (("audible", "US"), ("audible.uk", "UK"))
US_STORE = "audible"
# Where the bookkeeping sits in a request's book_data, beside ``failed_releases``.
BOOK_DATA_KEY = "existence_check"
# One store lookup may take this long, and one is not started with less than this of the
# request's search budget left: the pass must not stall on a store that stopped answering.
_LOOKUP_TIMEOUT = 15.0
_MIN_LOOKUP_BUDGET = 5.0
# A book both stores sell, asked about before a "not listed" is believed: Audiobookshelf
# answers a failed Audible search with an empty list, which reads the same as "no such book".
CANARY = ("The Hobbit", "J.R.R. Tolkien")
# Outcomes of a pass that mean the request found its book, so the count starts over.
_MATCHED = frozenset({"queued", "in_library"})
NOT_LISTED_REASON = "Not found on Audible (US/UK) after {passes} searches, {date}"
LISTED_REASON = "Exists on Audible ({asin}), not on our sources yet"
@dataclass(frozen=True)
class ExistenceResult:
"""What the stores said: ``found``, ``not_found`` or ``unknown`` (nothing decided)."""
status: str
match: StoreMatch | None = None
store: str | None = None
detail: str = ""
def enabled() -> bool:
return bool(app_config.get(ENABLED_SETTING, False))
def passes_before_check() -> int:
"""How many consecutive no-match passes a request goes through before the lookup."""
raw = coerce_int(app_config.get(AFTER_PASSES_SETTING, DEFAULT_AFTER_PASSES), 0)
return raw if raw >= 1 else DEFAULT_AFTER_PASSES
def _search(
title: str, author: str, provider: str, store: str, deadline: float | None
) -> list[dict[str, Any]] | ExistenceResult:
"""One store search, or the ``unknown`` result that stands in for it."""
timeout = _LOOKUP_TIMEOUT
if deadline is not None:
left = deadline - time.monotonic()
if left < _MIN_LOOKUP_BUDGET:
return ExistenceResult(
"unknown", store=store, detail=f"no search time left for the {store} store"
)
timeout = min(timeout, left)
try:
return audiobookshelf.search_books(title, author, provider, timeout=timeout)
except (requests.RequestException, ValueError) as exc:
return ExistenceResult("unknown", store=store, detail=f"{store} lookup failed: {exc}")
def look_up(
title: str | None, author: str | None, *, budget_seconds: float | None = None
) -> ExistenceResult:
"""Ask Audible US, then UK, whether ``title`` by ``author`` exists as an audiobook.
The lookups share ``budget_seconds`` when one is given; a store that cannot be asked
within it is not asked, and the answer is ``unknown``. Any server or store error is
``unknown`` too, and so is a "not listed" that the stores also give for ``CANARY``:
an error never says the book is missing.
"""
query = search_title(title, author)
if not query:
return ExistenceResult("unknown", detail="the request has no title to search")
deadline = None if budget_seconds is None else time.monotonic() + budget_seconds
for provider, store in STORES:
store_query = us_spelling(query) if provider == US_STORE else query
results = _search(store_query, author or "", provider, store, deadline)
if isinstance(results, ExistenceResult):
return results
match = find_match(title, author, results)
if match is not None:
return ExistenceResult("found", match=match, store=store)
canary_title, canary_author = CANARY
for provider, store in STORES:
results = _search(canary_title, canary_author, provider, store, deadline)
if isinstance(results, ExistenceResult):
return results
if find_match(canary_title, canary_author, results) is None:
return ExistenceResult(
"unknown",
store=store,
detail=f"the {store} store did not list {canary_title} either; taken as a failure",
)
return ExistenceResult("not_found")
def without_state(book_data: dict[str, Any]) -> dict[str, Any] | None:
"""``book_data`` without this check's bookkeeping, or None when it has none."""
if BOOK_DATA_KEY not in book_data:
return None
return _with_state(book_data, None)
def _state(book_data: dict[str, Any]) -> dict[str, Any]:
raw = book_data.get(BOOK_DATA_KEY)
return dict(raw) if isinstance(raw, dict) else {}
def _with_state(book_data: dict[str, Any], state: dict[str, Any] | None) -> dict[str, Any]:
cleared = {key: value for key, value in book_data.items() if key != BOOK_DATA_KEY}
if state:
cleared[BOOK_DATA_KEY] = state
return cleared
def _checked_recently(state: dict[str, Any], now: datetime) -> bool:
"""True when the store said "found" less than ``RECHECK_DAYS`` ago."""
if state.get("result") not in ("found", "not_found"):
return False
try:
checked_at = datetime.fromisoformat(str(state.get("checked_at") or ""))
except ValueError:
return False
if checked_at.tzinfo is None:
checked_at = checked_at.replace(tzinfo=UTC)
return now - checked_at < timedelta(days=RECHECK_DAYS)
def after_request(
user_db: UserDB,
row: dict[str, Any],
outcome: AutoDownloadOutcome,
*,
admin_user_id: int,
content_type: str,
budget_seconds: float | None = None,
now: datetime | None = None,
) -> str | None:
"""Count the pass for one request and, when it is time, ask the stores.
Called by the auto-download pass after each request with that request's outcome.
Returns what happened: ``counted`` (not yet time), ``waiting`` (the store listed it
recently), ``listed`` (the store lists it; the request stays pending and says so),
``not_listed`` (not on either store; stays pending and says so), ``unknown`` (lookup failed, nothing changed) or None when
the request was not an audiobook no-match, or the check is off.
"""
if content_type != "audiobook" or not enabled():
return None
request_id = int(row["id"])
# The pass may have written to the request (a failed release, a status change), so
# work from what is stored now, not from the row the pass started with.
current = user_db.get_request(request_id)
if current is None:
return None
book_data = current.get("book_data")
if not isinstance(book_data, dict):
return None
state = _state(book_data)
if outcome.status != "no_match":
if outcome.status in _MATCHED and state:
# The book was found; a retry after a failed download starts counting afresh.
user_db.update_request(request_id, book_data=_with_state(book_data, None))
return None
if current.get("status") != RequestStatus.PENDING:
return None
moment = now or datetime.now(UTC)
passes = coerce_int(state.get("no_match_passes"), 0) + 1
state["no_match_passes"] = passes
# Written before the lookup, so a pass still counts if the lookup never finishes.
user_db.update_request(
request_id,
expected_current_status=RequestStatus.PENDING,
book_data=_with_state(book_data, state),
)
if passes < passes_before_check():
return "counted"
if _checked_recently(state, moment):
return "waiting"
title = str(book_data.get("title") or "")
author = str(book_data.get("author") or "")
result = look_up(title, author, budget_seconds=budget_seconds)
if result.status == "unknown":
logger.warning(
"existence check: request %s (%s) not decided: %s", request_id, title, result.detail
)
return "unknown"
if result.status == "found" and result.match is not None:
state.update(
result="found",
checked_at=moment.isoformat(timespec="seconds"),
asin=result.match.asin,
store=result.store,
)
user_db.update_request(
request_id,
expected_current_status=RequestStatus.PENDING,
book_data=_with_state(book_data, state),
last_failure_reason=LISTED_REASON.format(asin=result.match.asin),
)
logger.info(
"existence check: request %s (%s) is on Audible %s as %s; still pending",
request_id,
title,
result.store,
result.match.asin,
)
return "listed"
# Not on either store. Audible search misses too much to decline on that alone (books
# sold only in a collection, retitled releases, fan recordings), so the request stays
# pending for an admin to decide, with the reason saying what the store answered.
state.update(result="not_found", checked_at=moment.isoformat(timespec="seconds"))
user_db.update_request(
request_id,
expected_current_status=RequestStatus.PENDING,
book_data=_with_state(book_data, state),
last_failure_reason=NOT_LISTED_REASON.format(passes=passes, date=moment.date().isoformat()),
)
logger.info(
"existence check: request %s (%s) not on Audible US or UK after %d searches; "
"left pending for review",
request_id,
title,
passes,
)
return "not_listed"
+273
View File
@@ -0,0 +1,273 @@
"""Does an Audible catalogue result name the audiobook we want?
Text work only, for the results Audiobookshelf's ``/api/search/books`` returns from its
Audible providers (``audible`` is the US store, ``audible.uk`` the UK one). A result names
the wanted book when its title, normalised the same way as the wanted title, is the same
sequence of words (or one begins the other, see ``titles_match``), and one of the wanted
author's surnames appears in its author field.
Normalising a title: diacritics are folded; bracketed notes such as "(Unabridged)" go; a
leading series label ("Robert Langdon, Book 4 - ") goes; the subtitle after ":", ", or",
" aka " or a spaced dash is dropped; "&" reads as "and"; a possessive "'s" is dropped; and
a roman, spelled-out or digit numeral all become the digit ("Part II", "Part Two" and
"Part 2" are one title). Stopwords are left out of the comparison.
The matching leans towards "found" on purpose. A wrong "found" leaves a request pending,
where it already was; a wrong "not found" declines a book that exists.
Standard library only, on purpose: the stress test against a live library runs this file
as-is next to the Audiobookshelf server, so what is tested is what ships.
"""
from __future__ import annotations
import re
import unicodedata
from dataclasses import dataclass
_STOPWORDS = frozenset({"a", "an", "the", "of", "and", "or", "to", "in", "on", "for", "with", "by"})
_NUMERALS = {
"zero": "0",
"one": "1",
"two": "2",
"three": "3",
"four": "4",
"five": "5",
"six": "6",
"seven": "7",
"eight": "8",
"nine": "9",
"ten": "10",
"eleven": "11",
"twelve": "12",
"thirteen": "13",
"fourteen": "14",
"fifteen": "15",
"sixteen": "16",
"seventeen": "17",
"eighteen": "18",
"nineteen": "19",
"twenty": "20",
"i": "1",
"ii": "2",
"iii": "3",
"iv": "4",
"v": "5",
"vi": "6",
"vii": "7",
"viii": "8",
"ix": "9",
"x": "10",
"xi": "11",
"xii": "12",
"xiii": "13",
"xiv": "14",
"xv": "15",
"xvi": "16",
"xvii": "17",
"xviii": "18",
"xix": "19",
"xx": "20",
}
# British spellings the US store does not find ("The Armour of Light" is "The Armor of
# Light" there). Titles compare in the US spelling, and the US store is asked in it.
_US_SPELLING = {
"armour": "armor",
"behaviour": "behavior",
"colour": "color",
"colours": "colors",
"endeavour": "endeavor",
"favour": "favor",
"harbour": "harbor",
"honour": "honor",
"humour": "humor",
"labour": "labor",
"neighbour": "neighbor",
"neighbours": "neighbors",
"rumour": "rumor",
"rumours": "rumors",
"saviour": "savior",
"splendour": "splendor",
"valour": "valor",
}
_US_SPELLING_WORD = re.compile(r"\b(" + "|".join(_US_SPELLING) + r")\b", re.IGNORECASE)
_BRACKETED = re.compile(r"\([^)]*\)|\[[^\]]*\]|\{[^}]*\}")
# A dash with space on at least one side splits off a part: "Born a Crime- Stories from
# ...", "Robert Langdon, Book 4 - Inferno". Hyphenated words ("Sword-Sworn") survive.
_DASH = re.compile(r"\s+[-–—]+\s*|\s*[-–—]+\s+")
# ": subtitle", ", or Alternative Title", " aka Other Title": the first ends the title.
_SUBTITLE = re.compile(r"\s*(?::|,\s+or\b|\s+aka\b).*$", re.IGNORECASE)
# A leading part that ends in a series position: "Mayfair Witches 01", "Robert Langdon,
# Book 4", "Discworld #3". A plain number does not count ("Fahrenheit 451 - A Novel").
_NUMBERED_PART = re.compile(
r"(?:\b(?:book|vol(?:ume)?|part|no)\.?\s*\d+(?:\.\d+)?|\b0\d+|#\d+(?:\.\d+)?)$", re.IGNORECASE
)
# A series label marked by a bracketed number in front of the title: "HGTTG [01] The
# Hitchhiker's Guide to the Galaxy".
_BRACKETED_LABEL = re.compile(r"^[^\[\]()]{0,40}?[\[(]\s*#?\d+(?:\.\d+)?\s*[\])]\s*(?=\S)")
# A zero-padded track number in front of a title ("04 The Visitor").
_TRACK_NUMBER = re.compile(r"^0\d\s+(?=\S)")
_POSSESSIVE = re.compile(r"[’']s\b")
_SPLIT = re.compile(r"[^a-z0-9]+")
# Separators between the names in an author field: "Anne Rice, Christopher Rice",
# "Kristin Hannah/Polly Stone", "Lee Child & Andrew Child", "Douglas Adams and ...".
_AUTHOR_SPLIT = re.compile(r"\s*(?:[,;/&]|\band\b)\s*", re.IGNORECASE)
# A prefix match ("Start with Why" for "Start with Why 15th Anniversary Edition") needs
# this many words in the shorter title, so a one-word title never matches a longer one.
_MIN_PREFIX_WORDS = 2
def _fold(text: str) -> str:
"""ASCII-folded, lower-cased text: diacritics and typographic quotes removed."""
decomposed = unicodedata.normalize("NFKD", text)
stripped = "".join(ch for ch in decomposed if not unicodedata.combining(ch))
return stripped.replace("’", "'").replace("‘", "'").lower()
def _words(text: str) -> list[str]:
folded = _fold(text).replace("&", " and ")
folded = _POSSESSIVE.sub("", folded).replace("'", "")
return [_US_SPELLING.get(word, word) for word in _SPLIT.split(folded) if word]
def us_spelling(text: str) -> str:
"""``text`` with the British spellings in ``_US_SPELLING`` turned American."""
def _swap(match: re.Match[str]) -> str:
word = match.group(0)
us = _US_SPELLING[word.lower()]
return us.capitalize() if word[0].isupper() else us
return _US_SPELLING_WORD.sub(_swap, text)
def search_title(
title: str | None, author: str | None = None, *, keep_subtitle: bool = False
) -> str:
"""The title alone, as it is sent to the store.
Bracketed notes ("(Unabridged)", "[Series 02]") go. Of the parts a spaced dash
separates, leading ones that are a numbered series label or the author's name go too,
and the first that is left is the title; the rest is subtitle. A subtitle after ":",
", or" and " aka " goes (unless ``keep_subtitle``), and so does a trailing ellipsis.
"""
original = (title or "").strip()
text = _BRACKETED_LABEL.sub("", original)
text = _BRACKETED.sub(" - ", text)
parts = [part.strip(" ,;") for part in _DASH.split(text)]
parts = [part for part in parts if part]
author_words = _words(author or "")
while len(parts) > 1 and (
_NUMBERED_PART.search(parts[0]) or (author_words and _words(parts[0]) == author_words)
):
parts.pop(0)
if keep_subtitle:
text = " ".join(parts)
else:
text = parts[0] if parts else ""
text = _SUBTITLE.sub("", text)
text = _TRACK_NUMBER.sub("", text)
text = re.sub(r"\s+", " ", text).strip(" ,;")
text = re.sub(r"\s*(?:\.{3}|…)$", "", text)
return text or original
def title_words(
title: str | None, author: str | None = None, *, keep_subtitle: bool = False
) -> tuple[str, ...]:
"""The comparable words of a title, in order."""
words = _words(search_title(title, author, keep_subtitle=keep_subtitle))
return tuple(_NUMERALS.get(word, word) for word in words if word not in _STOPWORDS)
def _same_title(wanted: tuple[str, ...], candidate: tuple[str, ...]) -> bool:
if not wanted or not candidate:
return False
if wanted == candidate:
return True
shorter, longer = sorted((wanted, candidate), key=len)
return len(shorter) >= _MIN_PREFIX_WORDS and longer[: len(shorter)] == shorter
def titles_match(wanted: str | None, candidate: str | None, author: str | None = None) -> bool:
"""True when the two titles name the same book once normalised.
Same words, or one title's words begin the other's when the shorter has at least two
("Merry Christmas" and "Merry Christmas, Alex Cross"). Each title is compared both
without and with its subtitle, so "The Mummy, or Ramses the Damned" matches "The Mummy
or Ramses the Damned". A store title with a series in front of a colon ("Ramses the
Damned: The Reign of Osiris") is also tried without it.
"""
wanted_forms = {
title_words(wanted, author),
title_words(wanted, author, keep_subtitle=True),
}
candidate_title = candidate or ""
candidate_forms = {
title_words(candidate_title),
title_words(candidate_title, keep_subtitle=True),
}
if ":" in candidate_title:
candidate_forms.add(title_words(candidate_title.split(":", 1)[1]))
return any(_same_title(w, c) for w in wanted_forms for c in candidate_forms)
def author_surnames(author: str | None) -> set[str]:
"""The surnames in an author field, one per name.
"Lee Child" gives {"child"}; "R.C. Bray, Andy Weir" gives {"bray", "weir"}; "Child,
Lee" gives {"child", "lee"}, which is harmless as the match is on any of them.
"""
surnames = set()
for name in _AUTHOR_SPLIT.split(author or ""):
words = [w for w in _SPLIT.split(_fold(name)) if len(w) >= 2 and w not in _STOPWORDS]
if words:
surnames.add(words[-1])
return surnames
def author_matches(wanted_author: str | None, candidate_author: str | None) -> bool:
"""True when one of the wanted author's surnames is in the result's author field.
A wanted author with no surname (empty, or initials only) matches on title alone.
"""
surnames = author_surnames(wanted_author)
if not surnames:
return True
return bool(surnames & set(_SPLIT.split(_fold(candidate_author or ""))))
@dataclass(frozen=True)
class StoreMatch:
"""The catalogue entry that names the wanted book."""
asin: str
title: str
author: str
def find_match(
wanted_title: str | None, wanted_author: str | None, results: list[dict]
) -> StoreMatch | None:
"""The first store result that is the wanted book, or None.
``results`` is what ``/api/search/books`` returns: dicts with ``title``, ``author`` and
``asin``. Results without an ASIN are not catalogue entries and are skipped.
"""
for result in results:
if not isinstance(result, dict):
continue
asin = str(result.get("asin") or "").strip().upper()
title = str(result.get("title") or "")
author = str(result.get("author") or "")
if not asin:
continue
if titles_match(wanted_title, title, wanted_author) and author_matches(
wanted_author, author
):
return StoreMatch(asin=asin, title=title, author=author)
return None
+54 -1
View File
@@ -109,6 +109,59 @@ def trigger_async(*, force: bool = True) -> bool:
return True
def _quick_retry_interval_seconds() -> float:
"""How often failed downloads are checked between cycles, 0 when switched off."""
raw = app_config.get("AUTO_DOWNLOAD_FAILURE_RETRY_MINUTES", 0)
try:
minutes = float(raw) if isinstance(raw, (int, float, str)) else 0.0
except ValueError:
return 0.0
return max(minutes * 60.0, _MIN_INTERVAL_SECONDS) if minutes > 0 else 0.0
def run_quick_retry() -> dict[str, int] | None:
"""Pick another release for downloads that failed since the last look.
Skipped (None) while a cycle or a manual sync runs, and when no download failed.
"""
if not _run_lock.acquire(blocking=False):
return None
try:
with _ctx_lock:
user_db = _ctx.get("user_db")
queue_release = _ctx.get("queue_release")
db_path = _ctx.get("db_path")
if user_db is None or queue_release is None:
return None
from shelfmark.core.auto_download import retry_failed_downloads
return retry_failed_downloads(user_db, queue_release=queue_release, db_path=db_path)
finally:
_run_lock.release()
def _wait_for_next_cycle() -> None:
"""Sleep until the next cycle, looking at failed downloads on the way when that is on."""
deadline = time.monotonic() + _interval_seconds()
while True:
remaining = deadline - time.monotonic()
if remaining <= 0:
return
step = _quick_retry_interval_seconds()
if step <= 0:
time.sleep(remaining)
return
time.sleep(min(step, remaining))
if time.monotonic() >= deadline:
return
try:
result = run_quick_retry()
if result is not None:
logger.info("Hardcover scheduler: quick retry result %s", result)
except Exception:
logger.exception("Hardcover scheduler: quick retry failed")
def _scheduler_loop() -> None:
logger.info("Hardcover scheduler started")
time.sleep(_WARMUP_SECONDS)
@@ -120,7 +173,7 @@ def _scheduler_loop() -> None:
logger.info("Hardcover scheduler: cycle result %s", result)
except Exception:
logger.exception("Hardcover scheduler: cycle failed")
time.sleep(_interval_seconds())
_wait_for_next_cycle()
def start(
@@ -130,6 +130,38 @@ def _fetch_library_entries(url: str, token: str, lib_ids: list[str]) -> list[Lib
return entries
def search_books(
title: str,
author: str,
provider: str,
*,
timeout: float = _REQUEST_TIMEOUT,
overrides: Mapping[str, Any] | None = None,
) -> list[dict[str, Any]]:
"""Ask Audiobookshelf's metadata search (``GET /api/search/books``) about a book.
``provider`` names one of the server's metadata providers: ``audible`` is the US
store, ``audible.uk`` the UK one. The result is the server's list of matches, best
first, each with at least ``title``, ``author`` and (for Audible) ``asin``. Raises
ValueError without a URL and token, and the requests error when the server or the
store fails; the caller decides what an error means.
"""
_enabled, url, token, _lib_ids = _config(overrides)
if not url or not token:
raise ValueError("Set the Audiobookshelf URL and token first.")
resp = _session(url, token).get(
f"{url}/api/search/books",
params={"title": title, "author": author, "provider": provider},
timeout=timeout,
)
resp.raise_for_status()
payload = resp.json()
if not isinstance(payload, list):
msg = "Audiobookshelf answered the book search with something other than a list"
raise ValueError(msg) # noqa: TRY004 - a bad answer from the server, not a bad argument
return [item for item in payload if isinstance(item, dict)]
class AudiobookshelfLibrary:
"""Audiobook ownership via the Audiobookshelf REST API."""
+16 -4
View File
@@ -29,8 +29,12 @@ if TYPE_CHECKING:
logger = setup_logger(__name__)
# "(Alex Cross Series #11)", "[Illustrated Edition]" and friends.
_PARENTHETICAL = re.compile(r"[(\[][^)\]]*[)\]]")
# "(Alex Cross Series #11)", "(Book 2)": a bracketed segment with a number in it.
_NUMBERED_PARENTHETICAL = re.compile(r"[(\[][^)\]]*\d[^)\]]*[)\]]")
# "Label NN: Title", a series label and number in front of the work's own title. The
# label often abbreviates the series ("HHG 5: Mostly Harmless" for The Hitchhiker's
# Guide to the Galaxy), so its words are in neither the series nor the author name.
_SERIES_LABEL = re.compile(r"^\s*(?P<label>[^:]{1,40}?)\s+\d+(?:\.\d+)?\s*:\s+\S")
_DEFAULT_DB_PATH = "/calibre-library/metadata.db"
_BUSY_TIMEOUT_SECONDS = 5
@@ -111,12 +115,20 @@ def _read_entries(conn: sqlite3.Connection) -> list[LibraryEntry]:
entries: list[LibraryEntry] = []
for book_id, title in titles.items():
# A trailing parenthetical is series or edition metadata by convention rather
# A numbered parenthetical is series or volume metadata by convention rather
# than part of the work name, and Calibre users often put it there instead of
# in the series field. It stays in the recall set below and is dropped from the
# title set, which is what decides whether the shelf holds a different book.
title_tok = set(tokens(_PARENTHETICAL.sub(" ", title)))
# An unnumbered one describes the edition, so its words stay in the title set:
# the matcher ignores printing words there ("Illustrated Edition", "Unabridged")
# but reads anything else ("Full Cast Edition", "Dramatized Adaptation") as a
# different recording of the work, which is not the one being asked for.
title_tok = set(tokens(_NUMBERED_PARENTHETICAL.sub(" ", title)))
context_tok = set(tokens(series.get(book_id)))
# Only for a book with a series: there a leading "Label NN:" is the series
# label, not words naming another work. Without one it is left to the matcher.
if book_id in series and (label := _SERIES_LABEL.match(title)):
context_tok |= set(tokens(label.group("label")))
for name in authors.get(book_id, ()):
context_tok |= set(tokens(name))
tok = title_tok | context_tok
+12
View File
@@ -507,6 +507,17 @@ def reject_request(
normalized_admin_note = _normalize_admin_note(admin_note)
extra: dict[str, Any] = {}
book_data = request_row.get("book_data")
if isinstance(book_data, dict):
from shelfmark.core.existence_check import without_state
# The audiobook existence check's pass count ends with the review, so a request
# that is reopened later counts its passes afresh.
cleared = without_state(book_data)
if cleared is not None:
extra["book_data"] = cleared
try:
return user_db.update_request(
request_id,
@@ -515,6 +526,7 @@ def reject_request(
admin_note=normalized_admin_note,
reviewed_by=admin_user_id,
reviewed_at=_now_timestamp(),
**extra,
)
except TypeError as exc:
raise RequestServiceError(str(exc), status_code=400) from exc
+8
View File
@@ -485,6 +485,14 @@ class DownloadHandler(ABC):
"""
return None
def seed_complaints(self, release_data: dict[str, Any]) -> int | None:
"""How many comments on the release's page say nobody is seeding it.
For sources that report no seeder count of their own. None when the source has no
such signal or the page could not be read.
"""
return None
@abstractmethod
def cancel(self, task_id: str) -> bool:
"""Cancel an in-progress download."""
@@ -117,6 +117,18 @@ class AudiobookBayHandler(ExternalClientHandler):
return None
return scraper.extract_authors(detail_html) or None
def seed_complaints(self, release_data: dict[str, Any]) -> int | None:
"""Count the comments on the post that say nobody is seeding it (None: page unreadable)."""
raw_url = release_data.get("download_url") or release_data.get("source_url")
detail_url = raw_url.strip() if isinstance(raw_url, str) else ""
hostname = _resolve_allowed_detail_hostname()
if not detail_url or not _detail_url_matches_host(detail_url, hostname):
return None
detail_html = scraper.fetch_detail_html(detail_url, hostname)
if not detail_html:
return None
return scraper.extract_seed_complaints(detail_html)
def _get_client(self, protocol: str) -> DownloadClient | None:
"""Compatibility shim so module-level patching still works in tests."""
return get_client(protocol)
@@ -498,6 +498,26 @@ def extract_authors(detail_html: str) -> list[str]:
return []
# What a visitor writes under a post nobody is seeding: "0 seeders", "no seeds", "please seed",
# "unseeded", "dead". Anything shorter or vaguer than these is ignored.
_SEED_COMPLAINT_PATTERN = re.compile(
r"\b(?:0|no|zero)\s+seed(?:s|ers?)?\b"
r"|\bunseeded\b|\bnot\s+seeding\b|\bno\s+one\s+(?:is\s+)?seeding\b"
r"|\bplease\s+(?:re-?)?seed\b|\bseeders?\s+needed\b|\bneeds?\s+seed|\bdead\b",
re.IGNORECASE,
)
def extract_seed_complaints(detail_html: str) -> int:
"""How many comments on a post say nobody is seeding it. A hint, not a seed count."""
soup = BeautifulSoup(detail_html, "html.parser")
return sum(
1
for body in soup.select('[itemprop="reviewBody"]')
if _SEED_COMPLAINT_PATTERN.search(body.get_text(" ", strip=True))
)
def extract_magnet_link(details_url: str, hostname: str = "audiobookbay.lu") -> str | None:
"""Extract info hash and trackers from book detail page, then construct magnet link.
+29
View File
@@ -495,3 +495,32 @@ class TestExtractMagnetLink:
assert magnet_link is not None
# Info hash should be cleaned (no spaces, uppercase)
assert "ABC123DEF456" in magnet_link
def _post_with_comments(*comments: str) -> str:
bodies = "".join(
f'<li itemprop="review"><div itemprop="reviewBody"><p>{text}</p></div></li>'
for text in comments
)
return f"<html><body><p>Seed it please, this page text is not a comment</p><ul>{bodies}</ul></body></html>"
def test_seed_complaints_counts_the_comments_that_say_nobody_is_seeding():
html = _post_with_comments(
"Please seed and thank you",
"I think you have a problem, as it has 0 seeders. Can somebody please seed or reup?",
"No seeds :(",
"Great narrator, thanks for the upload",
)
assert scraper.extract_seed_complaints(html) == 3
def test_seed_complaints_ignores_page_text_outside_the_comments():
assert scraper.extract_seed_complaints(_post_with_comments("Loved it")) == 0
def test_seed_complaints_reads_dead_and_unseeded():
html = _post_with_comments("link is dead", "all of these are unseeded", "worked for me")
assert scraper.extract_seed_complaints(html) == 2
@@ -0,0 +1,283 @@
[
{
"title": "The Hobbit (Dramatised)",
"subtitle": "The Classic BBC Radio Production",
"author": "J.R.R. Tolkien",
"narrator": "Heron Carvic, Paul Daneman, Anthony Jackson, Francis de Wolff, John Justin, John Pullen, Full cast",
"publisher": "BBC Digital Audio",
"publishedYear": "2007",
"description": "Dwarves, elves, goblins, trolls, dragons and wizards - these are the ingredients of J.R.R. Tolkien's wonderful fantasy, ...",
"cover": "https://m.media-amazon.com/images/I/81wq-zTbNNL.jpg",
"asin": "B004F440NU",
"isbn": "9781405629423",
"genres": [
"Children's Audiobooks",
"Science Fiction & Fantasy"
],
"tags": [
"Science Fiction & Fantasy",
"Fantasy",
"Science Fiction"
],
"series": null,
"language": "English",
"duration": 222,
"region": "uk",
"rating": "4.4",
"abridged": false,
"descriptionPlain": "Dwarves, elves, goblins, trolls, dragons and wizards - these are the ingredients of J.R.R. Tolkien's wonderful fantasy, ..."
},
{
"title": "The Hobbit",
"subtitle": null,
"author": "J. R. R. Tolkien",
"narrator": "Martin Shaw",
"publisher": "HarperCollins",
"publishedYear": "2005",
"description": "<p>Bilbo Baggins is a hobbit who enjoys a comfortable and quiet life. His contentment is disturbed one day when the wiza...",
"cover": "https://m.media-amazon.com/images/I/61giqd1Hw2L.jpg",
"asin": "B004EWDDIQ",
"isbn": "9780007227761",
"genres": [
"Literature & Fiction",
"Science Fiction & Fantasy"
],
"tags": [
"Historical Fiction",
"Fantasy",
"Action & Adventure",
"Epic"
],
"series": null,
"language": "English",
"duration": 357,
"region": "uk",
"rating": "4.6",
"abridged": true,
"descriptionPlain": "Bilbo Baggins is a hobbit who enjoys a comfortable and quiet life. His contentment is disturbed one day when the wizard,..."
},
{
"title": "Le Hobbit",
"subtitle": null,
"author": "J. R. R. Tolkien",
"narrator": "Dominique Pinon",
"publisher": "Audiolib",
"publishedYear": "2012",
"description": "<p>Premier récit publié par J.R.R. Tolkien, en 1937, cette histoire, inventée par l’auteur pour ses propres enfants, rac...",
"cover": "https://m.media-amazon.com/images/I/61QhIC8qW1L.jpg",
"asin": "B00U6GZ0DU",
"isbn": "9782356414847",
"genres": [
"Science Fiction & Fantasy"
],
"tags": [
"Fantasy",
"Action & Adventure"
],
"series": null,
"language": "French",
"duration": 614,
"region": "uk",
"rating": "4.7",
"abridged": false,
"descriptionPlain": "Premier récit publié par J.R.R. Tolkien, en 1937, cette histoire, inventée par l’auteur pour ses propres enfants, racont..."
},
{
"title": "Der Hobbit",
"subtitle": "Der Herr der Ringe 0.5",
"author": "J. R. R. Tolkien",
"narrator": "Gert Heidenreich",
"publisher": "Der Hörverlag",
"publishedYear": "2010",
"description": "Bilbo Beutlin, der kleine Hobbit, macht sich auf den Weg zum Einsamen Berg, um den rechtmäßigen Schatz der Zwerge zurück...",
"cover": "https://m.media-amazon.com/images/I/91k9ci7Xf4L.jpg",
"asin": "B00THFMDYA",
"genres": null,
"tags": [
"Literature & Fiction",
"Genre Fiction",
"Movie, TV & Video Game Tie-Ins",
"Science Fiction & Fantasy",
"Fantasy",
"Action & Adventure",
"Dragons & Mythical Creatures"
],
"series": null,
"language": "German",
"duration": 653,
"region": "us",
"rating": "4.7",
"abridged": false,
"descriptionPlain": "Bilbo Beutlin, der kleine Hobbit, macht sich auf den Weg zum Einsamen Berg, um den rechtmäßigen Schatz der Zwerge zurück..."
},
{
"title": "A Hobbit",
"subtitle": null,
"author": "J. R. R. Tolkien",
"narrator": "Kaszás Attila",
"publisher": "Mojzer Publishing House",
"publishedYear": "2019",
"description": "<p>A Hobbit, mint Tolkien sok más írása, gyermekeinek szóló történetként indult. Először 1937-ben jelent meg és ma méltá...",
"cover": "https://m.media-amazon.com/images/I/91t3yGvAcNL.jpg",
"asin": "9630999633",
"isbn": "9789630999632",
"genres": [
"Science Fiction & Fantasy"
],
"tags": [
"Fantasy",
"Epic"
],
"series": null,
"language": "Hungarian",
"duration": 742,
"region": "uk",
"rating": "5.0",
"abridged": false,
"descriptionPlain": "A Hobbit, mint Tolkien sok más írása, gyermekeinek szóló történetként indult. Először 1937-ben jelent meg és ma méltán v..."
},
{
"title": "The Hobbit",
"subtitle": "The BBC TV soundtrack of the Jackanory multi-voice reading",
"author": "J. R. R. Tolkien",
"narrator": "Bernard Cribbins, Maurice Denham, Jan Francis, David Wood",
"publisher": "BBC Digital Audio",
"publishedYear": "2013",
"description": "<p>The BBC TV soundtrack of the acclaimed Jackanory multi-voice reading of <i>The Hobbit</i>. Broadcast to mark the 3000...",
"cover": "https://m.media-amazon.com/images/I/91QjFToijwL.jpg",
"asin": "B00FH7B866",
"isbn": "9781471358388",
"genres": [
"Science Fiction & Fantasy",
"Arts & Entertainment"
],
"tags": [
"Fantasy",
"Entertainment & Performing Arts"
],
"series": null,
"language": "English",
"duration": 147,
"region": "uk",
"rating": "4.6",
"abridged": true,
"descriptionPlain": "The BBC TV soundtrack of the acclaimed Jackanory multi-voice reading of The Hobbit. Broadcast to mark the 3000th program..."
},
{
"title": "El Hobbit",
"subtitle": null,
"author": "J. R. R. Tolkien",
"narrator": "Javier Gutiérrez",
"publisher": "Audible Studios y Planeta Audio",
"publishedYear": "2023",
"description": "<p>La extraordinaria precuela de El Señor de los Anillos.</p> <p>Smaug parecía profundamente dormido cuando Bilbo espió ...",
"cover": "https://m.media-amazon.com/images/I/91snBF2IakL.jpg",
"asin": "B0C8YZD5HL",
"isbn": "9788445016282",
"genres": [
"Science Fiction & Fantasy"
],
"tags": [
"Fantasy"
],
"series": [
{
"series": "El Señor de los Anillos",
"sequence": "0.5"
}
],
"language": "Spanish",
"duration": 640,
"region": "uk",
"rating": "4.7",
"abridged": false,
"descriptionPlain": "La extraordinaria precuela de El Señor de los Anillos. Smaug parecía profundamente dormido cuando Bilbo espió una vez má..."
},
{
"title": "Der Hobbit",
"subtitle": "Das Hörspiel",
"author": "J. R. R. Tolkien",
"narrator": "Martin Benrath, Horst Bollmann, Bernhard Minetti",
"publisher": "Der Hörverlag",
"publishedYear": "2010",
"description": "\"Der Hobbit\", erschienen 1937, ist Tolkiens erstes nichtwissenschaftliches Werk und gilt als Vorläufer der großen Trilog...",
"cover": "https://m.media-amazon.com/images/I/91BI9o5aCCL.jpg",
"asin": "B00TPM5GLW",
"genres": null,
"tags": [
"Literature & Fiction",
"Genre Fiction",
"Movie, TV & Video Game Tie-Ins",
"Science Fiction & Fantasy",
"Fantasy",
"Dragons & Mythical Creatures"
],
"series": null,
"language": "German",
"duration": 267,
"region": "us",
"rating": "5.0",
"abridged": false,
"descriptionPlain": "\"Der Hobbit\", erschienen 1937, ist Tolkiens erstes nichtwissenschaftliches Werk und gilt als Vorläufer der großen Trilog..."
},
{
"title": "The Hobbit",
"subtitle": null,
"author": "J.R.R. Tolkien",
"narrator": "Rob Inglis",
"publisher": "Recorded Books",
"publishedYear": "2012",
"description": "Like every other hobbit, Bilbo Baggins likes nothing better than a quiet evening in his snug hole in the ground, dining ...",
"cover": "https://m.media-amazon.com/images/I/81K05vSpbxL.jpg",
"asin": "B07935Q3J5",
"isbn": "9781470337582",
"genres": [
"Children's Audiobooks",
"Teen & Young Adult"
],
"tags": [
"Literature & Fiction",
"Classics",
"Science Fiction & Fantasy"
],
"series": [
{
"series": "The Lord of the Rings",
"sequence": "0.5"
}
],
"language": "English",
"duration": 664,
"region": "uk",
"rating": "0.0",
"abridged": false,
"descriptionPlain": "Like every other hobbit, Bilbo Baggins likes nothing better than a quiet evening in his snug hole in the ground, dining ..."
},
{
"title": "El hòbbit [The Hobbit]",
"subtitle": "o viatge d’anada i tornada [Or, There and Back Again]",
"author": "J.R.R. Tolkien",
"narrator": "Jordi Boixaderas",
"publisher": "Penguin Random House Audio",
"publishedYear": "2022",
"description": "<p><b>Acompanya en Bilbo Saquet en una aventura plena de màgia, criatures fantàstiques i un anell misteriós...</b></p> <...",
"cover": "https://m.media-amazon.com/images/I/71D9MZTRzPL.jpg",
"asin": "B0BK4WJMHJ",
"genres": null,
"tags": [
"Literature & Fiction",
"Classics",
"Science Fiction & Fantasy",
"Fantasy",
"Action & Adventure",
"Epic"
],
"series": null,
"language": "Catalan",
"duration": 677,
"region": "us",
"rating": "0.0",
"abridged": false,
"descriptionPlain": "Acompanya en Bilbo Saquet en una aventura plena de màgia, criatures fantàstiques i un anell misteriós... Situada l'any 2..."
}
]
@@ -0,0 +1 @@
[]
@@ -0,0 +1,34 @@
[
{
"title": "The Advocate's Dilemma",
"subtitle": "The Advocate Series, Book 4",
"author": "Teresa Burrell",
"narrator": "Laurel Schroeder",
"publisher": "Teresa Burrell",
"publishedYear": "2014",
"description": "<p>Attorney Sabre Brown's day is going well until she walks into her office and finds a dead man sprawled across her des...",
"cover": "https://m.media-amazon.com/images/I/817+pXebv0L.jpg",
"asin": "B00K23YZJM",
"isbn": "",
"genres": [
"Mystery, Thriller & Suspense"
],
"tags": [
"Mystery",
"Thriller & Suspense",
"Legal"
],
"series": [
{
"series": "The Advocate",
"sequence": "4"
}
],
"language": "English",
"duration": 516,
"region": "us",
"rating": "4.2",
"abridged": false,
"descriptionPlain": "Attorney Sabre Brown's day is going well until she walks into her office and finds a dead man sprawled across her desk. ..."
}
]
@@ -0,0 +1,302 @@
[
{
"title": "The Hobbit",
"subtitle": null,
"author": "J. R. R. Tolkien",
"narrator": "Andy Serkis",
"publisher": "Recorded Books",
"publishedYear": "2020",
"description": "<p>THE GREAT MODERN CLASSIC AND PRELUDE TO THE LORD OF THE RINGS<br /> <br /> Deep down here by the dark water lived old...",
"cover": "https://m.media-amazon.com/images/I/91Q00mYqCFL.jpg",
"asin": "1705009050",
"isbn": "9781705009055",
"genres": [
"Literature & Fiction",
"Science Fiction & Fantasy",
"Teen & Young Adult"
],
"tags": [
"Action & Adventure",
"Classics",
"Genre Fiction",
"Literary Fiction",
"Fantasy"
],
"series": [
{
"series": "The Lord of the Rings",
"sequence": "0.5"
}
],
"language": "English",
"duration": 625,
"region": "us",
"rating": "4.9",
"abridged": false,
"descriptionPlain": "THE GREAT MODERN CLASSIC AND PRELUDE TO THE LORD OF THE RINGS Deep down here by the dark water lived old Gollum, a smal..."
},
{
"title": "The Hobbit",
"subtitle": null,
"author": "J.R.R. Tolkien",
"narrator": "Rob Inglis",
"publisher": "Recorded Books",
"publishedYear": "2012",
"description": "Like every other hobbit, Bilbo Baggins likes nothing better than a quiet evening in his snug hole in the ground, dining ...",
"cover": "https://m.media-amazon.com/images/I/81K05vSpbxL.jpg",
"asin": "B0099RKI5W",
"isbn": "9781470337582",
"genres": [
"Children's Audiobooks",
"Teen & Young Adult"
],
"tags": [
"Literature & Fiction",
"Classics",
"Science Fiction & Fantasy",
"Fantasy & Magic",
"Fantasy",
"Epic"
],
"series": [
{
"series": "The Lord of the Rings",
"sequence": "0.5"
}
],
"language": "English",
"duration": 664,
"region": "us",
"rating": "4.8",
"abridged": false,
"descriptionPlain": "Like every other hobbit, Bilbo Baggins likes nothing better than a quiet evening in his snug hole in the ground, dining ..."
},
{
"title": "The Hobbit",
"subtitle": null,
"author": "J.R.R. Tolkien",
"narrator": "Ensemble Cast",
"publisher": "Highbridge Audio",
"publishedYear": "2007",
"description": "The original American full dramatization as broadcast on National Public Radio. <p>Bilbo Baggins, a gentle hobbit who lo...",
"cover": "https://m.media-amazon.com/images/I/51+Hqnuf-YL.jpg",
"asin": "B002VA9IR8",
"isbn": "9781598874495",
"genres": [
"Literature & Fiction",
"Science Fiction & Fantasy"
],
"tags": [
"Classics",
"Fantasy",
"Epic"
],
"series": null,
"language": "English",
"duration": 253,
"region": "us",
"rating": "4.3",
"abridged": false,
"descriptionPlain": "The original American full dramatization as broadcast on National Public Radio. Bilbo Baggins, a gentle hobbit who loves..."
},
{
"title": "Le Hobbit",
"subtitle": null,
"author": "J. R. R. Tolkien",
"narrator": "Dominique Pinon",
"publisher": "Audiolib",
"publishedYear": "2012",
"description": "<p>Premier récit publié par J.R.R. Tolkien, en 1937, cette histoire, inventée par l’auteur pour ses propres enfants, rac...",
"cover": "https://m.media-amazon.com/images/I/61QhIC8qW1L.jpg",
"asin": "B00U6GS58C",
"isbn": "9782356414847",
"genres": [
"Mystery, Thriller & Suspense",
"Science Fiction & Fantasy"
],
"tags": [
"Thriller & Suspense",
"Fantasy",
"Action & Adventure"
],
"series": null,
"language": "French",
"duration": 614,
"region": "us",
"rating": "4.8",
"abridged": false,
"descriptionPlain": "Premier récit publié par J.R.R. Tolkien, en 1937, cette histoire, inventée par l’auteur pour ses propres enfants, racont..."
},
{
"title": "The Hobbit",
"subtitle": null,
"author": "J. R. R. Tolkien",
"narrator": "Martin Shaw",
"publisher": "HarperCollins",
"publishedYear": "2005",
"description": "<p>Bilbo Baggins is a hobbit who enjoys a comfortable and quiet life. His contentment is disturbed one day when the wiza...",
"cover": "https://m.media-amazon.com/images/I/61giqd1Hw2L.jpg",
"asin": "B0036GMKSK",
"isbn": "9780007227761",
"genres": [
"Literature & Fiction",
"Science Fiction & Fantasy"
],
"tags": [
"Historical Fiction",
"Fantasy",
"Action & Adventure",
"Epic"
],
"series": null,
"language": "English",
"duration": 357,
"region": "us",
"rating": "4.6",
"abridged": true,
"descriptionPlain": "Bilbo Baggins is a hobbit who enjoys a comfortable and quiet life. His contentment is disturbed one day when the wizard,..."
},
{
"title": "Der Hobbit",
"subtitle": "Der Herr der Ringe 0.5",
"author": "J. R. R. Tolkien",
"narrator": "Gert Heidenreich",
"publisher": "Der Hörverlag",
"publishedYear": "2010",
"description": "Bilbo Beutlin, der kleine Hobbit, macht sich auf den Weg zum Einsamen Berg, um den rechtmäßigen Schatz der Zwerge zurück...",
"cover": "https://m.media-amazon.com/images/I/91m09wFq9PL.jpg",
"asin": "B00TJF0K5W",
"isbn": "9783844507157",
"genres": [
"Literature & Fiction",
"Science Fiction & Fantasy"
],
"tags": [
"Genre Fiction",
"Movie, TV & Video Game Tie-Ins",
"Fantasy",
"Action & Adventure",
"Dragons & Mythical Creatures"
],
"series": null,
"language": "German",
"duration": 653,
"region": "us",
"rating": "4.8",
"abridged": false,
"descriptionPlain": "Bilbo Beutlin, der kleine Hobbit, macht sich auf den Weg zum Einsamen Berg, um den rechtmäßigen Schatz der Zwerge zurück..."
},
{
"title": "El Hobbit",
"subtitle": null,
"author": "J. R. R. Tolkien",
"narrator": "Javier Gutiérrez",
"publisher": "Audible Studios y Planeta Audio",
"publishedYear": "2023",
"description": "<p>La extraordinaria precuela de El Señor de los Anillos.</p> <p>Smaug parecía profundamente dormido cuando Bilbo espió ...",
"cover": "https://m.media-amazon.com/images/I/91snBF2IakL.jpg",
"asin": "B0C8Z11P9T",
"isbn": "9788445016282",
"genres": [
"Science Fiction & Fantasy"
],
"tags": [
"Fantasy"
],
"series": [
{
"series": "El Señor de los Anillos",
"sequence": "0.5"
}
],
"language": "Spanish",
"duration": 640,
"region": "us",
"rating": "4.8",
"abridged": false,
"descriptionPlain": "La extraordinaria precuela de El Señor de los Anillos. Smaug parecía profundamente dormido cuando Bilbo espió una vez má..."
},
{
"title": "A Hobbit",
"subtitle": null,
"author": "J. R. R. Tolkien",
"narrator": "Kaszás Attila",
"publisher": "Mojzer Publishing House",
"publishedYear": "2019",
"description": "<p>A Hobbit, mint Tolkien sok más írása, gyermekeinek szóló történetként indult. Először 1937-ben jelent meg és ma méltá...",
"cover": "https://m.media-amazon.com/images/I/91t3yGvAcNL.jpg",
"asin": "9630999633",
"isbn": "9789630999632",
"genres": [
"Science Fiction & Fantasy"
],
"tags": [
"Fantasy",
"Epic"
],
"series": null,
"language": "Hungarian",
"duration": 742,
"region": "us",
"rating": "4.8",
"abridged": false,
"descriptionPlain": "A Hobbit, mint Tolkien sok más írása, gyermekeinek szóló történetként indult. Először 1937-ben jelent meg és ma méltán v..."
},
{
"title": "The Hobbit (Dramatised)",
"subtitle": "The Classic BBC Radio Production",
"author": "J.R.R. Tolkien",
"narrator": "Heron Carvic, Paul Daneman, Anthony Jackson, Francis de Wolff, John Justin, John Pullen, Full cast",
"publisher": "BBC Digital Audio",
"publishedYear": "2007",
"description": "Dwarves, elves, goblins, trolls, dragons and wizards - these are the ingredients of J.R.R. Tolkien's wonderful fantasy, ...",
"cover": "https://m.media-amazon.com/images/I/81wq-zTbNNL.jpg",
"asin": "B002VA91WK",
"isbn": "9781405629423",
"genres": [
"Children's Audiobooks",
"Science Fiction & Fantasy"
],
"tags": [
"Science Fiction & Fantasy",
"Fantasy & Magic",
"Fantasy",
"Science Fiction"
],
"series": null,
"language": "English",
"duration": 222,
"region": "us",
"rating": "4.0",
"abridged": false,
"descriptionPlain": "Dwarves, elves, goblins, trolls, dragons and wizards - these are the ingredients of J.R.R. Tolkien's wonderful fantasy, ..."
},
{
"title": "The Hobbit",
"subtitle": "The BBC TV soundtrack of the Jackanory multi-voice reading",
"author": "J. R. R. Tolkien",
"narrator": "Bernard Cribbins, Maurice Denham, Jan Francis, David Wood",
"publisher": "BBC Digital Audio",
"publishedYear": "2013",
"description": "<p>The BBC TV soundtrack of the acclaimed Jackanory multi-voice reading of <i>The Hobbit</i>. Broadcast to mark the 3000...",
"cover": "https://m.media-amazon.com/images/I/91QjFToijwL.jpg",
"asin": "B00FH73ZA8",
"isbn": "9781471358388",
"genres": [
"Arts & Entertainment",
"Science Fiction & Fantasy"
],
"tags": [
"Entertainment & Performing Arts",
"Radio",
"Fantasy"
],
"series": null,
"language": "English",
"duration": 147,
"region": "us",
"rating": "4.4",
"abridged": true,
"descriptionPlain": "The BBC TV soundtrack of the acclaimed Jackanory multi-voice reading of The Hobbit. Broadcast to mark the 3000th program..."
}
]
@@ -0,0 +1,39 @@
[
{
"title": "The Mummy or Ramses the Damned",
"subtitle": "A Novel",
"author": "Anne Rice",
"narrator": "Michael York",
"publisher": "Random House Audio",
"publishedYear": "2000",
"description": "He was Ramses the Damned in ancient Egypt, but awoke in opulent Edwardian London as Dr. Ramsey, expert in Egyptology. He...",
"cover": "https://m.media-amazon.com/images/I/9194bYyVy9L.jpg",
"asin": "B002UUKMQA",
"isbn": "9780375418259",
"genres": [
"Literature & Fiction",
"Science Fiction & Fantasy"
],
"tags": [
"Genre Fiction",
"Psychological",
"Sagas",
"Horror",
"Fantasy",
"Paranormal & Urban",
"Paranormal"
],
"series": [
{
"series": "Ramses the Damned (abridged)",
"sequence": "1"
}
],
"language": "English",
"duration": 177,
"region": "us",
"rating": "3.9",
"abridged": true,
"descriptionPlain": "He was Ramses the Damned in ancient Egypt, but awoke in opulent Edwardian London as Dr. Ramsey, expert in Egyptology. He..."
}
]
@@ -0,0 +1 @@
[]
File diff suppressed because it is too large. Load diff
+432
View File
@@ -0,0 +1,432 @@
"""Tests for the audiobook existence check (does Audible sell this audiobook at all?).
The store answers are real ``/api/search/books`` responses from Audiobookshelf, recorded
in ``fixtures/audible_search`` (descriptions trimmed): a book the US store sells, a book
neither store sells, the canary on both stores, and an alternative-title listing.
"""
from __future__ import annotations
import json
from datetime import UTC, datetime, timedelta
from pathlib import Path
import pytest
import requests
from shelfmark.core import auto_download, existence_check
from shelfmark.core.existence_match import find_match, search_title, titles_match
from shelfmark.core.requests_service import reject_request
from shelfmark.core.user_db import UserDB
from tests.core.fakes import FakeConfig
FIXTURES = Path(__file__).parent / "fixtures" / "audible_search"
NOW = datetime(2026, 10, 11, 18, 0, tzinfo=UTC)
def _recorded(name: str) -> list[dict]:
return json.loads((FIXTURES / f"{name}.json").read_text(encoding="utf-8"))
# (query sent, provider) -> recorded answer. Anything else is an empty answer, which is
# also what Audiobookshelf gives for a title the store does not know.
RECORDED = {
("The Advocate's Dilemma", "audible"): "us_advocates_dilemma",
("Young Zaphod Plays It Safe", "audible"): "us_young_zaphod",
("Young Zaphod Plays It Safe", "audible.uk"): "uk_young_zaphod",
("The Hobbit", "audible"): "us_hobbit",
("The Hobbit", "audible.uk"): "uk_hobbit",
("The Mummy", "audible"): "us_the_mummy",
}
class FakeStore:
"""Stands in for ``audiobookshelf.search_books``, answering from the recordings."""
def __init__(self, *, fail_on: str | None = None, canary_lost: bool = False) -> None:
self.calls: list[tuple[str, str, str]] = []
self.fail_on = fail_on
self.canary_lost = canary_lost
def __call__(self, title, author, provider, *, timeout=None, overrides=None):
self.calls.append((title, author, provider))
if self.fail_on == provider:
raise requests.ConnectionError("Audiobookshelf did not answer")
if self.canary_lost and title == existence_check.CANARY[0]:
return []
name = RECORDED.get((title, provider))
return _recorded(name) if name else []
@pytest.fixture
def store(monkeypatch):
fake = FakeStore()
monkeypatch.setattr(existence_check.audiobookshelf, "search_books", fake)
return fake
@pytest.fixture
def settings(monkeypatch):
fake = FakeConfig(
AUTO_DOWNLOAD_EXISTENCE_CHECK_ENABLED=True,
AUTO_DOWNLOAD_EXISTENCE_CHECK_AFTER_PASSES=3,
)
monkeypatch.setattr(existence_check, "app_config", fake)
return fake
@pytest.fixture
def user_db(tmp_path):
db = UserDB(str(tmp_path / "users.db"))
db.initialize()
return db
@pytest.fixture
def admin(user_db):
return user_db.create_user(username="admin", role="admin")
def _request(user_db, admin, title, author, content_type="audiobook"):
return user_db.create_request(
user_id=admin["id"],
content_type=content_type,
request_level="book",
policy_mode="request_book",
book_data={
"title": title,
"author": author,
"provider": "hardcover",
"provider_id": "1",
"content_type": content_type,
},
)
def _pass(user_db, admin, request, status="no_match", *, now=NOW, content_type="audiobook"):
row = user_db.get_request(int(request["id"]))
outcome = auto_download.AutoDownloadOutcome(int(request["id"]), status)
return existence_check.after_request(
user_db,
row,
outcome,
admin_user_id=admin["id"],
content_type=content_type,
now=now,
)
def _state(user_db, request):
book_data = user_db.get_request(int(request["id"]))["book_data"]
return book_data.get(existence_check.BOOK_DATA_KEY)
class TestMatching:
@pytest.mark.parametrize(
("raw", "author", "expected"),
[
("The Frood: The Authorised and Very Official History", "Jem Roberts", "The Frood"),
("The Hobbit, or There and Back Again", "Tolkien", "The Hobbit"),
("Running Blind aka The Visitor", "Lee Child", "Running Blind"),
("Born a Crime- Stories from a South African Childhood", "", "Born a Crime"),
("The Armour of Light (Unabridged)", "", "The Armour of Light"),
("[Pillars of the Earth 02] - World Without End", "", "World Without End"),
("Robert Langdon, Book 4 - Inferno", "Dan Brown", "Inferno"),
(
"Anne Rice - Mayfair Witches 01 - The Witching Hour",
"Anne Rice",
"The Witching Hour",
),
(
"HGTTG [01] The Hitchhiker's Guide to the Galaxy",
"",
"The Hitchhiker's Guide to the Galaxy",
),
("And Another Thing... (Unabridged)", "", "And Another Thing"),
("Fahrenheit 451 - A Novel", "", "Fahrenheit 451"),
("Catch-22", "", "Catch-22"),
],
)
def test_search_title(self, raw, author, expected):
assert search_title(raw, author) == expected
@pytest.mark.parametrize(
("wanted", "candidate"),
[
("The Book of Lost Tales, Part II", "The Book of Lost Tales, Part Two"),
("The Book of Lost Tales, Part II", "The Book of Lost Tales: Part 2"),
("The Fall of Númenor", "The Fall of Numenor"),
("Morgoth’s Ring", "Morgoth's Ring"),
("Merry Christmas", "Merry Christmas, Alex Cross"),
("Start With Why", "Start with Why 15th Anniversary Edition"),
("The Reign of Osiris", "Ramses the Damned: The Reign of Osiris"),
("The Mummy, or Ramses the Damned", "The Mummy or Ramses the Damned"),
("The Armour of Light", "The Armor of Light"),
],
)
def test_same_book(self, wanted, candidate):
assert titles_match(wanted, candidate)
@pytest.mark.parametrize(
("wanted", "candidate"),
[
("Cross", "Cross Fire"), # one word never matches a longer title
("The Lays of Beleriand", "The Lost Road"),
("Sauron Defeated", "Sauron"),
],
)
def test_different_book(self, wanted, candidate):
assert not titles_match(wanted, candidate)
def test_author_is_any_surname_in_the_field(self):
results = [{"title": "The Martian", "author": "Andy Weir", "asin": "B082BHJMFF"}]
assert find_match("The Martian", "R.C. Bray, Andy Weir", results) is not None
assert find_match("The Martian", "Kristin Hannah/Polly Stone", results) is None
def test_result_without_asin_is_skipped(self):
results = [{"title": "Blood Canticle", "author": "Anne Rice", "asin": ""}]
assert find_match("Blood Canticle", "Anne Rice", results) is None
class TestLookUp:
def test_found_on_the_us_store(self, store):
result = existence_check.look_up("The Advocate's Dilemma", "Teresa Burrell")
assert result.status == "found"
assert result.store == "US"
assert result.match.asin == "B00K23YZJM"
assert [call[2] for call in store.calls] == ["audible"]
def test_found_under_its_alternative_title(self, store):
result = existence_check.look_up("The Mummy, or Ramses the Damned", "Anne Rice")
assert result.status == "found"
assert result.match.asin == "B002UUKMQA"
def test_not_found_on_either_store(self, store):
result = existence_check.look_up("Young Zaphod Plays It Safe", "Douglas Adams")
assert result.status == "not_found"
asked = [(title, provider) for title, _author, provider in store.calls]
assert asked == [
("Young Zaphod Plays It Safe", "audible"),
("Young Zaphod Plays It Safe", "audible.uk"),
("The Hobbit", "audible"),
("The Hobbit", "audible.uk"),
]
def test_store_that_lost_the_canary_too_decides_nothing(self, monkeypatch):
monkeypatch.setattr(
existence_check.audiobookshelf, "search_books", FakeStore(canary_lost=True)
)
result = existence_check.look_up("Young Zaphod Plays It Safe", "Douglas Adams")
assert result.status == "unknown"
@pytest.mark.parametrize("failing", ["audible", "audible.uk"])
def test_error_is_unknown_not_missing(self, monkeypatch, failing):
monkeypatch.setattr(
existence_check.audiobookshelf, "search_books", FakeStore(fail_on=failing)
)
result = existence_check.look_up("Young Zaphod Plays It Safe", "Douglas Adams")
assert result.status == "unknown"
assert "did not answer" in result.detail
def test_us_store_is_asked_in_us_spelling(self, store):
existence_check.look_up("The Armour of Light", "Ken Follett")
asked = [(title, provider) for title, _author, provider in store.calls[:2]]
assert asked == [("The Armor of Light", "audible"), ("The Armour of Light", "audible.uk")]
def test_no_budget_left_asks_nothing(self, store):
result = existence_check.look_up(
"Young Zaphod Plays It Safe", "Douglas Adams", budget_seconds=1
)
assert result.status == "unknown"
assert store.calls == []
class TestAfterRequest:
def test_setting_off_counts_nothing(self, user_db, admin, store, settings):
settings.values["AUTO_DOWNLOAD_EXISTENCE_CHECK_ENABLED"] = False
request = _request(user_db, admin, "Young Zaphod Plays It Safe", "Douglas Adams")
for _ in range(5):
assert _pass(user_db, admin, request) is None
assert _state(user_db, request) is None
assert user_db.get_request(int(request["id"]))["status"] == "pending"
assert store.calls == []
def test_ebook_requests_are_not_checked(self, user_db, admin, store, settings):
request = _request(user_db, admin, "Young Zaphod Plays It Safe", "Douglas Adams", "ebook")
for _ in range(5):
assert _pass(user_db, admin, request, content_type="ebook") is None
assert _state(user_db, request) is None
assert store.calls == []
def test_not_listed_is_marked_not_declined(self, user_db, admin, store, settings):
request = _request(user_db, admin, "Young Zaphod Plays It Safe", "Douglas Adams")
assert _pass(user_db, admin, request) == "counted"
assert _pass(user_db, admin, request) == "counted"
assert store.calls == []
assert _pass(user_db, admin, request) == "not_listed"
row = user_db.get_request(int(request["id"]))
assert row["status"] == "pending"
assert row["last_failure_reason"] == (
"Not found on Audible (US/UK) after 3 searches, 2026-10-11"
)
assert row["reviewed_by"] is None
assert _state(user_db, request)["result"] == "not_found"
# Not asked again inside the week.
assert _pass(user_db, admin, request) == "waiting"
def test_listed_stays_pending_and_says_why(self, user_db, admin, store, settings):
settings.values["AUTO_DOWNLOAD_EXISTENCE_CHECK_AFTER_PASSES"] = 1
request = _request(user_db, admin, "The Advocate's Dilemma", "Teresa Burrell")
assert _pass(user_db, admin, request) == "listed"
row = user_db.get_request(int(request["id"]))
assert row["status"] == "pending"
assert row["last_failure_reason"] == (
"Exists on Audible (B00K23YZJM), not on our sources yet"
)
state = _state(user_db, request)
assert state["result"] == "found"
assert state["asin"] == "B00K23YZJM"
def test_listed_book_is_asked_again_after_a_week(self, user_db, admin, store, settings):
settings.values["AUTO_DOWNLOAD_EXISTENCE_CHECK_AFTER_PASSES"] = 1
request = _request(user_db, admin, "The Advocate's Dilemma", "Teresa Burrell")
assert _pass(user_db, admin, request) == "listed"
asked = len(store.calls)
assert _pass(user_db, admin, request, now=NOW + timedelta(days=1)) == "waiting"
assert _pass(user_db, admin, request, now=NOW + timedelta(days=6)) == "waiting"
assert len(store.calls) == asked
assert _pass(user_db, admin, request, now=NOW + timedelta(days=7)) == "listed"
assert len(store.calls) == asked + 1
assert _state(user_db, request)["checked_at"] == (NOW + timedelta(days=7)).isoformat(
timespec="seconds"
)
def test_lookup_error_changes_nothing_and_asks_again_next_pass(
self, user_db, admin, settings, monkeypatch
):
settings.values["AUTO_DOWNLOAD_EXISTENCE_CHECK_AFTER_PASSES"] = 1
failing = FakeStore(fail_on="audible")
monkeypatch.setattr(existence_check.audiobookshelf, "search_books", failing)
request = _request(user_db, admin, "Young Zaphod Plays It Safe", "Douglas Adams")
assert _pass(user_db, admin, request) == "unknown"
assert _pass(user_db, admin, request) == "unknown"
row = user_db.get_request(int(request["id"]))
assert row["status"] == "pending"
assert row["last_failure_reason"] is None
assert len(failing.calls) == 2
def test_a_match_resets_the_count(self, user_db, admin, store, settings):
request = _request(user_db, admin, "Young Zaphod Plays It Safe", "Douglas Adams")
_pass(user_db, admin, request)
_pass(user_db, admin, request)
assert _state(user_db, request)["no_match_passes"] == 2
assert _pass(user_db, admin, request, status="queued") is None
assert _state(user_db, request) is None
def test_a_search_error_neither_counts_nor_resets(self, user_db, admin, store, settings):
request = _request(user_db, admin, "Young Zaphod Plays It Safe", "Douglas Adams")
_pass(user_db, admin, request)
assert _pass(user_db, admin, request, status="error") is None
assert _state(user_db, request)["no_match_passes"] == 1
def test_any_rejection_drops_the_count_so_a_reopen_starts_over(
self, user_db, admin, store, settings
):
request = _request(user_db, admin, "Young Zaphod Plays It Safe", "Douglas Adams")
_pass(user_db, admin, request)
_pass(user_db, admin, request)
reject_request(user_db, request_id=int(request["id"]), admin_user_id=admin["id"])
assert _state(user_db, request) is None
# What the admin reopen does to the row: back to pending, review undone.
conn = user_db._connect()
try:
conn.execute(
"UPDATE download_requests SET status = 'pending', reviewed_by = NULL, "
"reviewed_at = NULL WHERE id = ?",
(int(request["id"]),),
)
conn.commit()
finally:
conn.close()
assert _pass(user_db, admin, request) == "counted"
assert _state(user_db, request)["no_match_passes"] == 1
def test_pass_works_from_the_stored_row_not_the_stale_one(
self, user_db, admin, store, settings
):
request = _request(user_db, admin, "Young Zaphod Plays It Safe", "Douglas Adams")
stale = user_db.get_request(int(request["id"]))
# The pass recorded a failed release after the row was read.
book_data = dict(stale["book_data"], failed_releases=[{"source": "s", "source_id": "1"}])
user_db.update_request(int(request["id"]), book_data=book_data)
existence_check.after_request(
user_db,
stale,
auto_download.AutoDownloadOutcome(int(request["id"]), "no_match"),
admin_user_id=admin["id"],
content_type="audiobook",
now=NOW,
)
stored = user_db.get_request(int(request["id"]))["book_data"]
assert stored["failed_releases"] == [{"source": "s", "source_id": "1"}]
assert stored[existence_check.BOOK_DATA_KEY]["no_match_passes"] == 1
def test_the_pass_runs_the_check_and_counts_not_listed(user_db, admin, store, monkeypatch):
monkeypatch.setattr(
auto_download,
"app_config",
FakeConfig(AUTO_DOWNLOAD_ENABLED=True),
)
monkeypatch.setattr(
existence_check,
"app_config",
FakeConfig(
AUTO_DOWNLOAD_EXISTENCE_CHECK_ENABLED=True,
AUTO_DOWNLOAD_EXISTENCE_CHECK_AFTER_PASSES=1,
),
)
monkeypatch.setattr(auto_download, "_configured_source_priority", lambda _t: ["src"])
monkeypatch.setattr(
auto_download,
"auto_download_request",
lambda _db, row, **_kw: auto_download.AutoDownloadOutcome(int(row["id"]), "no_match"),
)
request = _request(user_db, admin, "Young Zaphod Plays It Safe", "Douglas Adams")
summary = auto_download.auto_download_pending(
user_db, queue_release=lambda *_a, **_kw: (True, None), admin_user_id=admin["id"]
)
assert summary["no_match"] == 1
assert summary["not_listed"] == 1
assert user_db.get_request(int(request["id"]))["status"] == "pending"
+76
View File
@@ -167,3 +167,79 @@ class TestTriggerAsync:
assert scheduler.trigger_async(force=force) is True
assert finished.wait(timeout=5)
assert seen == [{"force": force}]
class _Clock:
"""A clock the scheduler's sleeps move, so a wait that is hours long runs instantly."""
def __init__(self) -> None:
self.now = 0.0
self.sleeps: list[float] = []
def sleep(self, seconds: float) -> None:
self.sleeps.append(seconds)
self.now += seconds
def monotonic(self) -> float:
return self.now
def _run_wait(monkeypatch, *, interval, quick, retries):
clock = _Clock()
monkeypatch.setattr(scheduler.time, "sleep", clock.sleep)
monkeypatch.setattr(scheduler.time, "monotonic", clock.monotonic)
monkeypatch.setattr(scheduler, "_interval_seconds", lambda: interval)
monkeypatch.setattr(scheduler, "_quick_retry_interval_seconds", lambda: quick)
monkeypatch.setattr(scheduler, "run_quick_retry", lambda: retries.append(clock.now))
scheduler._wait_for_next_cycle()
return clock
def test_the_wait_between_cycles_is_one_sleep_when_the_quick_retry_is_off(monkeypatch):
retries: list[float] = []
clock = _run_wait(monkeypatch, interval=3600.0, quick=0.0, retries=retries)
assert clock.sleeps == [3600.0]
assert retries == []
def test_failed_downloads_are_looked_at_between_cycles_when_it_is_on(monkeypatch):
retries: list[float] = []
clock = _run_wait(monkeypatch, interval=3600.0, quick=900.0, retries=retries)
assert clock.now == 3600.0
assert retries == [900.0, 1800.0, 2700.0] # not again at the cycle itself
def test_a_failing_quick_retry_does_not_stop_the_wait(monkeypatch):
clock = _Clock()
monkeypatch.setattr(scheduler.time, "sleep", clock.sleep)
monkeypatch.setattr(scheduler.time, "monotonic", clock.monotonic)
monkeypatch.setattr(scheduler, "_interval_seconds", lambda: 3600.0)
monkeypatch.setattr(scheduler, "_quick_retry_interval_seconds", lambda: 1800.0)
def boom():
raise RuntimeError("search down")
monkeypatch.setattr(scheduler, "run_quick_retry", boom)
scheduler._wait_for_next_cycle()
assert clock.now == 3600.0
def test_the_quick_retry_interval(monkeypatch):
monkeypatch.setattr(scheduler, "app_config", _Config(AUTO_DOWNLOAD_FAILURE_RETRY_MINUTES=20))
assert scheduler._quick_retry_interval_seconds() == 1200.0
monkeypatch.setattr(scheduler, "app_config", _Config())
assert scheduler._quick_retry_interval_seconds() == 0.0
def test_the_quick_retry_skips_while_a_cycle_holds_the_lock():
scheduler._run_lock.acquire()
try:
assert scheduler.run_quick_retry() is None
finally:
scheduler._run_lock.release()
@@ -159,6 +159,88 @@ def test_a_plain_shelf_title_is_unchanged_by_the_series_handling() -> None:
assert library_index.match_entries(_searching("Kiss the Girls"), [entry]) == "owned"
@pytest.mark.parametrize(
"shelf_title",
[
"Dungeon Crawler Carl (Full Cast Edition)",
"Dungeon Crawler Carl (Audio Immersion Tunnel)",
"Dungeon Crawler Carl (GraphicAudio)",
"Dungeon Crawler Carl [Dramatized Adaptation]",
"Dungeon Crawler Carl (Part 1 of 3) (Dramatized Adaptation)",
],
)
def test_an_alternate_edition_on_the_shelf_is_not_the_book(shelf_title: str) -> None:
# A full-cast or dramatized recording shares the title and author of the
# single-narrator original, but owning one is not owning the other. The bracketed
# words stay in the title set, where the matcher reads them as naming another
# edition.
entry = audiobookshelf._item_to_entry(_item(shelf_title, "Matt Dinniman"))
assert (
library_index.match_entries(_searching("Dungeon Crawler Carl", "Matt Dinniman"), [entry])
is None
)
@pytest.mark.parametrize(
("shelf_title", "searching"),
[
("Dungeon Crawler Carl (Unabridged)", "Dungeon Crawler Carl"),
("Dungeon Crawler Carl (Abridged)", "Dungeon Crawler Carl"),
("Dungeon Crawler Carl (Illustrated Edition)", "Dungeon Crawler Carl"),
("Dungeon Crawler Carl (Full Cast Edition)", "Dungeon Crawler Carl (Full Cast Edition)"),
],
)
def test_a_printing_word_or_the_same_edition_marker_is_still_the_book(
shelf_title: str, searching: str
) -> None:
# "(Unabridged)" is shelf noise, not an edition; and the edition asked for by its
# own name is the one on the shelf.
entry = audiobookshelf._item_to_entry(_item(shelf_title, "Matt Dinniman"))
assert library_index.match_entries(_searching(searching, "Matt Dinniman"), [entry]) == "owned"
def test_a_marker_on_the_searched_title_alone_is_not_on_the_shelf() -> None:
entry = audiobookshelf._item_to_entry(_item("Dungeon Crawler Carl", "Matt Dinniman"))
assert (
library_index.match_entries(
_searching("Dungeon Crawler Carl (GraphicAudio)", "Matt Dinniman"), [entry]
)
is None
)
class _Response:
def __init__(self, payload: dict[str, Any]) -> None:
self.payload = payload
def raise_for_status(self) -> None:
return None
def json(self) -> dict[str, Any]:
return self.payload
class _Session:
def __init__(self, libraries: list[dict[str, Any]], pages: dict[int, dict[str, Any]]) -> None:
self.libraries = libraries
self.pages = pages
self.calls: list[tuple[str, dict[str, Any]]] = []
def get(self, url: str, params: dict[str, Any] | None = None, timeout: int = 0) -> _Response:
self.calls.append((url, dict(params or {})))
if url.endswith("/api/libraries"):
return _Response({"libraries": self.libraries})
return _Response(self.pages[int(self.calls[-1][1]["page"])])
def _install(monkeypatch: pytest.MonkeyPatch, session: _Session) -> None:
monkeypatch.setattr(audiobookshelf, "_session", lambda url, token: session)
monkeypatch.setattr(audiobookshelf, "_ITEMS_PAGE_LIMIT", 2)
def test_fetch_pages_through_book_libraries(monkeypatch: pytest.MonkeyPatch) -> None:
session = _Session(
libraries=[{"id": "books", "mediaType": "book"}, {"id": "pods", "mediaType": "podcast"}],
+140 -1
View File
@@ -5,7 +5,7 @@ from __future__ import annotations
import os
import sqlite3
from pathlib import Path
from typing import Any
from typing import TYPE_CHECKING, Any
import pytest
@@ -13,6 +13,9 @@ from shelfmark.core import library_index
from shelfmark.core.library_providers import calibre
from shelfmark.metadata_providers import BookMetadata
if TYPE_CHECKING:
from collections.abc import Callable
_SCHEMA = """
CREATE TABLE books (id INTEGER PRIMARY KEY, title TEXT NOT NULL, uuid TEXT);
CREATE TABLE authors (id INTEGER PRIMARY KEY, name TEXT NOT NULL);
@@ -74,6 +77,19 @@ def _configure(monkeypatch: pytest.MonkeyPatch, path: Path, *, enabled: bool = T
monkeypatch.setattr(library_index, "_cache", {})
@pytest.fixture
def calibre_library(monkeypatch: pytest.MonkeyPatch) -> Callable[..., Path]:
"""Point the provider at a library, building it first unless ``create=False``."""
def make(*, path: Path, create: bool = True, enabled: bool = True) -> Path:
if create:
_make_library(path)
_configure(monkeypatch, path, enabled=enabled)
return path
return make
def _infos(monkeypatch: pytest.MonkeyPatch) -> list[str]:
messages: list[str] = []
monkeypatch.setattr(calibre.logger, "info", lambda msg, *args: messages.append(msg % args))
@@ -317,3 +333,126 @@ def test_fingerprint_follows_the_database_and_wal_mtime(
os.utime(wal, (later, later))
assert provider.fingerprint() == later
def _library_with(path: Path, books: list[tuple[int, str, str, str | None]]) -> None:
"""A library of (id, title, author, series) rows."""
conn = sqlite3.connect(path)
conn.executescript(_SCHEMA)
series_ids: dict[str, int] = {}
for book_id, title, author, series_name in books:
conn.execute(
"INSERT INTO books(id, title, uuid) VALUES (?, ?, ?)", (book_id, title, f"u{book_id}")
)
conn.execute("INSERT INTO authors(id, name) VALUES (?, ?)", (book_id, author))
conn.execute(
"INSERT INTO books_authors_link(book, author) VALUES (?, ?)", (book_id, book_id)
)
if series_name:
sid = series_ids.setdefault(series_name, len(series_ids) + 1)
conn.execute("INSERT OR IGNORE INTO series(id, name) VALUES (?, ?)", (sid, series_name))
conn.execute(
"INSERT INTO books_series_link(book, series) VALUES (?, ?)", (book_id, sid)
)
conn.commit()
conn.close()
def _owned(entries: list[Any], title: str, author: str) -> str | None:
book = BookMetadata(provider="hardcover", provider_id="x", title=title, authors=[author])
return library_index.match_entries(book, entries)
def test_an_abbreviated_series_label_in_the_title_does_not_hide_an_owned_book(
tmp_path: Path, calibre_library: Callable[..., Path]
) -> None:
# The library names books "Label NN: Title". When the label abbreviates the series
# ("HHG" for The Hitchhiker's Guide to the Galaxy) its words are in neither the
# series nor the author, and the book read as not owned, so it was fetched again.
path = tmp_path / "metadata.db"
_library_with(
path,
[
(1, "HHG 5: Mostly Harmless", "Douglas Adams", "The Hitchhiker's Guide to the Galaxy"),
(2, "DCC 7: This Inevitable Ruin", "Matt Dinniman", "Dungeon Crawler Carl"),
],
)
calibre_library(path=path, create=False)
entries = calibre.CalibreLibrary().fetch_entries()
assert _owned(entries, "Mostly Harmless", "Douglas Adams") == "owned"
assert _owned(entries, "This Inevitable Ruin", "Matt Dinniman") == "owned"
def test_a_series_label_still_does_not_make_a_different_book_match(
tmp_path: Path, calibre_library: Callable[..., Path]
) -> None:
path = tmp_path / "metadata.db"
_library_with(
path,
[(1, "HHG 5: Mostly Harmless", "Douglas Adams", "The Hitchhiker's Guide to the Galaxy")],
)
calibre_library(path=path, create=False)
entries = calibre.CalibreLibrary().fetch_entries()
assert _owned(entries, "Harmless", "Douglas Adams") is None
assert _owned(entries, "Mostly Harmless", "Someone Else") is None
def test_a_title_prefix_without_a_series_is_left_alone(
tmp_path: Path, calibre_library: Callable[..., Path]
) -> None:
# Only a book that has a series gets its leading "Label NN:" treated as the label.
path = tmp_path / "metadata.db"
_library_with(path, [(1, "Messiah 2: Dune", "Frank Herbert", None)])
calibre_library(path=path, create=False)
entries = calibre.CalibreLibrary().fetch_entries()
assert _owned(entries, "Dune", "Frank Herbert") is None
@pytest.mark.parametrize(
"shelf_title",
[
"Dungeon Crawler Carl (Full Cast Edition)",
"Dungeon Crawler Carl (Dramatized Adaptation)",
"Dungeon Crawler Carl [GraphicAudio]",
],
)
def test_an_edition_marker_in_the_title_is_a_different_edition(
tmp_path: Path, calibre_library: Callable[..., Path], shelf_title: str
) -> None:
# A bracketed marker with no number in it describes the edition. Owning a full-cast
# or dramatized recording is not owning the book it adapts, so the request must
# not be closed as already in the library.
path = tmp_path / "metadata.db"
_library_with(path, [(1, shelf_title, "Matt Dinniman", None)])
calibre_library(path=path, create=False)
entries = calibre.CalibreLibrary().fetch_entries()
assert _owned(entries, "Dungeon Crawler Carl", "Matt Dinniman") is None
# The same edition, asked for by its own name, is on the shelf.
assert _owned(entries, shelf_title, "Matt Dinniman") == "owned"
@pytest.mark.parametrize(
("shelf_title", "series"),
[
("Kiss the Girls (Alex Cross Series #11)", None),
("Kiss the Girls (Alex Cross, Book 2)", None),
("Alex Cross 02: Kiss the Girls", "Alex Cross"),
("Kiss the Girls (Illustrated Edition)", None),
("Kiss the Girls (Unabridged)", None),
],
)
def test_series_metadata_and_printing_words_in_the_title_do_not_hide_the_book(
tmp_path: Path, calibre_library: Callable[..., Path], shelf_title: str, series: str | None
) -> None:
# A numbered parenthetical is the series by Calibre convention, often in place of
# the series field, and a printing word changes nothing about which work it is.
path = tmp_path / "metadata.db"
_library_with(path, [(1, shelf_title, "James Patterson", series)])
calibre_library(path=path, create=False)
entries = calibre.CalibreLibrary().fetch_entries()
assert _owned(entries, "Kiss the Girls", "James Patterson") == "owned"