diff --git a/.github/workflows/build-and-publish-docker-image.yml b/.github/workflows/build-and-publish-docker-image.yml
index 9c43aee1..e57eb4ad 100644
--- a/.github/workflows/build-and-publish-docker-image.yml
+++ b/.github/workflows/build-and-publish-docker-image.yml
@@ -113,70 +113,3 @@ jobs:
subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}${{ matrix.image_name_suffix }}
subject-digest: ${{ steps.push.outputs.digest }}
push-to-registry: true
-
- # Create aliases for backwards compatibility
- create-aliases:
- needs: build-and-push-images
- runs-on: ubuntu-latest
- if: github.event_name != 'pull_request'
- permissions:
- contents: read
- packages: write
- env:
- # Legacy name for backwards compatibility (hardcoded so it works after rename)
- LEGACY_NAME: calibre-web-automated-book-downloader
- steps:
- - name: Log in to registry
- uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
- with:
- registry: ${{ env.REGISTRY }}
- username: ${{ github.actor }}
- password: ${{ secrets.GITHUB_TOKEN }}
-
- - name: Set up Docker Buildx
- uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
-
- - name: Create legacy aliases
- run: |
- # Current image names (follows repo name)
- STANDARD="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}"
- LITE="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-lite"
-
- # Legacy image names (hardcoded for backwards compatibility)
- LEGACY="${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ env.LEGACY_NAME }}"
- LEGACY_TOR="${LEGACY}-tor"
- LEGACY_EXTBP="${LEGACY}-extbp"
-
- SHA_SHORT=$(echo "${{ github.sha }}" | cut -c1-7)
-
- # Helper function to create alias with all standard tags
- create_alias() {
- local SOURCE=$1
- local ALIAS=$2
-
- # Always create SHA tag
- docker buildx imagetools create -t "${ALIAS}:sha-${SHA_SHORT}" "${SOURCE}:sha-${SHA_SHORT}"
-
- if [[ "${{ github.ref }}" == refs/tags/v* ]]; then
- VERSION="${{ github.ref_name }}"
- VERSION_NUM="${VERSION#v}"
- MINOR="${VERSION_NUM%.*}"
-
- docker buildx imagetools create -t "${ALIAS}:latest" "${SOURCE}:latest"
- docker buildx imagetools create -t "${ALIAS}:${VERSION_NUM}" "${SOURCE}:${VERSION_NUM}"
- docker buildx imagetools create -t "${ALIAS}:${MINOR}" "${SOURCE}:${MINOR}"
- docker buildx imagetools create -t "${ALIAS}:${VERSION}" "${SOURCE}:${VERSION}"
- else
- docker buildx imagetools create -t "${ALIAS}:dev" "${SOURCE}:dev"
- fi
- }
-
- # Create legacy aliases pointing to current images
- # calibre-web-automated-book-downloader → standard image
- create_alias "${STANDARD}" "${LEGACY}"
-
- # calibre-web-automated-book-downloader-tor → standard image
- create_alias "${STANDARD}" "${LEGACY_TOR}"
-
- # calibre-web-automated-book-downloader-extbp → lite image
- create_alias "${LITE}" "${LEGACY_EXTBP}"
diff --git a/compose/docker-compose.lite.yml b/compose/docker-compose.lite.yml
index 4fc1fb36..5c89bbec 100644
--- a/compose/docker-compose.lite.yml
+++ b/compose/docker-compose.lite.yml
@@ -1,6 +1,6 @@
services:
shelfmark-lite:
- image: ghcr.io/calibrain/shelfmark-lite:latest
+ image: ghcr.io/infiniteavenger/shelfmark-lite:latest
container_name: shelfmark-lite
environment:
# EXT_BYPASSER_URL: http://flaresolverr:8191 #If using Flaresolverr
diff --git a/compose/docker-compose.tor.yml b/compose/docker-compose.tor.yml
index 0dff1164..f0763b03 100644
--- a/compose/docker-compose.tor.yml
+++ b/compose/docker-compose.tor.yml
@@ -1,7 +1,7 @@
# Routes all traffic through Tor - requires root startup
services:
shelfmark-tor:
- image: ghcr.io/calibrain/shelfmark:latest
+ image: ghcr.io/infiniteavenger/shelfmark:latest
environment:
FLASK_PORT: 8084
USING_TOR: true
diff --git a/compose/docker-compose.yml b/compose/docker-compose.yml
index d771ff77..36a76435 100644
--- a/compose/docker-compose.yml
+++ b/compose/docker-compose.yml
@@ -1,6 +1,6 @@
services:
shelfmark:
- image: ghcr.io/calibrain/shelfmark:latest
+ image: ghcr.io/infiniteavenger/shelfmark:latest
container_name: shelfmark
environment:
PUID: 1000
diff --git a/docs/configuration.md b/docs/configuration.md
index 5b1632f8..0e9b25d2 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -34,7 +34,7 @@ Example `docker-compose`:
```yaml
services:
shelfmark:
- image: ghcr.io/calibrain/shelfmark:latest
+ image: ghcr.io/infiniteavenger/shelfmark:latest
volumes:
- /path/to/config:/config
- /path/to/books:/books
diff --git a/docs/custom-scripts.md b/docs/custom-scripts.md
index de78ad5f..c359e91a 100644
--- a/docs/custom-scripts.md
+++ b/docs/custom-scripts.md
@@ -22,7 +22,7 @@ If you run Shelfmark in Docker, the script must exist inside the container. The
```yaml
services:
shelfmark:
- image: ghcr.io/calibrain/shelfmark:latest
+ image: ghcr.io/infiniteavenger/shelfmark:latest
volumes:
- /path/to/your/scripts:/scripts:ro
```
diff --git a/docs/installation.md b/docs/installation.md
index b41c2cf9..1b12975b 100644
--- a/docs/installation.md
+++ b/docs/installation.md
@@ -7,7 +7,7 @@ Shelfmark is typically deployed with Docker Compose.
1. Download the compose file from the repository:
```bash
-curl -O https://raw.githubusercontent.com/calibrain/shelfmark/main/compose/docker-compose.yml
+curl -O https://raw.githubusercontent.com/InfiniteAvenger/shelfmark/main/compose/docker-compose.yml
```
2. Start the service:
diff --git a/readme.md b/readme.md
index 9aee1791..7d29fd87 100644
--- a/readme.md
+++ b/readme.md
@@ -3,7 +3,11 @@
> [!NOTE]
-> This project is in a stable state as of May 2026 but is not under active maintenance.
+> This is a **fork** of [calibrain/shelfmark](https://github.com/calibrain/shelfmark) that adds the
+> automation the upstream project intentionally left out — Hardcover wishlist sync, automatic
+> downloading, and Audiobookshelf library-aware de-duplication. All original functionality is
+> preserved and full credit goes to the original authors; upstream is in maintenance mode. See
+> [Fork Additions](#-fork-additions) for what's different.
Shelfmark is a self-hosted web interface for searching and requesting books and audiobooks across multiple sources. Bring your own sources, metadata providers, and download clients to build a single hub for your digital library. Supports multiple users with a built-in request system, so you can share your instance with others and let them browse and request books on their own.
@@ -25,6 +29,19 @@ Works great alongside the following library tools, with support for automatic im
- **Real-Time Progress** - Unified download queue with live status updates across all sources
- **Network Flexibility** - Configurable proxy support, DNS settings, and optional Cloudflare handling for protected sources
+## 🔱 Fork Additions
+
+This fork adds an optional automation pipeline on top of upstream Shelfmark, configurable under **Settings → Hardcover Sync**. Everything here is **off by default and opt-in**:
+
+- **Hardcover Wishlist Sync** - Automatically pull books from a Hardcover reading shelf (e.g. "Want to Read") into Shelfmark as requests, on a schedule (configurable in minutes or hours) or on demand via a "Sync now" button. Synced requests carry full metadata and cover art.
+- **Automatic Downloads** - Auto-approve and download synced books, selecting the best release from a **drag-to-reorder source priority** list. A strict title/author/audiobook-format (and seeder) match guard avoids grabbing the wrong file; anything without a confident match is left as a pending request for manual review.
+- **Library-Aware De-duplication** - Check your [Audiobookshelf](https://github.com/advplyr/audiobookshelf) library before adding or downloading a book and skip anything you already own. Fails open (proceeds) if Audiobookshelf is unreachable, so the pipeline never stalls.
+
+> [!TIP]
+> **Designed to pair with [ShelfBridge](https://github.com/rohit-purandare/ShelfBridge).** ShelfBridge syncs your Audiobookshelf *listening progress* **up to** Hardcover; this fork closes the loop in the other direction — pulling your Hardcover "Want to Read" shelf **down into** your library and skipping anything you already own. Run both and Hardcover becomes a single hub: mark a book "Want to Read" and it lands in your library automatically; finish listening and your Hardcover status updates on its own.
+
+These build on Shelfmark's existing request and download systems; with all of them disabled, the app behaves exactly like upstream.
+
## 🖼️ Screenshots
**Home screen**
@@ -49,7 +66,7 @@ Works great alongside the following library tools, with support for automatic im
1. Download the [docker-compose file](compose/docker-compose.yml):
```bash
- curl -O https://raw.githubusercontent.com/calibrain/shelfmark/main/compose/docker-compose.yml
+ curl -O https://raw.githubusercontent.com/InfiniteAvenger/shelfmark/main/compose/docker-compose.yml
```
2. Start the service:
@@ -131,7 +148,7 @@ The full-featured image with all network capabilities included.
#### Tor Routing
Optional Tor support for network privacy:
```bash
-curl -O https://raw.githubusercontent.com/calibrain/shelfmark/main/compose/docker-compose.tor.yml
+curl -O https://raw.githubusercontent.com/InfiniteAvenger/shelfmark/main/compose/docker-compose.tor.yml
docker compose -f docker-compose.tor.yml up -d
```
@@ -149,7 +166,7 @@ A lighter image without the built-in browser automation. Ideal for:
- **Audiobooks** - Using Shelfmark primarily for audiobooks
```bash
-curl -O https://raw.githubusercontent.com/calibrain/shelfmark/main/compose/docker-compose.lite.yml
+curl -O https://raw.githubusercontent.com/InfiniteAvenger/shelfmark/main/compose/docker-compose.lite.yml
docker compose -f docker-compose.lite.yml up -d
```
@@ -182,25 +199,6 @@ volumes:
With any authentication method enabled, Shelfmark supports multi-user management with admin/user roles. Users can have per-user settings for download destinations, email recipients, and notification preferences. Non-admin users only see their own downloads and can submit book requests for admin review. Admins can configure request policies per source to control whether users can download directly, must submit a request, or are blocked entirely.
-## Project Scope
-
-Shelfmark is a manual search and download tool, the entry point to your book library, not a library manager. It finds books, downloads them, and sends them to a configured destination. That's the full scope.
-
-Shelfmark intentionally does not:
-
-- **Track or manage your library** - it doesn't know or care what you already own
-- **Integrate with library software** - what happens after delivery is up to your library tool
-- **Monitor authors, series, or new releases** - there is no background automation
-- **Queue future downloads** - if a book isn't available now, Shelfmark won't watch for it
-
-These are non-goals, not missing features.
-
-## Contributing
-
-Shelfmark's core feature set is complete. Development focuses on stability, bug fixes, quality-of-life improvements, and refining the search experience. Contributions in these areas are welcome, please file issues or submit pull requests on GitHub.
-
-Feature requests that fall outside the project scope (library integration, automation, collection management) will be closed. If you're unsure whether something fits, open a discussion first.
-
## Health Monitoring
The application exposes a health endpoint at `/api/health` (no authentication required). Add a health check to your compose:
@@ -261,4 +259,4 @@ Use of this tool is entirely at your own risk.
## Support
-For issues or questions, please [file an issue](https://github.com/calibrain/shelfmark/issues) on GitHub.
+For issues or questions about this fork, please [file an issue](https://github.com/InfiniteAvenger/shelfmark/issues) on GitHub. For issues with upstream Shelfmark, see [calibrain/shelfmark](https://github.com/calibrain/shelfmark/issues).
diff --git a/scripts/bypasser_permission_lab.sh b/scripts/bypasser_permission_lab.sh
index 6f84a045..37b2b471 100755
--- a/scripts/bypasser_permission_lab.sh
+++ b/scripts/bypasser_permission_lab.sh
@@ -2,7 +2,8 @@
set -euo pipefail
-LATEST_IMAGE="${LATEST_IMAGE:-ghcr.io/calibrain/shelfmark:latest}"
+LATEST_IMAGE="${LATEST_IMAGE:-ghcr.io/infiniteavenger/shelfmark:latest}"
+# Historical upstream baseline for comparison (the fork has no equivalent old tag).
LEGACY_IMAGE="${LEGACY_IMAGE:-ghcr.io/calibrain/shelfmark:v1.0.2}"
WAIT_SECONDS="${WAIT_SECONDS:-5}"
STARTUP_TIMEOUT_SECONDS="${STARTUP_TIMEOUT_SECONDS:-120}"
diff --git a/scripts/sync_hardcover_wishlist.py b/scripts/sync_hardcover_wishlist.py
new file mode 100755
index 00000000..d3a318e8
--- /dev/null
+++ b/scripts/sync_hardcover_wishlist.py
@@ -0,0 +1,243 @@
+#!/usr/bin/env python3
+"""
+Sync your Hardcover "Want to Read" list to Shelfmark's request queue.
+
+This script fetches books from your Hardcover wishlist (status_id: 1) and adds
+them to the Shelfmark SQLite database (`download_requests` table) if they aren't
+already requested or downloaded.
+
+Usage:
+ python scripts/sync_hardcover_wishlist.py --token --db
+
+Alternatively, set the environment variables:
+ export HARDCOVER_TOKEN="your_hardcover_token"
+ export SHELFMARK_DB="/config/users.db"
+ python scripts/sync_hardcover_wishlist.py
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import os
+import sqlite3
+import sys
+import urllib.request
+from typing import Any
+
+
+def fetch_hardcover_wishlist(token: str, limit: int = 100) -> list[dict[str, Any]]:
+ """Fetch all books on the 'Want to Read' shelf (status_id: 1) from Hardcover."""
+ query = """
+ query getUserBooks($offset: Int, $limit: Int) {
+ me {
+ user_books(
+ offset: $offset,
+ limit: $limit,
+ where: {status_id: {_eq: 1}}
+ ) {
+ book {
+ id
+ title
+ contributions(where: {contributable_type: {_eq: "Book"}}) {
+ author {
+ name
+ }
+ }
+ }
+ }
+ }
+ }
+ """
+
+ books: list[dict[str, Any]] = []
+ offset = 0
+
+ while True:
+ req_data = json.dumps({
+ "query": query,
+ "variables": {"offset": offset, "limit": limit}
+ }).encode('utf-8')
+
+ req = urllib.request.Request(
+ "https://api.hardcover.app/v1/graphql",
+ data=req_data,
+ headers={
+ "Authorization": f"Bearer {token}",
+ "Content-Type": "application/json"
+ }
+ )
+ try:
+ with urllib.request.urlopen(req) as response:
+ res = json.loads(response.read().decode('utf-8'))
+
+ # Check for GraphQL errors
+ if "errors" in res:
+ print(f"GraphQL Errors: {res['errors']}", file=sys.stderr)
+ break
+
+ me_data = res.get("data", {}).get("me", [])
+ if not me_data:
+ break
+
+ user_books = me_data[0].get("user_books", [])
+ if not user_books:
+ break
+
+ for ub in user_books:
+ book_info = ub.get("book")
+ if book_info:
+ # Extract primary author
+ authors = [
+ c.get("author", {}).get("name")
+ for c in book_info.get("contributions", [])
+ ]
+ authors = [a for a in authors if a]
+ author = authors[0] if authors else "Unknown"
+
+ books.append({
+ "id": book_info.get("id"),
+ "title": book_info.get("title"),
+ "author": author
+ })
+
+ offset += limit
+ if len(user_books) < limit:
+ break
+ except Exception as e:
+ print(f"Error fetching from Hardcover API: {e}", file=sys.stderr)
+ break
+
+ return books
+
+
+def sync_to_db(db_path: str, books: list[dict[str, Any]], username: str, content_type: str) -> None:
+ """Sync list of Hardcover books into Shelfmark's requests queue."""
+ if not os.path.exists(db_path):
+ print(f"Error: Database file not found at {db_path}", file=sys.stderr)
+ sys.exit(1)
+
+ conn = sqlite3.connect(db_path)
+ cur = conn.cursor()
+
+ try:
+ # 1. Ensure a user exists in Shelfmark users table
+ cur.execute("SELECT id FROM users WHERE username = ? LIMIT 1;", (username,))
+ user_row = cur.fetchone()
+
+ if not user_row:
+ # Check if there are any users at all
+ cur.execute("SELECT id FROM users LIMIT 1;")
+ any_user = cur.fetchone()
+ if not any_user:
+ print(f"No users found in Shelfmark database. Provisioning '{username}' user...")
+ cur.execute(
+ "INSERT INTO users (id, username, role, auth_source) VALUES (1, ?, 'admin', 'proxy');",
+ (username,)
+ )
+ conn.commit()
+ user_id = 1
+ else:
+ user_id = any_user[0]
+ else:
+ user_id = user_row[0]
+
+ # 2. Process book entries
+ added_count = 0
+ skipped_count = 0
+
+ for b in books:
+ title = b["title"]
+ author = b["author"]
+ provider_id = str(b["id"])
+
+ # Check if already requested (check book_data JSON fields or provider_id)
+ cur.execute(
+ "SELECT id FROM download_requests WHERE json_extract(book_data, '$.provider_id') = ?;",
+ (provider_id,)
+ )
+ if cur.fetchone():
+ skipped_count += 1
+ continue
+
+ # Check download history
+ cur.execute(
+ "SELECT id FROM download_history WHERE title = ? AND author = ?;",
+ (title, author)
+ )
+ if cur.fetchone():
+ skipped_count += 1
+ continue
+
+ # Insert request
+ book_data = {
+ "title": title,
+ "author": author,
+ "provider": "hardcover",
+ "provider_id": provider_id,
+ "content_type": content_type
+ }
+
+ cur.execute(
+ """
+ INSERT INTO download_requests (
+ user_id, status, delivery_state, content_type, request_level, policy_mode, book_data
+ ) VALUES (?, 'pending', 'none', ?, 'book', 'request', ?)
+ """,
+ (user_id, content_type, json.dumps(book_data))
+ )
+ print(f"Added request for: '{title}' by {author}")
+ added_count += 1
+
+ conn.commit()
+ print(f"Sync complete. Added {added_count} new requests, skipped {skipped_count} existing.")
+ except Exception as e:
+ conn.rollback()
+ print(f"Database sync failed: {e}", file=sys.stderr)
+ finally:
+ conn.close()
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser(
+ description='Sync Hardcover "Want to Read" list to Shelfmark request queue.'
+ )
+ parser.add_argument(
+ '--token',
+ default=os.environ.get('HARDCOVER_TOKEN'),
+ help='Hardcover Developer API token (env: HARDCOVER_TOKEN)'
+ )
+ parser.add_argument(
+ '--db',
+ default=os.environ.get('SHELFMARK_DB', '/config/users.db'),
+ help='Path to Shelfmark users.db SQLite file (env: SHELFMARK_DB)'
+ )
+ parser.add_argument(
+ '--username',
+ default=os.environ.get('SHELFMARK_USERNAME', 'CalebWest'),
+ help='Shelfmark username to associate request with (env: SHELFMARK_USERNAME)'
+ )
+ parser.add_argument(
+ '--content-type',
+ default=os.environ.get('CONTENT_TYPE', 'audiobook'),
+ choices=['audiobook', 'book'],
+ help='Default requested content type: audiobook or book (env: CONTENT_TYPE)'
+ )
+
+ args = parser.parse_args()
+
+ if not args.token:
+ print("Error: Hardcover token must be specified via --token or HARDCOVER_TOKEN env var.", file=sys.stderr)
+ sys.exit(1)
+
+ print(f"Fetching wishlist from Hardcover...")
+ wishlist = fetch_hardcover_wishlist(args.token)
+ print(f"Found {len(wishlist)} books on your Hardcover 'Want to Read' list.")
+
+ if wishlist:
+ print(f"Syncing list to Shelfmark database at: {args.db}...")
+ sync_to_db(args.db, wishlist, args.username, args.content_type)
+
+
+if __name__ == '__main__':
+ main()
diff --git a/shelfmark/config/hardcover_sync_settings.py b/shelfmark/config/hardcover_sync_settings.py
new file mode 100644
index 00000000..3012505e
--- /dev/null
+++ b/shelfmark/config/hardcover_sync_settings.py
@@ -0,0 +1,217 @@
+"""Settings tab for Hardcover wishlist sync + automatic downloads."""
+
+from __future__ import annotations
+
+from typing import Any
+
+from shelfmark.core.settings_registry import (
+ ActionButton,
+ CheckboxField,
+ HeadingField,
+ NumberField,
+ OrderableListField,
+ PasswordField,
+ SelectField,
+ SettingsField,
+ TextField,
+ register_settings,
+)
+
+_CONTENT_TYPE_OPTIONS = [
+ {"value": "audiobook", "label": "Audiobooks"},
+ {"value": "ebook", "label": "Ebooks"},
+]
+
+
+def _status_options() -> list[dict[str, str]]:
+ from shelfmark.metadata_providers.hardcover import HARDCOVER_STATUSES
+
+ return [{"value": str(status["id"]), "label": status["label"]} for status in HARDCOVER_STATUSES]
+
+
+def _audiobook_source_options() -> list[dict[str, Any]]:
+ """Orderable-list options: release sources that can return audiobooks."""
+ from shelfmark.release_sources import list_available_sources
+
+ options: list[dict[str, Any]] = []
+ for src in list_available_sources():
+ supported = src.get("supported_content_types") or ["ebook", "audiobook"]
+ if "audiobook" not in supported:
+ continue
+ enabled = bool(src.get("enabled"))
+ options.append(
+ {
+ "id": src["name"],
+ "label": src.get("display_name") or src["name"],
+ "description": None if enabled else "Source not configured / unavailable",
+ "isLocked": not enabled,
+ "disabledReason": None if enabled else "Source not configured / unavailable",
+ }
+ )
+ return options
+
+
+def _test_library_connection(current_values: dict[str, Any] | None = None) -> dict[str, Any]:
+ """Action-button callback: verify Audiobookshelf connectivity + item count."""
+ from shelfmark.core import library_index
+
+ return library_index.test_connection()
+
+
+def _sync_now(current_values: dict[str, Any] | None = None) -> dict[str, Any]:
+ """Action-button callback: trigger an immediate sync + auto-download pass."""
+ from shelfmark.core import hardcover_scheduler
+
+ started = hardcover_scheduler.trigger_async(force=True)
+ if not started:
+ return {"success": False, "message": "A sync is already running. Try again shortly."}
+ return {
+ "success": True,
+ "message": "Sync started. New wishlist books will appear as requests; "
+ "matching audiobooks auto-download if enabled.",
+ }
+
+
+@register_settings("hardcover_sync", "Hardcover Sync", icon="book", order=8)
+def hardcover_sync_settings() -> list[SettingsField]:
+ """Configure Hardcover wishlist sync and automatic downloads."""
+ return [
+ HeadingField(
+ key="hardcover_sync_heading",
+ title="Hardcover Wishlist Sync",
+ description=(
+ "Automatically pull books from your Hardcover reading shelves into "
+ "Shelfmark as requests, on a schedule."
+ ),
+ ),
+ CheckboxField(
+ key="HARDCOVER_SYNC_ENABLED",
+ label="Enable scheduled sync",
+ description="Periodically sync the selected Hardcover shelves into requests.",
+ default=False,
+ ),
+ PasswordField(
+ key="HARDCOVER_SYNC_TOKEN",
+ label="Hardcover API Token",
+ description=(
+ "Bearer token for your Hardcover account. Leave blank to reuse the "
+ "token from the Hardcover metadata provider."
+ ),
+ placeholder="Reuses metadata provider token if blank",
+ ),
+ SelectField(
+ key="HARDCOVER_SYNC_STATUSES",
+ label="Shelves to sync",
+ description="Which Hardcover reading shelf to pull from.",
+ options=_status_options,
+ default="1",
+ ),
+ SelectField(
+ key="HARDCOVER_SYNC_CONTENT_TYPE",
+ label="Request as",
+ description="Content type assigned to synced requests and targeted for downloads.",
+ options=_CONTENT_TYPE_OPTIONS,
+ default="audiobook",
+ ),
+ NumberField(
+ key="HARDCOVER_SYNC_INTERVAL",
+ label="Sync interval",
+ description="How often the scheduled sync runs (minimum 1 minute).",
+ default=6,
+ min_value=1,
+ max_value=10000,
+ ),
+ SelectField(
+ key="HARDCOVER_SYNC_INTERVAL_UNIT",
+ label="Interval unit",
+ description="Whether the sync interval is measured in minutes or hours.",
+ options=[
+ {"value": "minutes", "label": "Minutes"},
+ {"value": "hours", "label": "Hours"},
+ ],
+ default="hours",
+ ),
+ HeadingField(
+ key="auto_download_heading",
+ title="Automatic Downloads",
+ description=(
+ "When enabled, synced requests are auto-approved and downloaded from the "
+ "first source below that yields a strict title/author/format match. If no "
+ "confident match is found, the request is left pending for manual review."
+ ),
+ ),
+ CheckboxField(
+ key="AUTO_DOWNLOAD_ENABLED",
+ label="Enable automatic downloads",
+ description="Auto-approve and auto-download matching releases for synced requests.",
+ default=False,
+ ),
+ OrderableListField(
+ key="AUTO_DOWNLOAD_SOURCE_PRIORITY",
+ label="Source priority",
+ description=(
+ "Drag to set which release sources are tried first. The first source with "
+ "a strict match wins. Disabled sources are skipped."
+ ),
+ options=_audiobook_source_options,
+ # Default left empty so registration never imports release_sources (which would
+ # deadlock the registry lock). When empty, all available sources are used in
+ # registry order (see auto_download._configured_source_priority).
+ default=[],
+ ),
+ NumberField(
+ key="AUTO_DOWNLOAD_MIN_SEEDERS",
+ label="Minimum seeders (torrents)",
+ description="Skip torrent releases with fewer seeders than this.",
+ default=1,
+ min_value=0,
+ max_value=1000,
+ ),
+ HeadingField(
+ key="library_check_heading",
+ title="Library Check (Audiobookshelf)",
+ description=(
+ "Skip books you already own. When enabled, the sync and auto-download "
+ "steps check your Audiobookshelf library and skip anything already there."
+ ),
+ ),
+ CheckboxField(
+ key="LIBRARY_CHECK_ENABLED",
+ label="Skip books already in Audiobookshelf",
+ description="Check the Audiobookshelf library before adding/downloading a book.",
+ default=False,
+ ),
+ TextField(
+ key="AUDIOBOOKSHELF_URL",
+ label="Audiobookshelf URL",
+ description=(
+ "Base URL reachable from the Shelfmark container (not the public/Cloudflare "
+ "URL). Often the host LAN IP, e.g. http://10.0.0.91:13378."
+ ),
+ placeholder="http://10.0.0.91:13378",
+ ),
+ PasswordField(
+ key="AUDIOBOOKSHELF_TOKEN",
+ label="Audiobookshelf API Token",
+ description="API token from Audiobookshelf (Settings > Users > your user > API Token).",
+ ),
+ TextField(
+ key="AUDIOBOOKSHELF_LIBRARY_IDS",
+ label="Library IDs (optional)",
+ description="Comma-separated library IDs to check. Leave blank for all book libraries.",
+ placeholder="Blank = all book libraries",
+ ),
+ ActionButton(
+ key="test_library_connection",
+ label="Test library connection",
+ description="Check that Shelfmark can reach Audiobookshelf and count the items.",
+ callback=_test_library_connection,
+ ),
+ ActionButton(
+ key="sync_now",
+ label="Sync now",
+ description="Run a sync + auto-download pass immediately (uses saved settings).",
+ style="primary",
+ callback=_sync_now,
+ ),
+ ]
diff --git a/shelfmark/config/settings.py b/shelfmark/config/settings.py
index 18da1631..0d19531e 100644
--- a/shelfmark/config/settings.py
+++ b/shelfmark/config/settings.py
@@ -1869,3 +1869,8 @@ def advanced_settings() -> list[SettingsField]:
register_on_save("advanced", _on_save_advanced)
+
+
+# Register the Hardcover Sync settings tab. Imported here (rather than at each
+# settings endpoint) so every code path that imports this module also registers it.
+from shelfmark.config import hardcover_sync_settings as _hardcover_sync_settings # noqa: E402,F401
diff --git a/shelfmark/core/activity_routes.py b/shelfmark/core/activity_routes.py
index 4bf8ffc3..eebc3594 100644
--- a/shelfmark/core/activity_routes.py
+++ b/shelfmark/core/activity_routes.py
@@ -577,6 +577,13 @@ def api_activity_snapshot() -> Response | tuple[Response, int]:
continue
visible_request_rows.append(row)
+ from shelfmark.core.request_routes import (
+ _populate_requests_metadata,
+ _transform_requests_for_response,
+ )
+ _populate_requests_metadata(visible_request_rows, user_db)
+ _transform_requests_for_response(visible_request_rows)
+
return jsonify(
{
"status": status,
@@ -1047,6 +1054,8 @@ def api_activity_history() -> Response | tuple[Response, int]:
raise RuntimeError(msg)
populate_request_usernames([request_row], user_db)
+ from shelfmark.core.request_routes import _transform_requests_for_response
+ _transform_requests_for_response([request_row])
entry = _request_history_entry(
request_row,
dismissed_at=dismissed_at,
diff --git a/shelfmark/core/auto_download.py b/shelfmark/core/auto_download.py
new file mode 100644
index 00000000..77b5623e
--- /dev/null
+++ b/shelfmark/core/auto_download.py
@@ -0,0 +1,374 @@
+"""Automatic release selection + download for pending requests.
+
+Given a pending request (typically synced from a Hardcover wishlist), this module
+searches release sources in a user-configured priority order, applies a strict
+title/author/format match guard, picks the best candidate from the first source
+that yields a match, and queues it via the normal request-fulfilment path.
+
+Design goals:
+- **Strict by default.** Bias toward leaving a request pending (manual review) over
+ grabbing the wrong file. Title and author must both match; the format must be a
+ real audiobook format (no ebook-only fallbacks when targeting audiobooks).
+- **Source priority is primary.** Walk the configured source order top-down and take
+ the first source that produces at least one strict match.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import TYPE_CHECKING, Any
+
+from shelfmark.core.config import config as app_config
+from shelfmark.core.logger import setup_logger
+from shelfmark.core.release_search import search_book_releases
+from shelfmark.core.text_match import (
+ author_surname,
+ title_tokens_match,
+)
+from shelfmark.core.text_match import (
+ tokens as _tokens,
+)
+
+if TYPE_CHECKING:
+ from collections.abc import Callable
+
+ from shelfmark.core.user_db import UserDB
+ from shelfmark.metadata_providers import BookMetadata
+ from shelfmark.release_sources import Release
+
+logger = setup_logger(__name__)
+
+# Title threshold: fraction of significant book-title tokens that must appear in the
+# release title. Kept as a module constant so it's easy to tune.
+TITLE_MATCH_THRESHOLD = 0.85
+
+DEFAULT_AUDIOBOOK_FORMATS = ("m4b", "mp3")
+EBOOK_FORMAT_MARKERS = (
+ "epub",
+ "mobi",
+ "azw3",
+ "azw",
+ "pdf",
+ "fb2",
+ "djvu",
+ "cbz",
+ "cbr",
+)
+# Audiobook signals we recognise in free-text release titles in addition to formats.
+AUDIOBOOK_TITLE_MARKERS = ("audiobook", "unabridged", "m4b", "audio book")
+
+# Audiobook format ranking for tie-breaking within a single source.
+_FORMAT_RANK = {"m4b": 3, "m4a": 2, "mp3": 1}
+
+
+@dataclass(frozen=True)
+class AutoDownloadOutcome:
+ """Result of attempting to auto-download a single request."""
+
+ request_id: int
+ status: str # "queued" | "no_match" | "skipped" | "error"
+ detail: str = ""
+ source: str | None = None
+
+
+def _audiobook_formats() -> set[str]:
+ configured = app_config.get("SUPPORTED_AUDIOBOOK_FORMATS", list(DEFAULT_AUDIOBOOK_FORMATS))
+ if isinstance(configured, str):
+ configured = [configured]
+ formats = {str(fmt).strip().lower() for fmt in (configured or []) if str(fmt).strip()}
+ return formats or set(DEFAULT_AUDIOBOOK_FORMATS)
+
+
+def _author_surname_tokens(book: BookMetadata) -> list[str]:
+ """Return distinctive author tokens (prefer the surname of the primary author)."""
+ author = book.search_author or (book.authors[0] if book.authors else "")
+ surname = author_surname(author)
+ return [surname] if surname else []
+
+
+def _title_match(book: BookMetadata, release_title: str) -> bool:
+ return title_tokens_match(
+ book.search_title or book.title,
+ set(_tokens(release_title)),
+ TITLE_MATCH_THRESHOLD,
+ )
+
+
+def _author_match(book: BookMetadata, release: Release) -> bool:
+ surname = _author_surname_tokens(book)
+ if not surname:
+ return True # No author metadata to verify against; don't block on it.
+ haystack = set(_tokens(release.title)) | set(_tokens(release.indexer))
+ extra_author = release.extra.get("author") if isinstance(release.extra, dict) else None
+ haystack |= set(_tokens(extra_author if isinstance(extra_author, str) else None))
+ return all(tok in haystack for tok in surname)
+
+
+def _format_match(release: Release, audiobook_formats: set[str]) -> bool:
+ """Require a real audiobook signal and reject ebook-only releases."""
+ fmt = (release.format or "").strip().lower()
+ title_l = (release.title or "").lower()
+
+ has_audiobook_signal = (
+ fmt in audiobook_formats
+ or any(marker in title_l for marker in AUDIOBOOK_TITLE_MARKERS)
+ or any(f".{af}" in title_l or f" {af}" in title_l for af in audiobook_formats)
+ )
+ if not has_audiobook_signal:
+ return False
+
+ # Reject things that are clearly an ebook and nothing else.
+ return fmt not in EBOOK_FORMAT_MARKERS
+
+
+def _seeders_ok(release: Release, min_seeders: int) -> bool:
+ from shelfmark.release_sources import ReleaseProtocol
+
+ if release.protocol != ReleaseProtocol.TORRENT:
+ return True # Non-torrent protocols have no seeder concept.
+ if release.seeders is None:
+ return min_seeders <= 0
+ return release.seeders >= min_seeders
+
+
+def strict_match(
+ release: Release,
+ book: BookMetadata,
+ *,
+ min_seeders: int = 1,
+ audiobook_formats: set[str] | None = None,
+) -> bool:
+ """Return True only if the release confidently matches the requested audiobook."""
+ formats = audiobook_formats if audiobook_formats is not None else _audiobook_formats()
+ return (
+ _title_match(book, release.title)
+ and _author_match(book, release)
+ and _format_match(release, formats)
+ and _seeders_ok(release, min_seeders)
+ )
+
+
+def _release_sort_key(release: Release) -> tuple[int, int, int]:
+ fmt = (release.format or "").strip().lower()
+ if fmt not in _FORMAT_RANK and "m4b" in (release.title or "").lower():
+ fmt = "m4b"
+ return (
+ _FORMAT_RANK.get(fmt, 0),
+ release.seeders or 0,
+ release.size_bytes or 0,
+ )
+
+
+def pick_best_release(releases: list[Release]) -> Release | None:
+ if not releases:
+ return None
+ return max(releases, key=_release_sort_key)
+
+
+def build_release_data(release: Release, book: BookMetadata, content_type: str) -> dict[str, Any]:
+ """Build a queue_release-compatible payload from a chosen Release."""
+ author = book.search_author or (book.authors[0] if book.authors else None)
+ extra = release.extra if isinstance(release.extra, dict) else {}
+ # Carry the book's cover so the download shows artwork immediately; the orchestrator
+ # reads release_data["preview"] and proxies it (orchestrator.queue_release / task_to_dict).
+ preview = book.cover_url or extra.get("preview")
+ payload: dict[str, Any] = {
+ "source": release.source,
+ "source_id": release.source_id,
+ "title": release.title,
+ "author": author,
+ "year": book.publish_year,
+ "preview": preview,
+ "format": release.format,
+ "size": release.size,
+ "size_bytes": release.size_bytes,
+ "download_url": release.download_url,
+ "info_url": release.info_url,
+ "protocol": release.protocol.value if release.protocol else None,
+ "indexer": release.indexer,
+ "seeders": release.seeders,
+ "language": release.language,
+ "content_type": release.content_type or content_type,
+ "series_name": book.series_name,
+ "series_position": book.series_position,
+ "subtitle": book.subtitle,
+ "extra": dict(extra),
+ }
+ return {key: value for key, value in payload.items() if value is not None}
+
+
+def _configured_source_priority() -> list[str]:
+ """Return enabled release-source names in configured priority order."""
+ from shelfmark.release_sources import list_available_sources
+
+ available = list_available_sources()
+ available_by_name = {src["name"]: src for src in available}
+
+ raw = app_config.get("AUTO_DOWNLOAD_SOURCE_PRIORITY", [])
+ ordered: list[str] = []
+ if isinstance(raw, list):
+ for item in raw:
+ if not isinstance(item, dict):
+ continue
+ name = item.get("id")
+ if not isinstance(name, str) or name not in available_by_name:
+ continue
+ if not bool(item.get("enabled", True)):
+ continue
+ if not available_by_name[name].get("enabled", False):
+ continue # Source itself not usable (unconfigured/unavailable).
+ ordered.append(name)
+
+ if ordered:
+ return ordered
+ # Fallback: every usable source, registry order.
+ return [src["name"] for src in available if src.get("enabled")]
+
+
+def auto_download_request(
+ user_db: UserDB,
+ request_row: dict[str, Any],
+ *,
+ sources: list[str],
+ content_type: str,
+ min_seeders: int,
+ queue_release: Callable[..., tuple[bool, str | None]],
+ admin_user_id: int = 1,
+) -> AutoDownloadOutcome:
+ """Search, strict-match, and queue a single pending request."""
+ from shelfmark.core.requests_service import RequestServiceError, fulfil_request
+ from shelfmark.metadata_providers import (
+ get_provider,
+ get_provider_kwargs,
+ is_provider_registered,
+ )
+
+ request_id = int(request_row["id"])
+ book_data = request_row.get("book_data") or {}
+ if not isinstance(book_data, dict):
+ return AutoDownloadOutcome(request_id, "skipped", "no book_data")
+
+ provider_name = book_data.get("provider")
+ provider_id = book_data.get("provider_id") or book_data.get("id")
+ if not provider_name or not provider_id or not is_provider_registered(provider_name):
+ return AutoDownloadOutcome(request_id, "skipped", "no usable provider/id")
+
+ try:
+ prov = get_provider(provider_name, **get_provider_kwargs(provider_name))
+ book = prov.get_book(str(provider_id))
+ except Exception as exc: # noqa: BLE001 - provider/network errors shouldn't kill the loop
+ logger.warning("auto-download: metadata lookup failed for request %s: %s", request_id, exc)
+ return AutoDownloadOutcome(request_id, "error", f"metadata lookup failed: {exc}")
+
+ if book is None:
+ return AutoDownloadOutcome(request_id, "skipped", "book not found in provider")
+
+ # Final guard: skip if the book is already in the Audiobookshelf library.
+ if bool(app_config.get("LIBRARY_CHECK_ENABLED", False)):
+ from shelfmark.core.library_index import is_in_library
+
+ if is_in_library(book):
+ logger.info(
+ "auto-download: request %s (%s) already in library; skipping",
+ request_id,
+ book.title,
+ )
+ return AutoDownloadOutcome(request_id, "in_library", "already in library")
+
+ audiobook_formats = _audiobook_formats()
+
+ # Walk sources in priority order; take the first source with a strict match.
+ for source_name in sources:
+ _all, by_source, _errors = search_book_releases(
+ book,
+ sources=[source_name],
+ content_type=content_type,
+ expand_search=True,
+ )
+ candidates = [
+ release
+ for release in by_source.get(source_name, [])
+ if strict_match(
+ release,
+ book,
+ min_seeders=min_seeders,
+ audiobook_formats=audiobook_formats,
+ )
+ ]
+ chosen = pick_best_release(candidates)
+ if chosen is None:
+ continue
+
+ release_data = build_release_data(chosen, book, content_type)
+ 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)
+
+ logger.info("auto-download: no strict match for request %s (%s)", request_id, book.title)
+ return AutoDownloadOutcome(request_id, "no_match", "no strict match across sources")
+
+
+def auto_download_pending(
+ user_db: UserDB,
+ *,
+ queue_release: Callable[..., tuple[bool, str | None]],
+ provider_filter: str | None = "hardcover",
+) -> dict[str, int]:
+ """Run the auto-download pass over all eligible pending requests.
+
+ Returns a summary count dict. No-ops (returns zeros) unless AUTO_DOWNLOAD_ENABLED.
+ """
+ if not bool(app_config.get("AUTO_DOWNLOAD_ENABLED", False)):
+ return {"queued": 0, "no_match": 0, "in_library": 0, "skipped": 0, "error": 0}
+
+ content_type = str(app_config.get("HARDCOVER_SYNC_CONTENT_TYPE", "audiobook") or "audiobook")
+ try:
+ min_seeders = int(app_config.get("AUTO_DOWNLOAD_MIN_SEEDERS", 1))
+ except (TypeError, ValueError):
+ min_seeders = 1
+
+ sources = _configured_source_priority()
+ if not sources:
+ logger.warning("auto-download: no usable release sources configured")
+ return {"queued": 0, "no_match": 0, "in_library": 0, "skipped": 0, "error": 0}
+
+ pending = user_db.list_requests(status="pending")
+ summary = {"queued": 0, "no_match": 0, "in_library": 0, "skipped": 0, "error": 0}
+
+ for row in pending:
+ book_data = row.get("book_data") or {}
+ if provider_filter and (
+ not isinstance(book_data, dict) or book_data.get("provider") != provider_filter
+ ):
+ continue
+ # Only act on requests that haven't already been dispatched.
+ if str(row.get("delivery_state") or "none").lower() not in {"none", ""}:
+ continue
+
+ outcome = auto_download_request(
+ user_db,
+ row,
+ sources=sources,
+ content_type=content_type,
+ min_seeders=min_seeders,
+ queue_release=queue_release,
+ )
+ summary[outcome.status] = summary.get(outcome.status, 0) + 1
+
+ logger.info("auto-download pass complete: %s", summary)
+ return summary
diff --git a/shelfmark/core/hardcover_scheduler.py b/shelfmark/core/hardcover_scheduler.py
new file mode 100644
index 00000000..5e2cb945
--- /dev/null
+++ b/shelfmark/core/hardcover_scheduler.py
@@ -0,0 +1,139 @@
+"""Background scheduler for Hardcover wishlist sync + auto-download.
+
+Runs an in-process daemon thread (mirroring the download coordinator pattern in
+``shelfmark.download.orchestrator``) that periodically syncs configured Hardcover
+shelves into requests and auto-downloads them. Also exposes a manual trigger used
+by the "Sync now" settings action button.
+"""
+
+from __future__ import annotations
+
+import threading
+import time
+from typing import TYPE_CHECKING, Any
+
+from shelfmark.core.config import config as app_config
+from shelfmark.core.logger import setup_logger
+
+if TYPE_CHECKING:
+ from collections.abc import Callable
+
+ from shelfmark.core.user_db import UserDB
+
+logger = setup_logger(__name__)
+
+_WARMUP_SECONDS = 60 # Delay before the first cycle after startup.
+_MIN_INTERVAL_SECONDS = 60 # Floor so a misconfigured interval can't hammer the API.
+_UNIT_SECONDS = {"minutes": 60.0, "hours": 3600.0}
+
+_ctx_lock = threading.Lock()
+_ctx: dict[str, Any] = {}
+
+_thread: threading.Thread | None = None
+_thread_lock = threading.Lock()
+
+# Prevents a scheduled cycle and a manual "Sync now" from overlapping.
+_run_lock = threading.Lock()
+
+
+def configure(
+ user_db: UserDB,
+ queue_release: Callable[..., tuple[bool, str | None]],
+ db_path: str | None,
+) -> None:
+ """Store the runtime context the background work needs."""
+ with _ctx_lock:
+ _ctx["user_db"] = user_db
+ _ctx["queue_release"] = queue_release
+ _ctx["db_path"] = db_path
+
+
+def _interval_seconds() -> float:
+ try:
+ value = float(app_config.get("HARDCOVER_SYNC_INTERVAL", 6))
+ except (TypeError, ValueError):
+ value = 6.0
+ unit = str(app_config.get("HARDCOVER_SYNC_INTERVAL_UNIT", "hours") or "hours").strip().lower()
+ multiplier = _UNIT_SECONDS.get(unit, _UNIT_SECONDS["hours"])
+ return max(value * multiplier, _MIN_INTERVAL_SECONDS)
+
+
+def _enabled() -> bool:
+ return bool(app_config.get("HARDCOVER_SYNC_ENABLED", False)) or bool(
+ app_config.get("AUTO_DOWNLOAD_ENABLED", False)
+ )
+
+
+def run_once(*, force: bool = False) -> dict[str, Any]:
+ """Run one sync + auto-download pass. Returns a combined summary.
+
+ ``force=True`` (the manual "Sync now" path) syncs regardless of the scheduler
+ toggle; auto-download always remains gated by ``AUTO_DOWNLOAD_ENABLED`` inside
+ ``auto_download_pending``. Skips (returns ``{"status": "busy"}``) if another run
+ is in progress.
+ """
+ if not _run_lock.acquire(blocking=False):
+ return {"status": "busy"}
+ 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 {"status": "unconfigured"}
+
+ from shelfmark.core.auto_download import auto_download_pending
+ from shelfmark.core.hardcover_sync import sync_wishlist
+
+ sync_summary: dict[str, Any] | None = None
+ if force or bool(app_config.get("HARDCOVER_SYNC_ENABLED", False)):
+ sync_summary = sync_wishlist(user_db, db_path=db_path)
+ auto_summary = auto_download_pending(user_db, queue_release=queue_release)
+ return {"status": "ok", "sync": sync_summary, "auto_download": auto_summary}
+ finally:
+ _run_lock.release()
+
+
+def trigger_async(*, force: bool = True) -> bool:
+ """Kick off a one-off run in a background thread. Returns False if one is active."""
+ if _run_lock.locked():
+ return False
+ thread = threading.Thread(
+ target=lambda: run_once(force=force), daemon=True, name="HardcoverSyncManual"
+ )
+ thread.start()
+ return True
+
+
+def _scheduler_loop() -> None:
+ logger.info("Hardcover scheduler started")
+ time.sleep(_WARMUP_SECONDS)
+ while True:
+ try:
+ if _enabled():
+ logger.info("Hardcover scheduler: starting cycle")
+ result = run_once()
+ logger.info("Hardcover scheduler: cycle result %s", result)
+ except Exception:
+ logger.exception("Hardcover scheduler: cycle failed")
+ time.sleep(_interval_seconds())
+
+
+def start(
+ user_db: UserDB,
+ queue_release: Callable[..., tuple[bool, str | None]],
+ db_path: str | None,
+) -> None:
+ """Configure context and start the scheduler thread (idempotent)."""
+ configure(user_db, queue_release, db_path)
+ global _thread
+ with _thread_lock:
+ if _thread is not None and _thread.is_alive():
+ logger.debug("Hardcover scheduler already running")
+ return
+ _thread = threading.Thread(
+ target=_scheduler_loop, daemon=True, name="HardcoverScheduler"
+ )
+ _thread.start()
+ logger.info("Hardcover scheduler thread launched")
diff --git a/shelfmark/core/hardcover_sync.py b/shelfmark/core/hardcover_sync.py
new file mode 100644
index 00000000..9edb1bf0
--- /dev/null
+++ b/shelfmark/core/hardcover_sync.py
@@ -0,0 +1,207 @@
+"""Sync a Hardcover reading shelf (e.g. "Want to Read") into Shelfmark requests.
+
+This replaces the standalone ``scripts/sync_hardcover_wishlist.py`` with an in-app
+service that reuses the registered Hardcover metadata provider (so synced requests
+carry full metadata incl. covers) and the request-service validation/dedup path.
+"""
+
+from __future__ import annotations
+
+import sqlite3
+from typing import TYPE_CHECKING, Any
+
+from shelfmark.core.config import config as app_config
+from shelfmark.core.logger import setup_logger
+
+if TYPE_CHECKING:
+ from shelfmark.core.user_db import UserDB
+ from shelfmark.metadata_providers import BookMetadata
+
+logger = setup_logger(__name__)
+
+# Hardcover status_id -> shelf. Default sync target is "Want to Read" (1).
+DEFAULT_SYNC_STATUSES = ("1",)
+_PAGE_LIMIT = 25 # Hardcover API page size.
+_MAX_PAGES = 40 # Safety bound (~1000 books) so a bad response can't loop forever.
+
+
+def _configured_token() -> str:
+ """Prefer a dedicated sync token, fall back to the provider API key."""
+ token = app_config.get("HARDCOVER_SYNC_TOKEN", "") or app_config.get("HARDCOVER_API_KEY", "")
+ return str(token or "").strip()
+
+
+def _configured_statuses() -> list[int]:
+ raw = app_config.get("HARDCOVER_SYNC_STATUSES", list(DEFAULT_SYNC_STATUSES))
+ if isinstance(raw, str):
+ raw = [raw]
+ statuses: list[int] = []
+ for value in raw or []:
+ try:
+ statuses.append(int(value))
+ except (TypeError, ValueError):
+ continue
+ return statuses or [int(s) for s in DEFAULT_SYNC_STATUSES]
+
+
+def _build_provider() -> Any | None:
+ from shelfmark.metadata_providers import get_provider
+
+ token = _configured_token()
+ if not token:
+ logger.warning("hardcover-sync: no Hardcover token configured")
+ return None
+ provider = get_provider("hardcover", api_key=token)
+ if not provider.is_available():
+ logger.warning("hardcover-sync: Hardcover provider unavailable (bad token?)")
+ return None
+ return provider
+
+
+def _primary_author(book: BookMetadata) -> str:
+ if book.search_author:
+ return book.search_author
+ if book.authors:
+ return book.authors[0]
+ return "Unknown"
+
+
+def _book_to_book_data(book: BookMetadata, content_type: str) -> dict[str, Any]:
+ book_data: dict[str, Any] = {
+ "title": book.title,
+ "author": _primary_author(book),
+ "provider": "hardcover",
+ "provider_id": str(book.provider_id),
+ "content_type": content_type,
+ }
+ # Only set preview when we actually have a cover, so the lazy backfill in
+ # _populate_requests_metadata can still try later if it's missing.
+ if book.cover_url:
+ book_data["preview"] = book.cover_url
+ if book.publish_year:
+ book_data["year"] = book.publish_year
+ if book.subtitle:
+ book_data["subtitle"] = book.subtitle
+ return book_data
+
+
+def _existing_provider_ids(user_db: UserDB) -> set[str]:
+ ids: set[str] = set()
+ for row in user_db.list_requests():
+ book_data = row.get("book_data") or {}
+ if isinstance(book_data, dict):
+ pid = book_data.get("provider_id") or book_data.get("id")
+ if pid is not None:
+ ids.add(str(pid))
+ return ids
+
+
+def _already_downloaded(db_path: str | None, title: str, author: str) -> bool:
+ """Best-effort check that a title/author isn't already in download_history."""
+ if not db_path:
+ return False
+ try:
+ conn = sqlite3.connect(db_path)
+ try:
+ row = conn.execute(
+ "SELECT 1 FROM download_history "
+ "WHERE LOWER(title) = ? AND LOWER(author) = ? LIMIT 1",
+ (title.strip().lower(), author.strip().lower()),
+ ).fetchone()
+ return row is not None
+ finally:
+ conn.close()
+ except sqlite3.Error as exc:
+ logger.debug("hardcover-sync: history dedup query failed: %s", exc)
+ return False
+
+
+def _fetch_status_books(provider: Any, status_id: int) -> list[BookMetadata]:
+ """Page through a Hardcover status shelf, returning all books."""
+ books: list[BookMetadata] = []
+ for page in range(1, _MAX_PAGES + 1):
+ try:
+ result = provider._fetch_current_user_books_by_status(status_id, page, _PAGE_LIMIT)
+ except Exception as exc: # noqa: BLE001 - network/provider errors shouldn't abort the sync
+ logger.warning(
+ "hardcover-sync: fetch failed (status=%s page=%s): %s", status_id, page, exc
+ )
+ break
+ page_books = list(result.books or [])
+ books.extend(page_books)
+ if not result.has_more or not page_books:
+ break
+ return books
+
+
+def sync_wishlist(
+ user_db: UserDB,
+ *,
+ db_path: str | None = None,
+ user_id: int = 1,
+) -> dict[str, int]:
+ """Sync configured Hardcover shelves into pending requests.
+
+ Returns a summary dict: ``{"added", "skipped", "errors"}``. Requires a configured
+ token; enable-gating is the caller's responsibility (see hardcover_scheduler).
+ """
+ summary = {"added": 0, "skipped": 0, "in_library": 0, "errors": 0}
+
+ provider = _build_provider()
+ if provider is None:
+ summary["errors"] += 1
+ return summary
+
+ content_type = str(app_config.get("HARDCOVER_SYNC_CONTENT_TYPE", "audiobook") or "audiobook")
+ library_check = bool(app_config.get("LIBRARY_CHECK_ENABLED", False))
+ known_provider_ids = _existing_provider_ids(user_db)
+
+ from shelfmark.core.library_index import is_in_library
+ from shelfmark.core.requests_service import RequestServiceError, create_request
+
+ for status_id in _configured_statuses():
+ for book in _fetch_status_books(provider, status_id):
+ provider_id = str(book.provider_id)
+ author = _primary_author(book)
+
+ if provider_id in known_provider_ids:
+ summary["skipped"] += 1
+ continue
+ if _already_downloaded(db_path, book.title, author):
+ summary["skipped"] += 1
+ continue
+ if library_check and is_in_library(book):
+ summary["in_library"] += 1
+ logger.info("hardcover-sync: '%s' already in Audiobookshelf; skipping", book.title)
+ continue
+
+ try:
+ create_request(
+ user_db,
+ user_id=user_id,
+ source_hint=None,
+ content_type=content_type,
+ request_level="book",
+ policy_mode="request_book",
+ book_data=_book_to_book_data(book, content_type),
+ note=None,
+ )
+ except RequestServiceError as exc:
+ # Duplicate / max-pending / validation: treat as skip, not failure.
+ if exc.code in {"duplicate_pending_request", "max_pending_reached"}:
+ summary["skipped"] += 1
+ else:
+ logger.warning("hardcover-sync: could not add '%s': %s", book.title, exc)
+ summary["errors"] += 1
+ continue
+ except Exception:
+ logger.exception("hardcover-sync: unexpected error adding '%s'", book.title)
+ summary["errors"] += 1
+ continue
+
+ known_provider_ids.add(provider_id)
+ summary["added"] += 1
+ logger.info("hardcover-sync: added request for '%s' by %s", book.title, author)
+
+ logger.info("hardcover-sync complete: %s", summary)
+ return summary
diff --git a/shelfmark/core/library_index.py b/shelfmark/core/library_index.py
new file mode 100644
index 00000000..29e8dc8c
--- /dev/null
+++ b/shelfmark/core/library_index.py
@@ -0,0 +1,203 @@
+"""Audiobookshelf (ABS) library ownership check.
+
+Fetches the user's ABS library (title/author/isbn/asin per item), caches it, and
+answers whether a given book is already owned — so the Hardcover auto-sync /
+auto-download pipeline can skip re-downloading books already on the server.
+
+Matching mirrors the release matcher in ``auto_download``: ISBN exact match as a
+bonus, otherwise fuzzy title-token overlap plus the author surname. ABS library
+metadata here is messy (titles are often raw folder names, author frequently empty),
+so we tokenize title + author + folder path for each item.
+
+Fail-open by design: if the check is disabled or ABS is unreachable, ``is_in_library``
+returns False so the pipeline proceeds rather than stalling.
+"""
+
+from __future__ import annotations
+
+import threading
+import time
+from dataclasses import dataclass
+from typing import TYPE_CHECKING, Any
+
+import requests
+
+from shelfmark.core.config import config as app_config
+from shelfmark.core.logger import setup_logger
+from shelfmark.core.text_match import (
+ author_surname,
+ normalize_isbn,
+ title_tokens_match,
+ tokens,
+)
+
+if TYPE_CHECKING:
+ from shelfmark.metadata_providers import BookMetadata
+
+logger = setup_logger(__name__)
+
+_CACHE_TTL_SECONDS = 600 # Re-fetch the library at most every 10 minutes.
+_REQUEST_TIMEOUT = 15
+_ITEMS_PAGE_LIMIT = 500
+
+
+@dataclass(frozen=True)
+class LibraryEntry:
+ """Normalized, matchable representation of one ABS library item."""
+
+ tokens: frozenset[str]
+ isbns: frozenset[str]
+ asins: frozenset[str]
+
+
+_lock = threading.Lock()
+_cache_entries: list[LibraryEntry] | None = None
+_cache_time: float = 0.0
+
+
+def _config() -> tuple[bool, str, str, list[str]]:
+ enabled = bool(app_config.get("LIBRARY_CHECK_ENABLED", False))
+ url = str(app_config.get("AUDIOBOOKSHELF_URL", "") or "").strip().rstrip("/")
+ token = str(app_config.get("AUDIOBOOKSHELF_TOKEN", "") or "").strip()
+ lib_ids_raw = str(app_config.get("AUDIOBOOKSHELF_LIBRARY_IDS", "") or "")
+ lib_ids = [s.strip() for s in lib_ids_raw.split(",") if s.strip()]
+ return enabled, url, token, lib_ids
+
+
+def _session(token: str) -> requests.Session:
+ from shelfmark.download.network import get_ssl_verify
+
+ session = requests.Session()
+ session.headers.update({"Authorization": f"Bearer {token}"})
+ session.verify = get_ssl_verify()
+ return session
+
+
+def _item_to_entry(item: dict[str, Any]) -> LibraryEntry:
+ media = item.get("media") or {}
+ meta = media.get("metadata") or {}
+ title = meta.get("title") or ""
+ author = meta.get("authorName") or ""
+ rel_path = item.get("relPath") or ""
+
+ tok = set(tokens(title)) | set(tokens(author)) | set(tokens(rel_path))
+
+ isbns = {v for v in (normalize_isbn(meta.get("isbn")),) if v}
+ asin = str(meta.get("asin") or "").strip().upper()
+ asins = {asin} if asin else set()
+
+ return LibraryEntry(frozenset(tok), frozenset(isbns), frozenset(asins))
+
+
+def _fetch_library_entries(url: str, token: str, lib_ids: list[str]) -> list[LibraryEntry]:
+ session = _session(token)
+
+ resp = session.get(f"{url}/api/libraries", timeout=_REQUEST_TIMEOUT)
+ resp.raise_for_status()
+ libraries = resp.json().get("libraries", [])
+
+ selected: list[str] = []
+ for lib in libraries:
+ lib_id = lib.get("id")
+ if not lib_id:
+ continue
+ if lib_ids:
+ if lib_id in lib_ids:
+ selected.append(lib_id)
+ elif lib.get("mediaType") == "book":
+ selected.append(lib_id)
+
+ entries: list[LibraryEntry] = []
+ for lib_id in selected:
+ page = 0
+ while True:
+ resp = session.get(
+ f"{url}/api/libraries/{lib_id}/items",
+ params={"limit": _ITEMS_PAGE_LIMIT, "page": page},
+ timeout=_REQUEST_TIMEOUT,
+ )
+ resp.raise_for_status()
+ data = resp.json()
+ results = data.get("results", []) or []
+ entries.extend(_item_to_entry(item) for item in results)
+ total = int(data.get("total", 0) or 0)
+ page += 1
+ if not results or page * _ITEMS_PAGE_LIMIT >= total:
+ break
+
+ return entries
+
+
+def _get_entries() -> list[LibraryEntry]:
+ """Return cached library entries, refreshing past the TTL. Fail-open on error."""
+ global _cache_entries, _cache_time
+
+ enabled, url, token, lib_ids = _config()
+ if not enabled or not url or not token:
+ return []
+
+ now = time.monotonic()
+ with _lock:
+ if _cache_entries is not None and (now - _cache_time) < _CACHE_TTL_SECONDS:
+ return _cache_entries
+
+ try:
+ entries = _fetch_library_entries(url, token, lib_ids)
+ except Exception as exc: # noqa: BLE001 - any failure must fail open
+ logger.warning("library check: failed to fetch ABS library (%s); failing open", exc)
+ with _lock:
+ return _cache_entries or [] # Use stale cache if we have one.
+
+ with _lock:
+ _cache_entries = entries
+ _cache_time = time.monotonic()
+ logger.info("library check: indexed %d Audiobookshelf item(s)", len(entries))
+ return entries
+
+
+def book_matches_entries(book: BookMetadata, entries: list[LibraryEntry]) -> bool:
+ """Pure matcher: True if ``book`` matches any library entry (ISBN or fuzzy)."""
+ if not entries:
+ return False
+
+ book_isbns = {normalize_isbn(book.isbn_13), normalize_isbn(book.isbn_10)} - {""}
+ if book_isbns:
+ for entry in entries:
+ if entry.isbns & book_isbns:
+ return True
+
+ title = book.search_title or book.title
+ surname = author_surname(book.search_author or (book.authors[0] if book.authors else ""))
+ for entry in entries:
+ if title_tokens_match(title, set(entry.tokens)) and (
+ surname is None or surname in entry.tokens
+ ):
+ return True
+
+ return False
+
+
+def is_in_library(book: BookMetadata) -> bool:
+ """True if ``book`` appears to already exist in the ABS library (fail-open)."""
+ return book_matches_entries(book, _get_entries())
+
+
+def refresh_cache(entries: list[LibraryEntry]) -> None:
+ """Replace the cached entries (used after an explicit fetch)."""
+ global _cache_entries, _cache_time
+ with _lock:
+ _cache_entries = entries
+ _cache_time = time.monotonic()
+
+
+def test_connection() -> dict[str, Any]:
+ """Settings action: verify ABS connectivity and report the indexed item count."""
+ _enabled, url, token, lib_ids = _config()
+ if not url or not token:
+ return {"success": False, "message": "Set the Audiobookshelf URL and token first."}
+ try:
+ entries = _fetch_library_entries(url, token, lib_ids)
+ except Exception as exc: # noqa: BLE001 - surface any error to the user
+ return {"success": False, "message": f"Could not reach Audiobookshelf: {exc}"}
+ refresh_cache(entries)
+ return {"success": True, "message": f"Connected. Indexed {len(entries)} library item(s)."}
diff --git a/shelfmark/core/notifications.py b/shelfmark/core/notifications.py
index a4b2a1c2..7c56f60d 100644
--- a/shelfmark/core/notifications.py
+++ b/shelfmark/core/notifications.py
@@ -31,7 +31,7 @@
_APPRISE_APP_ID = "Shelfmark"
_APPRISE_APP_DESC = "Shelfmark notifications"
_APPRISE_LOGO_URL = (
- "https://raw.githubusercontent.com/calibrain/shelfmark/main/src/frontend/public/logo.png"
+ "https://raw.githubusercontent.com/InfiniteAvenger/shelfmark/main/src/frontend/public/logo.png"
)
_APPRISE_LOGGER_NAME = "apprise"
_APPRISE_DISPATCH_ERRORS = (RuntimeError, TypeError, ValueError)
diff --git a/shelfmark/core/release_search.py b/shelfmark/core/release_search.py
new file mode 100644
index 00000000..749ccee7
--- /dev/null
+++ b/shelfmark/core/release_search.py
@@ -0,0 +1,128 @@
+"""Shared release-search helpers.
+
+Extracted from the ``/api/releases`` route so the same per-source search logic can
+be reused by background automation (e.g. Hardcover auto-download) without going
+through HTTP. Behaviour for the HTTP route is preserved: the route now delegates
+its inner per-source search to :func:`search_source_releases`.
+"""
+
+from __future__ import annotations
+
+import sqlite3
+from typing import TYPE_CHECKING, Any
+
+from shelfmark.core.logger import setup_logger
+from shelfmark.core.search_plan import build_release_search_plan
+
+if TYPE_CHECKING:
+ from shelfmark.core.models import SearchFilters
+ from shelfmark.metadata_providers import BookMetadata
+ from shelfmark.release_sources import Release, ReleaseSource
+
+logger = setup_logger(__name__)
+
+# Mirror of main._OPERATIONAL_ERRORS so a misbehaving source can't crash a caller.
+_OPERATIONAL_ERRORS = (OSError, RuntimeError, TypeError, ValueError, sqlite3.Error)
+
+
+def search_source_releases(
+ source_name: str,
+ search_book: BookMetadata,
+ *,
+ languages: list[str] | None = None,
+ manual_query: str | None = None,
+ indexers: list[str] | None = None,
+ expand_search: bool = False,
+ content_type: str = "ebook",
+ source_filters: SearchFilters | None = None,
+) -> tuple[ReleaseSource | None, list[Release], str | None]:
+ """Search a single release source, returning any error instead of raising.
+
+ Returns ``(source, releases, error_message)``. On failure ``source`` is ``None``
+ and ``error_message`` describes the problem.
+ """
+ from shelfmark.release_sources import SourceUnavailableError, get_source
+
+ try:
+ source = get_source(source_name)
+
+ plan = build_release_search_plan(
+ search_book,
+ languages=languages,
+ manual_query=manual_query,
+ indexers=indexers,
+ source_filters=source_filters,
+ )
+
+ if plan.source_filters is not None:
+ planned_query = plan.manual_query or plan.primary_query
+ planned_query_type = "query"
+ elif plan.manual_query:
+ planned_query = plan.manual_query
+ planned_query_type = "manual"
+ elif not expand_search and plan.isbn_candidates:
+ planned_query = plan.isbn_candidates[0]
+ planned_query_type = "isbn"
+ else:
+ planned_query = plan.primary_query
+ planned_query_type = "title_author"
+
+ logger.debug(
+ "Searching %s: %s='%s' (title='%s', authors=%s, expand=%s, content_type=%s)",
+ source_name,
+ planned_query_type,
+ planned_query,
+ search_book.title,
+ search_book.authors,
+ expand_search,
+ content_type,
+ )
+
+ releases = source.search(
+ search_book, plan, expand_search=expand_search, content_type=content_type
+ )
+ except ValueError:
+ return None, [], f"Unknown source: {source_name}"
+ except (SourceUnavailableError, *_OPERATIONAL_ERRORS) as exc:
+ logger.warning("Release search failed for source %s: %s", source_name, exc)
+ return None, [], f"{source_name}: {exc!s}"
+ else:
+ return source, releases, None
+
+
+def search_book_releases(
+ book: BookMetadata,
+ *,
+ sources: list[str],
+ content_type: str = "ebook",
+ expand_search: bool = True,
+ languages: list[str] | None = None,
+ indexers: list[str] | None = None,
+) -> tuple[list[Release], dict[str, list[Release]], list[str]]:
+ """Search several sources for releases of ``book``.
+
+ Returns ``(all_releases, releases_by_source, errors)``. ``releases_by_source``
+ preserves the requested ``sources`` order so callers can prioritise.
+ """
+ all_releases: list[Release] = []
+ by_source: dict[str, list[Any]] = {}
+ errors: list[str] = []
+
+ for source_name in sources:
+ source, releases, error = search_source_releases(
+ source_name,
+ book,
+ languages=languages,
+ manual_query=None,
+ indexers=indexers,
+ expand_search=expand_search,
+ content_type=content_type,
+ source_filters=None,
+ )
+ if source is not None:
+ by_source[source_name] = list(releases)
+ all_releases.extend(releases)
+ if error is not None:
+ errors.append(error)
+
+ return all_releases, by_source, errors
diff --git a/shelfmark/core/request_routes.py b/shelfmark/core/request_routes.py
index ea3200e8..ad758800 100644
--- a/shelfmark/core/request_routes.py
+++ b/shelfmark/core/request_routes.py
@@ -72,13 +72,7 @@ def _require_request_endpoints_available(
resolve_auth_mode: Callable[[], str],
) -> tuple[Response, int] | None:
auth_mode = resolve_auth_mode()
- if auth_mode == "none":
- return _error_response(
- "Request workflow is unavailable in no-auth mode",
- 403,
- code="requests_unavailable",
- )
- if "user_id" not in session:
+ if auth_mode != "none" and "user_id" not in session:
return jsonify({"error": "Unauthorized"}), 401
return None
@@ -87,6 +81,9 @@ def _require_db_user_id() -> tuple[int | None, ResponseReturnValue | None]:
"""Return the logged-in DB user id or a ready-made error response."""
raw_user_id = session.get("db_user_id")
if raw_user_id is None:
+ from shelfmark.main import get_auth_mode
+ if get_auth_mode() == "none":
+ return 1, None
return None, _error_response(
"User identity is unavailable for request workflow",
403,
@@ -103,10 +100,15 @@ def _require_db_user_id() -> tuple[int | None, ResponseReturnValue | None]:
def _require_admin_user_id() -> tuple[int | None, ResponseReturnValue | None]:
- if not session.get("is_admin", False):
+ from shelfmark.main import get_auth_mode
+ auth_mode = get_auth_mode()
+ is_admin = bool(session.get("is_admin", False)) or (auth_mode == "none")
+ if not is_admin:
return None, (jsonify({"error": "Admin access required"}), 403)
raw_admin_id = session.get("db_user_id")
if raw_admin_id is None:
+ if auth_mode == "none":
+ return 1, None
return None, (jsonify({"error": "Admin user identity unavailable"}), 403)
normalized_admin_user_id = normalize_positive_int(raw_admin_id)
if normalized_admin_user_id is None:
@@ -244,6 +246,91 @@ def _resolve_request_title(request_row: dict[str, Any]) -> str:
return _resolve_title_from_book_data(request_row.get("book_data"))
+def _populate_requests_metadata(
+ rows: list[dict[str, Any]],
+ user_db: UserDB,
+) -> list[dict[str, Any]]:
+ """Populate missing metadata (covers, subtitle, etc.) on request items."""
+ from shelfmark.metadata_providers import (
+ get_configured_provider,
+ get_provider,
+ get_provider_kwargs,
+ is_provider_registered,
+ )
+
+ for row in rows:
+ book_data = row.get("book_data")
+ if not isinstance(book_data, dict):
+ continue
+
+ if "preview" not in book_data:
+ provider_name = book_data.get("provider")
+ provider_id = book_data.get("provider_id") or book_data.get("id")
+ if not provider_id:
+ continue
+
+ prov = None
+ if provider_name and is_provider_registered(provider_name):
+ try:
+ kwargs = get_provider_kwargs(provider_name)
+ prov = get_provider(provider_name, **kwargs)
+ except Exception as exc: # noqa: BLE001 - provider init must not break the loop
+ logger.warning(
+ "Failed to instantiate metadata provider %s: %s",
+ provider_name,
+ exc,
+ )
+
+ if not prov:
+ try:
+ prov = get_configured_provider(row.get("content_type") or "ebook")
+ except Exception as exc: # noqa: BLE001 - fallback lookup must not break the loop
+ logger.warning(
+ "Failed to get configured provider for content type: %s",
+ exc,
+ )
+
+ if prov:
+ try:
+ metadata = prov.get_book(str(provider_id))
+ if metadata:
+ book_data["preview"] = metadata.cover_url or ""
+ if metadata.publish_year and not book_data.get("year"):
+ book_data["year"] = metadata.publish_year
+ if metadata.subtitle and not book_data.get("subtitle"):
+ book_data["subtitle"] = metadata.subtitle
+ else:
+ book_data["preview"] = ""
+
+ updated_row = user_db.update_request(row["id"], book_data=book_data)
+ row.update(updated_row)
+ except Exception:
+ logger.exception(
+ "Failed to populate metadata for request ID %s",
+ row.get("id"),
+ )
+
+ return rows
+
+
+def _transform_requests_for_response(
+ rows: list[dict[str, Any]],
+) -> list[dict[str, Any]]:
+ """Transform book_data preview URLs to local proxy URLs before returning to client."""
+ from shelfmark.core.utils import transform_cover_url
+
+ for row in rows:
+ book_data = row.get("book_data")
+ if isinstance(book_data, dict):
+ preview = book_data.get("preview")
+ if isinstance(preview, str) and preview:
+ provider = book_data.get("provider") or "manual"
+ provider_id = book_data.get("provider_id") or book_data.get("id") or str(row["id"])
+ cache_id = f"{provider}_{provider_id}"
+ book_data["preview"] = transform_cover_url(preview, cache_id)
+ return rows
+
+
def _format_user_label(username: str | None, user_id: int | None = None) -> str:
normalized_username = normalize_optional_text(username)
if normalized_username is not None:
@@ -276,7 +363,9 @@ def _resolve_request_user_context(
actor_label = _format_user_label(actor_username, actor_user_id)
return actor_user_id, actor_username, actor_label
- if not session.get("is_admin", False):
+ from shelfmark.main import get_auth_mode
+ is_admin = bool(session.get("is_admin", False)) or (get_auth_mode() == "none")
+ if not is_admin:
msg = "Admin required"
raise RequestServiceError(msg, status_code=403)
@@ -570,7 +659,7 @@ def api_request_policy() -> ResponseReturnValue:
if auth_gate is not None:
return auth_gate
- is_admin = bool(session.get("is_admin", False))
+ is_admin = bool(session.get("is_admin", False)) or (resolve_auth_mode() == "none")
db_user_id: int | None = None
if not is_admin:
db_user_id, db_gate = _require_db_user_id()
@@ -700,6 +789,7 @@ def api_create_request() -> ResponseReturnValue:
request_row=created,
)
+ _transform_requests_for_response([created])
return jsonify(created), 201
@app.route("/api/requests/batch", methods=["POST"])
@@ -814,6 +904,7 @@ def api_create_requests_batch() -> ResponseReturnValue:
ordered_results = [results_by_index[index] for index in range(len(prepared_requests))]
status_code = 201 if request_prepared_items else 200
+ _transform_requests_for_response(ordered_results)
return jsonify(ordered_results), status_code
@app.route("/api/requests", methods=["GET"])
@@ -845,6 +936,8 @@ def api_list_requests() -> ResponseReturnValue:
)
except ValueError as exc:
return jsonify({"error": str(exc)}), 400
+ _populate_requests_metadata(rows, user_db)
+ _transform_requests_for_response(rows)
return jsonify(rows)
@app.route("/api/requests/", methods=["DELETE"])
@@ -872,6 +965,8 @@ def api_cancel_request(request_id: int) -> ResponseReturnValue:
except RequestServiceError as exc:
return _error_response(str(exc), exc.status_code, code=exc.code)
+ _populate_requests_metadata([updated], user_db)
+
event_payload = {
"request_id": updated["id"],
"status": updated["status"],
@@ -899,6 +994,7 @@ def api_cancel_request(request_id: int) -> ResponseReturnValue:
room="admins",
)
+ _transform_requests_for_response([updated])
return jsonify(updated)
@app.route("/api/admin/requests", methods=["GET"])
@@ -906,7 +1002,7 @@ def api_admin_list_requests() -> ResponseReturnValue:
auth_gate = _require_request_endpoints_available(resolve_auth_mode)
if auth_gate is not None:
return auth_gate
- if not session.get("is_admin", False):
+ if not (session.get("is_admin", False) or resolve_auth_mode() == "none"):
return jsonify({"error": "Admin access required"}), 403
status = request.args.get("status")
@@ -919,6 +1015,8 @@ def api_admin_list_requests() -> ResponseReturnValue:
return jsonify({"error": str(exc)}), 400
populate_request_usernames(rows, user_db)
+ _populate_requests_metadata(rows, user_db)
+ _transform_requests_for_response(rows)
return jsonify(rows)
@@ -927,7 +1025,7 @@ def api_admin_request_counts() -> ResponseReturnValue:
auth_gate = _require_request_endpoints_available(resolve_auth_mode)
if auth_gate is not None:
return auth_gate
- if not session.get("is_admin", False):
+ if not (session.get("is_admin", False) or resolve_auth_mode() == "none"):
return jsonify({"error": "Admin access required"}), 403
by_status = {status: len(user_db.list_requests(status=status)) for status in RequestStatus}
@@ -968,6 +1066,8 @@ def api_admin_fulfil_request(request_id: int) -> ResponseReturnValue:
except RequestServiceError as exc:
return _error_response(str(exc), exc.status_code, code=exc.code)
+ _populate_requests_metadata([updated], user_db)
+
event_payload = {
"request_id": updated["id"],
"status": updated["status"],
@@ -1003,6 +1103,7 @@ def api_admin_fulfil_request(request_id: int) -> ResponseReturnValue:
request_row=updated,
)
+ _transform_requests_for_response([updated])
return jsonify(updated)
@app.route("/api/admin/requests//reject", methods=["POST"])
@@ -1031,6 +1132,8 @@ def api_admin_reject_request(request_id: int) -> ResponseReturnValue:
except RequestServiceError as exc:
return _error_response(str(exc), exc.status_code, code=exc.code)
+ _populate_requests_metadata([updated], user_db)
+
event_payload = {
"request_id": updated["id"],
"status": updated["status"],
@@ -1066,4 +1169,5 @@ def api_admin_reject_request(request_id: int) -> ResponseReturnValue:
request_row=updated,
)
+ _transform_requests_for_response([updated])
return jsonify(updated)
diff --git a/shelfmark/core/text_match.py b/shelfmark/core/text_match.py
new file mode 100644
index 00000000..3f48cd7e
--- /dev/null
+++ b/shelfmark/core/text_match.py
@@ -0,0 +1,71 @@
+"""Shared text-normalization + fuzzy token-matching helpers for book matching.
+
+Used by both the release matcher (``auto_download``) and the Audiobookshelf library
+ownership check (``library_index``) so matching behaviour stays consistent.
+"""
+
+from __future__ import annotations
+
+import re
+
+DEFAULT_TITLE_MATCH_THRESHOLD = 0.85
+
+# Short/common words that add noise to title token matching.
+STOPWORDS = frozenset(
+ {
+ "a",
+ "an",
+ "the",
+ "of",
+ "and",
+ "or",
+ "to",
+ "in",
+ "on",
+ "for",
+ "with",
+ "is",
+ "by",
+ }
+)
+
+
+def tokens(text: str | None) -> list[str]:
+ """Lowercase alphanumeric tokens from arbitrary text."""
+ if not text:
+ return []
+ return [tok for tok in re.split(r"[^a-z0-9]+", text.lower()) if tok]
+
+
+def significant_tokens(text: str | None) -> list[str]:
+ """Tokens with stopwords and 1-char noise removed."""
+ return [tok for tok in tokens(text) if len(tok) >= 2 and tok not in STOPWORDS]
+
+
+def author_surname(author: str | None) -> str | None:
+ """Return the most distinctive author token (the surname), or None."""
+ value = author or ""
+ if "," in value: # "Last, First" -> keep the "Last" portion
+ value = value.split(",")[0]
+ toks = significant_tokens(value)
+ return toks[-1] if toks else None
+
+
+def title_tokens_match(
+ title: str | None,
+ haystack_tokens: set[str],
+ threshold: float = DEFAULT_TITLE_MATCH_THRESHOLD,
+) -> bool:
+ """True when enough significant title tokens appear in ``haystack_tokens``."""
+ title_toks = significant_tokens(title)
+ if not title_toks:
+ return False
+ present = sum(1 for tok in title_toks if tok in haystack_tokens)
+ return (present / len(title_toks)) >= threshold
+
+
+def normalize_isbn(value: object) -> str:
+ """Normalize an ISBN to comparable form (digits + trailing X, uppercased)."""
+ if not value:
+ return ""
+ return re.sub(r"[^0-9xX]", "", str(value)).upper()
diff --git a/shelfmark/main.py b/shelfmark/main.py
index 19557d2b..f348c7d2 100644
--- a/shelfmark/main.py
+++ b/shelfmark/main.py
@@ -200,6 +200,15 @@ def _raise_runtime_error(message: str) -> NoReturn:
# Start download coordinator
backend.start()
+# Start the Hardcover wishlist sync + auto-download scheduler (in-process daemon).
+if user_db is not None:
+ try:
+ from shelfmark.core import hardcover_scheduler
+
+ hardcover_scheduler.start(user_db, backend.queue_release, _user_db_path)
+ except (ImportError, RuntimeError, OSError) as exc:
+ logger.warning("Failed to start Hardcover scheduler: %s", exc)
+
# Rate limiting for login attempts
# Map usernames to their failed-attempt counters and lockout timestamps.
failed_login_attempts: dict[str, dict[str, Any]] = {}
@@ -2808,7 +2817,7 @@ def api_releases() -> Response | tuple[Response, int]:
try:
from dataclasses import asdict
- from shelfmark.core.search_plan import build_release_search_plan
+ from shelfmark.core.release_search import search_source_releases
from shelfmark.metadata_providers import (
BookMetadata,
get_provider,
@@ -2827,53 +2836,20 @@ def _search_source_releases(
source_name: str, search_book: BookMetadata
) -> tuple[Any | None, list[Any], str | None]:
"""Search one source and return any error message instead of raising."""
- try:
- source = get_source(source_name)
-
- plan = build_release_search_plan(
- search_book,
- languages=browse_filters.lang
- if source_query_filters is not None
- else languages,
- manual_query=query_text if source_query_filters is not None else manual_query,
- indexers=indexers,
- source_filters=source_query_filters,
- )
-
- if plan.source_filters is not None:
- planned_query = plan.manual_query or plan.primary_query
- planned_query_type = "query"
- elif plan.manual_query:
- planned_query = plan.manual_query
- planned_query_type = "manual"
- elif not expand_search and plan.isbn_candidates:
- planned_query = plan.isbn_candidates[0]
- planned_query_type = "isbn"
- else:
- planned_query = plan.primary_query
- planned_query_type = "title_author"
-
- logger.debug(
- "Searching %s: %s='%s' (title='%s', authors=%s, expand=%s, content_type=%s)",
- source_name,
- planned_query_type,
- planned_query,
- search_book.title,
- search_book.authors,
- expand_search,
- content_type,
- )
-
- releases = source.search(
- search_book, plan, expand_search=expand_search, content_type=content_type
- )
- except ValueError:
- return None, [], f"Unknown source: {source_name}"
- except (SourceUnavailableError, *_OPERATIONAL_ERRORS) as e:
- logger.warning("Release search failed for source %s: %s", source_name, e)
- return None, [], f"{source_name}: {e!s}"
- else:
- return source, releases, None
+ return search_source_releases(
+ source_name,
+ search_book,
+ languages=(
+ browse_filters.lang if source_query_filters is not None else languages
+ ),
+ manual_query=(
+ query_text if source_query_filters is not None else manual_query
+ ),
+ indexers=indexers,
+ expand_search=expand_search,
+ content_type=content_type,
+ source_filters=source_query_filters,
+ )
provider = request.args.get("provider", "").strip()
book_id = request.args.get("book_id", "").strip()
diff --git a/src/frontend/src/components/Footer.tsx b/src/frontend/src/components/Footer.tsx
index 8eddb935..35a320f9 100644
--- a/src/frontend/src/components/Footer.tsx
+++ b/src/frontend/src/components/Footer.tsx
@@ -23,7 +23,7 @@ export const Footer = ({ buildVersion, releaseVersion, debug }: FooterProps) =>
>