No description
  • Go 78.5%
  • HTML 19.9%
  • JavaScript 1.5%
  • Dockerfile 0.1%
Find a file
Bastien Quelen 424e30c09d
All checks were successful
Release Binaries / release (push) Successful in 2m20s
Docker Build & Push / Build and push Docker image (push) Successful in 16m48s
fix(epub): couverture déclarée hors du dossier de l'OPF (v1.197.0)
Le href de la cover-image est maintenant résolu comme une URL relative à l'OPF (« .. », « . », encodage %) : les exports Sigil qui rangent cover.jpg à la racine et le déclarent « ../cover.jpg » depuis OEBPS/content.opf n'avaient pas de couverture. Une couverture déclarée mais absente se rabat sur la première page du spine. Au premier rafraîchissement après démarrage, les EPUB déjà indexés sans couverture la récupèrent (une fois par processus).
2026-10-17 18:52:52 +02:00
.forgejo/workflows ci(docker): image uniquement sur tag v*, latest = dernière version 2026-10-13 21:28:03 +02:00
internal fix(epub): couverture déclarée hors du dossier de l'OPF (v1.197.0) 2026-10-17 18:52:52 +02:00
web feat(recommandations): archivage quand le destinataire termine ou abandonne le livre (v1.195.0) 2026-10-16 17:59:13 +02:00
.dockerignore chore: retrait du système ralph (règles reprises dans CLAUDE.md) 2026-10-06 17:39:24 +02:00
.gitignore chore: retrait du système ralph (règles reprises dans CLAUDE.md) 2026-10-06 17:39:24 +02:00
CLAUDE.md docs(CLAUDE.md): retire la mention de la boucle ralph 2026-10-16 17:59:13 +02:00
docker-compose.yml refactor: renomme nxt-opds → edda-library (org mimisbrunnr, forge git.helheim.net) 2026-06-27 16:29:20 +02:00
Dockerfile chore(go): passage à Go 1.27 (v1.183.0) 2026-10-06 17:38:40 +02:00
Dockerfile.dev chore(go): passage à Go 1.27 (v1.183.0) 2026-10-06 17:38:40 +02:00
edda.service refactor: renomme nxt-opds → edda-library (org mimisbrunnr, forge git.helheim.net) 2026-06-27 16:29:20 +02:00
go.mod chore(deps): Go 1.27.2 et dépendances à jour (v1.196.0) 2026-10-16 18:08:13 +02:00
go.sum chore(deps): Go 1.27.2 et dépendances à jour (v1.196.0) 2026-10-16 18:08:13 +02:00
LICENSE Licence MIT — © 2026 Bastien Quelen 2026-10-05 20:25:05 +02:00
main.go refactor: renomme nxt-opds → edda-library (org mimisbrunnr, forge git.helheim.net) 2026-06-27 16:29:20 +02:00
README.md feat(recommandations): archivage quand le destinataire termine ou abandonne le livre (v1.195.0) 2026-10-16 17:59:13 +02:00

edda-library

Edda is a lightweight personal eBook library server written in Go, with an OPDS catalog and a Vue 3 web UI.

Site de présentation : https://edda.helheim.net — source dans mimisbrunnr/edda-site (anciennement docs/ de ce dépôt).

OPDS (Open Publication Distribution System) is a catalog format for discovering and distributing digital publications. Point any OPDS reader (Kybook, Moon+ Reader, Calibre, etc.) at /opds to browse your library.

