- Go 77.3%
- HTML 22.1%
- JavaScript 0.4%
- Dockerfile 0.2%
|
All checks were successful
Docker Build & Push / Build and push Docker image (push) Successful in 11m39s
Le dossier docs/ servait le site de présentation (hérité de GitHub Pages, plus publié depuis la migration sur la forge). Il vit maintenant dans son propre dépôt et est servi sur https://edda.helheim.net par le Caddy du homelab. |
||
|---|---|---|
| .forgejo/workflows | ||
| .ralph | ||
| internal | ||
| web | ||
| .dockerignore | ||
| .gitignore | ||
| .ralphrc | ||
| CLAUDE.md | ||
| docker-compose.yml | ||
| Dockerfile | ||
| Dockerfile.dev | ||
| edda.service | ||
| go.mod | ||
| go.sum | ||
| 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, child profiles, user-coloured read banners
- To-read pile — personal ordered reading queue, exposed in OPDS feeds and the MCP server
- Reading statistics — per-user dashboard (totals, 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
- 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 - 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.24+ 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, not_indexed, age_rating/age_rating_min, spice_rating exact match, spice_min/spice_max advanced ranges) |
get_book |
Get full metadata for a book (includes the user's reading progress when available) |
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.) |
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 pending recommendations |
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 |
Authentication uses the same OPDS bearer token (?token=<value> or Authorization: Bearer header).
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 │
│ │ ◄──── {reply,error} JSON ─│ (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 returns a single JSON
{"reply":"...","error":"..."} once the loop terminates; Edda
forwards the body bytes verbatim, 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).
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 | Body-relay to ${librarian_url}/chat (JSON {reply,error} upstream); 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) |
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 |
POST /opds/books/{id}/read |
Mark book read/unread for the token's user (body {"isRead":true}, empty body ⇒ read); reachable with the per-user OPDS token so e-ink readers can flag a book "lu" at end of reading |
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/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 |
GET /opds/to-read |
To-read pile feed (per user; multi-user clients pass ?user=<id>) |
GET /opds/recommendations |
Recommendations feed |
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 and the edda web reader, so switching from one to the other shows the approximate position. The exact xpointer string (progress) is only meaningful between KOReader devices.
Most book-listing feeds accept ?spice=N (0–5, exact spice rating scoped to 16+/18+ titles); sub-16 titles are excluded since the spice axis is undefined for them. The same parameter is honoured on the OPDS v2 counterparts. (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 multi-value range filters, use the MCP search_books tool's spice_min/spice_max arguments.)
OPDS 2.0
Same paths under /opds/v2 (JSON format).
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 |
DELETE /api/books/{id} |
Delete a book |
POST /api/books/{id}/cover |
Replace book cover |
PUT /api/books/{id}/read |
Toggle read status |
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 |
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 |
List recommendations for current user |
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) |
GET /api/users |
List users (admin) |
POST /api/users |
Create a user (admin) |
PATCH /api/users/{id} |
Update a user (admin) |
DELETE /api/users/{id} |
Delete a user (admin) |
GET /api/config |
App config for the frontend |
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 (JSON {reply,error}) |
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.
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.read payload:
{
"bookId": "a1b2c3d4...",
"title": "Le Petit Prince",
"isRead": true,
"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)
└── embed.go # go:embed directive
License
MIT