Find a file
Chmouel Boudjnah 33732f14bc
Some checks are pending
build / binary (push) Waiting to run
build / container (push) Waiting to run
test / lint-openapi (push) Waiting to run
test / test (push) Waiting to run
fix: refuse a position that cannot be read instead of storing it as zero
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.
2026-08-26 23:46:58 +02:00
.github/workflows fix(deploy): name the environment variables the server reads 2026-08-18 22:29:57 +02:00
cmd/liseur-sync fix: harden credential enrolment 2026-08-20 06:10:51 +02:00
docs feat: sync annotations across devices (ADR-0028) 2026-08-25 12:04:15 +02:00
internal fix: refuse a position that cannot be read instead of storing it as zero 2026-08-26 23:46:58 +02:00
scripts test: keep screenshot fixture folders visible 2026-08-24 02:38:09 +02:00
.gitignore remove binary from root 2026-08-16 17:23:29 +02:00
.golangci.yml fix: add golangci-lint configuration and pre-commit hooks 2026-08-15 17:56:13 +02:00
.ko.yaml feat: Add web administration panel with account-level roles 2026-08-15 20:22:05 +02:00
.pre-commit-config.yaml fix: redirect after a settings mutation instead of rendering in place 2026-08-22 11:59:49 +02:00
.reflex.conf chore: Add development environment configuration and Makefile 2026-08-15 20:22:01 +02:00
AGENTS.md feat: scope catalog access to explicit per-user folder grants 2026-08-22 10:43:41 +02:00
CLAUDE.md feat: Implement initial reading position sync server backend 2026-08-10 16:19:12 +02:00
compose.yaml fix(deploy): name the environment variables the server reads 2026-08-18 22:29:57 +02:00
Dockerfile fix(deploy): name the environment variables the server reads 2026-08-18 22:29:57 +02:00
go.mod feat!: watch a folder, not a pipeline 2026-08-16 09:47:18 +02:00
go.sum feat!: watch a folder, not a pipeline 2026-08-16 09:47:18 +02:00
LICENSE Design document for liseur-sync 2026-08-10 13:06:47 +02:00
liseur-sync.example.toml fix: harden credential enrolment 2026-08-20 06:10:51 +02:00
Makefile chore: Update local development configuration template setup 2026-08-16 09:47:18 +02:00
README.md docs: replace em dashes and fix formatting 2026-08-22 07:33:05 +02:00
SECURITY.md fix: harden credential enrolment 2026-08-20 06:10:51 +02:00

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.

The dashboard

The reader-first library

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.

Book details and reading status

Web reader

The 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.

The administration section

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.