liseur-sync/docs/openapi.yaml
Chmouel Boudjnah 0a6d4167aa feat: sync annotations across devices (ADR-0028)
Highlights, notes and bookmarks are the first mutable kind of reading
state: each record carries a rev, and every write is a compare-and-set
against it. The server orders writes; it never merges.

Store: an annotations table with its own per-user seq counter
(migration 5 on both backends), batched push in one transaction under
the work-graph lock, a delta feed with tombstones, a per-work cap on
live records, split/merge reassignment, and an hourly sweep that drops
tombstones after the retention window.

API: four sync-scoped routes. POST /v1/annotations answers 200 with one
result per item (applied, duplicate, conflict carrying the server copy,
or invalid with the reason); one bad item fails alone, including an
item the decoder cannot type. The request cap is computed from the
documented field bounds with worst-case JSON escaping, and exceeding it
is a 413, not a misleading parse error. Tombstones carry identity, rev,
seq and timestamps, nothing else.

Web UI: the reader draws synced highlights through the foliate
overlayer, mapping palette tokens to fixed CSS. Notes, bookmarks and
highlights that no longer anchor appear in the drawer list. The work
page gains a read-only annotations panel.

The browser check now seeds three annotations and verifies the
highlight renders as an overlay rect in a real Chromium.
2026-08-25 12:04:15 +02:00

2791 lines
120 KiB
YAML

