Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
73c00fa
Add Shelfmark external search and request integration to CWA
EdLaughton Apr 9, 2026
62712fd
Allow local Shelfmark base URLs in scoped validator
EdLaughton Apr 9, 2026
f22d69c
Polish Shelfmark external search UI
EdLaughton Apr 9, 2026
486c23a
Fix Shelfmark browser request policy handling
EdLaughton Apr 9, 2026
3438143
Refine Shelfmark result links and request readiness
EdLaughton Apr 9, 2026
f212145
Split Shelfmark browser URL from server-side search
EdLaughton Apr 9, 2026
4a87840
Fix Shelfmark search UI and request upgrade flow
EdLaughton Apr 9, 2026
3b8c091
Polish Shelfmark search result presentation
EdLaughton Apr 9, 2026
4e278a4
Fix Shelfmark covers and search controls
EdLaughton Apr 9, 2026
7714a90
Refine Shelfmark detail page and cover sync
EdLaughton Apr 9, 2026
9531a0e
Polish Shelfmark request-focused results UX
EdLaughton Apr 10, 2026
ca6be4b
Add Shelfmark detail modal overlay
EdLaughton Apr 10, 2026
c80d53b
Add Shelfmark workflow status and batch request actions
EdLaughton Apr 10, 2026
19ebd5a
Refine Shelfmark metadata presentation
EdLaughton Apr 10, 2026
bc363f9
Finalize Shelfmark workflow polish
EdLaughton Apr 11, 2026
255f409
UI polish pass
EdLaughton Apr 11, 2026
c5b39e7
Optimize Shelfmark search render path
EdLaughton Apr 11, 2026
d1a8a3b
Load Shelfmark results page-first with progressive row enrichment
EdLaughton Apr 11, 2026
be9724e
Tidy
EdLaughton Apr 11, 2026
34402cf
Polish Shelfmark hydration, detail data, and toolbar cleanup
EdLaughton Apr 11, 2026
1786a8e
Add opt-in Shelfmark preferred release workflow
EdLaughton Apr 11, 2026
fdc886e
Improve settings
EdLaughton Apr 12, 2026
848bda4
Add explicit Hardcover provenance logging
EdLaughton Apr 12, 2026
83ae4cb
Improve bulk search
EdLaughton Apr 13, 2026
84a61ef
Refine exact Hardcover edition selection
EdLaughton Apr 13, 2026
b54e216
Fix Hardcover cover updates on save and ingest
EdLaughton Apr 13, 2026
1645c20
Polish Shelfmark results and exact ingest flow
EdLaughton Apr 13, 2026
2f263f1
Fix manual metadata save transaction recovery
EdLaughton Apr 13, 2026
3bec42e
Harden Shelfmark UI ingest and request flows
EdLaughton Apr 17, 2026
9d188e1
Polish Shelfmark filters and page fill
EdLaughton Apr 17, 2026
e5f07f3
Add Shelfmark state summary and compact batch mode
EdLaughton Apr 17, 2026
3aefbb4
Rework Shelfmark display pagination and footer state
EdLaughton Apr 17, 2026
504c09e
Fix load results
EdLaughton Apr 17, 2026
8b01530
Simplify Shelfmark search page chrome
EdLaughton Apr 18, 2026
44014cd
Add contextual Shelfmark sections and clean search glue
EdLaughton Apr 18, 2026
4231780
Add multi-series Shelfmark result context
EdLaughton Apr 18, 2026
9230e94
Load contextual Shelfmark pages asynchronously
EdLaughton Apr 18, 2026
40ce2a7
Add Requests workspace and harden author Shelfmark pages
EdLaughton Apr 18, 2026
0b872fd
Fix contextual Shelfmark author and series loads
EdLaughton Apr 18, 2026
efaaff8
Make Requests load progressively and tighten author bio
EdLaughton Apr 18, 2026
574295b
New requests page
EdLaughton Apr 18, 2026
9ea9b68
Fix requests sidebar section rendering
EdLaughton Apr 18, 2026
c01c9f6
Rework Requests IA and layout
EdLaughton Apr 18, 2026
84c6106
Fix css
EdLaughton Apr 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 40 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,45 @@ Built-in KOReader progress sync with automatic book identification:
- Configurable scheduling with progress tracking in Tasks
- Graceful handling of invalid tokens