Features

  • OPDS 1.2 and OPDS 2.0 compliant catalog feeds (navigation + acquisition)
  • Vue 3 + Tailwind CSS web UI (no build step) with Feedbooks-style book grid
  • EPUB upload with instant metadata extraction (title, authors, cover, series, tags, publisher, language) and duplicate detection (SHA-256 content hash — re-uploading the same file, even renamed, is rejected)
  • Editable metadata — title, authors, tags, series, collection, publisher, language, age rating, spice rating (0-5 for 16+/18+ titles), cover
  • Age classification — multi-select filter chips (?, 3+, 6+, 10+, 12+, 16+, 18+); per-child-profile max-age enforcement
  • Multi-user support — per-user read status (unread / finished / abandoned « DNF »), per-user star rating, child profiles, user-coloured read banners
  • Per-user agent context — each profile carries free-form text for AI agents (reading preferences, expected tone, constraints); served as the MCP instructions on initialize, by the get_user_context tool, and as user_context in the librarian chat payload
  • Agent memory — the librarian records durable facts about a reader (remember / recall / forget MCP tools, FTS5-backed user_memories table); the 8 most relevant are forwarded as user_memories on each chat message, and the reader can read and delete them from the UI
  • Per-user tag pre-filter — each profile can hide genres (e.g. Romance) from its default view: those books drop out of the grid, search, facets and OPDS feeds, but stay reachable via the genre's own page or ?showHidden=1
  • To-read pile — personal ordered reading queue, exposed in OPDS feeds and the MCP server
  • Reading statistics — per-user dashboard (totals, abandoned count, ratings, top authors/tags/series, 12-month chart)
  • Wishlist — personal reading wish list, exposed in OPDS feeds
  • Recommendations — send a book recommendation to another user; it is archived once the recipient finishes or abandons the book (still listed under « Archivées », and flagged « lu » in the sender's « Envoyées »), and reactivated if they set it back to unread or it is recommended again
  • Share links — hand out a capability URL for one book, series, tag or author; .html previews in a chat, .xml is OPDS 1.2, .json is OPDS 2.0; no account needed, revocable, scoped to that one resource, and mintable from the MCP server
  • Integrated EPUB reader with prev/next book navigation and swipe/keyboard support
  • Librarian pairing — pair an external "librarian" service to enable a chat assistant (uses the MCP server under the hood)
  • MCP server — AI agent access to the catalog over the Model Context Protocol
  • Auto-update — download and apply a new binary from the Forgejo (git.helheim.net) releases in one click
  • PWA — installable as a web app with offline service worker
  • Password-protected login (session cookie + OPDS bearer token)
  • Two catalog backends: in-memory (fs) or persistent SQLite (sqlite)
  • Background refresh — automatic rescan of the books directory
  • Nightly SQLite backups with configurable retention
  • Webhooks — admin-configured HTTP callbacks fired on book lifecycle events
  • Single static binary with embedded frontend; version shown in the UI footer

Quick Start

Binary

# Build (Go 1.27+ required)
go build -o edda .

# Run with SQLite backend (recommended)
AUTH_PASSWORD=secret BACKEND=sqlite ./edda

Open http://localhost:8080/ in a browser. Point OPDS readers at http://localhost:8080/opds.

Docker

# Build and start with Docker Compose
AUTH_PASSWORD=secret docker compose up -d

The books/ directory in the current folder is mounted at /data/books inside the container.

Or build and run manually:

docker build -t edda .
docker run -d \
  -p 8080:8080 \
  -v /path/to/books:/data/books \
  -e AUTH_PASSWORD=secret \
  -e BACKEND=sqlite \
  edda

Configuration

Configuration is loaded in this order (later sources override earlier ones):

  1. Built-in defaults
  2. YAML config file (see below)
  3. Environment variables

Environment Variables

Variable Default Description
LISTEN_ADDR :8080 TCP address to listen on
BOOKS_DIR ./books Directory where EPUB/PDF files are stored
AUTH_PASSWORD (none) Login password (leave empty to disable auth)
BACKEND fs Catalog backend: fs (in-memory) or sqlite (persistent)
REFRESH_INTERVAL 5m How often to rescan the books directory (0 to disable)
BACKUP_DIR {books_dir}/.backups Directory for nightly SQLite backups
BACKUP_KEEP 7 Number of backup files to retain
OPDS_TOKEN (derived) Bearer token for OPDS reader auth (derived from password if unset)
EDDA_CONFIG (search path) Explicit path to config YAML file

YAML Config File

Searched automatically at ./edda.yaml and ~/.config/edda/config.yaml.

listen_addr: ":8080"
books_dir: "/data/books"
auth_password: "mysecretpassword"
backend: "sqlite"
refresh_interval: "5m"
backup_keep: 7

Catalog Backends

Backend Storage Best For
fs .metadata.json Small libraries, no persistence required
sqlite .catalog.db Large libraries (fast queries, persistent metadata, backups)

MCP Server

Edda exposes a Model Context Protocol endpoint at /mcp for AI agent access. Available tools:

Tool Description
search_books Search the catalog with filters (query/author/tag/series/publisher/collection, unread_only, read_status — unread/finished/dnf, a string or an array —, rating — 0 = not rated, 1–5, an integer or an array —, not_indexed, not_analyzed, age_rating/age_rating_min, spice_rating exact level — an integer or an array of integers such as [1,3] —, spice_min/spice_max advanced ranges). read_status/rating/unread_only apply to the authenticated user (optional user_id, admin-only for someone else)
get_book Get full metadata for a book (includes the user's reading progress when available; EPUBs also carry an « Analysé / Non analysé » line)
get_reading_progress Get a user's reading progress for a book (percentage, device, date)
update_book Update book metadata (tags, summary, age rating, last indexed, etc.)
set_book_read Set a book's read state for a user: is_read (legacy), status (unread/finished/dnf) and/or rating (0–5); at least one required
upload_book Upload an EPUB file (base64-encoded)
update_cover Replace a book's cover image
list_tags List all tags in the catalog (default 50, max 200)
list_authors List all authors (default 100, max 500)
list_series List all series
list_publishers List all publishers (default 100, max 500)
list_wishlist List wishlist items
add_wishlist_item Add an entry to the wishlist
delete_wishlist_item Remove a wishlist entry
list_recommendations List active recommendations (archived ones — recipient finished/abandoned the book — are excluded)
get_user_context Get a user's profile context for agents (free-form text + hidden genres)
list_to_read List the current user's ordered to-read pile
add_to_read Append a book to the to-read pile
remove_to_read Remove a book from the to-read pile
reorder_to_read Reorder the to-read pile by book IDs
list_annotations List a user's notes/highlights for a book (oldest first)
add_annotation Add a note or highlight to a book
delete_annotation Delete a note/annotation by ID
create_share Mint a share link for a book, series, tag or author (optional expires_in_days)
list_shares List share links with their token, scope, hit count and expiry
delete_share Revoke a share link by token
remember Record one durable fact about the reader (agent-authored memory)
recall Search/list what is remembered about the reader, ids included
forget Drop one memory by id
get_book_chapters EPUB table of chapters: index, href, title, start/end progression (0–1), character count
get_book_text EPUB plain text in spine order (HTML stripped, chapters marked === title ===), windowed by from_progression/max_progression and capped at max_chars (default 12000, max 40000); nothing past max_progression is ever returned (spoiler guard)
get_book_analysis The librarian's stored analysis of a book (chapters up to max_progression only; overview only at progression 1) or {analyzed: false}
set_book_analysis Store the librarian's analysis of a book: model, chapters[] (index, href, title, summary, characters[]) and optional overview; progressions are recomputed from the EPUB itself
request_book_analysis Ask the paired librarian to analyse an EPUB (fires the book.analyze webhook)

Authentication uses the same OPDS bearer token (?token=<value> or Authorization: Bearer header).

When the caller authenticates with a per-user token, the initialize response carries that user's profile context in the MCP-standard instructions field (their free-form agent context plus the genres they pre-filter out), so an agent knows who it is talking to from the first message. With the shared instance token there is no user to describe, so instructions is omitted — call get_user_context with an explicit user_id instead. Note that search_books itself is never tag-pre-filtered: an agent must be able to answer an explicit request about a hidden genre.

Agent memory

remember / recall / forget give the agent a per-user long-term memory: short French sentences it writes itself when the reader states a durable preference ("dark romance, jamais de spoilers, offre des polars à sa sœur"). They live in user_memories, scoped by user_id like every other personal table, searched with the same FTS5 machinery as the catalog — no vector database, no embedding model, no extra service. Adding the same sentence twice refreshes the existing row instead of duplicating it.

Memory is not a hidden dossier: the reader lists it under Mon contexte in the chat panel and deletes any line from there (GET /api/me/memories, DELETE /api/me/memories/{id}). On every chat message the relay picks the 8 memories most relevant to what was just asked and forwards them as user_memories; the librarian renders them into its system prompt, so recall costs no extra round trip.

Book analysis (EPUB)

The librarian can read an EPUB chapter by chapter and store a chapter-by-chapter analysis (summaries, characters, a whole-book overview). Analyses are stored in SQLite (migration 29: book_analysis, book_analysis_chapters) and served back to readers without ever going past their position: get_book_text never returns text beyond max_progression, get_book_analysis hides chapters that start after the reader's position and withholds the overview until the book is finished.

  • The librarian reads a book with get_book_chapters + get_book_text (progressions come from the EPUB's own spine, so they match what readers report), then stores the result with set_book_analysis. The progression bounds are recomputed server-side from the EPUB — the caller cannot fake them.
  • search_books(not_analyzed: true) lists the EPUBs still waiting for an analysis (the librarian's batch pass walks this list at night).
  • Analysis requests arrive as the book.analyze webhook — fired automatically right after book.created for every EPUB (upload or folder deposit), and on demand via POST /api/books/{id}/analyze (the « Analyser ce livre » button on the book page) or the MCP request_book_analysis tool.
  • The book page shows an « Analysé (date) » badge once the analysis is stored (GET /api/books/{id}/analysis, also surfaced as analyzedAt in GET /api/books/{id}).

Librarian

Edda has no embedded LLM. The chat assistant in the UI is a thin proxy in front of a librarian — a separate service you run somewhere (typically next to a model provider). Once paired, the librarian gets authenticated access to this server's MCP endpoint and can manipulate the catalog on your behalf.

Topology

┌──────────────┐   POST /api/ai/chat       ┌──────────────┐
│ Vue chat box │ ────────────────────────► │     Edda     │
│              │ ◄── SSE frames (stream) ──│  (this app)  │
└──────────────┘                           └──────┬───────┘
                                                  │
                  Bearer chat_secret              │  Bearer chat_secret
                  POST ${librarian_url}/chat ◄────┤
                                                  │
                  X-Signature HMAC(webhook_secret)│  X-Edda-Event book.*
                  POST ${librarian_url}/webhooks/ │
                       ${instance}/book-event ◄───┘

Two parallel channels: the chat relay is request-driven (a user types → the librarian runs its agent loop → it streams the run back as SSE frames — {"kind":"delta"|"text"|"tool_call"|"tool_result"|"error"|"final"}, delta carrying the answer as the model writes it — plus a : ping comment every 15 s; Edda forwards the body bytes verbatim and flushes each chunk, but injects a user_token field holding the connected user's per-user OPDS/MCP token so the librarian's MCP calls — list_to_read, list_wishlist, list_recommendations, search_books(unread:true) — are scoped to the active user instead of falling back to the instance token); the webhook fan-out is push-driven (every book.created / book.updated / book.deleted / book.read event also hits the librarian alongside any admin-configured webhooks).

The stream exists for one reason: a long agent run that sends nothing is killed by the browser, a reverse proxy or a NAT table long before it finishes. Frames and heartbeats keep the connection warm, and the chat box turns each tool_call into a plain sentence ("Je parcours votre bibliothèque…") — tool names, arguments and raw results never reach the screen. A librarian too old to stream still answers a single {"reply","error"} JSON object, which the chat box handles.

The chat box announces "capabilities":["question"] in its request: the librarian may then end a turn with a {"kind":"question","question","options", "multiple"} frame. The question shows as the librarian's message with its options as buttons (single choice: a tap sends the label) or checkboxes and Valider (multiple: the labels go out joined with ;, in the options' order); typing a free answer works too. The history sends the question back as its text rendering (question, blank line, 1. option…, plus (plusieurs réponses possibles) for a multiple choice). Only the last, unanswered question is actionable.

Because a mobile connection dies anyway — screen lock, wifi → 4G, a tunnel — the run is not tied to the connection. The chat box names it with a session_id, keeps that pointer in localStorage, and reconnects (Last-Event-ID, relayed upstream) until the librarian's closing frame arrives; a full page reload picks the run back up too. Cancelling posts {session_id, cancel:true} so an abandoned run stops burning tokens.

Pairing flow

  1. An admin opens Administration → Librarian → Associer un librarian in the web UI. Edda mints a single-use code (XXXX-XXXX, ~40 bits entropy, 10-minute TTL) and shows it in a modal.
  2. On the librarian host, run the bundled CLI:
    librarian pair --nxt-opds https://books.example --code ABCD-1234
    
  3. The CLI hits POST /api/librarian/pair with the code. Edda mints two 32-byte hex secrets, persists the association, invalidates the code, and returns {mcp_url, mcp_token, chat_secret, webhook_secret, instance, label}. The librarian stores those.
  4. After pairing, /api/config flips librarianEnabled to true and the chat box appears in the SPA. Book lifecycle events start fanning out to the librarian automatically.

To rotate the secrets the librarian POSTs /api/librarian/rotate with its current X-Librarian-Chat-Secret; to unpair from the librarian side it POSTs /api/librarian/forget; from the admin UI a Désappairer button hits DELETE /api/librarian/association, which also best-effort notifies ${librarian_url}/instances/{instance}/forget so both sides clean up.

When the librarian process restarts (and especially when it has moved hostnames or ports — e.g. a container relocation), it POSTs /api/librarian/announce with {"librarian_url":"http://..."} and its X-Librarian-Chat-Secret. Edda updates the stored LibrarianURL (preserving the instance and both secrets) so subsequent webhook fan-out and /api/ai/chat relays target the new address without any operator intervention.

While librarian serve is running it POSTs /api/librarian/heartbeat roughly once a minute. Edda stamps a LastSeenAt field on the association without advancing UpdatedAt (a heartbeat is not a mutation). The admin UI surfaces this as a coloured dot on the Librarian card — green under 3 minutes since the last beat, amber 3–15 min, red past 15 min, grey when no heartbeat has ever been received.

Path Auth Purpose
POST /api/librarian/pairing-code admin session cookie Mint a one-time XXXX-XXXX pairing code (10-min TTL)
POST /api/librarian/pair body code Exchange the code for secrets + mcp_url + mcp_token
GET /api/librarian/association admin session cookie View paired librarian (URL + instance only — no secrets); 204 when unpaired
DELETE /api/librarian/association admin session cookie Local unpair + best-effort POST …/instances/{instance}/forget
POST /api/librarian/rotate X-Librarian-Chat-Secret header Roll both secrets without unpair/re-pair
POST /api/librarian/forget X-Librarian-Chat-Secret header Inbound unpair from the librarian side; idempotent
POST /api/librarian/announce X-Librarian-Chat-Secret header Realign LibrarianURL when the librarian moves host/port; preserves instance + secrets + CreatedAt
POST /api/librarian/heartbeat X-Librarian-Chat-Secret header Liveness ping (~60 s); stamps LastSeenAt only, does not advance UpdatedAt
POST /api/ai/chat session cookie Streaming relay to ${librarian_url}/chat (SSE frames, JSON fallback); 404 when unpaired

The pairing-code mint and association view/delete deliberately require a real session cookie so a leaked OPDS reader URL or shared token cannot start, view or break a pairing.

A share link exposes exactly one resource — a book, a series, a tag or an author — to whoever holds the link, with no edda account required. The token in the URL is the credential: it authorises that one resource and nothing else in the catalog, and it stops working the moment it is revoked.

One link, three representations, chosen by the file extension:

URL Serves
GET /s/{token}.html A self-contained page with OpenGraph tags, so the link previews properly when pasted into a chat
GET /s/{token}.xml OPDS 1.2 — a single entry for a book share, an acquisition feed for a collection
GET /s/{token}.json OPDS 2.0 — a publication document for a book share, a feed for a collection
GET /s/{token} Redirects to the .html representation

Covers and downloads are re-exposed under the same prefix so a recipient with no account can actually fetch what was shared:

URL Serves
GET /s/{token}/covers/{id} Cover image, if the book is inside the share's scope
GET /s/{token}/books/{id}/download Book file, if the book is inside the share's scope. ?i=<index> picks the file (default 0)

Both are gated on the share's scope: a share of one book cannot be used to download any other book. Files are selected positionally rather than by path, so a public link never reveals where the library lives on disk.

Create links from the web UI — the Partager button on a book page, or the link icon in the series / author / tag headers. The same modal lists every link you have created and revokes them.

Shares are stored by the catalog backend (shares table on SQLite, .shares.json with 0600 permissions on the filesystem backend). A backend that does not implement catalog.ShareManager answers 501 and the UI hides the feature.

API Endpoints

Web UI

Path Description
GET / Web UI (Vue 3 frontend)
GET /saga/install Saga e-reader installer page (Web Serial flasher + USB configuration; Chromium only, see Installer Saga depuis edda)

Authentication

Path Description
GET /login Login page
POST /login Submit login form
POST /logout Log out
Path Description
GET /s/{token}.html Share page (OpenGraph preview)
GET /s/{token}.xml Share as OPDS 1.2
GET /s/{token}.json Share as OPDS 2.0
GET /s/{token} Redirect to the .html representation
GET /s/{token}/covers/{id} Cover of a book within the share's scope
GET /s/{token}/books/{id}/download File of a book within the share's scope (?i=)

OPDS 1.2

Path Description
GET /opds Root navigation feed
GET /opds/books All books (acquisition feed)
GET /opds/books/{id} Single book entry
GET /opds/books/{id}/download Download book file
GET /opds/books/{id}/read Token user's read state: {"id","isRead","status":"unread"|"finished"|"dnf","dnf","rating":0-5,"readColors":[…]}
POST /opds/books/{id}/read Set the token user's read state. Body fields combine: {"isRead":bool} (legacy, true ⇔ finished), {"status":"unread"|"finished"|"dnf"} (wins over isRead; dnf = abandoned), {"rating":0-5} (0 clears). Empty body ⇒ finished. finished/dnf drop the book from the user's to-read pile; returns the same object as GET. Per-user in multi-user mode (incl. the rating), global books columns in single-user mode. Reachable with the per-user OPDS token so e-ink readers can flag a book at end of reading
GET /opds/books/{id}/next Next volume of the same series as a bare OPDS 2 publication object (application/opds-publication+json): numeric seriesIndex order (1, 2, 2.5, 10), then title, unpositioned volumes last; 404 when the book has no series or is the last one. Carries edda:status/edda:rating and edda:nextInSeries (the volume after it)
GET /opds/books/{id}/thumbnail?w=W&h=H Cover as an 8-bit grayscale baseline JPEG (quality 85) fitted inside W×H, ratio kept, never upscaled (W/H in 16–1200, otherwise 480×800); cached in <covers>/thumbs/ and rebuilt when the cover changes; 404 without cover. Used by the Saga e-reader
PUT /opds/books/{id}/progress Save the token user's reading position (body {"locator":{…Readium Locator…},"progression":0.0-1.0,"device":"…","updatedAt":<unix-ms>}); replaces any prior position
GET /opds/books/{id}/progress Return the token user's last reading position (404 when none recorded) so devices (web epub.js / e-ink Readium) resync
GET /opds/books/{id}/annotations List the token user's highlights & notes for the book ({"annotations":[…]})
POST /opds/books/{id}/annotations Create a highlight/note for the token user (body {"locator":{…Readium Locator…},"text":"…","color":"#FFFF00"}; locator or text required); returns the created annotation with its id
DELETE /opds/books/{id}/annotations/{aid} Delete one of the token user's annotations (scoped to the owner)
GET /opds/chat {"enabled":bool} — whether a librarian is paired (token-authenticated, for e-readers)
POST /opds/chat Same chat relay as POST /api/ai/chat (SSE frames delta/text/tool_call/error/final, {message, history, session_id} body) but authenticated by the per-user ?token=; the librarian speaks for that user. Used by the Saga e-reader's « Libraire » screen
GET /opds/search?q=... Search results
GET /opds/authors Author navigation feed
GET /opds/authors/{author} Books by author
GET /opds/tags Genre/tag navigation feed
GET /opds/tags/{tag} Books by tag
GET /opds/publishers Publisher navigation feed
GET /opds/publishers/{publisher} Books by publisher
GET /opds/unread Unread books feed (neither finished nor abandoned for the token user)
GET /opds/to-read To-read pile feed (per user; multi-user clients pass ?user=<id>)
POST /opds/to-read/{id} Append a book to the token user's to-read pile (204; no-op if already piled; 404 unknown book; 501 when the backend keeps no pile — fs backend or single-user mode)
DELETE /opds/to-read/{id} Remove a book from the token user's pile (204, idempotent; 501 as above)
GET /opds/recommendations Recommendations feed (active only: archived once the token user finished or abandoned the book)
GET /opds/spice Spice-level navigation feed (6 exact buckets 0–5 → /opds/books?spice=N; hidden for child profiles)

KOReader synchronisation (kosync)

edda exposes a kosync-compatible endpoint that lets KOReader devices synchronise reading progress against the library without any third-party service.

Configuration in KOReader: Tools → Progress sync → Custom sync server = base URL of your edda instance (e.g. https://books.example.com); Username = your edda username; Password = your OPDS token (found in edda's user settings page).

Path Description
POST /users/create Always returns 403 — create accounts via the edda admin UI instead
GET /users/auth Validates credentials (x-auth-key: md5(token)) — 200 or 401
PUT /syncs/progress Store reading position for a book identified by its KOReader partial-MD5 hash
GET /syncs/progress/{document} Retrieve reading position; returns {} when no position recorded yet

All responses use Content-Type: application/vnd.koreader.v1+json.

Auth: edda uses the existing per-user OPDS token as the kosync "password". KOReader sends x-auth-key: md5(password) in lowercase hex; edda resolves it to the matching user.

Backend requirement: only the sqlite backend supports reading progress. The fs backend always returns 501 for these endpoints.

Cross-device sync: the percentage field is shared between KOReader clients and the Readium clients (edda web reader, Muninn/Huginn — PUT /opds/books/{id}/progress). An xpointer (progress) written by a KOReader client is returned verbatim. When the last position came from a Readium app (a Readium Locator, no xpointer), GET /syncs/progress/{document} computes the xpointer from the locator on the fly (nothing is written back): the locator href gives the spine item (/body/DocFragment[spine index + 1]), locations.cssSelector the element (:root > :nth-child(…), #id, .class, tag…), turned into KOReader steps tag[k] (k = rank among same-tag siblings) ending with /text().0 when the element starts with text; without a usable selector, locations.progression picks the block (p, div, li, h1–h6, blockquote) at that fraction of the chapter text; without either, the chapter start. If the book has no EPUB (PDF) or the locator cannot be placed, progress is empty and the client falls back to percentage.

The reverse direction works the same way: when the last position came from KOReader (an xpointer, no locator), GET /opds/progress and GET /opds/books/{id}/progress serve a Readium Locator computed on the fly (nothing is written back). DocFragment[N] gives spine item N-1 (href = its path in the EPUB, type = its media type); the tag[k] steps (missing index = 1) are followed from <body>, a step that does not resolve stopping at the deepest resolved ancestor, and become locations.cssSelector (:root > :nth-child(i) > …); locations.progression is the share of the chapter text before that element (plus the text().offset), 0 for the chapter start, and locations.totalProgression the stored percentage. An unknown DocFragment, a PDF or a missing file leave the locator out (percentage only).

Saga e-reader firmware (Xteink X4 Pro / X3)

Saga is the Mimisbrunnr e-reader firmware (fork of CrossInk). It only uses the endpoints above, configured from the edda URL and a per-user token, from which Saga derives an OPDS server entry (/opds?token=…) and kosync settings (password = token, binary matching). Books finished (or abandoned, rated) on the device are sent to POST /opds/books/{id}/read and highlights to POST /opds/books/{id}/annotations (Readium Locator with chapter href, progression, totalProgression, text.highlight; the passage is also the annotation text, shown under « Mes notes »). The shared OPDS_TOKEN is not enough for those two writes — they need a user's token.

Setup (Saga v0.6+): in the web UI, click « Configurer une liseuse Saga » (header, next to « Copier l'URL OPDS ») to download saga-edda.json for yourself, or, as an admin, the Saga button of a user in the administration page to get that user's file. Copy it to the root of the reader's SD card: Saga imports it at startup, then deletes it (it holds the token in clear). The file is built in the browser (nothing is sent to the server): {"saga":1,"server":"<origin seen by the browser>","token":"<per-user token>","user":"<name>"} — user is omitted in single-user mode, where the token is the shared OPDS_TOKEN. Fallback: type the edda URL and the per-user token by hand, on the device or from its File Transfer web page (the token is on the user's settings page).

Installer Saga depuis edda

/saga/install (« Installer Saga » in the header, or the per-user Installer Saga button of the administration page, which pre-selects that user) is a standalone page that puts Saga on a brand-new Xteink reader without any tool on the computer:

  1. Browser constraint, checked first. The page uses Web Serial, which exists only in Chromium-based browsers (Chrome, Edge, Brave, Opera, Chromium ≥ 89) on Linux, Windows, macOS or ChromeOS, over HTTPS (or localhost). Firefox, Safari and every phone/tablet browser are rejected up front with an explanatory banner and the buttons stay disabled.
  2. Device choice — X4 Pro (ESP32-S3) or X3 / X4 (ESP32-C3), with the tag and image size of the latest Saga release (GET /api/saga/release).
  3. « Connecter et installer » — the browser asks for the USB port, the firmware is downloaded through edda (GET /api/saga/firmware/{device}, verified against the release's SHA256SUMS.txt server-side and again in the browser), the chip is auto-detected and must match the chosen device (an X3 image is never written on an S3 and vice-versa), the APP image is written at 0x10000 with a progress bar, MD5-verified from flash, then the reader is hard-reset into Saga. Readers already running Saga can skip this step (« Ignorer le flash, configurer seulement »).
  4. Configuration edda — the page polls Saga's USB-serial file protocol (CMND S every 2 s, up to 60 s) until the reader answers from its Home screen, then writes /saga-edda.json (same payload as « Configurer une liseuse Saga ») over the same protocol (path, 256-byte chunks each acknowledged, CRC-32). Saga ≥ 0.11 imports it immediately and shows « Compte edda configuré » on the device.
  5. Done — plus a « Télécharger saga-edda.json » fallback and the SD-card path for readers that cannot be reached over USB.

Errors (port busy, wrong chip, reader not on Home, CRC mismatch, timeouts) are shown in French with what to do. The flasher is esptool-js 0.7.0 (Apache-2.0, Espressif Systems), vendored as web/vendor/esptool-js/esptool-js-0.7.0.bundle.js next to its LICENSE; Tailwind comes from the same CDN as the SPA. The firmware itself is never stored on disk: edda keeps the latest release description and image in memory for 10 minutes (16 MiB cap).

Library covers come from GET /opds/books/{id}/thumbnail?w=480&h=800&token=…, fetched after each OPDS download and during « Synchroniser avec edda »: the ESP32 cannot decode progressive JPEGs and is slow on multi-megabyte embedded covers, so edda serves a small grayscale baseline JPEG instead (the EPUB file itself is never modified). Saga falls back to the embedded cover when the route answers 404.

Most book-listing feeds accept ?spice= (levels 0–5, exact spice rating scoped to 16+/18+ titles); several levels may be combined, either as a comma list ?spice=1,3 or repeated ?spice=1&spice=3 (OR semantics; duplicates and out-of-range values are ignored, a single ?spice=2 keeps working). Sub-16 titles are excluded since the spice axis is undefined for them. The same parameter is honoured on the OPDS v2 counterparts and on GET /api/books. On /opds/v2/publications the « Piment » facet is a multi-select: each level link toggles that level in the current selection (other query params are kept) and the selected levels carry "rel": "self". (The legacy ?spice_max=N parameter accepted in v1.122–v1.127 is no longer interpreted — clients still hitting it receive an unfiltered list. For range filters, use the MCP search_books tool's spice_min/spice_max arguments.)

The same feeds (OPDS 1 and 2, search, author/tag/publisher/series feeds, GET /api/books) accept the token user's own reading state: ?status= (unread = neither finished nor abandoned, finished, dnf = abandoned) and ?rating= (0 = not rated, 1–5 stars), each a comma list or repeated parameter with OR semantics (?status=dnf,finished, ?rating=0&rating=5; duplicates and unknown values are ignored). Both are per-user in multi-user mode and use the global books columns in single-user mode. The legacy ?unread=1 keeps working and adds unread to the status set. On OPDS v2 acquisition feeds the « Lecture » (Non lu / Lu / Abandonné) and « Note » (Sans note, ★ … ★★★★★) facets work like « Piment » — toggling links, selected values carry "rel": "self", counts ignore the facet's own filter — and are omitted when the request has no user state (shared instance token in multi-user mode); /opds/v2/unread has no « Lecture » facet since its status is fixed.

OPDS 2.0

Same paths under /opds/v2 (JSON format).

The root GET /opds/v2 also returns groups[] (« Pile à lire », « Recommandations reçues », « Derniers ajoutés », « Non lus ») for carousel-style home screens. ?group_limit=N caps the publications per group (clamped to 1–20, default 10); each group's metadata.numberOfItems is always the full count and its self link points to the complete, paginated feed.

Each publication's images[] lists the full cover (/covers/{id}) once, plus a resized grayscale baseline JPEG {"href":".../opds/books/{id}/thumbnail?w=160&h=240&token=…","type":"image/jpeg","width":160,"height":240} for memory-constrained readers. Share feeds (/s/{token}.json) omit the thumbnail entry.

GET /opds/v2/to-read returns the whole pile by default; ?offset=&limit= paginate it (standard first/previous/next/last links are added only when limit is given, metadata.numberOfItems is always the full pile size).

Extension fields (prefixed edda:, in each publication's metadata, scoped to the token user):

Field Where Value
edda:status every feed, /opds/books/{id}/next "unread", "finished" or "dnf" (abandoned) for the token user. Omitted when no user state is available: share links, or the shared instance token in multi-user mode
edda:rating every feed, /opds/books/{id}/next the token user's stars, 0–5 (same omission rule)
edda:nextInSeries /opds/v2/series/{series}, /opds/books/{id}/next ID of the following volume of the same series. Only emitted where the series order is already loaded — absent on other feeds means unknown, call /opds/books/{id}/next

Each feed costs one extra read-state query (not one per book). spiceRating and numberOfPages are plain (non-prefixed) metadata fields.

REST API (Web UI)

Path Description
GET /api/books Books list (JSON, with filters)
GET /api/books/{id} Single book (JSON)
PATCH /api/books/{id} Update book metadata; isRead / status (unread|finished|dnf) / rating go to the logged-in user's read state (reply carries isRead, dnf, rating)
DELETE /api/books/{id} Delete a book
POST /api/books/{id}/cover Replace book cover
PUT /api/books/{id}/read Set the read state (same body/reply as POST /opds/books/{id}/read)
GET /api/books/{id}/annotations List the logged-in user's notes/highlights (session-auth; shown on the book page)
DELETE /api/books/{id}/annotations/{aid} Delete one of the user's annotations
GET /api/books/{id}/analysis Stored librarian analysis of a book ({analyzed: false} when none; session-auth)
POST /api/books/{id}/analyze Ask the paired librarian to analyse an EPUB (202; fires book.analyze; 501 for non-EPUB)
POST /api/books/{id}/recommend Recommend a book to a user
DELETE /api/books/{id}/recommend/{userID} Remove a recommendation
POST /api/upload Upload an EPUB or PDF (409 on duplicate content)
GET /api/authors List all authors
GET /api/tags List all tags
DELETE /api/tags/{tag} Delete a tag
GET /api/publishers List all publishers
GET /api/series List all series
GET /api/collections List all collections
GET /api/recommendations Active recommendations addressed to the current user (newest first); ?archived=1 → archived ones (recipient finished/abandoned the book, archivedAt set, most recently archived first); ?sent=1 → recommendations sent by the current user, archived ones included (archivedAt set ⇒ read)
GET /api/wishlist List wishlist items
POST /api/wishlist Add wishlist item
PATCH /api/wishlist/{id} Update wishlist item
DELETE /api/wishlist/{id} Remove wishlist item
GET /api/to-read Current user's to-read pile
POST /api/to-read Add a book to the to-read pile
PUT /api/to-read/reorder Reorder the to-read pile
DELETE /api/to-read/{bookId} Remove a book from the to-read pile
GET /api/stats Reading statistics (per user; booksDnf counts abandoned books, ratings are the user's own stars)
PUT /api/me/hidden-tags Replace the current user's hidden genres (self-service)
PUT /api/me/agent-context Replace the current user's agent context (self-service, max 4000 bytes)
GET /api/me/memories What the agent remembers about the current user
DELETE /api/me/memories/{id} Forget one memory (idempotent)
GET /api/users List users (admin)
POST /api/users Create a user (admin)
PATCH /api/users/{id} Update a user, incl. hiddenTags and agentContext (admin)
DELETE /api/users/{id} Delete a user (admin)
GET /api/config App config for the frontend
GET /api/saga/release Latest Saga firmware release proxied from the forge: {tag, assets:{"x4-pro":{name,size,sha256},"x3-x4":{…}}} (502 when the forge is unreachable)
GET /api/saga/firmware/{device} Saga firmware image for x4-pro or x3-x4 (application/octet-stream, Content-Length, Content-Disposition), fetched server-side, sha256-checked, cached 10 min; 404 unknown device, 502 upstream error
POST /api/refresh Trigger catalog rescan
GET /api/update/check Check for a new release on the forge
POST /api/update/apply Download and apply the new binary
POST /api/restart Restart the server process
POST /mcp MCP server endpoint
POST /api/librarian/pairing-code Mint a one-time pairing code (admin)
POST /api/librarian/pair Exchange a pairing code for secrets
GET /api/librarian/association View paired librarian (admin)
DELETE /api/librarian/association Unpair the librarian (admin)
POST /api/librarian/rotate Roll librarian secrets (librarian-side)
POST /api/librarian/forget Inbound unpair (librarian-side)
POST /api/librarian/announce Realign LibrarianURL on librarian restart
POST /api/librarian/heartbeat Liveness ping from librarian serve (~60 s)
POST /api/ai/chat Chat relay to the paired librarian (SSE stream, JSON fallback)
GET /api/shares List share links (own; all for admins)
POST /api/shares Create a share link ({kind, ref, label?, expiresInDays?})
DELETE /api/shares/{token} Revoke a share link
GET /api/webhooks List webhooks (admin)
POST /api/webhooks Create a webhook (admin)
PATCH /api/webhooks/{id} Update a webhook (admin)
DELETE /api/webhooks/{id} Delete a webhook (admin)
POST /api/webhooks/{id}/test Send a test ping to a webhook
GET /health Health check
GET /covers/{id} Book cover image

Webhooks

Admins can register outbound HTTP callbacks from the admin page (#/admin). Each webhook subscribes to one or more events and receives a JSON POST whenever the matching event happens on the catalog.

Supported events: book.created, book.updated, book.deleted, book.read, book.analyze. Leaving the subscription list empty subscribes the webhook to every event.

book.created covers both ways a book enters the catalog: an upload through the web UI / API, and a file copied straight into books_dir, which the background refresh picks up. To keep a bulk deposit from queueing hundreds of enrichment jobs at once, a single refresh announces at most 20 books (the most recently added ones); the rest are logged and left to the librarian's periodic pass over not-yet-indexed books.

Request headers

Header Value
Content-Type application/json
User-Agent edda/webhook
X-Edda-Event The event name (e.g. book.created)
X-Signature sha256=<hex> HMAC of the body, only if a secret is set

Envelope

{
  "event": "book.created",
  "timestamp": "2026-05-15T18:42:01Z",
  "data": { ... event-specific payload ... }
}

book.created / book.updated / book.deleted payloads carry the public book fields:

{
  "id": "a1b2c3d4...",
  "title": "Le Petit Prince",
  "authors": ["Antoine de Saint-Exupéry"],
  "tags": ["jeunesse", "classique"],
  "summary": "...",
  "language": "fr",
  "publisher": "Gallimard",
  "series": "",
  "seriesIndex": "",
  "seriesTotal": "",
  "collection": "Folio",
  "collectionIndex": "",
  "rating": 5,
  "ageRating": 6,
  "spiceRating": 0,
  "isRead": false,
  "coverUrl": "/covers/a1b2c3d4",
  "downloadUrl": "/opds/books/a1b2c3d4/download",
  "fileType": "application/epub+zip",
  "fileSize": 1843200
}

book.analyze asks the paired librarian to analyse a book's text (see « Book analysis » above). It is fired automatically right after book.created for every EPUB — whether the book arrived through an upload or a file dropped into books_dir — and on demand through POST /api/books/{id}/analyze or the MCP request_book_analysis tool. Its payload is the same public book object as book.created.

book.read payload (fired on every status change — status is "unread", "finished" or "dnf"; isRead stays for existing receivers; a rating-only change does not fire it):

{
  "bookId": "a1b2c3d4...",
  "title": "Le Petit Prince",
  "isRead": true,
  "status": "finished",
  "userId": "u-uuid",
  "userName": "Alice"
}

Test deliveries (via the Tester button) use event: "test" with a simple { "message": "..." } payload, bypassing the enabled flag and subscription list so admins can validate a fresh receiver. The last delivery status is recorded on the webhook row and shown back in the admin UI.

Librarian fan-out

When a librarian is paired, the same envelope is also POSTed to ${librarian_url}/webhooks/${librarian_instance}/book-event with X-Edda-Event and an X-Signature HMAC computed using the webhook_secret minted at pairing time. This target is not part of the admin webhooks list — it is wired by the pairing handshake and is hidden from the admin UI on purpose. To stop it, unpair the librarian.

Project Structure

.
├── main.go
├── Dockerfile
├── docker-compose.yml
├── edda.service              # systemd unit file
├── internal/
│   ├── catalog/              # Catalog interface and core data types
│   ├── config/               # YAML config loading
│   ├── epub/                 # EPUB/PDF metadata extraction
│   ├── mcp/                  # MCP server (AI agent access)
│   ├── opds/                 # OPDS/Atom feed types and XML serialisation
│   ├── server/               # HTTP server, routing, handlers, auth
│   ├── updater/              # Auto-update (Forgejo releases)
│   ├── webhooks/             # Async outbound webhook dispatcher (HMAC)
│   └── backend/
│       ├── fs/               # In-memory filesystem backend
│       └── sqlite/           # SQLite-backed persistent backend
└── web/
    ├── index.html            # Vue 3 + Tailwind CSS frontend (embedded)
    ├── saga-install.html/.js # Saga e-reader installer (Web Serial + esptool-js)
    ├── vendor/esptool-js/    # esptool-js 0.7.0 bundle + Apache-2.0 LICENSE
    └── embed.go              # go:embed directive

License

MIT — © 2026 Bastien Quelen.