Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
31 changes: 31 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,37 @@ DASHBOARD_QUOTE_CACHE_TTL=86400 # in seconds; default 24h
# Frontend settings (build-time)
PUBLIC_DEFAULT_LOCALE=en # UI default locale: en | de

# Book import search providers
# At least one source must be able to answer. If every source below is disabled, the
# backend refuses to start and names the variables in the error.
OPEN_LIBRARY_IMPORT_SEARCH_ENABLED=true

# BnF (Bibliotheque nationale de France) - no API key needed, HTTPS
BNF_IMPORT_SEARCH_ENABLED=true

# Inventaire.io - no API key needed, HTTPS, ISBN searches only
INVENTAIRE_IMPORT_SEARCH_ENABLED=true

# Hardcover and Google Books need credentials before they can answer anything, so they
# are set to false here. The built-in default is true so that upgrading never silently
# drops a source; set either to true once its key or token is configured:
# HARDCOVER_APP_API_TOKEN (see https://codebude.github.io/librislog/guide/api-keys)
# GOOGLE_BOOKS_API_KEY
# A source left enabled without its key makes no request and is reported as skipped,
# so leaving the default is harmless, just noisier in the search progress list.
# Setting GOOGLE_BOOKS_IMPORT_SEARCH_ENABLED=false also disables the on-demand
# "Search Google Books too" button.
HARDCOVER_IMPORT_SEARCH_ENABLED=false
GOOGLE_BOOKS_IMPORT_SEARCH_ENABLED=false

# Library of Congress - no API key needed
# WARNING: This source is reached over PLAIN HTTP (unencrypted) on port 210; the LC
# offers no HTTPS for its SRU endpoint. Book queries and responses travel unencrypted
# and can be observed or tampered with on the network path. Only public bibliographic
# data is transferred. Set this to false if unencrypted egress to port 210 is not
# acceptable in your environment. All other sources use HTTPS.
LIBRARY_OF_CONGRESS_IMPORT_SEARCH_ENABLED=false

# Cover scraper settings
THALIA_COVER_SEARCH_ENABLED=false # For research only. Do not use in production without understanding the legal implications of scraping.

Expand Down
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
<img src="https://img.shields.io/badge/license-MIT-green" alt="License">
</p>

**Multi-user book tracking webapp** — maintain four reading lists, import books from Open Library & Google Books, scrape cover art, and get rich reading analytics — all on your own hardware.
**Multi-user book tracking webapp** — maintain four reading lists, import books from six metadata sources, scrape cover art, and get rich reading analytics — all on your own hardware.

> `docker compose up -d` → full data ownership, no vendor lock-in, no API keys required.

Expand Down Expand Up @@ -62,7 +62,7 @@ Open **http://localhost:8001** and create your account.
## Why LibrisLog?