#### **Shelfmark External Search (Stage 4)** 🔎📚
- Optional CWA-side integration that shows Shelfmark external results alongside normal library search results
- Simple search and advanced search can both surface Shelfmark results:
- simple search forwards the search term directly
- advanced search only sends title, author, and publisher metadata when those fields are present
- other advanced filters stay local to CWA
- Duplicate awareness is deliberately exact and read-only:
- `metadata.db`
- exact `hardcover-id`
- no filename, title, or author fuzzy matching
- External results are grouped to make the state obvious:
- already in your library
- external candidates with an exact Hardcover ID but no local match
- external results where duplicate status is unavailable because Shelfmark returned no exact Hardcover ID
- The search and detail views now highlight those states visually with grouped headers, quick-jump links back into CWA, and clearer primary actions for:
- opening the existing CWA book
- requesting in Shelfmark
- opening Shelfmark directly
- External results that already exist in the library link straight back to the existing CWA book page
- External detail pages keep the same exact duplicate rules and show the existing CWA book when a match exists
- Request attribution stays on the Shelfmark side:
- CWA can fetch external results server-side
- the browser probes Shelfmark session state via Shelfmark auth APIs
- if the browser already has a valid Shelfmark session and the current Shelfmark policy allows it, the action upgrades to `Request in Shelfmark`
- otherwise the safe fallback remains `Open in Shelfmark`
- Recommended deployment model for direct requests:
- put CWA and Shelfmark behind the same browser origin or reverse proxy path
- if CWA reaches Shelfmark server-side through a private or LAN-only URL, set `Shelfmark Browser URL` to the browser-facing same-origin or reverse-proxied Shelfmark address
- if the browser sees Shelfmark as cross-origin, CWA falls back to `Open in Shelfmark` rather than trying to fake request attribution
- if Shelfmark is configured to sync/auth users from the mounted CWA `app.db`, users can log into Shelfmark with those synced CWA credentials and direct requests will then be attributed in Shelfmark as that same user
- Admin configuration:
- enable `Shelfmark External Search`
- set `Shelfmark Base URL`
- `Shelfmark Base URL` is the exact trust boundary for CWA's server-side Shelfmark metadata requests, including local/private Shelfmark hosts
- optionally set `Shelfmark Browser URL` when browsers should open Shelfmark through a different same-origin or reverse-proxied address than the server-side search URL
- if Shelfmark protects `/api/metadata/search`, set Shelfmark search username/password in CWA admin for CWA's server-side metadata search
- those search-account fields are separate from the browser's Shelfmark login used for `Request in Shelfmark`
- when Shelfmark is using the mounted CWA `app.db` for auth, users can still use their own synced CWA credentials in the browser for per-user request attribution, while CWA can use a dedicated search account here if needed

#### **Library Auto-Detect** 📚🕵️
- Made to **MASSIVELY** simplify the setup process for both **new and existing users** alike
- **New Users without existing Libraries:** 🆕
Expand Down Expand Up @@ -486,4 +525,4 @@ CWA is and always will be free and open source. If it makes your library life ea
- Development tools and infrastructure
- Coffee ☕ (lots of coffee)

[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/crocodilestick)
[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/crocodilestick)
26 changes: 25 additions & 1 deletion cps/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
from .embed_helper import get_calibre_binarypath
from .gdriveutils import is_gdrive_ready, gdrive_support
from .render_template import render_title_template, get_sidebar_config
from .services.shelfmark_search import run_shelfmark_settings_diagnostics
from .services.worker import WorkerThread
from .usermanagement import user_login_required
from .cw_babel import get_available_translations, get_available_locale, get_user_locale_language
Expand Down Expand Up @@ -2353,6 +2354,20 @@ def _configuration_update_helper():
_config_checkbox(to_save, "config_hardcover_annotations_sync")
_config_string(to_save, "config_hardcover_token")

# Shelfmark external search configuration
_config_checkbox(to_save, "config_shelfmark_search")
_config_string(to_save, "config_shelfmark_url")
_config_string(to_save, "config_shelfmark_browser_url")
_config_string(to_save, "config_shelfmark_username")
if to_save.get("config_shelfmark_password_e", ""):
_config_string(to_save, "config_shelfmark_password_e")
_config_checkbox(to_save, "config_shelfmark_preferred_release_enabled")
_config_string(to_save, "config_shelfmark_preferred_release_provider")
_config_string(to_save, "config_shelfmark_preferred_release_content_type")
_config_string(to_save, "config_shelfmark_preferred_release_ranking")
if config.config_shelfmark_search and not config.config_shelfmark_url:
return _configuration_result(_('Shelfmark Base URL is required when integrated Shelfmark search is enabled'))

_config_int(to_save, "config_updatechannel")

# Reverse proxy login configuration
Expand Down Expand Up @@ -2958,6 +2973,15 @@ def test_metadata():
log.error("Metadata test failed: %s", e)
return json.dumps({'success': False, 'message': _('An unknown error occurred.')}), 200


