The web reader guarded the fraction it pushes with a typeof check, and typeof NaN is "number". JSON.stringify turns that NaN into null, and the op handler decoded progression into a plain float64, where null becomes the zero value and passes a 0..1 range check unharmed. So "the reader's position is unknown" was recorded as "the reader is at the start of the book", four times in a row, and every other device was pulled to the first page. Both halves are fixed, because either alone leaves the other reachable: a client that has not been updated can still post a null, and a server that has not been updated will still take one. Inbound progressions are decoded through their own request struct with a pointer field, so an absent or null value is refused rather than defaulted. The range check is written so that NaN fails it. The response structs keep a plain float64: they are what /v1/heads, /v1/changes and the positions endpoint answer with, and a client reading those has never been able to receive a null. The NaN itself has a cause worth writing down. The paginator derives pages from the container's own bounding rect, so a container that measures zero makes the page count Infinity and the page index NaN, and the fraction computed from the two is NaN for as long as that lasts — which is why four relocate events seventeen seconds apart all carried it, rather than one at startup. A hidden or zero-sized tab is enough. Guarding alone would have traded a wrong position for no position: with nothing finite to push, the reader would simply stop saving its place and never say so. So a non-finite fraction now asks the engine to measure again on a bounded backoff, restarts that when the tab becomes visible, and after the retries are spent tells the reader that syncing is paused rather than leaving them to find out later. The progress bar holds its last good value instead of flashing back to nought. |
||
|---|---|---|
| .github/workflows | ||
| cmd/liseur-sync | ||
| docs | ||
| internal | ||
| scripts | ||
| .gitignore | ||
| .golangci.yml | ||
| .ko.yaml | ||
| .pre-commit-config.yaml | ||
| .reflex.conf | ||
| AGENTS.md | ||
| CLAUDE.md | ||
| compose.yaml | ||
| Dockerfile | ||
| go.mod | ||
| go.sum | ||
| LICENSE | ||
| liseur-sync.example.toml | ||
| Makefile | ||
| README.md | ||
| SECURITY.md | ||
liseur-sync
A self-hosted server for book and reading-progress sync.
Liseur's companion server. It also works with stock KOReader through a kosync-compatible API.
It is a single Go binary with SQLite by default, optional PostgreSQL, and multi-user support.
Why liseur-sync
Komga and calibre-web both cover part of the job.
Komga has full Readium locator sync and a clean REST API. Its filesystem-first design means clients cannot upload or delete books; you put books in library folders by other means. Its JVM footprint is also large for a Raspberry Pi or small VPS (about 600 MB on disk).
calibre-web has a rich web UI and can delete books. Its Kobo-protocol sync carries percentages rather than exact reading positions. Upload and delete use browser forms behind sessions and CSRF tokens, which makes them a poor fit for another client.
Neither server syncs a book opened outside its catalog, such as one from a file manager. Neither offers personal series claims or an append-only sync log for deterministic conflict resolution.
liseur-sync combines those missing pieces: full-locator sync, including books resolved by hash; REST API upload and delete; personal series claims; and an append-only sync log. It stays small enough for a Raspberry Pi, at about 30 MB on disk.
| Capability | Komga | calibre-web | liseur-sync |
|---|---|---|---|
| Catalog and search | REST API | OPDS | REST API + OPDS |
| File download | REST API | OPDS | REST API |
| Position sync | Full locators | Percentages only | Full locators |
| Book upload | Not possible | Web UI form only | REST API |
| Book delete | Not possible | Web UI form only | REST API |
| Series management | Read-only | None | Personal claims |
| Footprint | ~600 MB (JVM) | ~200 MB (Python) | ~30 MB (Go) |
Features
Reading progress sync
liseur-sync records each user's reading positions from every device.
It keeps every update, not only the latest one. A stale device cannot overwrite newer progress, and the history drives reading sessions and statistics.
It matches books by content rather than filename, so a rename, move, or re-encoded EPUB can retain its reading position.
Watched folders and OPDS
Point liseur-sync at a directory of EPUBs or a Calibre library. It
watches the folder, reads metadata, and serves books through the web UI,
native catalog API, and OPDS 1.2.
Folders are read-only until an administrator enables uploads. Books stay
where they are: the server does not modify, rename, or delete files below
the folder root. An upload-enabled folder accepts a new file or Calibre
entry from the library page or a client with the library-upload scope.
The cover cache is the only other location where the server writes files.
liseur-sync admin folder-uploads <folder-id> on
Browse by series, contributor, or tag, and search within the selected
folder. In a plain folder, subdirectories form the series structure. In
a Calibre library, metadata.db is the catalog.
Web reader
liseur-sync includes a browser-based EPUB reader.
The browser unpacks and renders EPUBs without exposing publisher content through application routes. Scripts inside EPUBs do not run.
The reader syncs through the same API as other clients. Switch between it, Liseur, KOReader, and other apps without losing your place.
See ADR-0007 for the design and security model, including the optional separate hostname for the reader.
Quick start
The installer detects Docker or configures rootless Podman with a systemd user service. It then asks you to choose a database, starts the server, and creates the first account.
curl -fsSL https://raw.githubusercontent.com/chmouel/liseur-sync/main/scripts/install.sh | bash
If you prefer not to pipe a remote script directly into a shell, clone the repository and run:
scripts/install.sh
You can install a specific release with:
LISEUR_VERSION=vX.Y.Z scripts/install.sh
Docker Compose
Pick a database profile:
docker compose --profile sqlite up -d # SQLite
docker compose --profile postgres up -d # bundled PostgreSQL
docker compose --profile external up -d # existing PostgreSQL server
Build from source
go build ./cmd/liseur-sync
./liseur-sync serve
Once the server is running, open /ui/.
When no accounts exist, the setup page opens automatically. The first account becomes the administrator.
Add a folder from Settings > Administration > Folders, or from a shell:
liseur-sync admin add-folder Shelf /srv/books
Use Settings or the admin CLI to manage users, folders, API tokens, and
reader pairing.
See docs/deployment.md for KOReader pairing, TLS, watched folders, Calibre integration, and backups.
Client integration
Liseur is the reference client.
If you are building another client, docs/integrating.md covers the synchronization model and protocol.
Full API spec: docs/openapi.yaml.
Security
See SECURITY.md for reporting security issues.
Please do not report security vulnerabilities through public GitHub issues.
License
MIT.
The screenshots use public-domain editions from Standard
Ebooks and are generated by
scripts/screenshots.sh.