- **Your data, your rules.** Fully self-hosted under MIT license — no ads, no user tracking, no vendor lock-in. A single SQLite file you can back up anytime.
- **No API keys required.** Works with Open Library out of the box. Add Google Books or Hardcover.app tokens optionally for richer search results.
- **No API keys required.** Four sources work out of the box: Open Library, BnF, Inventaire.io, and the Library of Congress. BnF and Inventaire also find books that Open Library misses, which matters for non-English editions. Add Google Books or Hardcover.app tokens optionally (both are off in `.env.example`; set their key and their `*_IMPORT_SEARCH_ENABLED=true` to use them), and switch any source on or off individually. The Library of Congress endpoint is only reachable over plain HTTP, so it sends queries unencrypted unless you disable it — see [Configuration](https://docs.librislog.app/guide/configuration).
- **Rich insights from day one.** Calendar heatmap, language/status/page distribution charts, books finished per month/year, top authors — all on your hardware.
- **Multi-user from the start.** User roles (admin/user), optional OIDC SSO, per-user libraries. One instance works for your whole household or small group.
- **Import any format you have.** Goodreads or Bookstats exports with automatic field mapping, generic CSV or Excel (XLSX) with per-field Python transforms, JSON, ZIP with covers.
Expand All @@ -80,11 +80,13 @@ Open **http://localhost:8001** and create your account.
- **Library** — Grid/list view, search and sort, four reading statuses (Want to Read, Currently Reading, Read, Did Not Finish)
- **Reading progress** — Page-level slider, full progress timeline per book with edit/history
- **Statistics dashboard** — Calendar heatmap, distribution charts, books finished per period, top authors
- **Book import** — Search Open Library, Google Books, Hardcover.app. Scan ISBN barcodes on mobile. Manual entry for anything not found
- **Reading streaks & goals** — Current and all-time longest reading streak, plus optional daily, monthly, and yearly goals
- **Book import** — Search Open Library, BnF, Inventaire.io, the Library of Congress, Hardcover.app, and Google Books in parallel, each source individually switchable. Results describing the same book are grouped so you can compare editions side by side before importing. Scan ISBN barcodes on mobile, collect candidates in a basket, or enter manually
- **Data portability** — Export as JSON, CSV, or ZIP with covers. Import from the Goodreads or Bookstats presets, or any CSV/Excel file with custom field mapping
- **Cover management** — Automatic multi-source cover search with manual override, URL paste, or file upload
- **Data hygiene** — Find and fix missing metadata (covers, page counts, authors) in bulk
- **Multi-user** — Admin/user roles, per-user libraries, optional OIDC login
- **Public profiles** — Shareable, read-only profile links with configurable sections, audience, language, and optional expiry
- **Themes** — Light, dark, and custom DaisyUI themes with persistent preferences
- **Administration** — Full backup/restore of the SQLite database, user management, API key management

Expand Down
10 changes: 10 additions & 0 deletions backend/app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,16 @@ class Settings(BaseSettings):
cover_import_timeout_seconds: int = 15
hardcover_app_api_token: str = ""
thalia_cover_search_enabled: bool = False
open_library_import_search_enabled: bool = True
# Hardcover and Google Books need credentials to answer at all. The built-in default
# stays true so upgrading never silently drops a source someone had configured
# before these switches existed; without a token/key the source is skipped anyway.
# .env.example and the docs recommend setting these to false when unused.
hardcover_import_search_enabled: bool = True
google_books_import_search_enabled: bool = True
bnf_import_search_enabled: bool = True
inventaire_import_search_enabled: bool = True
library_of_congress_import_search_enabled: bool = True
embed_enabled: bool = True
mail_server: str = ""
mail_port: int = 587
Expand Down
37 changes: 37 additions & 0 deletions backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,9 +76,46 @@ async def _telemetry_heartbeat() -> None:
await asyncio.sleep(_TELEMETRY_INTERVAL_SECONDS)


def _validate_import_sources() -> None:
"""Ensure at least one import search provider can actually answer a query.

Counting enabled flags alone is not enough: Hardcover and Google Books require a
token/API key, so being switched on without credentials still means no source can
answer. This checks for a provider that is both enabled and usable.
"""
from app.services import book_import

active: list[str] = []
for spec in book_import._build_sources(""):
if not spec.enabled():
continue
if spec.stage == "hardcover" and not settings.hardcover_app_api_token.strip():
continue
active.append(spec.stage)

if book_import._google_fallback_enabled() and settings.google_books_api_key.strip():
active.append("google_books")

if not active:
keyless = ", ".join(
sorted(
f"{spec.stage.upper()}_IMPORT_SEARCH_ENABLED"
for spec in book_import._build_sources("")
if spec.stage not in {"hardcover", "google_books"}
)
)
raise RuntimeError(
"No usable book import source is configured. "
f"Set at least one of {keyless} to true, or provide "
"HARDCOVER_APP_API_TOKEN with HARDCOVER_IMPORT_SEARCH_ENABLED=true, "
"or GOOGLE_BOOKS_API_KEY with GOOGLE_BOOKS_IMPORT_SEARCH_ENABLED=true."
)


@asynccontextmanager
async def lifespan(app: FastAPI):
"""Application lifespan: create required directories and start background tasks."""
_validate_import_sources()
Path(settings.covers_dir).mkdir(parents=True, exist_ok=True)
Path(settings.import_temp_dir).mkdir(parents=True, exist_ok=True)
cleanup_temp_files()
Expand Down
5 changes: 5 additions & 0 deletions backend/app/routers/import_.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
from app.schemas import BookImportCandidate, BookImportRequest, BookRead
from app.services import book_import
from app.services.authors import resolve_authors_payload, sync_book_authors
from app.services.cover_import import is_safe_cover_import_url
from app.services.cover_storage import download_cover
from app.services.tags import build_book_read, sync_book_tags

Expand Down Expand Up @@ -134,6 +135,10 @@ async def import_book(

# Attempt to download and cache the cover locally; fall back to external URL.
cover_url = c.cover_url
if cover_url and not is_safe_cover_import_url(cover_url):
# The candidate is user-submitted, so its cover URL is untrusted input.
logger.warning("Rejecting cover URL for unsafe address: %s", cover_url)
cover_url = None
if cover_url:
async with httpx.AsyncClient(timeout=15.0, follow_redirects=True) as client:
filename = await download_cover(cover_url, settings.covers_dir, client, current_user.id)
Expand Down
2 changes: 1 addition & 1 deletion backend/app/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ class BookImportCandidate(SQLModel):
language: Optional[str] = None
tags: Optional[str] = None
blurb: Optional[str] = None
source: str # "open_library" | "google_books" | "hardcover"
source: str # "open_library" | "google_books" | "hardcover" | "bnf" | "inventaire" | "library_of_congress"


class CoverCandidate(SQLModel):
Expand Down
Loading
Loading