@admi.route("/admin/test_shelfmark_connection", methods=["POST"])
@user_login_required
@admin_required
def test_shelfmark_connection():
payload = request.get_json(silent=True) or {}
diagnostics = run_shelfmark_settings_diagnostics(payload)
return jsonify(diagnostics.to_template_dict())

# --- Last Resort Calibre DB Restore ---
@admi.route("/admin/restore_calibre_db", methods=["POST"])
@user_login_required
Expand Down Expand Up @@ -3113,4 +3137,4 @@ def restore_calibre_db():
except Exception:
pass
except Exception:
pass
pass
215 changes: 215 additions & 0 deletions cps/author_profile.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
# -*- coding: utf-8 -*-
# Calibre-Web Automated – fork of Calibre-Web
# Copyright (C) 2026 Calibre-Web Automated contributors
# SPDX-License-Identifier: GPL-3.0-or-later

from __future__ import annotations

from dataclasses import dataclass
import re

from flask_babel import gettext as _

from . import logger, services

log = logger.create()


@dataclass(frozen=True)
class AuthorProfileView:
name: str
image_url: str | None = None
safe_about: str | None = None
link: str | None = None
source_label: str | None = None


def _normalize_author_key(value: str | None) -> str:
if not value:
return ""
normalized = str(value).replace("|", ",").lower()
normalized = re.sub(r"[^a-z0-9]+", " ", normalized)
return " ".join(normalized.split())


def _candidate_author_names(author_name: str) -> tuple[str, ...]:
normalized = " ".join((author_name or "").replace("|", ",").split())
if not normalized:
return tuple()

candidates = [normalized]
if "," in normalized:
parts = [part.strip() for part in normalized.split(",") if part.strip()]
if len(parts) == 2:
display_name = " ".join(reversed(parts)).strip()
if display_name and display_name not in candidates:
candidates.append(display_name)
return tuple(candidates)


def _from_hardcover(author_name: str) -> AuthorProfileView | None:
hardcover_service = getattr(services, "hardcover", None)
if not hardcover_service:
return None

for candidate_name in _candidate_author_names(author_name):
try:
profile = hardcover_service.get_author_profile(candidate_name)
except Exception as exc: # pragma: no cover - defensive app-path guard
log.warning("Hardcover author profile lookup failed for %s: %s", candidate_name, exc)
continue

if not profile:
continue

return AuthorProfileView(
name=profile.name or candidate_name,
image_url=profile.image_url,
safe_about=profile.safe_about,
link=profile.link,
source_label=_("Hardcover"),
)
return None


def _extract_hardcover_book_ids(library_books) -> tuple[int, ...]:
seen: set[int] = set()
normalized_ids: list[int] = []
for book in library_books or ():
for identifier in getattr(book, "identifiers", None) or ():
identifier_type = str(getattr(identifier, "type", "") or "").strip().lower()
if identifier_type != "hardcover-id":
continue
raw_value = getattr(identifier, "val", None)
try:
hardcover_id = int(str(raw_value).strip())
except (TypeError, ValueError):
continue
if hardcover_id in seen:
continue
seen.add(hardcover_id)
normalized_ids.append(hardcover_id)
return tuple(normalized_ids)


def _select_hardcover_author_id(author_name: str, library_books) -> int | None:
hardcover_service = getattr(services, "hardcover", None)
if not hardcover_service or not hasattr(hardcover_service, "get_hardcover_client"):
return None

book_ids = _extract_hardcover_book_ids(library_books)
if not book_ids:
return None

try:
client = hardcover_service.get_hardcover_client(load_privacy=False)
except Exception as exc: # pragma: no cover - defensive app-path guard
log.warning("Hardcover client lookup failed while resolving author profile for %s: %s", author_name, exc)
return None
if client is None:
return None

try:
books = client.list_books_by_ids(list(book_ids))
except Exception as exc: # pragma: no cover - defensive app-path guard
log.warning("Hardcover book lookup failed while resolving author profile for %s: %s", author_name, exc)
return None

candidate_keys = {_normalize_author_key(candidate) for candidate in _candidate_author_names(author_name)}
candidate_keys.discard("")
if not candidate_keys:
return None

scores: dict[int, int] = {}
for book in books or ():
for contribution in book.get("contributions") or ():
author = contribution.get("author") if isinstance(contribution, dict) else None
if not isinstance(author, dict):
continue
try:
hardcover_author_id = int(author.get("id"))
except (TypeError, ValueError):
continue
if _normalize_author_key(author.get("name")) not in candidate_keys:
continue
scores[hardcover_author_id] = scores.get(hardcover_author_id, 0) + 1

if not scores:
return None

