- Go 78.5%
- HTML 19.9%
- JavaScript 1.5%
- Dockerfile 0.1%
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). |
||
|---|---|---|
| .forgejo/workflows | ||
| internal | ||
| web | ||
| .dockerignore | ||
| .gitignore | ||
| CLAUDE.md | ||
| docker-compose.yml | ||
| Dockerfile | ||
| Dockerfile.dev | ||
| edda.service | ||
| go.mod | ||
| go.sum | ||
| LICENSE | ||
| main.go | ||
| README.md | ||
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
instructionson initialize, by theget_user_contexttool, and asuser_contextin the librarian chat payload - Agent memory — the librarian records durable facts about a reader (
remember/recall/forgetMCP tools, FTS5-backeduser_memoriestable); the 8 most relevant are forwarded asuser_memorieson 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;
.htmlpreviews in a chat,.xmlis OPDS 1.2,.jsonis 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):
- Built-in defaults
- YAML config file (see below)
- 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 withset_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.analyzewebhook — fired automatically right afterbook.createdfor every EPUB (upload or folder deposit), and on demand viaPOST /api/books/{id}/analyze(the « Analyser ce livre » button on the book page) or the MCPrequest_book_analysistool. - The book page shows an « Analysé (date) » badge once the analysis is
stored (
GET /api/books/{id}/analysis, also surfaced asanalyzedAtinGET /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
- 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. - On the librarian host, run the bundled CLI:
librarian pair --nxt-opds https://books.example --code ABCD-1234 - The CLI hits
POST /api/librarian/pairwith 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. - After pairing,
/api/configflipslibrarianEnabledtotrueand 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.
Librarian-related endpoints
| 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.
Share links
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 |
Share links (public — the token is the credential)
| 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:
- 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.
- 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). - « 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'sSHA256SUMS.txtserver-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 at0x10000with 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 »). - Configuration edda — the page polls Saga's USB-serial file protocol (
CMND Severy 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. - 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.