openapi: 3.1.0
info:
title: liseur-sync native API
version: "1.0"
description: |
Reading-position sync and reading-statistics API.
Conventions that apply everywhere:
- **Auth**: native `/v1` endpoints except `/v1/login` and
`/v1/register` require `Authorization: Bearer <token>`;
`/healthz` is unauthenticated. OPDS uses HTTP Basic with a device
token secret. Login returns a short-lived *auth token* usable only
for token management; device tokens carry one or more scopes
(`sync`, `read-insights`, `library-read`, `library-manage`,
`library-upload`, `library-delete`, `admin`).
- **Folder access**: catalog scopes and folder grants are independent.
Catalog listings contain only books from folders explicitly granted to
the token's account. Direct access to an inaccessible folder, book or
entity returns `404`; administrator status does not bypass this rule.
- **Identity**: books are *works*. Clients never need a work to
exist before pushing: call `/v1/works/resolve` with every
identifier you have and use the returned `work_id`.
- **Idempotency**: `POST /v1/ops` and `POST /v1/sessions` are safe
to retry. Replaying the same `op_id`/`session_id` with the same
payload reports `duplicate`; the same id with a different payload
is a conflict (never an overwrite).
- **Delta sync**: `GET /v1/changes?since=<seq>` streams every op
after `seq` with a high-water mark. If your cursor falls below
the compaction horizon the server answers 410 `resync_required`;
fetch `/v1/heads` and resume from its `snapshot_seq`.
- **Errors**: JSON `{"error": "..."}`. Malformed input is always a
4xx with a reason, never a 5xx.
license:
name: MIT
url: https://opensource.org/license/mit
servers:
- url: https://your-instance.example.com
tags:
- name: auth
- name: identity
- name: sync
- name: sessions
- name: insights
- name: library
- name: opds
paths:
/healthz:
get:
summary: Liveness probe (unauthenticated)
description: |
Also reports which build is answering, so an operator can tell a
stale container from a current one without signing in. The image
carries no OCI labels; this is the stamp from the binary itself.
security: []
responses:
"200":
description: OK
content:
application/json:
schema:
type: object
properties:
status: { type: string, example: ok }
version:
type: string
description: Release tag, an abbreviated revision, or `dev`; suffixed `(modified)` when built from a dirty tree.
example: v1.2.3
revision:
type: string
description: Abbreviated VCS revision. Omitted when unknown.
example: 46ac8da1b2c3
/v1/login:
post:
tags: [auth]
security: []
summary: Exchange credentials for a short-lived auth token
description: |
The auth token (1 h) authorises token management only; it cannot
sync or read insights. Requires HTTPS unless the instance sets
`insecure_http`.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [username, password]
properties:
username: { type: string }
password: { type: string }
responses:
"200":
description: Authenticated
content:
application/json:
schema:
type: object
properties:
auth_token: { type: string, description: "Short-lived (1 h) token-management credential" }
expires_in: { type: integer, description: seconds }
"401": { $ref: "#/components/responses/Error" }
"403": { $ref: "#/components/responses/Error", description: "HTTPS required" }
"429": { $ref: "#/components/responses/Error", description: "Rate limited" }
/v1/register:
post:
tags: [auth]
security: []
summary: Create an account with an invite code
description: Registration is invite-only. Invite codes are single-use and expire.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [invite, username, password]
properties:
invite: { type: string }
username: { type: string }
password: { type: string, minLength: 8 }
timezone:
type: string
description: IANA name. Omitted or invalid values use UTC.
responses:
"201":
description: Account created
content:
application/json:
schema:
type: object
properties:
user_id: { type: string }
"400": { $ref: "#/components/responses/Error" }
"403": { $ref: "#/components/responses/Error", description: "Invalid or expired invite" }
"409": { $ref: "#/components/responses/Error", description: "Username taken" }
/v1/token:
get:
tags: [auth]
summary: Describe the calling token
description: |
Returns the device token that made the request: its id, its
device id, its name and its scopes. Call it once at startup and
draw the interface from the answer, rather than probing routes
and reading `403`s.
Authenticated like every other route — an absent, revoked or
expired token gets `401` — but it requires no particular scope.
The subject of the response is the credential presenting it, so
the narrowest token can ask about itself.
It never returns a secret, a secret hash, or any token other
than the caller's. To enumerate the account's tokens, use
`GET /v1/tokens`, which needs the login credential.
`HEAD` is served as the status line of the `GET`, which is what
makes it a cheap liveness check for a stored credential. Every
other method is `405`.
Like every authenticated route, a call updates the token's
last-used timestamp. Nothing about the credential's authority —
its scopes, device binding, expiry or secret — changes.
security: [{ deviceToken: [] }]
responses:
"200":
description: The calling token
content:
application/json:
schema: { $ref: "#/components/schemas/TokenSelf" }
"401": { $ref: "#/components/responses/Error" }
head:
tags: [auth]
summary: Check the calling token is still live
description: |
The status line of the `GET`, with no body. The cheapest way for
a client holding a stored secret to find out whether it is still
good before using it: `200` if the token authenticates, `401` if
it is absent, revoked or expired.
security: [{ deviceToken: [] }]
responses:
"200": { description: The token is live }
"401": { $ref: "#/components/responses/Error" }
/v1/tokens:
post:
tags: [auth]
summary: Create a device token
description: |
Requires the login auth token (not a device token). The token
secret is returned once; store it. Each token is bound to a
server-generated `device_id` that stamps every op and session it
pushes.
`admin` scope may only be requested by a caller who already
holds an active admin token; otherwise the request is refused
with `403`. The first admin token on an instance is minted with
`liseur-sync admin mint-token -scope admin`.
Legacy clients may send `scope`. New clients send `scopes`.
Supplying both is accepted only when they describe the same
singleton set. Responses always return `scopes` and return the
deprecated `scope` field only for singleton sets.
security: [{ loginAuth: [] }]
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [name]
anyOf:
- required: [scope]
- required: [scopes]
properties:
name: { type: string, example: "Boox Palma" }
scope:
allOf:
- { $ref: "#/components/schemas/Scope" }
deprecated: true
description: "Legacy singleton form."
scopes: { $ref: "#/components/schemas/ScopeSet" }
expires_in_seconds: { type: integer }
responses:
"201":
description: Token created; secret shown once
content:
application/json:
schema:
type: object
required: [token_id, device_id, name, scopes, secret, expires_at]
properties:
token_id: { type: string }
device_id: { type: string }
name: { type: string }
scope:
allOf:
- { $ref: "#/components/schemas/Scope" }
deprecated: true
description: "Present only for singleton scope sets."
scopes: { $ref: "#/components/schemas/ScopeSet" }
secret: { type: string, description: "Bearer secret, returned exactly once" }
expires_at: { type: [string, "null"], format: date-time }
"400": { $ref: "#/components/responses/Error" }
"401": { $ref: "#/components/responses/Error" }
"403":
description: |
`admin` scope requested by a caller who is not already an
admin.
content:
application/json:
schema:
type: object
properties: { error: { type: string } }
get:
tags: [auth]
summary: List your tokens
security: [{ loginAuth: [] }]
responses:
"200":
description: Token list (secrets never included)
content:
application/json:
schema:
type: object
properties:
tokens:
type: array
items: { $ref: "#/components/schemas/TokenInfo" }
/v1/tokens/{id}:
patch:
tags: [auth]
summary: Update a device token's scopes in place
description: |
Preserves the token id, device id, and secret. Requires the
short-lived login credential. Adding `admin` requires the user
to already hold an active admin token.
security: [{ loginAuth: [] }]
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
requestBody:
required: true
content:
application/json:
schema:
type: object
anyOf:
- required: [scope]
- required: [scopes]
properties:
scope:
allOf:
- { $ref: "#/components/schemas/Scope" }
deprecated: true
scopes: { $ref: "#/components/schemas/ScopeSet" }
responses:
"200":
description: Token scopes updated in place
content:
application/json:
schema:
type: object
required: [status, scopes]
properties:
status: { type: string, const: updated }
scope:
allOf:
- { $ref: "#/components/schemas/Scope" }
deprecated: true
description: "Present only for singleton scope sets."
scopes: { $ref: "#/components/schemas/ScopeSet" }
"400": { $ref: "#/components/responses/Error" }
"401": { $ref: "#/components/responses/Error" }
"403": { $ref: "#/components/responses/Error" }
"404": { $ref: "#/components/responses/Error" }
delete:
tags: [auth]
summary: Revoke a token
security: [{ loginAuth: [] }]
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
responses:
"200": { $ref: "#/components/responses/StatusOK" }
"404": { $ref: "#/components/responses/Error" }
/v1/works/resolve:
post:
tags: [identity]
summary: Resolve a file's identifiers to a work
description: |
Send every identifier you have for the file. Resolution priority is
`sha256` → `partial-md5` → `source` → `dc` → `ta`, but every supplied
identifier that already matches must agree on one work. On a
high-confidence result, all supplied identifiers are registered as
aliases and every supplied SHA-256 is registered as an edition of the
resolved work (this is how identity converges across re-encodes).
Fuzzy `ta` matches return `confidence: low` — confirm with the
user before treating it as authoritative. On a low match nothing
is registered, so a guess cannot be laundered into a certainty;
send `confirmed: true` once the user has said it is the same
book, and the match resolves high and registers the identifiers.
If the identifiers resolve to more than one distinct work, the
server answers 409 with the conflicting work ids and changes
nothing; call `/v1/works/merge` to confirm the merge.
security: [{ deviceToken: [] }] # requires sync scope
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [identifiers]
properties:
identifiers:
type: array
minItems: 1
items: { $ref: "#/components/schemas/Identifier" }
title: { type: string }
author: { type: string }
confirmed:
type: boolean
description: Confirm that a low-confidence title/author match is the same work.
responses:
"200":
description: Resolved to an existing work
content:
application/json:
schema: { $ref: "#/components/schemas/ResolveResult" }
"201":
description: New work created
content:
application/json:
schema: { $ref: "#/components/schemas/ResolveResult" }
"400": { $ref: "#/components/responses/Error" }
"409":
description: Identifiers map to multiple works; nothing changed
content:
application/json:
schema:
type: object
properties:
error: { type: string }
works: { type: array, items: { type: string } }
/v1/works/{id}/split:
post:
tags: [identity]
summary: Detach an edition into a new work
description: |
Repairs a wrong merge. Moves the edition and its ops/sessions to
a new work. Aliases belong to works, not editions, so pass the
aliases to move explicitly (the edition's own `sha256` alias is
implied). A different edition's `sha256` alias is rejected.
Works with compacted session rollups cannot be split because the
aggregate no longer retains enough edition provenance. The same
restriction applies to legacy inferred sessions whose edition or
origin alias cannot be recovered.
security: [{ deviceToken: [] }] # requires sync scope
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [edition_sha]
properties:
edition_sha: { type: string }
aliases:
type: array
items: { $ref: "#/components/schemas/Identifier" }
responses:
"200":
description: Split complete
content:
application/json:
schema:
type: object
properties: { work_id: { type: string } }
"400": { $ref: "#/components/responses/Error" }
"404": { $ref: "#/components/responses/Error" }
"409": { $ref: "#/components/responses/Error", description: "Edition or history cannot be split from this work" }
/v1/works/merge:
post:
tags: [identity]
summary: Merge one work into another (explicit confirmation)
description: |
Moves aliases, editions, operations, and raw sessions into the
target work. A source work with compacted session rollups cannot
be merged because those aggregates no longer retain session
provenance.
security: [{ deviceToken: [] }] # requires sync scope
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [from_work_id, into_work_id]
properties:
from_work_id: { type: string }
into_work_id: { type: string }
responses:
"200":
description: Merged
content:
application/json:
schema:
type: object
properties: { work_id: { type: string } }
"400": { $ref: "#/components/responses/Error" }
"404": { $ref: "#/components/responses/Error" }
"409": { $ref: "#/components/responses/Error", description: "Source work has compacted history" }
/v1/ops:
post:
tags: [sync]
summary: Push a batch of position ops
description: |
Ops are appended to your per-user log with a monotonic `seq`.
Retries are safe: replaying an `op_id` with the same payload
returns `duplicate` with the original seq; a reused `op_id` with
a different payload returns `conflict` and is not stored.
`device_id` is taken from the token; a client-supplied device id
is ignored.
security: [{ deviceToken: [] }] # requires sync scope
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [ops]
properties:
ops:
type: array
maxItems: 500
items: { $ref: "#/components/schemas/OpInput" }
responses:
"200":
description: Per-item results (valid items applied even if others conflict)
content:
application/json:
schema:
type: object
properties:
results:
type: array
items: { $ref: "#/components/schemas/OpResult" }
"400":
$ref: "#/components/responses/Error"
description: |
Malformed batch, or one item naming a work the server no
longer holds. The whole batch is rejected atomically. An
unknown work comes back as `code: unknown_work` with the
`work_id` and the offending `op_id`: re-resolve the book to
refresh its cached identity, then retry.
/v1/changes:
get:
tags: [sync]
summary: Delta-sync every op after a seq
security: [{ deviceToken: [] }] # requires sync scope
parameters:
- { name: since, in: query, schema: { type: integer, default: 0 } }
- { name: limit, in: query, schema: { type: integer, default: 500, maximum: 500 } }
responses:
"200":
description: Page of ops with the new high-water mark
content:
application/json:
schema:
type: object
properties:
ops:
type: array
items: { $ref: "#/components/schemas/Op" }
high_water: { type: integer }
has_more: { type: boolean }
"410":
description: Cursor below the compaction horizon
content:
application/json:
schema:
type: object
properties:
error: { type: string, enum: [resync_required] }
high_water: { type: integer }
heads_endpoint: { type: string }
/v1/heads:
get:
tags: [sync]
summary: Resync snapshot — newest op per (work, device)
description: |
The recovery protocol after `resync_required` (or first sync of
a fresh client): returns the newest op for every work+device
pair plus an atomic `snapshot_seq`. Rebuild your baseline from
the heads, then resume `/v1/changes?since=<snapshot_seq>`.
security: [{ deviceToken: [] }] # requires sync scope
responses:
"200":
description: Heads snapshot
content:
application/json:
schema:
type: object
properties:
ops:
type: array
items: { $ref: "#/components/schemas/Op" }
snapshot_seq: { type: integer }
/v1/works/{id}/positions:
get:
tags: [sync]
summary: Recent position history for a work (newest first)
security: [{ deviceToken: [] }] # requires sync scope
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
- { name: limit, in: query, schema: { type: integer, default: 50, maximum: 200 } }
responses:
"200":
description: Recent ops
content:
application/json:
schema:
type: object
properties:
ops:
type: array
items: { $ref: "#/components/schemas/Op" }
"404": { $ref: "#/components/responses/Error" }
/v1/sessions:
post:
tags: [sessions]
summary: Push a batch of reading sessions
description: |
Sessions are append-only facts. Report progression fractions
measured at the time of reading (`start_progression`,
`end_progression`); never derive page numbers yourself — the
server derives pages from edition metadata when known. Same
`session_id` with a different payload is a 409.
security: [{ deviceToken: [] }] # requires sync scope
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [sessions]
properties:
sessions:
type: array
maxItems: 1000
items: { $ref: "#/components/schemas/SessionInput" }
responses:
"200":
description: Accepted
content:
application/json:
schema:
type: object
properties: { accepted: { type: integer } }
"400":
$ref: "#/components/responses/Error"
description: |
Malformed batch, or one item naming a work the server no
longer holds. The whole batch is rejected atomically. An
unknown work comes back as `code: unknown_work` with the
`work_id` and the offending `session_id`: re-resolve the book
to refresh its cached identity, then retry.
"409": { $ref: "#/components/responses/Error", description: "session_id reused with different payload" }
/v1/annotations:
post:
tags: [sync]
summary: Push a batch of annotations (highlights, notes, bookmarks)
description: |
Annotations are the mutable kind of reading state (ADR-0028):
each record carries a `rev`, and every write is a compare-and-set
against it. A create sends `base_rev: 0`; an edit sends the rev
it read. A byte-identical retry of the last write is `duplicate`
and acknowledged with the stored rev and seq; any other stale
`base_rev` is a per-item `conflict` carrying the server's copy —
the server orders, it never merges. The batch is never atomic:
one bad item fails alone, whether its problem is shape (bad
kind, oversized excerpt or body, a color off the palette, a
locator on a note) or reference (unknown work, per-work cap) —
both come back as per-item `invalid` results with a reason.
Only a request nothing in it can excuse is refused whole:
malformed JSON, an empty batch or one over 100 items (400), or
a body larger than any legal batch could need (413).
`device_id` is taken from the token.
security: [{ deviceToken: [] }] # requires sync scope
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [annotations]
properties:
annotations:
type: array
maxItems: 100
items: { $ref: "#/components/schemas/AnnotationInput" }
responses:
"200":
description: Per-item results (valid items applied even if others conflict)
content:
application/json:
schema:
type: object
properties:
results:
type: array
items: { $ref: "#/components/schemas/AnnotationResult" }
"400": { $ref: "#/components/responses/Error" }
"413": { $ref: "#/components/responses/Error" }
/v1/annotations/changes:
get:
tags: [sync]
summary: Delta-sync annotations after a seq
description: |
The annotation feed is *state*, not history: it has its own
per-user seq counter, separate from the op log, and an edit or
delete moves the record to the head of the feed with its current
content. Tombstones are included, carrying identity, `rev`,
`seq` and `deleted_at` and nothing else; after the retention
window they are swept, so a device offline longer than that
reconciles against the live set instead. `/v1/changes` remains
positions-only.
security: [{ deviceToken: [] }] # requires sync scope
parameters:
- { name: since, in: query, schema: { type: integer, default: 0 } }
- { name: limit, in: query, schema: { type: integer, default: 500, maximum: 500 } }
responses:
"200":
description: Page of annotations with the new high-water mark
content:
application/json:
schema:
type: object
properties:
annotations:
type: array
items: { $ref: "#/components/schemas/Annotation" }
high_water: { type: integer }
has_more: { type: boolean }
/v1/annotations/{id}:
delete:
tags: [sync]
summary: Delete an annotation (tombstone, rev-checked)
security: [{ deviceToken: [] }] # requires sync scope
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
- name: rev
in: query
required: true
description: The rev the client read; the delete applies iff it is current.
schema: { type: integer, minimum: 1 }
responses:
"200":
description: Tombstone written (`applied`), or the record was already deleted (`duplicate`)
content:
application/json:
schema:
type: object
properties:
id: { type: string }
status: { type: string, enum: [applied, duplicate] }
rev: { type: integer }
seq: { type: integer }
"400": { $ref: "#/components/responses/Error" }
"404": { $ref: "#/components/responses/Error" }
"409":
description: Stale rev; the body carries the server's current copy under `server`.
content:
application/json:
schema:
type: object
properties:
error: { type: string }
server: { $ref: "#/components/schemas/Annotation" }
/v1/works/{id}/annotations:
get:
tags: [sync]
summary: Live annotations for a work
description: |
The live set only — no tombstones — ordered by progression, then
`client_ts` (records without a progression sort last).
security: [{ deviceToken: [] }] # requires sync scope
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
responses:
"200":
description: Live annotations
content:
application/json:
schema:
type: object
properties:
annotations:
type: array
items: { $ref: "#/components/schemas/Annotation" }
"404": { $ref: "#/components/responses/Error" }
/v1/insights/summary:
get:
tags: [insights]
summary: Reading totals, streak, speed over a range
security: [{ deviceToken: [] }] # requires read-insights scope
parameters:
- name: from
in: query
description: |
First day of the span, inclusive, in the user's timezone.
Must be paired with `to`, and takes precedence over `range`.
Prefer this over `range`: a count of days is resolved against
the server's clock, a pair of dates says exactly what was
meant, and both are echoed back so a client can tell an
honoured span from one a server too old to understand it
ignored.
schema: { type: string, format: date }
- name: to
in: query
description: Last day of the span, inclusive. Must be paired with `from` and not fall before it.
schema: { type: string, format: date }
- name: range
in: query
description: |
The older spelling of a span: a whole number of *calendar*
days from 1 through 3660 ending today, such as `30d`, or
`all` for everything on record, which is genuinely unbounded.
Omitted or invalid values use `30d`. Ignored when a valid
`from`/`to` pair is given. `streak_days` is not narrowed by
any of this: it counts every consecutive day on record, so a
short span cannot truncate a long run.
schema: { type: string, default: 30d }
responses:
"200":
description: |
Aggregates (rereads count time but zero pages). `from` and
`to` are echoed back whenever the span was bounded, and must
be checked: a server that did not understand the request
answers about a different span, and nothing in the totals
themselves would say so.
content:
application/json:
schema:
type: object
properties:
range_days: { type: integer, description: "Days covered; 0 when unbounded" }
from: { type: string, format: date }
to: { type: string, format: date }
total_active_minutes: { type: number }
total_pages: { type: number }
sessions: { type: integer }
streak_days: { type: integer }
speed_prog_per_hour: { type: number }
/v1/insights/works/{id}:
get:
tags: [insights]
summary: Per-work statistics and ETA
security: [{ deviceToken: [] }] # requires read-insights scope
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
- name: from
in: query
description: |
First day of the span, inclusive, in the user's timezone.
Must be paired with `to`, and takes precedence over `range`.
Prefer this over `range`: a count of days is resolved against
the server's clock, a pair of dates says exactly what was
meant, and both are echoed back so a client can tell an
honoured span from one a server too old to understand it
ignored.
schema: { type: string, format: date }
- name: to
in: query
description: Last day of the span, inclusive. Must be paired with `from` and not fall before it.
schema: { type: string, format: date }
- name: range
in: query
description: |
The older spelling of a span: a whole number of calendar days
from 1 through 3660, or `all`. Omitted, the aggregate covers
the work's whole history, which is what this endpoint
answered before spans existed. `current_progression` and
`eta_seconds` are never narrowed: where the reader is in a
book is true now, whatever span the totals beside it cover.
schema: { type: string }
responses:
"200":
description: |
One work's aggregate; `eta_seconds` is null when no speed
history exists. The span is named back as `range_days`
(always) and `from`/`to` (when bounded).
content:
application/json:
schema:
type: object
properties:
range_days: { type: integer, description: "Days covered; 0 when unbounded" }
from: { type: string, format: date }
to: { type: string, format: date }
work_id: { type: string }
sessions: { type: integer }
total_active_minutes: { type: number }
total_pages: { type: number }
current_progression: { type: number }
eta_seconds: { type: [number, "null"] }
last_read_at: { type: [string, "null"], format: date-time }
"404": { $ref: "#/components/responses/Error" }
/v1/insights/works:
get:
tags: [insights]
summary: Per-work statistics for every work with reading history
security: [{ deviceToken: [] }] # requires read-insights scope
parameters:
- name: from
in: query
description: |
First day of the span, inclusive, in the user's timezone.
Must be paired with `to`, and takes precedence over `range`.
Prefer this over `range`: a count of days is resolved against
the server's clock, a pair of dates says exactly what was
meant, and both are echoed back so a client can tell an
honoured span from one a server too old to understand it
ignored.
schema: { type: string, format: date }
- name: to
in: query
description: Last day of the span, inclusive. Must be paired with `from` and not fall before it.
schema: { type: string, format: date }
- name: range
in: query
description: |
The older spelling of a span: a whole number of calendar days
from 1 through 3660, or `all`. Omitted, every aggregate
covers its work's whole history. Give this the same span as
`/v1/insights/summary` when the two are shown on one screen,
or the headline and the rows below it will describe
different spans.
schema: { type: string }
responses:
"200":
description: |
Per-work aggregates, ordered by active time descending.
`range_days` is always present, and `from`/`to` whenever the
span was bounded; both must be checked before the rows are
labelled with a span.
content:
application/json:
schema:
type: object
required: [works, range_days]
properties:
range_days: { type: integer, description: "Days covered; 0 when unbounded" }
from: { type: string, format: date }
to: { type: string, format: date }
works:
type: array
items:
type: object
required: [work_id, sessions, total_active_minutes, total_pages, current_progression]
properties:
work_id: { type: string }
sessions: { type: integer }
total_active_minutes: { type: number }
total_pages: { type: number }
current_progression: { type: number }
eta_seconds: { type: [number, "null"] }
last_read_at: { type: [string, "null"], format: date-time }
/v1/insights/calendar:
get:
tags: [insights]
summary: Daily reading minutes over a year or an arbitrary span (user's timezone)
security: [{ deviceToken: [] }] # requires read-insights scope
parameters:
- name: year
in: query
description: A year from 1971 through 2999. Omitted or invalid values use the server's current year. Ignored when a valid `from`/`to` pair is given.
schema: { type: integer }
- name: from
in: query
description: |
First day to include. Must be paired with `to`. A rolling
window costs one request in this form rather than one per
calendar year it straddles. A span longer than 4000 days is
refused rather than served.
schema: { type: string, format: date }
- name: to
in: query
description: Last day to include, inclusive. Must be paired with `from` and not fall before it.
schema: { type: string, format: date }
responses:
"200":
description: |
Days with activity; midnight-crossing sessions are split.
`from`, `to` and `range_days` are echoed back only when
`from`/`to` were honoured, so a client can tell this server
from one that predates them, ignored them, and answered with
the current year.
content:
application/json:
schema:
type: object
properties:
year: { type: integer }
from: { type: string, format: date }
to: { type: string, format: date }
range_days:
type: integer
description: Calendar days the answer covers, present only when `from`/`to` were honoured.
days:
type: array
items:
type: object
properties:
date: { type: string, format: date }
minutes: { type: number }
pages: { type: number }
"400": { $ref: "#/components/responses/Error" }
/v1/folders:
get:
tags: [library]
summary: The folders this server watches
security: [{ deviceToken: [] }] # requires library-read scope
description: |
Where a catalog client starts: every other route needs a folder
id. The list contains only folders explicitly granted to the token's
account; administrator status does not imply a grant. `root_path` is deliberately not
here — it is a fact about the server's filesystem, and naming it
would make this route an oracle for one.
parameters:
- { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 200 } }
- { name: after, in: query, schema: { type: string }, description: "Opaque cursor from a previous page's `next_after`" }
responses:
"200":
description: The folders
content:
application/json:
schema:
type: object
required: [folders]
properties:
next_after:
type: string
description: Present only when a further page exists.
folders:
type: array
items:
type: object
required: [folder_id, name, kind, created_at]
properties:
folder_id: { type: string, format: uuid }
name: { type: string }
kind:
type: string
enum: [plain, calibre]
description: |
`calibre` when the directory holds a
`metadata.db`, which the server reads
read-only for series, tags, descriptions and
covers. `plain` otherwise, where the
directory tree is the organisation: a
subdirectory of EPUBs is a series.
accepts_uploads:
type: boolean
description: |
Whether an administrator turned uploads on
for this folder. A client offers the action
only where this is true; the server refuses
it everywhere else regardless.
created_at: { type: string, format: date-time }
/v1/folders/{folder}/books:
get:
tags: [library]
summary: Books in a folder
security: [{ deviceToken: [] }] # requires library-read scope
description: |
Cursor-paginated, oldest first unless `order=recent`. Books
whose file is missing are excluded. `next_cursor` appears only when a further
page exists, so a client pages until it is absent. Treat the
cursor as opaque — it means "where the last page ended", which
is a different place in each order, so cursors do not carry
across a change of `order`.
parameters:
- { name: folder, in: path, required: true, schema: { type: string, format: uuid } }
- name: order
in: query
description: |
`recent` is newest first, which is the "what arrived since
last time" listing. An unrecognized value is refused rather
than defaulted.
schema: { type: string, enum: [oldest, recent], default: oldest }
- name: limit
in: query
schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
- name: cursor
in: query
schema: { type: string }
description: A `next_cursor` from a previous page.
responses:
"200":
description: One page of the catalog
content:
application/json:
schema:
type: object
required: [books]
properties:
books:
type: array
items: { $ref: "#/components/schemas/CatalogBook" }
next_cursor:
type: string
description: Absent on the last page.
"400": { $ref: "#/components/responses/Error", description: "Bad limit or malformed cursor" }
"404": { $ref: "#/components/responses/Error", description: "No such folder, or the folder is not granted to this account" }
post:
tags: [library]
summary: Upload a book into a folder
security: [{ deviceToken: [] }] # requires library-upload scope
parameters:
- { name: folder, in: path, required: true, schema: { type: string, format: uuid } }
description: |
An upload is a file written into a folder, and nothing more
(ADR-0023). This route does not write the catalog: it puts the
publication where the folder can see it and then runs the same
pass a file copied in by hand would get.
Two conditions, both deliberate. The folder must have
`accepts_uploads` — an administrator turns that on per folder —
and the token must carry `library-upload`, which no other route
uses. A folder nobody marked is refused even with the scope.
In a Calibre folder the file alone would not be a book, because
discovery there comes from `metadata.db`. So a Calibre folder
gets a Calibre book: rows, Calibre's directory layout, a
`cover.jpg` and a `metadata.opf`.
The SHA-256 of the bytes is the idempotency key. There is no
client-supplied key and no pending state: a retry after a lost
connection is answered with the book the first attempt made.
requestBody:
required: true
content:
multipart/form-data:
schema:
type: object
required: [file]
properties:
file:
type: string
format: binary
description: The EPUB. Other parts are ignored.
responses:
"201":
description: The book was written and the pass catalogued it.
content:
application/json:
schema:
type: object
required: [book_id, folder_id, relative_path, content_sha256, duplicate]
properties:
book_id: { type: string, format: uuid }
folder_id: { type: string, format: uuid }
relative_path: { type: string }
content_sha256: { type: string }
duplicate: { type: boolean, enum: [false] }
"200":
description: |
The caller's visible catalog already held these bytes, here or in
another granted folder. Nothing was written and this is the book.
content:
application/json:
schema:
type: object
required: [book_id, folder_id, content_sha256, duplicate]
properties:
book_id: { type: string, format: uuid }
folder_id: { type: string, format: uuid }
content_sha256: { type: string }
duplicate: { type: boolean, enum: [true] }
"202":
description: |
The bytes are on the disk and durable, but the pass
concluded nothing yet — rules 1 and 2 of ADR-0017 allow
that. The watcher will come back to it; resolve the book
later by its digest.
content:
application/json:
schema:
type: object
required: [folder_id, relative_path, content_sha256]
properties:
folder_id: { type: string, format: uuid }
relative_path: { type: string }
content_sha256: { type: string }
"400": { $ref: "#/components/responses/Error", description: "Malformed multipart body or an empty upload" }
"403":
description: The folder does not accept uploads, or the token lacks `library-upload`.
"404": { description: "No such folder, or the folder is not granted to this account" }
"409":
description: |
A Calibre library that is busy. Close Calibre and retry.
"413": { description: Larger than `content.max_upload_bytes` }
"422": { description: Not a readable EPUB }
"503": { description: This server is running without a folder watcher }
/v1/folders/{folder}/search:
get:
tags: [library]
summary: Find a book in a folder
security: [{ deviceToken: [] }] # requires library-read scope
description: |
Matches `q` against everything a book says about itself — title,
subtitle, description, publisher, and the names of the series,
contributors and tags it claims — and returns the best
matches first. A book *called* Dune outranks one that merely
mentions it.
`q` is words, never index syntax. Punctuation and operators are
split away rather than interpreted, so no query can change how it
is read; a query made only of punctuation matches nothing.
Diacritics and case are folded, so `emile` finds `Émile`.
The answer is unpaged. A relevance order has no stable cursor, and
this route answers "where is that book" rather than "show me
everything"; `truncated` says the answer was cut at `limit` so a
client can ask the person to narrow it rather than implying it
found all there was. Browsing a whole folder is what
`/v1/folders/{folder}/books` is for.
`facets` describe the books actually returned, counted over that
set rather than over the folder, and each `id` can be passed back
as `entity` to narrow the query. A filter needs no `kind`: an id
already knows what it is. Repeating `entity` ANDs the filters.
There is no reading-state filter and there will not be one. A
catalog-only credential must not be able to observe reading state,
so this route has no vocabulary for it; reading state is reported
by the `sync` and `read-insights` surfaces, which ask for their
own scopes.
parameters:
- { name: folder, in: path, required: true, schema: { type: string, format: uuid } }
- name: q
in: query
description: The words to match. Absent or empty matches every book, which is useful with `entity`.
schema: { type: string, maxLength: 512 }
- name: entity
in: query
description: Narrow to books claiming this entity, whatever its kind. Repeatable; filters are ANDed.
schema: { type: array, items: { type: string, format: uuid }, maxItems: 8 }
- name: limit
in: query
description: Most books returned.
schema: { type: integer, minimum: 1, maximum: 100, default: 100 }
responses:
"200":
description: The matching books and what they have in common
content:
application/json:
schema: { $ref: "#/components/schemas/SearchResult" }
"400": { $ref: "#/components/responses/Error", description: "Bad limit, over-long query, or malformed entity filter" }
"404": { $ref: "#/components/responses/Error", description: "No such folder, or the folder is not granted to this account" }
/v1/books/{id}:
get:
tags: [library]
summary: One book, in the same shape every listing returns
security: [{ deviceToken: [] }] # requires library-read scope
parameters:
- { name: id, in: path, required: true, schema: { type: string, format: uuid } }
responses:
"200":
description: The book
content:
application/json:
schema: { $ref: "#/components/schemas/CatalogBook" }
"404": { $ref: "#/components/responses/Error", description: "Unknown book, or no access" }
delete:
tags: [library]
summary: Delete a book from the server
security: [{ deviceToken: [] }] # requires library-delete scope
parameters:
- { name: id, in: path, required: true, schema: { type: string, format: uuid } }
- name: forget_reading
in: query
required: false
schema: { type: boolean, default: false }
description: |
Also forget the caller's own reading of this book, and only
theirs. Another reader's history is never touched.
description: |
The counterpart of an upload, bounded the same way (ADR-0025).
This deletes the file itself and then the catalog row: the bytes
are gone from this server's disk, for every device, and there is
no trash to take them back out of.
Two conditions, and they mirror the upload's. The book's folder
must have `accepts_uploads` — that flag marks the one place this
server may write under a folder root, and deleting is a write —
and the token must carry `library-delete`, which no other route
uses. `library-upload` is not enough: sending your own book and
destroying everyone's are different questions.
Reading survives by default. A work with history outlives its
book and becomes an entry only its own reader can remove
(ADR-0024), which is what lets a reader on another device keep
their position. `forget_reading=true` removes the caller's, and
is declined silently when another copy of the same book still
maps that work — the reader asked to forget a book they still
have.
In a Calibre folder the row is the book, so the delete goes
through `metadata.db`: the row goes, Calibre's own triggers take
the links with it, and the book's directory is removed. The
directory comes from `books.path`, not from anything cached,
because Calibre renames directories when metadata changes.
responses:
"204": { description: The book is gone. }
"403":
description: |
The book's folder does not accept uploads, or the token
lacks `library-delete`.
"404": { description: "No such book, or its folder is not granted to this account" }
"409":
description: |
The file changed since it was last scanned and was left
alone, or a Calibre library is busy — close Calibre and
retry.
"503": { description: This server is running without a folder watcher }
/v1/books/{id}/series:
get:
tags: [library]
summary: Read a book's series layers
security: [{ deviceToken: [] }] # requires library-read scope
description: |
Returns the effective series memberships for one book and the
three layers they resolve from. This is the read an editor uses
to show what the folder said underneath a claim, and whether
clearing a claim would change anything.
`shared` and `personal` are `null` when that layer has no claim.
They are `[]` when the layer explicitly claims the book is in no
series. That difference is meaningful: `null` means there is
nothing to reset at that layer, while `[]` is a resettable claim.
parameters:
- { name: id, in: path, required: true, schema: { type: string, format: uuid } }
responses:
"200":
description: The effective series and the layers beneath it
content:
application/json:
schema: { $ref: "#/components/schemas/BookSeriesLayers" }
"404": { $ref: "#/components/responses/Error", description: "Unknown book, or no access" }
put:
tags: [library]
summary: Replace one layer's series claim for a book
security: [{ deviceToken: [] }] # requires library-manage scope
description: |
States the whole of one writable layer's opinion about the
book's series memberships. It is a replacement, not a diff:
omitted memberships are removed from that layer's claim.
The default scope is `personal`, which affects only the caller.
`shared` affects every reader without a personal claim and also
requires the `admin` scope; a non-admin token with
`library-manage` receives `403`.
An absent or empty `series` array is a real claim that the book
is in no series. It is not the same as no claim; use `DELETE` to
clear a layer and fall back to the one beneath it.
parameters:
- { name: id, in: path, required: true, schema: { type: string, format: uuid } }
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/BookSeriesClaim" }
responses:
"200":
description: The effective series and all layers after the claim
content:
application/json:
schema: { $ref: "#/components/schemas/BookSeriesLayers" }
"400": { $ref: "#/components/responses/Error", description: "Malformed input, unwritable scope, invalid membership, unknown series id, non-finite position, or too many series" }
"403": { $ref: "#/components/responses/Error", description: "The token lacks library-manage, or the shared layer requires admin" }
"404": { $ref: "#/components/responses/Error", description: "Unknown book, or no access" }
"409": { $ref: "#/components/responses/Error", description: "client_ts was reused for different claim state" }
delete:
tags: [library]
summary: Clear one layer's series claim for a book
security: [{ deviceToken: [] }] # requires library-manage scope
description: |
Drops one writable layer's claim so the book falls back to the
layer beneath. Clearing a claim that is not present is not an
error. The default scope is `personal`; `shared` also requires
the `admin` scope.
parameters:
- { name: id, in: path, required: true, schema: { type: string, format: uuid } }
- name: scope
in: query
required: false
description: The claim layer to clear. Defaults to `personal`.
schema: { $ref: "#/components/schemas/SeriesClaimScope" }
- name: client_ts
in: query
required: false
description: Client-generated stable idempotency key. Reuse it exactly when retrying the same deletion.
schema: { type: string }
- name: if_updated_at
in: query
required: false
description: Last server claim revision observed by the client. A different current revision returns `stale` without mutation. Revisions are millisecond-precise and compared at that precision.
schema: { type: string, format: date-time }
responses:
"200":
description: The effective series and all layers after clearing
content:
application/json:
schema: { $ref: "#/components/schemas/BookSeriesLayers" }
"400": { $ref: "#/components/responses/Error", description: "Unwritable scope" }
"403": { $ref: "#/components/responses/Error", description: "The token lacks library-manage, or the shared layer requires admin" }
"404": { $ref: "#/components/responses/Error", description: "Unknown book, or no access" }
"409": { $ref: "#/components/responses/Error", description: "client_ts was reused for different claim state" }
/v1/entities/{kind}:
get:
tags: [library]
summary: The series, contributors and tags in the library
security: [{ deviceToken: [] }] # requires library-read scope
description: |
Entities are library-wide and shared by every book that claims
them, whichever folder that book was found in: one series named
the same way in two folders is one entity here (ADR-0019).
`book_count` counts only books whose file is present, so an
entity whose books have all gone missing reads as empty rather
than leading to a blank page.
Paging resumes after a name rather than at an offset, because an
offset would skip or repeat entities as books are added
underneath it.
parameters:
- { $ref: "#/components/parameters/EntityKind" }
- name: after
in: query
description: The `next_after` of the previous page.
schema: { type: string, maxLength: 512 }
- name: limit
in: query
schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
responses:
"200":
description: One page of entities, ordered by name
content:
application/json:
schema:
type: object
required: [entities]
properties:
entities:
type: array
items: { $ref: "#/components/schemas/CatalogEntity" }
next_after:
type: string
description: Absent on the last page.
"400": { $ref: "#/components/responses/Error", description: "Bad limit or cursor" }
"404": { $ref: "#/components/responses/Error", description: "No such kind" }
/v1/entities/{kind}/{entity}/books:
get:
tags: [library]
summary: The books claiming one entity
security: [{ deviceToken: [] }] # requires library-read scope
description: |
Active books only, from every folder. A series returns its books
in reading order, with books that have no position last, because
an unplaced book is an unanswered question rather than book zero.
Every other kind returns them oldest first.
parameters:
- { $ref: "#/components/parameters/EntityKind" }
- { name: entity, in: path, required: true, schema: { type: string, format: uuid } }
- { name: cursor, in: query, schema: { type: string } }
- name: limit
in: query
schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
responses:
"200":
description: The entity and one page of its books
content:
application/json:
schema:
type: object
required: [entity, books]
properties:
entity: { $ref: "#/components/schemas/CatalogEntity" }
books:
type: array
items: { $ref: "#/components/schemas/CatalogBook" }
next_cursor:
type: string
description: Absent on the last page.
"400": { $ref: "#/components/responses/Error", description: "Bad limit or malformed cursor" }
"404": { $ref: "#/components/responses/Error", description: "No such kind or entity" }
/v1/entities/{kind}/{entity}/order:
put:
tags: [library]
summary: Reorder the books in a series
security: [{ deviceToken: [] }] # requires library-manage scope
description: |
Bulk-renumbers books within one series in a single transaction.
Only `kind=series` is accepted; other entity kinds are `404`.
The operation restates positions in one writable layer and
preserves each book's other series memberships. Replaying the
same order is idempotent. The default scope is `personal`;
`shared` also requires `admin`.
The series is library-wide, so an order covers every folder's
copy of it. Send the ids of the books actually on the shelf you
are renumbering.
parameters:
- { $ref: "#/components/parameters/EntityKind" }
- { name: entity, in: path, required: true, schema: { type: string, format: uuid } }
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/SeriesReorder" }
responses:
"204": { description: The order was applied }
"400": { $ref: "#/components/responses/Error", description: "Malformed input, empty order, duplicate book, non-finite position, or too many books" }
"403": { $ref: "#/components/responses/Error", description: "The token lacks library-manage, or the shared layer requires admin" }
"404": { $ref: "#/components/responses/Error", description: "No such series, book, or reorderable kind" }
/v1/entities/{kind}/{entity}/name:
put:
tags: [library]
summary: Rename a series
security: [{ deviceToken: [] }] # requires library-manage scope
description: |
Renames one series in one layer (ADR-0020). Only `kind=series`
is accepted; other entity kinds are `404`, because a name a scan
gave a tag or a contributor has no layer over it.
The rename is a display layer. The name the scan observed is
left alone and stays what a folder pass matches against, so a
series renamed here still absorbs a folder that goes on calling
it by its old name — which is the point, and is why a rename
survives a rescan.
The default scope is `personal`; `shared` also requires `admin`.
A name that already belongs to another series in the caller's
view is a `409`. That is a request to merge two shelves, which
this API does not do.
parameters:
- { $ref: "#/components/parameters/EntityKind" }
- { name: entity, in: path, required: true, schema: { type: string, format: uuid } }
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/SeriesRename" }
responses:
"200":
description: The series as it now reads
content:
application/json:
schema: { $ref: "#/components/schemas/CatalogEntity" }
"400": { $ref: "#/components/responses/Error", description: "Malformed body, empty name, or a name over 512 bytes" }
"403": { $ref: "#/components/responses/Error", description: "The token lacks library-manage, or the shared layer requires admin" }
"404": { $ref: "#/components/responses/Error", description: "No such series, or a kind that cannot be renamed" }
"409": { $ref: "#/components/responses/Error", description: "That name already belongs to another series" }
delete:
tags: [library]
summary: Undo a rename
security: [{ deviceToken: [] }] # requires library-manage scope
description: |
Drops one layer's rename so the series falls back to the layer
beneath it, and ultimately to the name the scan gave it.
Clearing a name that was never set is not an error.
parameters:
- { $ref: "#/components/parameters/EntityKind" }
- { name: entity, in: path, required: true, schema: { type: string, format: uuid } }
- name: scope
in: query
schema: { type: string, enum: [personal, shared], default: personal }
responses:
"200":
description: The series as it now reads
content:
application/json:
schema: { $ref: "#/components/schemas/CatalogEntity" }
"400": { $ref: "#/components/responses/Error", description: "Unknown scope" }
"403": { $ref: "#/components/responses/Error", description: "The token lacks library-manage, or the shared layer requires admin" }
"404": { $ref: "#/components/responses/Error", description: "No such series, or a kind that cannot be renamed" }
/v1/entities/{kind}/{entity}/merge:
post:
tags: [library]
summary: Merge this series into another
security: [{ deviceToken: [] }] # requires library-manage AND admin
description: |
Folds the addressed series into the one named by `into`
(ADR-0021). The addressed series is the one absorbed; the answer
describes the survivor, which is where its books now are.
A merge is a statement about the library's shape, so it is
shared and takes `admin` on top of `library-manage`. There is no
personal form: a reader who wants their own arrangement has the
personal claim layer.
Memberships and claims naming the absorbed series are repointed
at the survivor, and a book already on both shelves keeps the
survivor's position. **Nothing is renumbered**: two shelves that
each had a volume 1 still do.
The absorbed row is deleted, but its name is not forgotten: a
binding records that the name now means the survivor, and a
folder pass that observes it folds it in again. That is what
makes a merge survive a rescan.
Reading state is untouched. Series do not appear in the op log,
in sessions or in a position.
parameters:
- { $ref: "#/components/parameters/EntityKind" }
- { name: entity, in: path, required: true, schema: { type: string, format: uuid } }
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/SeriesMerge" }
responses:
"200":
description: The surviving series
content:
application/json:
schema: { $ref: "#/components/schemas/CatalogEntity" }
"400": { $ref: "#/components/responses/Error", description: "Malformed body, missing `into`, or a series merged into itself" }
"403": { $ref: "#/components/responses/Error", description: "The token lacks library-manage, or the caller is not an admin" }
"404": { $ref: "#/components/responses/Error", description: "No such series, either addressed or named by `into`" }
/v1/entities/{kind}/{entity}/split:
post:
tags: [library]
summary: Split one folder's books onto a new series
security: [{ deviceToken: [] }] # requires library-manage AND admin
description: |
Moves the books one folder contributed to this shelf onto a new
series called `name` (ADR-0021), undoing the automatic fold that
put two folders' identically named series together.
Every name that folds into the old shelf — its own and any it
absorbed in an earlier merge — is bound to the new series *in
that folder only*, so the next pass over it agrees.
Splitting a shelf whose books all came from one folder is a
rename, and is refused as one. Splitting a *single* folder's
shelf into two — an omnibus directory holding two series — is a
per-book decision: use the shared claim layer
(`PUT /v1/books/{id}/series`).
parameters:
- { $ref: "#/components/parameters/EntityKind" }
- { name: entity, in: path, required: true, schema: { type: string, format: uuid } }
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/SeriesSplit" }
responses:
"200":
description: The new series
content:
application/json:
schema: { $ref: "#/components/schemas/CatalogEntity" }
"400": { $ref: "#/components/responses/Error", description: "Malformed body, missing `folder_id`, empty or over-long name, or every book on the shelf came from that folder" }
"403": { $ref: "#/components/responses/Error", description: "The token lacks library-manage, or the caller is not an admin" }
"404": { $ref: "#/components/responses/Error", description: "No such series, or that folder has no books on this shelf" }
"409": { $ref: "#/components/responses/Error", description: "That name already belongs to another series" }
/v1/entities/{kind}/{entity}/bindings:
get:
tags: [library]
summary: List the names that fold into this series
security: [{ deviceToken: [] }] # requires library-manage AND admin
description: |
Returns the observed names a merge or a split made resolve to
this series (ADR-0021), which is what a client shows to offer an
undo. A binding with no `folder_id` applies to every folder; one
with a `folder_id` applies only there.
This is not what a folder pass observed today. A binding is a
resolver rule and outlives the folder's own spelling on purpose:
it is what keeps a merge from coming undone when somebody edits
the name back in their library.
parameters:
- { $ref: "#/components/parameters/EntityKind" }
- { name: entity, in: path, required: true, schema: { type: string, format: uuid } }
responses:
"200":
description: The bindings pointing at this series
content:
application/json:
schema: { $ref: "#/components/schemas/SeriesBindingList" }
"403": { $ref: "#/components/responses/Error", description: "The token lacks library-manage, or the caller is not an admin" }
"404": { $ref: "#/components/responses/Error", description: "No such series, or a kind that has no bindings" }
/v1/entities/{kind}/{entity}/bindings/{binding}:
delete:
tags: [library]
summary: Undo a merge or a split
security: [{ deviceToken: [] }] # requires library-manage AND admin
description: |
Deletes one binding. No book moves: the next pass over a folder
that observes the freed name resolves it to nothing, mints the
series again and refills it from what the folder says.
So an unmerge restores what the disk holds, not what readers
claimed. A claim that named the absorbed series was repointed by
the merge and stays repointed, and a series that only ever
existed as a claim has no folder to come back from.
parameters:
- { $ref: "#/components/parameters/EntityKind" }
- { name: entity, in: path, required: true, schema: { type: string, format: uuid } }
- { name: binding, in: path, required: true, schema: { type: string, format: uuid } }
responses:
"204": { description: The binding is gone }
"403": { $ref: "#/components/responses/Error", description: "The token lacks library-manage, or the caller is not an admin" }
"404": { $ref: "#/components/responses/Error", description: "No such series or binding" }
/v1/books/{id}/resolve:
post:
tags: [library, sync]
summary: Join a catalog book to your own work
security: [{ deviceToken: [] }] # requires BOTH library-read and sync
description: |
Returns the `work_id` to report positions and sessions against for a
book that came from the catalog, creating it on first call.
Send no identifiers: the server collects them from the catalog. A
client that has only browsed has not seen the bytes, and two devices
should resolve the same book from the same evidence.
The mapping is per user. Two readers of one shared book get two
different works, which is what keeps reading history private.
A title/author-only match is a guess: it answers `confidence: low`
and stores nothing. Repeat with `confirmed: true` to accept it.
This is the one route that needs two scopes. It reads the catalog
and writes the caller's work graph, so a catalog-only credential
cannot perform it and neither can a sync-only one.
parameters:
- { name: id, in: path, required: true, schema: { type: string, format: uuid } }
requestBody:
required: false
content:
application/json:
schema:
type: object
properties:
confirmed:
type: boolean
description: Accept a low-confidence title/author match.
responses:
"200":
description: The existing work, or a low-confidence proposal
content:
application/json:
schema: { $ref: "#/components/schemas/CatalogResolution" }
"201":
description: A work was created for this book
content:
application/json:
schema: { $ref: "#/components/schemas/CatalogResolution" }
"400": { $ref: "#/components/responses/Error", description: "Malformed body" }
"403": { $ref: "#/components/responses/Error", description: "The token lacks library-read or sync" }
"404": { $ref: "#/components/responses/Error", description: "Unknown book, or no access" }
"409":
description: The book's identifiers resolve to more than one work, and nothing was changed
content:
application/json:
schema:
type: object
required: [error, works]
properties:
error: { type: string }
works: { type: array, items: { type: string } }
/v1/books/{id}/download:
get:
tags: [library]
summary: Download a book's file
security: [{ deviceToken: [] }] # requires library-read scope
description: |
Serves the newest available file. Supports `HEAD`, byte ranges
and conditional requests; `Content-Disposition` carries a
sanitized filename that is a label, never a path.
For a book whose bytes this server keeps a copy of, the `ETag` is
the content digest and never changes, and the answer may be
cached indefinitely. For a book read out of a watched
directory, the bytes belong to whoever owns that directory: the
`ETag` is weak and the answer is `private, no-cache`. If such a
file no longer matches what was catalogued — a different size or
modification time — it is refused with `409` and the book is
flagged for an administrator rather than served under a title
that may no longer describe it.
parameters:
- { name: id, in: path, required: true, schema: { type: string, format: uuid } }
- { name: Range, in: header, required: false, schema: { type: string } }
- { name: If-None-Match, in: header, required: false, schema: { type: string } }
responses:
"200":
description: The file
content:
application/epub+zip:
schema: { type: string, format: binary }
"206": { description: The requested byte range }
"304": { description: The client's copy is current }
"404": { $ref: "#/components/responses/Error", description: "Unknown book, or no access" }
"409": { $ref: "#/components/responses/Error", description: "The file behind this book changed on disk since it was catalogued" }
"410": { $ref: "#/components/responses/Error", description: "The book exists but its content is no longer stored" }
"416": { $ref: "#/components/responses/Error", description: "The requested byte range is not satisfiable" }
"503": { $ref: "#/components/responses/Error", description: "Content storage is not configured on this instance" }
/v1/books/{id}/cover:
get:
tags: [library]
summary: Fetch a book's cover image
security: [{ deviceToken: [] }] # requires library-read scope
description: |
Renders the cover declared by the publication and serves it as a
JPEG. The type is always `image/jpeg` regardless of what the book
contains, and `X-Content-Type-Options: nosniff` is set: covers are
publisher-supplied bytes, and this route decides what they are.
Rendered covers are cached, so the first request for a book pays
for the decode and later ones do not. The `ETag` combines the
content digest with the size, both of which are fixed for a given
book, so a client that has one may reuse it indefinitely — except
for a book read out of a watched folder, whose
bytes are not this server's to promise: there the validator is
weak and the answer is `private, no-cache`.
A book whose publication declares no cover, or one this server
cannot decode, answers `404`. That answer is cached too, so it
stays cheap for a catalog full of coverless books.
parameters:
- { name: id, in: path, required: true, schema: { type: string, format: uuid } }
- name: size
in: query
required: false
description: |
`thumbnail` is a catalog grid image and the default; `full` is
sized for a book page. Any other value is rejected rather than
treated as the default.
schema: { type: string, enum: [thumbnail, full], default: thumbnail }
- { name: If-None-Match, in: header, required: false, schema: { type: string } }
responses:
"200":
description: The rendered cover
content:
image/jpeg:
schema: { type: string, format: binary }
"304": { description: The client's copy is current }
"400": { $ref: "#/components/responses/Error", description: "Unknown cover size" }
"404": { $ref: "#/components/responses/Error", description: "Unknown book, no access, or no cover this server can serve" }
"409": { $ref: "#/components/responses/Error", description: "The file behind this book changed on disk since it was catalogued" }
"410": { $ref: "#/components/responses/Error", description: "The book exists but its content is no longer stored" }
"503": { $ref: "#/components/responses/Error", description: "Content storage is not configured on this instance" }
/opds/v1.2:
get:
tags: [opds]
summary: OPDS 1.2 navigation feed
security: [{ opdsBasic: [] }] # requires library-read scope
description: |
The catalog root for e-reader clients, and the only entry point
they need: it lists the server's folders, each linking to its
acquisition feed. `/opds/v1.2/` is accepted too, because readers
differ on the trailing slash.
OPDS is catalog-only by design. It exposes no reading positions,
sessions or statistics even when the token also carries `sync`.
responses:
"200":
description: An Atom navigation feed
content:
application/atom+xml;profile=opds-catalog;kind=navigation:
schema: { type: string }
"401": { description: "Missing or invalid credentials; carries WWW-Authenticate" }
"403": { $ref: "#/components/responses/Error", description: "Valid credential without library-read" }
/opds/v1.2/folders/{folder}:
get:
tags: [opds]
summary: OPDS 1.2 acquisition feed for one folder
security: [{ opdsBasic: [] }] # requires library-read scope
description: |
One page of books, each with an EPUB acquisition link. A `next`
link appears while more pages remain; readers follow it rather
than constructing cursors.
parameters:
- { name: folder, in: path, required: true, schema: { type: string, format: uuid } }
- name: cursor
in: query
schema: { type: string }
description: Opaque; only ever taken from a `next` link.
responses:
"200":
description: An Atom acquisition feed
content:
application/atom+xml;profile=opds-catalog;kind=acquisition:
schema: { type: string }
"400": { $ref: "#/components/responses/Error", description: "Malformed cursor" }
"404": { $ref: "#/components/responses/Error", description: "No such folder" }
/opds/v1.2/folders/{folder}/search.xml:
get:
tags: [opds]
summary: OpenSearch description for one folder
security: [{ opdsBasic: [] }] # requires library-read scope
description: |
Readers find this through the `search` link on the folder's
acquisition feed rather than being configured with it, and read
the query template out of it. `searchTerms` is the only term
offered: a reader that could substitute anything else would be
asking a question this surface deliberately cannot answer.
parameters:
- { name: folder, in: path, required: true, schema: { type: string, format: uuid } }
responses:
"200":
description: An OpenSearch description document
content:
application/opensearchdescription+xml:
schema: { type: string }
"404": { $ref: "#/components/responses/Error", description: "No such folder" }
/opds/v1.2/folders/{folder}/search:
get:
tags: [opds]
summary: OPDS search results for one folder
security: [{ opdsBasic: [] }] # requires library-read scope
description: |
The same search as `GET /v1/folders/{folder}/search`, rendered
as an acquisition feed so every result can be downloaded from
where it was found. It carries no `next` link: the feed is
unpaged, and the answer to wanting more is a better query.
parameters:
- { name: folder, in: path, required: true, schema: { type: string, format: uuid } }
- { name: q, in: query, schema: { type: string, maxLength: 512 } }
responses:
"200":
description: An Atom acquisition feed of the matches
content:
application/atom+xml;profile=opds-catalog;kind=acquisition:
schema: { type: string }
"400": { $ref: "#/components/responses/Error", description: "Over-long query" }
"404": { $ref: "#/components/responses/Error", description: "No such folder" }
/opds/v1.2/folders/{folder}/recent:
get:
tags: [opds]
summary: OPDS acquisition feed of a folder's newest books
security: [{ opdsBasic: [] }] # requires library-read scope
description: |
The feed a reader opens to see what has arrived since last time,
reached from the `http://opds-spec.org/sort/new` link on the
folder's own feed. It is `order=recent` on the native listing,
rendered as a feed.
parameters:
- { name: folder, in: path, required: true, schema: { type: string, format: uuid } }
- name: cursor
in: query
schema: { type: string }
description: Opaque; only ever taken from a `next` link.
responses:
"200":
description: An Atom acquisition feed, newest first
content:
application/atom+xml;profile=opds-catalog;kind=acquisition:
schema: { type: string }
"400": { $ref: "#/components/responses/Error", description: "Malformed cursor" }
"404": { $ref: "#/components/responses/Error", description: "No such folder" }
/opds/v1.2/entities/{kind}:
get:
tags: [opds]
summary: OPDS navigation feed of the library's series, contributors or tags
security: [{ opdsBasic: [] }] # requires library-read scope
description: |
Reached from the `http://opds-spec.org/facet` links on a folder's
acquisition feed. Each entry leads to the books claiming it,
across every folder.
parameters:
- $ref: "#/components/parameters/EntityKind"
- name: after
in: query
schema: { type: string }
description: Only ever taken from a `next` link.
responses:
"200":
description: An Atom navigation feed
content:
application/atom+xml;profile=opds-catalog;kind=navigation:
schema: { type: string }
"400": { $ref: "#/components/responses/Error", description: "Over-long cursor" }
"404": { $ref: "#/components/responses/Error", description: "No such kind" }
/opds/v1.2/entities/{kind}/{entity}:
get:
tags: [opds]
summary: OPDS acquisition feed of the books claiming one entity
security: [{ opdsBasic: [] }] # requires library-read scope
description: |
A series comes back in reading order, with unplaced books last,
which is the whole reason a reader would rather browse a series
than search for it.
parameters:
- $ref: "#/components/parameters/EntityKind"
- { name: entity, in: path, required: true, schema: { type: string, format: uuid } }
- name: cursor
in: query
schema: { type: string }
description: Opaque; only ever taken from a `next` link.
responses:
"200":
description: An Atom acquisition feed
content:
application/atom+xml;profile=opds-catalog;kind=acquisition:
schema: { type: string }
"400": { $ref: "#/components/responses/Error", description: "Malformed cursor" }
"404": { $ref: "#/components/responses/Error", description: "No such kind or entity" }
/opds/v1.2/books/{id}/download:
get:
tags: [opds]
summary: Acquire a book over OPDS
security: [{ opdsBasic: [] }] # requires library-read scope
description: |
The same download as `/v1/books/{id}/download`, reachable with
the Basic credential a reader already holds. Ranges, `HEAD` and
conditional requests behave identically.
parameters:
- { name: id, in: path, required: true, schema: { type: string, format: uuid } }
- { name: Range, in: header, required: false, schema: { type: string } }
- { name: If-None-Match, in: header, required: false, schema: { type: string } }
responses:
"200":
description: The file
content:
application/epub+zip:
schema: { type: string, format: binary }
"206": { description: The requested byte range }
"304": { description: The client's copy is current }
"404": { $ref: "#/components/responses/Error", description: "Unknown book, or no access" }
"409": { $ref: "#/components/responses/Error", description: "The file behind this book changed on disk since it was catalogued" }
"410": { $ref: "#/components/responses/Error", description: "The book exists but its content is no longer stored" }
"416": { $ref: "#/components/responses/Error", description: "The requested byte range is not satisfiable" }
"503": { $ref: "#/components/responses/Error", description: "Content storage is not configured on this instance" }
/opds/v1.2/books/{id}/cover:
get:
tags: [opds]
summary: Fetch a book cover over OPDS
security: [{ opdsBasic: [] }] # requires library-read scope
description: |
The same rendered JPEG cover as `/v1/books/{id}/cover`, reachable
with the Basic credential used for the OPDS feed. `HEAD` and
conditional requests behave identically.
parameters:
- { name: id, in: path, required: true, schema: { type: string, format: uuid } }
- name: size
in: query
required: false
schema: { type: string, enum: [thumbnail, full], default: thumbnail }
- { name: If-None-Match, in: header, required: false, schema: { type: string } }
responses:
"200":
description: The rendered cover
content:
image/jpeg:
schema: { type: string, format: binary }
"304": { description: The client's copy is current }
"400": { $ref: "#/components/responses/Error", description: "Unknown cover size" }
"404": { $ref: "#/components/responses/Error", description: "Unknown book, no access, or no cover this server can serve" }
"409": { $ref: "#/components/responses/Error", description: "The file behind this book changed on disk since it was catalogued" }
"410": { $ref: "#/components/responses/Error", description: "The book exists but its content is no longer stored" }
"503": { $ref: "#/components/responses/Error", description: "Content storage is not configured on this instance" }
components:
securitySchemes:
deviceToken:
type: http
scheme: bearer
description: Scoped device token (`sync`, `read-insights`, `library-read`, `library-manage`, `library-upload`, `library-delete`, or `admin`)
loginAuth:
type: http
scheme: bearer
description: Short-lived auth token from /v1/login (token management only)
opdsBasic:
type: http
scheme: basic
description: |
OPDS only. Username is the token's name or the literal `token`;
the password is a device token secret carrying `library-read`.
Account passwords are rejected — a reader stores its credential
in plain text on the device.
parameters:
EntityKind:
name: kind
in: path
required: true
description: |
Which kind of library-wide entity. The set is closed, and a kind
outside it is a 404 rather than a 400: it names no resource.
schema:
type: string
enum: [series, contributors, tags]
responses:
Error:
description: |
Error with a human-readable reason. Most failures carry only
`error`; a refusal a client can act on also carries a
machine-readable `code` and the identifiers that explain it.
Clients must key any recovery off `code`, never off the message
text.
content:
application/json:
schema:
type: object
required: [error]
properties:
error: { type: string }
code:
type: string
enum: [unknown_work]
description: |
Present when the refusal is recoverable. `unknown_work`
means a batch item named a work the server no longer
holds — refresh the cached work identity (re-resolve the
book) and retry the batch.
work_id:
type: string
description: The work the server no longer holds.
op_id:
type: string
description: The op that named the unknown work.
session_id:
type: string
description: The session that named the unknown work.
StatusOK:
description: OK
content:
application/json:
schema:
type: object
properties: { status: { type: string } }
schemas:
CatalogResolution:
type: object
description: |
The join between a catalog book and the caller's own sync work,
plus the identifiers the server used to make it, so a client can
show why two books were treated as one.
required: [book_id, work_id, confidence, created, identifiers]
properties:
book_id: { type: string }
work_id: { type: string }
confidence:
type: string
enum: [high, low]
description: |
`low` means the match rests on title and author alone. Nothing
was stored; resend with `confirmed: true` to accept it.
created:
type: boolean
description: Whether this call created the work.
identifiers:
type: array
description: The catalog's evidence, strongest first.
items:
type: object
required: [kind, value]
properties:
kind: { type: string, enum: [sha256, partial-md5, source, dc, ta] }
value: { type: string }
CatalogBook:
type: object
description: |
One book, and the only shape a book is ever returned in: the
folder listing, search, entity listings and `GET /v1/books/{id}`
all return exactly this. Detail is not a richer shape.
A book is one file in one watched folder, so its file fields are
flat rather than a list. Optional metadata fields are omitted
when unknown rather than sent empty, so a client can tell "no
publisher recorded" from "publisher is blank". The relationship
fields are the opposite: `contributors` and `series` are always
present, and empty when the book has none.
required:
- book_id
- folder_id
- title
- status
- sha256
- size_bytes
- media_type
- filename
- created_at
- updated_at
- cover_url
- contributors
- series
- series_source
properties:
book_id: { type: string, format: uuid }
folder_id: { type: string, format: uuid }
title: { type: string }
subtitle: { type: string }
description: { type: string }
publisher: { type: string }
published_date: { type: string }
status:
type: string
enum: [active, missing]
description: |
`missing` means the last complete pass over the folder did
not see the file. The book stays in the catalog, and its
reading history with it, because a disconnected disk is not
a deleted book. A pass that failed part way, or that
observed nothing at all, never marks anything missing.
A missing book is left out of the listing and search
responses, because downloading it or fetching its cover
answers `410`. It is still readable by id, so a client
holding one can tell a book that went away from a book that
never existed. It comes back to the listings when a complete
pass sees the file again.
sha256:
type: string
description: |
The digest of the file's **content** — what the bytes are.
It is what a client matches its own copies against, and it
is never the address of anything inside the server.
size_bytes:
type: integer
format: int64
description: |
The length those bytes had when the server last read them.
That is a fact about the last look, not a promise about now;
the download is the truth.
media_type: { type: string, example: application/epub+zip }
filename:
type: string
description: The file's own name on disk, without its directory.
created_at: { type: string, format: date-time }
updated_at: { type: string, format: date-time }
cover_url:
type: string
description: |
Offered for every book, whether or not one is known: a client
that asks and gets 404 has learned the same thing at the same
cost as a flag would have told it.
contributors:
type: array
description: |
Every contributor in every role, in the order the book stores
them — not just the authors. Roles are normalized, so select
authors with `role == "author"` rather than by guessing.
items:
type: object
required: [id, name, role]
properties:
id: { type: string, format: uuid }
name: { type: string }
role: { type: string, example: author }
series:
type: array
description: |
Every series the book belongs to. A book may be in more than
one, and reporting a single series would silently pick one.
For a plain folder a series is the subdirectory the file sits
in; for a Calibre folder it is what `metadata.db` says. The
`source` field names the layer now in force for this
membership.
items: { $ref: "#/components/schemas/CatalogSeriesMembership" }
series_source:
allOf: [{ $ref: "#/components/schemas/SeriesSource" }]
description: Effective layer for the whole book, including when `series` is empty. `personal` is only the caller's own claim.
series_claim_updated_at:
type: string
format: date-time
description: Revision of the effective shared or personal claim. Omitted for the folder layer.
CatalogSeriesMembership:
type: object
description: |
One membership of a book in a series. `source` is the layer that
supplied this membership after override resolution: `folder` is
what the last pass observed, `shared` is an administrator's claim
for everyone without a personal claim, and `personal` is the
caller's own claim.
required: [id, name, source]
properties:
id: { type: string, format: uuid }
name: { type: string }
source: { $ref: "#/components/schemas/SeriesSource" }
position:
type: number
description: |
Omitted when the book's place in the series is unknown. It
is never invented: 0 would mean "first".
SeriesSource:
type: string
enum: [folder, shared, personal]
description: The layer a resolved series membership came from.
SeriesClaimScope:
type: string
enum: [personal, shared]
default: personal
description: |
Writable series-claim layer. `personal` affects only the caller;
`shared` affects every reader without a personal claim and also
requires `admin`.
BookSeriesLayers:
type: object
description: |
All series layers for one book. `series` is the effective list
returned by every other catalog read. `folder` is what the last
reconcile pass observed. `shared` and `personal` are `null` when
that layer has no claim, and an array — possibly empty — when it
does. `shared_updated_at` and `personal_updated_at` are the last
layer revisions, including a deletion represented by a null layer.
Revisions are millisecond-precise, so a client may hold one as
milliseconds since the epoch and quote it back as a precondition.
required: [book_id, source, series, folder, shared, personal]
properties:
book_id: { type: string, format: uuid }
source: { $ref: "#/components/schemas/SeriesSource" }
series:
type: array
items: { $ref: "#/components/schemas/CatalogSeriesMembership" }
folder:
type: array
items: { $ref: "#/components/schemas/CatalogSeriesMembership" }
shared:
type: [array, "null"]
items: { $ref: "#/components/schemas/CatalogSeriesMembership" }
description: |
`null` means no shared claim exists; `[]` means the shared
layer claims the book is in no series.
personal:
type: [array, "null"]
items: { $ref: "#/components/schemas/CatalogSeriesMembership" }
description: |
`null` means no personal claim exists; `[]` means the
personal layer claims the book is in no series.
shared_updated_at: { type: string, format: date-time }
personal_updated_at: { type: string, format: date-time }
outcome: { $ref: "#/components/schemas/SeriesClaimOutcome" }
BookSeriesClaim:
type: object
description: |
Replacement claim for one book at one writable layer. The
`series` array is the whole claim, not a diff. Omitted or empty
means "this book is in no series"; use `DELETE` to remove the
claim and fall back.
properties:
scope: { $ref: "#/components/schemas/SeriesClaimScope" }
client_ts:
type: string
description: Client-generated stable idempotency key. Reuse exactly on retry; reuse with different state returns 409.
if_updated_at:
type: [string, "null"]
format: date-time
description: Last server revision observed for this layer. A non-matching revision returns `stale` without mutation. Revisions are millisecond-precise and compared at that precision.
series:
type: array
maxItems: 64
default: []
items: { $ref: "#/components/schemas/BookSeriesClaimItem" }
BookSeriesClaimItem:
type: object
description: |
One series membership in a claim. Exactly one of `series_id` and
`name` is accepted. A `series_id` must already exist. A `name`
creates or reuses the series by the same normalized-name folding
a reconcile pass uses; blank names are rejected. `position`,
when present, must be finite.
oneOf:
- required: [series_id]
- required: [name]
properties:
series_id: { type: string, format: uuid, minLength: 1 }
name: { type: string, minLength: 1, maxLength: 512 }
position:
type: number
description: Omitted when the claimant does not place the book in the series; NaN and infinities are rejected.
SeriesClaimOutcome:
type: string
enum: [applied, stale, duplicate]
description: "`applied` changed the layer; `stale` was older than its revision; `duplicate` retried the current deletion."
SeriesReorder:
type: object
description: |
Bulk renumbering for one series. The order must be non-empty and
must not name the same book twice. It restates only positions in
the named series and preserves each book's other series
memberships.
required: [order]
properties:
scope: { $ref: "#/components/schemas/SeriesClaimScope" }
order:
type: array
minItems: 1
maxItems: 1000
items:
type: object
required: [book_id]
properties:
book_id: { type: string, format: uuid }
position:
type: number
description: Omitted when the book is unplaced; NaN and infinities are rejected.
SeriesRename:
type: object
description: |
A new display name for one series, in one layer. The name is
trimmed; a name that is empty once trimmed is rejected, because
a shelf nobody can name is a shelf nobody can find.
required: [name]
properties:
scope: { $ref: "#/components/schemas/SeriesClaimScope" }
name: { type: string, minLength: 1, maxLength: 512 }
SeriesMerge:
type: object
description: |
The series that survives a merge. The absorbed one is the one in
the path.
required: [into]
properties:
into:
type: string
format: uuid
description: |
The surviving series. It must still exist: merging into a
series that was itself absorbed is a `404` rather than a
chain the resolver would have to follow.
SeriesSplit:
type: object
description: |
Which folder's books leave the shelf, and what to call them.
required: [folder_id, name]
properties:
folder_id:
type: string
format: uuid
description: The folder whose contribution moves to a new series.
name:
type: string
minLength: 1
maxLength: 512
description: |
The new series' name. It must be free: a name another series
already holds is a `409`, not a merge.
SeriesBindingList:
type: object
required: [bindings]
properties:
bindings:
type: array
items: { $ref: "#/components/schemas/SeriesBinding" }
SeriesBinding:
type: object
description: |
One observed name that resolves to this series because somebody
merged or split it there (ADR-0021).
required: [binding_id, name, created_at]
properties:
binding_id: { type: string, format: uuid }
name:
type: string
description: The absorbed name, in the spelling it was written in.
folder_id:
type: [string, "null"]
format: uuid
description: |
The folder this binding applies to, or `null` when it
applies to every folder. A merge binds everywhere; a split
binds in the folder that left.
folder_name: { type: [string, "null"] }
created_at: { type: string, format: date-time }
CatalogEntity:
type: object
description: |
One library-wide series, contributor or tag. Matching folds
case and spacing while the display spelling is preserved, so
"Frank Herbert" and "frank herbert" are one entity spelled one
way — including when the two spellings were found in different
folders (ADR-0019).
required: [id, name, book_count]
properties:
id: { type: string, format: uuid }
name:
type: string
description: |
The display spelling as this reader sees it. For a series
that can be a rename (ADR-0020).
scanned_name:
type: string
description: |
What the last scan called it. Equal to `name` unless a
series has been renamed; it is what a revert would restore.
name_source:
type: string
enum: [folder, shared, personal]
description: |
Which layer `name` came from. Always `folder` for a
contributor or a tag, which have no rename layer.
book_count:
type: integer
description: Books claiming it whose file is present.
SearchResult:
type: object
description: |
One folder's answer to a search: the matching books, best first,
and what those books have in common.
required: [books, facets, truncated]
properties:
books:
type: array
items: { $ref: "#/components/schemas/CatalogBook" }
facets:
type: array
description: |
What the returned books share, at most 20 of each kind, most
claimed first. Counted over the answer rather than over the
folder, so a facet can never describe a different set than
the one returned.
items: { $ref: "#/components/schemas/SearchFacet" }
truncated:
type: boolean
description: |
The answer was cut at `limit`. Ask the person to narrow it
rather than paging: there is no cursor.
SearchFacet:
type: object
description: |
One entity the matching books share. `id` is what to send back as
the `entity` filter.
required: [kind, id, name, book_count]
properties:
kind: { type: string, enum: [series, contributor, tag] }
id: { type: string, format: uuid }
name: { type: string, description: The display spelling. }
book_count:
type: integer
description: Returned books claiming it, not the entity's whole shelf.
Scope:
type: string
enum: [sync, read-insights, library-read, library-manage, library-upload, library-delete, admin]
description: |
`sync` writes and reads positions, `read-insights` reads
statistics, `library-read` reads the catalog, and
`library-manage` states series claims. `library-upload` sends a
book into a folder that accepts uploads (ADR-0023), and
`library-delete` takes one back out of one (ADR-0025); the two
are separate because sending your own book and destroying
everyone's are different questions. `library-manage` does
not grant catalog reads; request `library-read` too when a
client needs both. Scopes never imply folder grants, including
`admin`. `admin` implies all scopes. No other scope implies
another. `admin` cannot be self-granted: creating or
updating a token to include it requires an existing admin token
or the admin CLI.
ScopeSet:
type: array
minItems: 1
items: { $ref: "#/components/schemas/Scope" }
description: |
A set of token capabilities. Duplicate values are accepted and
canonicalized; responses use deterministic scope order.
Identifier:
type: object
required: [kind, value]
properties:
kind:
type: string
enum: [sha256, partial-md5, source, dc, ta]
description: |
`sha256`: exact file bytes. `partial-md5`: KOReader
fingerprint (`util.partialMD5`; note LuaJIT's first offset
overflows to 0, so the first sample is the head of the
file). `source`: the catalog server's own id for the book
(e.g. `komga:<id>`), shared by devices browsing the same
catalog before any of them has the file. `dc`: the EPUB's
dc:identifier (ISBN, UUID).
`ta`: normalised title+author (fuzzy).
`ta` values are compared as exact strings and are never
normalised by the server, so every client must normalise
identically or they will silently fail to match each other:
`fold(title) + "|" + fold(author)`, where fold is NFKD,
`\p{Mn}` stripped, lowercased, runs of non-alphanumerics
replaced with a single space, trimmed. Send it only when
both title and author are known.
value: { type: string, maxLength: 512 }
ResolveResult:
type: object
properties:
work_id: { type: string }
confidence:
type: string
enum: [high, low]
description: |
`low` only for fuzzy ta: matches. Confirm with the user
before exchanging any reading state, and remember a refusal
— a client that forgets will resolve the same book again on
its next run and ask forever.
created: { type: boolean }
TokenInfo:
type: object
required: [id, device_id, name, scopes, created_at, revoked]
properties:
id: { type: string }
device_id: { type: string }
name: { type: string }
scope:
allOf:
- { $ref: "#/components/schemas/Scope" }
deprecated: true
description: "Present only for singleton scope sets."
scopes: { $ref: "#/components/schemas/ScopeSet" }
created_at: { type: string, format: date-time }
revoked: { type: boolean }
TokenSelf:
type: object
description: |
A token's description of itself. The same field names TokenInfo
uses for the same things; `created_at` and `revoked` are absent
because a caller that reaches this route is neither revoked nor
expired.
required: [id, device_id, name, scopes]
properties:
id: { type: string }
device_id: { type: string }
name: { type: string }
scope:
allOf:
- { $ref: "#/components/schemas/Scope" }
deprecated: true
description: "Present only for singleton scope sets."
scopes: { $ref: "#/components/schemas/ScopeSet" }
OpInput:
type: object
required: [op_id, work_id, client_ts, progression]
properties:
op_id:
type: string
maxLength: 64
description: |
Client-generated idempotency key. UUIDv7 is the suggestion,
but the server treats it as opaque and does not check the
format. Deriving it from (device, work, revision) rather
than drawing it at random makes an interrupted push free to
repeat: the same id with the same payload is `duplicate`,
the same id with a different payload is `conflict`, never an
overwrite. That only holds if every field of the payload
comes from stored state — in particular `client_ts` must be
the stored update time, not the clock at send time.
work_id: { type: string, description: "From /v1/works/resolve" }
edition_sha: { type: string, description: "sha256 of the exact file, if known" }
client_ts: { type: string, format: date-time }
progression: { type: number, minimum: 0, maximum: 1 }
locator:
description: |
Any JSON reader-native position (for example, a Readium
locator), opaque to the server and replayed verbatim. Refused with 400 above
`ops.max_locator_bytes` (16 KiB by default), and the refusal
fails the whole batch. A client with an oversized locator
should drop it and send the progression alone rather than
lose the position entirely.
foreign_pos:
type: string
description: Engine-native position string from another protocol (CRe xpointer), stored and replayed verbatim
Op:
allOf:
- $ref: "#/components/schemas/OpInput"
- type: object
properties:
seq: { type: integer, description: Server-assigned per-user monotonic sequence }
device_id: { type: string }
origin: { type: string, enum: [native, kosync] }
received_at: { type: string, format: date-time }
OpResult:
type: object
properties:
op_id: { type: string }
status: { type: string, enum: [applied, duplicate, conflict, invalid] }
seq: { type: integer }
reason: { type: string }
SessionInput:
type: object
required: [session_id, work_id, started_at, ended_at, start_progression, end_progression]
properties:
session_id:
type: string
maxLength: 64
description: |
Client-generated idempotency key, opaque to the server, same
bargain as `op_id`. It matters more here: a position sent
twice is the same position, but an hour sent twice is an
hour that never happened. Send only sessions that have
ended, so the payload behind the id can never change.
work_id: { type: string }
edition_sha: { type: string }
started_at: { type: string, format: date-time }
ended_at: { type: string, format: date-time }
start_progression: { type: number, minimum: 0, maximum: 1 }
end_progression: { type: number, minimum: 0, maximum: 1 }
idle_ms: { type: integer, minimum: 0, description: "Reader-visible idle time, excluded from speed" }
AnnotationInput:
type: object
required: [id, base_rev, work_id, kind, client_ts]
properties:
id:
type: string
maxLength: 64
description: |
Client-generated identity, opaque to the server, stable for
the record's whole life across edits and the delete. Unlike
an `op_id` it names a mutable record, not one write.
base_rev:
type: integer
minimum: 0
description: |
The rev this write was based on: 0 to create, the rev the
client read to edit. A mismatch is a per-item `conflict`.
work_id: { type: string, maxLength: 128, description: "From /v1/works/resolve" }
edition_sha: { type: string, maxLength: 128, description: "sha256 of the exact file the locator was made against, if known" }
kind:
type: string
enum: [highlight, note, bookmark]
description: |
A highlight anchors to the text and may carry a body; a
bookmark is an anchor with no body; a note is a body with no
anchor.
locator:
description: |
Any JSON reader-native anchor (for example, a Readium
locator), opaque to the server and replayed verbatim.
Required for a highlight or bookmark, refused on a note,
bounded by `ops.max_locator_bytes`. A JSON `null` counts
as absent.
progression: { type: number, minimum: 0, maximum: 1, description: "Position in the book, used only to sort" }
excerpt:
type: string
maxLength: 1024
description: The selected text a highlight shows in a list, bounded (1 KiB by default).
color:
type: string
enum: [yellow, green, blue, pink, purple, orange]
description: A palette token, never CSS; highlights only.
body:
type: string
description: The user's own words, bounded (16 KiB by default).
client_ts: { type: string, format: date-time, maxLength: 64 }
Annotation:
type: object
description: |
One annotation as the server holds it. A tombstone carries only
`id`, `rev`, `seq`, `updated_at`, `deleted` and `deleted_at`.
required: [id, rev]
properties:
id: { type: string }
rev: { type: integer }
seq: { type: integer, description: "Server-assigned per-user monotonic sequence, separate from the op log" }
work_id: { type: string }
edition_sha: { type: string }
kind: { type: string, enum: [highlight, note, bookmark] }
locator: { description: "The anchor, verbatim as pushed" }
progression: { type: number, minimum: 0, maximum: 1 }
excerpt: { type: string }
color: { type: string, enum: [yellow, green, blue, pink, purple, orange] }
body: { type: string }
device_id: { type: string }
client_ts: { type: string, format: date-time }
updated_at: { type: string, format: date-time }
deleted: { type: boolean }
deleted_at: { type: string, format: date-time }
AnnotationResult:
type: object
properties:
id: { type: string }
status: { type: string, enum: [applied, duplicate, conflict, invalid] }
rev: { type: integer, description: When applied or duplicate }
seq: { type: integer, description: When applied or duplicate }
reason: { type: string, description: When invalid }
server:
allOf: [{ $ref: "#/components/schemas/Annotation" }]
description: The server's current copy, carried on a conflict.