return max(scores.items(), key=lambda item: (item[1], -item[0]))[0]


def _from_hardcover_library_books(author_name: str, library_books) -> AuthorProfileView | None:
hardcover_service = getattr(services, "hardcover", None)
if not hardcover_service or not hasattr(hardcover_service, "get_author_profile_by_id"):
return None

hardcover_author_id = _select_hardcover_author_id(author_name, library_books)
if hardcover_author_id is None:
return None

try:
profile = hardcover_service.get_author_profile_by_id(hardcover_author_id)
except Exception as exc: # pragma: no cover - defensive app-path guard
log.warning(
"Hardcover author profile by id failed for %s (author_id=%s): %s",
author_name,
hardcover_author_id,
exc,
)
return None
if not profile:
return None

return AuthorProfileView(
name=profile.name or author_name,
image_url=profile.image_url,
safe_about=profile.safe_about,
link=profile.link,
source_label=_("Hardcover"),
)


def _from_goodreads(author_name: str) -> AuthorProfileView | None:
goodreads_service = getattr(services, "goodreads_support", None)
if not goodreads_service:
return None

for candidate_name in _candidate_author_names(author_name):
try:
profile = goodreads_service.get_author_info(candidate_name)
except Exception as exc: # pragma: no cover - defensive app-path guard
log.warning("Goodreads author profile lookup failed for %s: %s", candidate_name, exc)
continue

if not profile:
continue

return AuthorProfileView(
name=getattr(profile, "name", None) or candidate_name,
image_url=getattr(profile, "image_url", None),
safe_about=getattr(profile, "safe_about", None),
link=getattr(profile, "link", None),
source_label=_("Goodreads"),
)
return None


def build_author_profile(author_name: str, *, library_books=None) -> AuthorProfileView | None:
if not author_name:
return None

hardcover_profile = _from_hardcover_library_books(author_name, library_books)
if hardcover_profile and (hardcover_profile.image_url or hardcover_profile.safe_about):
return hardcover_profile

hardcover_profile = _from_hardcover(author_name)
if hardcover_profile and (hardcover_profile.image_url or hardcover_profile.safe_about):
return hardcover_profile

goodreads_profile = _from_goodreads(author_name)
if goodreads_profile and (goodreads_profile.image_url or goodreads_profile.safe_about):
return goodreads_profile

return hardcover_profile or goodreads_profile
14 changes: 13 additions & 1 deletion cps/config_sql.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,15 @@ class _Settings(_Base):
config_use_goodreads = Column(Boolean, default=False)
config_goodreads_api_key = Column(String)
config_hardcover_token = Column(String)
config_shelfmark_search = Column(Boolean, default=False)
config_shelfmark_url = Column(String, default="")
config_shelfmark_browser_url = Column(String, default="")
config_shelfmark_username = Column(String, default="")
config_shelfmark_password_e = Column(String)
config_shelfmark_preferred_release_enabled = Column(Boolean, default=False)
config_shelfmark_preferred_release_provider = Column(String, default="")
config_shelfmark_preferred_release_content_type = Column(String, default="ebook")
config_shelfmark_preferred_release_ranking = Column(String, default="seeders_desc")

config_register_email = Column(Boolean, default=False)
config_login_type = Column(Integer, default=0)
Expand Down Expand Up @@ -438,7 +447,10 @@ def invalidate(self, error=None):
self.save()

def get_book_path(self):
return self.config_calibre_split_dir if self.config_calibre_split else self.config_calibre_dir
split_library = bool(getattr(self, "config_calibre_split", False))
if split_library:
return getattr(self, "config_calibre_split_dir", None) or getattr(self, "config_calibre_dir", None)
return getattr(self, "config_calibre_dir", None)

def store_calibre_uuid(self, calibre_db, Library_table):
from . import app
Expand Down
3 changes: 2 additions & 1 deletion cps/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@
SIDEBAR_DOWNLOAD = 1 << 16
SIDEBAR_LIST = 1 << 17
SIDEBAR_DUPLICATES = 1 << 18
SIDEBAR_REQUESTS = 1 << 19

sidebar_settings = {
"detail_random": DETAIL_RANDOM,
Expand All @@ -112,7 +113,7 @@


ADMIN_USER_ROLES = sum(r for r in ALL_ROLES.values()) & ~ROLE_ANONYMOUS
ADMIN_USER_SIDEBAR = (SIDEBAR_DUPLICATES << 1) - 1
ADMIN_USER_SIDEBAR = (SIDEBAR_REQUESTS << 1) - 1

UPDATE_STABLE = 0 << 0
AUTO_UPDATE_STABLE = 1 << 0
Expand Down
Loading
Loading