liseur-sync/docs/openapi.yaml
Chmouel Boudjnah 63151d6aeb
Some checks failed
test / test (push) Has been cancelled
test / lint-openapi (push) Has been cancelled
build / binary (push) Has been cancelled
build / container (push) Has been cancelled
fix: bound the translation cache by what a row really costs
The per-user budget counted only the translated text, so rows of tiny
text spent almost none of it and the row count grew without bound.
Each entry now spends its hash and its text in both the total and the
eviction measure. The per-user stamp table also gains the cascading
user reference its sibling tables carry, and the OpenAPI push schema
marks the translations array as non-empty, as the route already
refuses it.
2026-10-13 07:26:56 +02:00

3913 lines
171 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:
/v1/events:
get:
tags: [sync]
security: [{ deviceToken: [] }]
summary: Subscribe to live reading-state invalidations
description: |
Opens an authenticated Server-Sent Events stream for this account.
The `sync` scope permits `positions` and `annotations`;
`read-insights` permits `insights`. A credential with no eligible
topic is refused. Reader tokens do not need insight authority.
Registration precedes an opening invalidation of all permitted
topics. Later invalidations coalesce topics by union. There is no
event ID, sequence number, `since` parameter or replay history.
Unknown topics should be ignored. Read the affected topic through
its existing feed or snapshot endpoint; keep durable cursors there.
Comment heartbeats arrive every 20 seconds; clients should reconnect
after 60 seconds without bytes, using bounded backoff and jitter.
The stream ends at token expiry or when revalidation finds revoked
authority. After headers have been sent, these conditions close
the connection rather than returning a new HTTP status.
The current limit is eight concurrent streams per account.
responses:
"200":
description: |
SSE frames containing topic invalidations, comment heartbeats
and retry advice in milliseconds. Each event ends with a blank
line. The stream carries no book, device or reading position.
headers:
Cache-Control:
schema: { type: string, const: no-store }
X-Accel-Buffering:
schema: { type: string, const: "no" }
content:
text/event-stream:
schema: { type: string }
example: "retry: 5000\n\nevent: invalidate\ndata: {\"topics\":[\"positions\",\"annotations\"]}\n\n: ping\n\n"
"401": { $ref: "#/components/responses/Error", description: "Missing, invalid, revoked or expired token" }
"403": { $ref: "#/components/responses/Error", description: "No eligible topic, or HTTPS required" }
"429":
description: Too many concurrent streams for this account
headers:
Retry-After:
description: Reconnect delay in seconds
schema: { type: integer, example: 5 }
content:
application/json:
schema:
type: object
properties:
error: { type: string }
"503": { $ref: "#/components/responses/Error", description: "Live events are not enabled" }
/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/RateLimited" }
/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" }
"429": { $ref: "#/components/responses/RateLimited", description: "Per-IP limit or shared password capacity reached. The invite has not been consumed." }
/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 }
device_id:
type: string
maxLength: 64
description: |
Ask the new token to carry an existing device identity
instead of a fresh one. Accepted only if a token of
the same account — live, expired or revoked — already
carries it; a client that stored the id from a lapsed
credential stays one device in the op log and its
replays stay `duplicate`. An id this account never
carried (a deleted predecessor, or another account's)
is refused with `400` and `code: unknown_device`.
A device id is a label, not a credential: two live
tokens may share one and are then one device. Older
servers ignore the field; compare the `device_id` in
the response with the one you asked for.
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":
description: |
Malformed request, or `device_id` names no device of this
account (body carries `code: unknown_device` and echoes
`device_id`).
content:
application/json:
schema:
type: object
required: [error]
properties:
error: { type: string }
code: { type: string, enum: [unknown_device] }
device_id: { type: string }
"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/me:
get:
tags: [auth]
summary: The account behind this credential
security: [{ deviceToken: [] }] # requires library-read scope
description: |
Reads the token's own account — never a path parameter, so there
is no id here to get wrong. It exists for a client that holds a
token and nothing else: the browser reader served from the
separate reader origin has no session and no user, so it asks
once the token is in hand.
`reader_prompt_template` is the text the account keeps for the
reader's "copy a prompt about this passage" button, with
`{title} {author} {series} {chapter} {page} {pages} {percent}
{text}` substituted at the moment of the copy. The server never
interprets it. It is empty unless the account wrote one, and an
empty one means the reader shows no button at all.
responses:
"200":
description: The account
content:
application/json:
schema:
type: object
required: [id, name, timezone, reader_prompt_template]
properties:
id: { type: string }
name: { type: string }
timezone:
type: string
description: IANA name; the account-local day statistics are attributed to.
reader_prompt_template:
type: string
description: Empty when the account has not written one.
/v1/me/settings:
get:
tags: [sync]
summary: Read this device's stored settings
security: [{ deviceToken: [] }] # requires sync scope
description: |
Returns every key/value pair stored for the calling device, the
one the token belongs to. Another device of the same account
never sees them (ADR-0050). The server treats keys and values as
opaque strings; which keys exist and what values mean is the
client's business.
`scope` is always `device`. A server that answers without it is
older and its map is account-wide; a client keeping settings per
device should adopt nothing from it.
responses:
"200":
description: The settings map (empty object when none are stored)
content:
application/json:
schema:
type: object
required: [settings, scope]
properties:
settings:
type: object
additionalProperties:
$ref: "#/components/schemas/SettingEntry"
scope:
type: string
enum: [device]
description: Whose settings these are; always the calling device's.
"401": { $ref: "#/components/responses/Error" }
"403": { $ref: "#/components/responses/Error" }
"500": { $ref: "#/components/responses/Error" }
put:
tags: [sync]
summary: Upsert this device's settings (newer wins)
security: [{ deviceToken: [] }] # requires sync scope
description: |
Each key in the request is upserted only when the supplied
`updated_at` is strictly newer than what the server holds for
that key; a stale write is silently dropped. Keys not mentioned
are untouched — this is a partial update, not a replace.
Rows belong to the calling device; nothing written here is
visible to another device of the account.
The response is the merged result: every key the device holds
after the upsert, not just the ones sent. A dropped write is not
an error, so a client must compare the response against what it
sent rather than read `200` as "my value was stored".
`updated_at` is supplied by the client, so a retry arriving late
cannot undo a newer write from the same device. It may not sit
more than 24 hours ahead of the server's clock: without that bound a single write stamped far in
the future would pin the key forever, and there is no delete.
Refused with `400`: an empty `settings` map, an absent or
unparseable `updated_at`, one more than 24 hours ahead
(`code: time_in_future`), an empty key, a key or value over
`ops.settings_max_key_bytes` / `ops.settings_max_value_bytes`,
and a NUL byte in either. Refused with `409` when the device
already holds `ops.settings_max_per_account` keys (a per-device
cap, despite its name) and the request would add another;
nothing is stored in that case.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [settings]
properties:
settings:
type: object
additionalProperties:
$ref: "#/components/schemas/SettingEntry"
responses:
"200":
description: The merged settings map after the upsert
content:
application/json:
schema:
type: object
required: [settings, scope]
properties:
settings:
type: object
additionalProperties:
$ref: "#/components/schemas/SettingEntry"
scope:
type: string
enum: [device]
description: Whose settings these are; always the calling device's.
"400": { $ref: "#/components/responses/Error" }
"401": { $ref: "#/components/responses/Error" }
"403": { $ref: "#/components/responses/Error" }
"409": { $ref: "#/components/responses/Error" }
"500": { $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":
description: |
The batch was refused as a whole; nothing was stored. A
batch-level refusal (`ops required`, `code: batch_too_large`
with `limit`) has no item to name. An item-level refusal
names the first bad item: `code`, its zero-based `item_index`,
and its `op_id` when it had one. `unknown_work` is the one a
client recovers from by re-resolving the book and retrying
under the fresh `work_id`; `locator_too_large` carries the
byte `limit` and the op may be retried under the same
`op_id` without its locator, since the original batch was
never stored.
content:
application/json:
schema: { $ref: "#/components/schemas/BatchItemError" }
"413":
description: Request body over `ops.max_body_bytes`
content:
application/json:
schema: { $ref: "#/components/schemas/Error" }
/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, minimum: 0 } }
- { name: limit, in: query, schema: { type: integer, default: 500, maximum: 500 } }
responses:
"200":
description: |
Page of ops with the new high-water mark. The page, the mark
and the compaction check come from one snapshot: a page you
were not told to resync from has no holes in it.
content:
application/json:
schema:
type: object
properties:
ops:
type: array
items: { $ref: "#/components/schemas/Op" }
high_water: { type: integer }
has_more: { type: boolean }
"400":
description: "`since` is not a non-negative integer"
content:
application/json:
schema: { $ref: "#/components/schemas/Error" }
"410":
description: |
Cursor below the compaction horizon. Fetch `heads_endpoint`
and resume from its `snapshot_seq`.
content:
application/json:
schema:
type: object
properties:
error: { type: string, enum: [resync_required] }
high_water: { type: integer }
heads_endpoint: { type: string }
message: { 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>`.
The separate `furthest` array contains retained maximum-progression
operations per work/edition/origin-alias ownership group, from the
same snapshot. Store those as historical destinations, never as new
movement for latest-position reconciliation.
security: [{ deviceToken: [] }] # requires sync scope
responses:
"200":
description: Heads snapshot
content:
application/json:
schema:
type: object
required: [ops, furthest, snapshot_seq]
properties:
ops:
type: array
items: { $ref: "#/components/schemas/Op" }
furthest:
type: array
description: |
Full original maximum-progression operations, retained
through compaction. Equal fractions keep the earliest
sequence. Sorted by work_id, descending progression,
then sequence. Empty when there are no positions.
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)
description: |
Recent `ops` and retained `furthest` candidates come from one snapshot.
`limit` bounds only `ops`. `furthest` holds the greatest progression
per edition/origin-alias ownership group, with the earliest sequence
breaking ties, sorted by descending progression then sequence.
These are full original operations, not synthetic page numbers.
Clients offer them independently of current position and use
edition-aware locator restoration. Earlier history already compacted
before furthest retention was introduced cannot be recovered.
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
required: [ops, furthest]
properties:
ops:
type: array
items: { $ref: "#/components/schemas/Op" }
furthest:
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.
Optional `active_ms` is authoritative measured duration, including
zero, independent of wall-clock span and idle. Negotiate it through
insights capabilities or `GET /v1/token` (`session_active_ms: true`)
before sending it. Preserve its presence and value on retry.
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":
description: |
The batch was refused as a whole; nothing was stored. Same
shape as `POST /v1/ops`: a batch-level refusal (`sessions
required`, `code: batch_too_large` with `limit`) names no
item; an item-level one names the first bad item by `code`,
`item_index` and `session_id` when it had one. `unknown_work`
is recoverable by re-resolving the book and retrying.
content:
application/json:
schema: { $ref: "#/components/schemas/BatchItemError" }
"409":
description: |
A `session_id` in the batch was already accepted with a
different payload (live, or archived as a rollup tombstone).
`code: id_reused`, with the `session_id` and its `item_index`;
the batch was refused as a whole.
content:
application/json:
schema: { $ref: "#/components/schemas/BatchItemError" }
"413":
description: Request body over `ops.max_body_bytes`
content:
application/json:
schema: { $ref: "#/components/schemas/Error" }
/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/works/{id}/translations:
post:
tags: [sync]
summary: Push a batch of cached sentence translations
description: |
The account's sentence translation cache (ADR-0052): a batched,
atomic, idempotent upsert. Last write wins — a row is inserted
if absent and updated only when the text differs — and an
identical push writes nothing at all, so `updated_at` does not
move and pullers never re-download unchanged rows. There is no
conflict and no rev. The key is the client's opaque content hash
(64 hex characters); the sentence itself never reaches the
server. `device_id` and `updated_at` are stamped by the server
from the token. Validation is atomic like an ops batch: the
first bad item refuses the whole request with `code`
(`bad_key`, `too_large`, `bad_value`, `missing_field`),
`item_index` and the offending `key`; `bad_value` is a
translation holding a NUL, which no store can keep. A work the user does not have is
`400 unknown_work` with the `work_id`, the same refusal and
recovery as ops. The batch shares `ops.max_batch`; values are
bounded by `ops.translation_max_value_bytes` and the whole
cache by `ops.translation_budget_bytes`, evicting the
oldest-updated rows first.
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: [translations]
properties:
translations:
type: array
minItems: 1
maxItems: 500
items: { $ref: "#/components/schemas/WorkTranslationInput" }
responses:
"200":
description: The batch was applied (an identical batch is a no-op, not a different answer)
content:
application/json:
schema:
type: object
properties:
status: { type: string, enum: [applied] }
"400": { $ref: "#/components/responses/Error" }
"413": { $ref: "#/components/responses/Error" }
get:
tags: [sync]
summary: Pull a work's cached translations (delta feed)
description: |
One page of the work's sentence translation cache, ordered by
`(updated_at, key)` — the same order the cursor cuts at, so rows
sharing a timestamp cannot be skipped or repeated across pages.
The first pull (no `after`) is the whole work; later pulls pass
the `next_cursor` the last page returned and receive only the
delta. A cursor that does not parse is `400`; the recovery is to
drop the cursor and pull from the beginning — re-reading, never
skipping. A work the user does not have is `404`.
security: [{ deviceToken: [] }] # requires sync scope
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
- name: limit
in: query
description: Page size, capped by `ops.translation_max_pull` (500 by default).
schema: { type: integer, default: 500, maximum: 500 }
- name: after
in: query
description: The `next_cursor` of the last page; absent for the whole work.
schema: { type: string }
responses:
"200":
description: One page of cached translations
content:
application/json:
schema:
type: object
properties:
translations:
type: array
items: { $ref: "#/components/schemas/WorkTranslation" }
next_cursor: { type: string, description: The cursor of the last row returned; pass it as `after` to continue. }
has_more: { type: boolean }
"400": { $ref: "#/components/responses/Error" }
"404": { $ref: "#/components/responses/Error" }
/v1/insights/capabilities:
get:
tags: [insights]
summary: Negotiate statistics snapshot support
description: |
Requires `read-insights`. Returns the account id and timezone,
protocol and attribution versions, all-history and period-comparison
support, and request limits.
A sync-only client can negotiate session duration through
`GET /v1/token` instead; browser tokens do not need wider scopes.
security: [{ deviceToken: [] }] # requires read-insights scope
responses:
"200":
description: Supported statistics protocol
content:
application/json:
schema: { $ref: "#/components/schemas/InsightsCapabilities" }
"401": { $ref: "#/components/responses/Error" }
"403": { $ref: "#/components/responses/Error" }
"500": { $ref: "#/components/responses/Error" }
/v1/insights/snapshot:
post:
tags: [insights]
summary: Read coherent statistics and actual local-session overlap
description: |
Requires `read-insights`, not `sync`; candidates are evidence,
never uploaded. Reads effective sessions, rollups, archived proof,
positions, metadata, timezone and revision in one transaction.
Server history has no 10000-session cap.
The byte limit covers the entire body, including trailing whitespace.
Send one JSON value only; trailing JSON or garbage is invalid.
Candidates must contain the original native payload and original
device id, not the current token's device id. Only matching
contributions present in these aggregates count as overlap.
Upload acknowledgements, receipts and op cursors are not proof.
On a complete response, additive totals can be combined as
server + captured local - actual overlap. Keep the local capture
fixed and check account, snapshot id, timezone, versions and bounds.
Use local-only statistics if negotiation or complete proof is
unavailable, including on legacy servers.
An optional `comparison` asks for current and previous date spans
stopped at the same account-local wall-clock time. Its current dates
must equal the bounded headline dates and end today; comparison is
unavailable for `range: all`. Both server totals and overlap totals
come from this same snapshot. If raw timestamps or candidate evidence
cannot prove all four values, the response omits both comparison
objects and sets `comparison_incomplete_reason`; headline `complete`
and its totals are unaffected by comparison-only evidence.
Explicit calendar bounds affect only `days` and `overlap.days`.
Across chunks require the same decimal-string `stats_revision`
and response identity; do not sum repeated summary/work totals.
security: [{ deviceToken: [] }] # requires read-insights scope
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/InsightsSnapshotInput" }
responses:
"200":
description: |
Coherent aggregates and overlap. An incomplete headline still
returns totals, but cannot certify an exact local/server union.
A refused optional comparison is reported separately and never
invalidates an otherwise complete headline.
content:
application/json:
schema: { $ref: "#/components/schemas/InsightsSnapshot" }
"400":
description: Invalid payload, duplicate candidate id, excessive evidence or invalid bounds.
content:
application/json:
schema: { $ref: "#/components/schemas/Error" }
"401": { $ref: "#/components/responses/Error" }
"403": { $ref: "#/components/responses/Error" }
"409":
description: Requested timezone differs from the account's current timezone.
content:
application/json:
schema: { $ref: "#/components/schemas/Error" }
"413":
description: Request body exceeds `ops.max_body_bytes`.
content:
application/json:
schema: { $ref: "#/components/schemas/Error" }
"500": { $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:
allOf:
- { $ref: "#/components/schemas/InsightsSummary" }
- { $ref: "#/components/schemas/InsightsWindow" }
- { $ref: "#/components/schemas/InsightsRevision" }
- type: object
required: [first_activity_day]
properties:
first_activity_day: { $ref: "#/components/schemas/InsightsFirstActivityDay" }
/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` is
always current; ETA uses that position and measured pace
within the selected window.
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:
allOf:
- { $ref: "#/components/schemas/InsightsWork" }
- { $ref: "#/components/schemas/InsightsWindow" }
- type: object
required: [timezone]
properties:
timezone: { type: string }
"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. `title` and `author` name each work so
that a client can list one it holds no local file for.
content:
application/json:
schema:
allOf:
- { $ref: "#/components/schemas/InsightsWindow" }
- { $ref: "#/components/schemas/InsightsRevision" }
- type: object
required: [works]
properties:
works:
type: array
items: { $ref: "#/components/schemas/InsightsWork" }
/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 recorded contributions. Raw sessions and v2 rollups
count wholly on their end day; legacy split-day buckets retain
their original attribution.
`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:
allOf:
- { $ref: "#/components/schemas/InsightsRevision" }
- type: object
required: [year, days]
properties:
year: { type: integer }
from: { type: string, format: date }
to: { type: string, format: date }
range_days:
type: integer
description: Calendar days covered; present only when `from`/`to` were honoured.
days:
type: array
items: { $ref: "#/components/schemas/InsightsDay" }
"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/resolve:
post:
tags: [library, sync]
summary: Join many catalog books to your own works
security: [{ deviceToken: [] }] # requires BOTH library-read and sync
description: |
The same join as `POST /v1/books/{id}/resolve`, for a shelf at a
time. It exists for the moment a device signs in to an account and
has a name here for none of its books: a book with no work can
neither send a position nor receive one, so asking one request per
book is what made a fresh library arrive in batches.
Each book is resolved in its own transaction and takes its own
answer, so one book the server cannot place settles nothing about
the others. The response is therefore `200` with per-item results
rather than a status code: no single status could describe five
hundred answers, and refusing the batch would throw away the ones
that worked.
Results come back in the order the ids were sent. A refused one
carries `error`: `not_found`, `ambiguous` with the works, or
`conflict` when the work graph moved under the write — the only
one of the three worth sending again.
`confirmed` applies to the whole batch and should be sent only by a
caller with no reader to ask. Accepting a doubtful title/author
match is a question about one book, and the single-book route is
where the answer belongs.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [book_ids]
properties:
book_ids:
type: array
minItems: 1
maxItems: 500
items: { type: string, format: uuid }
confirmed:
type: boolean
description: Accept low-confidence title/author matches.
responses:
"200":
description: One result per requested book, in the order asked for
content:
application/json:
schema:
type: object
required: [results]
properties:
results:
type: array
items: { $ref: "#/components/schemas/CatalogResolutionItem" }
"400":
description: |
Malformed or empty body, or more than 500 ids — in which case
the answer carries `code: batch_too_large` and the `limit` the
server will take.
content:
application/json:
schema: { $ref: "#/components/schemas/BatchItemError" }
"403": { $ref: "#/components/responses/Error", description: "The token lacks library-read or sync" }
"413": { $ref: "#/components/responses/Error", description: "Request body too large" }
/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}/publication/manifest.json:
get:
tags: [library]
summary: Fetch a book's Readium publication manifest
security: [{ deviceToken: [] }]
description: |
Returns a Readium Web Publication manifest for the catalogued EPUB.
Its reading-order and resource links are versioned by the book's
content digest. Fetch the position-list and package links with the
same bearer token; a digest change means the client must reopen the
publication. `HEAD` is also supported and returns the same headers
without a body.
parameters:
- { name: id, in: path, required: true, schema: { type: string, format: uuid } }
responses:
"200":
description: Readium Web Publication manifest
headers:
Cache-Control: { schema: { type: string, const: "private, no-store" } }
content:
application/webpub+json:
schema: { type: object, additionalProperties: true }
"401": { $ref: "#/components/responses/Error" }
"404": { $ref: "#/components/responses/Error", description: "Unknown book, or no access" }
"410": { $ref: "#/components/responses/Error", description: "The book has no readable file" }
"422": { $ref: "#/components/responses/Error", description: "The EPUB is invalid or unsupported" }
/v1/books/{id}/publication/{digest}/positions.json:
get:
tags: [library]
summary: Fetch a Readium position list
security: [{ deviceToken: [] }]
description: Returns the positions generated for the versioned EPUB publication. `HEAD` is also supported.
parameters:
- { name: id, in: path, required: true, schema: { type: string, format: uuid } }
- { name: digest, in: path, required: true, description: Content SHA-256 from the manifest links, schema: { type: string, pattern: "^[0-9a-f]{64}$" } }
responses:
"200":
description: Readium position list
content:
application/vnd.readium.position-list+json:
schema:
type: object
required: [total, positions]
properties:
total: { type: integer, minimum: 0 }
positions: { type: array, items: { type: object, additionalProperties: true } }
"401": { $ref: "#/components/responses/Error" }
"404": { $ref: "#/components/responses/Error", description: "Unknown book, or no access" }
"409": { $ref: "#/components/responses/Error", description: "The digest is stale" }
"410": { $ref: "#/components/responses/Error", description: "The book has no readable file" }
/v1/books/{id}/publication/{digest}/resources/{resource}:
get:
tags: [library]
summary: Fetch one Readium publication resource
security: [{ deviceToken: [] }]
description: |
Returns one declared EPUB resource, addressed relative to the
package document. Clients should use the resource URLs in the
manifest rather than constructing paths.
`{resource}` is a greedy match on the server: it is registered as
Go's `{resource...}` wildcard, so it consumes every remaining
path segment after `/resources/`, slashes included (for example
`OEBPS/images/cover.jpg` arrives as that one value, not three
separate segments). OpenAPI has no path-parameter syntax for
that, so tooling that treats `{resource}` as a single segment
will not by itself construct a nested path correctly; use the
resource URLs already embedded in the manifest instead of
building this path yourself. `HEAD` is also supported.
parameters:
- { name: id, in: path, required: true, schema: { type: string, format: uuid } }
- { name: digest, in: path, required: true, schema: { type: string, pattern: "^[0-9a-f]{64}$" } }
- { name: resource, in: path, required: true, description: "Relative archive resource path; may itself contain slashes (see above)", schema: { type: string } }
responses:
"200":
description: The declared resource bytes
content:
application/octet-stream:
schema: { type: string, format: binary }
"401": { $ref: "#/components/responses/Error" }
"404": { $ref: "#/components/responses/Error", description: "Unknown book, no access, or undeclared resource" }
"409": { $ref: "#/components/responses/Error", description: "The digest is stale" }
"410": { $ref: "#/components/responses/Error", description: "The book has no readable file" }
/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; `icon` is a small square crop for a
browser tab. Any other value is rejected rather than
treated as the default. `icon` alone answers a coverless
book with a drawn placeholder rather than a `404`: the asker
there needs an image, not an answer.
schema: { type: string, enum: [thumbnail, full, icon], 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, icon], 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
(see `BatchItemError` for the batch routes). Clients must key
any recovery off `code`, never off the message text.
content:
application/json:
schema: { $ref: "#/components/schemas/Error" }
RateLimited:
description: Per-IP limit or shared password capacity reached
headers:
Retry-After:
description: Seconds to wait before retrying
schema: { type: integer, example: 1 }
content:
application/json:
schema: { $ref: "#/components/schemas/Error" }
StatusOK:
description: OK
content:
application/json:
schema:
type: object
properties: { status: { type: string } }
schemas:
Error:
type: object
required: [error]
properties:
error: { type: string }
code: { type: string }
BatchItemError:
type: object
description: |
A refusal of a batch (`POST /v1/ops`, `POST /v1/sessions`). The
batch is appended atomically, so a refusal means nothing was
stored, and it names the first item that caused it well enough
for a client to set that item aside and send the rest again:
`item_index` is always present for an item-level refusal;
`op_id`/`session_id` when the item had one (a `missing_field`
about the id itself cannot name it). Batch-level refusals
(`batch_too_large`) have no `item_index`.
required: [error]
properties:
error: { type: string }
code:
type: string
enum:
- unknown_work
- missing_field
- bad_time
- time_in_future
- progression_out_of_range
- locator_too_large
- idle_out_of_range
- active_out_of_range
- id_reused
- batch_too_large
description: |
`unknown_work`: the item named a work the server no longer
holds; re-resolve the book and retry under the new id.
`locator_too_large`: retry that op under the same `op_id`
without its locator. `id_reused` (409): the id was already
accepted with a different payload; that item will never be
taken as is. The validation codes are permanent for that
payload. Unrecognised codes should be treated as permanent
for the item but reported, not silently dropped.
item_index:
type: integer
description: Zero-based position of the item in the batch.
op_id: { type: string }
session_id: { type: string }
work_id:
type: string
description: With `unknown_work`, the work the server lacks.
limit:
type: integer
description: |
With `locator_too_large`, the byte bound; with
`batch_too_large`, the item bound.
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 }
CatalogResolutionItem:
type: object
description: |
One book's answer within a batch resolve. Either it resolved, and
carries the same fields `CatalogResolution` does, or it did not,
and carries the reason instead.
A doubtful match is not a refusal: the work comes back with
`confidence: low` and nothing is stored, exactly as on the
single-book route.
required: [book_id]
properties:
book_id: { type: string }
work_id: { type: string }
confidence: { type: string, enum: [high, low] }
created: { type: boolean }
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 }
error:
type: string
enum: [not_found, ambiguous, conflict]
description: |
Why this book alone was refused. `not_found` is the batch
spelling of the single route's `404`, `ambiguous` of its
`409`. `conflict` means the work graph moved under the write
and the book is worth asking about again.
works:
type: array
description: With `ambiguous`, the works the identifiers landed between.
items: { 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, account_id, device_id, name, scopes, session_active_ms]
properties:
id: { type: string }
account_id:
type: string
description: |
Opaque, stable name for the account the token belongs to.
Every token minted on one account carries the same value;
compare it, not `device_id` or `id`, to decide whether a
replaced credential is the same account (keep mirrors and
cursors) or a different one (clear them).
session_active_ms:
type: boolean
const: true
description: |
Supports optional authoritative `active_ms` on native sessions.
Available without `read-insights`, including to browser tokens
with only `sync` and `library-read`. Older servers omit this;
omission or false means send the legacy payload without it.
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] }
origin_alias:
type: string
description: "Source identifier for edition-less records, preserving ownership when a work is split."
received_at: { type: string, format: date-time }
OpResult:
type: object
properties:
op_id: { type: string }
status:
type: string
enum: [applied, duplicate, conflict]
description: |
`applied`: new, with its `seq`. `duplicate`: this `op_id`
was already accepted with this exact payload; `seq` is the
original. `conflict`: this `op_id` was already accepted with
a different payload (or from a different device); the item
was not stored and never will be under this id.
seq: { type: integer }
reason: { type: string }
InsightsCapabilities:
type: object
required: [version, account_id, all_time, active_ms, comparison, attribution_version, timezone, max_candidates, max_local_active_days, max_calendar_days, max_body_bytes]
properties:
version: { type: integer, const: 1 }
account_id:
type: string
description: Stable account id of the authenticated caller; must match the client's captured account.
all_time:
type: boolean
const: true
description: Supports range all without a total-history row cap.
active_ms: { type: boolean, const: true }
comparison:
type: boolean
const: true
description: Supports one-snapshot current/previous period comparison.
attribution_version: { type: integer, const: 2 }
timezone: { type: string, description: "Account IANA timezone", example: Europe/Paris }
max_candidates: { type: integer, const: 10000 }
max_local_active_days:
type: integer
const: 10000
description: Maximum entries in snapshot local_active_days evidence.
max_calendar_days: { type: integer, const: 4000 }
max_body_bytes:
type: integer
format: int64
description: |
Configured `ops.max_body_bytes` request limit, default 1 MiB
(1048576 bytes). Clients check the complete serialized UTF-8
snapshot body against this bound before requesting a proof.
example: 1048576
InsightsWindow:
type: object
required: [range_days]
properties:
range_days:
type: integer
minimum: 0
description: Inclusive aggregate calendar days; 0 for unbounded history through today.
from: { type: string, format: date, description: "First aggregate day; absent when unbounded" }
to: { type: string, format: date, description: "Last aggregate day; absent when unbounded" }
InsightsRevision:
type: object
required: [timezone, stats_revision, attribution_version]
properties:
timezone: { type: string, description: Account IANA timezone }
stats_revision:
type: string
pattern: '^[0-9]+$'
example: "42"
description: |
Per-account monotonic statistics revision encoded as a decimal
string, never a JSON number or op cursor. It covers aggregate
inputs and archive proof. Require equality across calendar
chunks before combining them.
attribution_version:
type: integer
const: 2
description: |
Raw sessions and new rollups use whole end-day attribution.
This does not certify legacy history; check snapshot `complete`.
InsightsFirstActivityDay:
type: [string, "null"]
format: date
description: |
Earliest server day with positive active duration, across all
history through today, or null. Local evidence does not alter it.
InsightsSummary:
type: object
required: [total_active_minutes, total_pages, sessions, streak_days, speed_prog_per_hour]
properties:
total_active_minutes: { type: number }
total_pages:
type: number
description: Edition-derived or source-reported pages; not universal pagination.
sessions: { type: integer }
streak_days:
type: integer
description: |
Consecutive server days with positive active duration ending
today or yesterday, over all history, independent of bounds.
speed_prog_per_hour:
type: number
description: |
Measured positive progression divided by measured active hours
in the aggregate window. Inferred sessions never supply pace.
Zero without measured duration or with unknown legacy pace.
InsightsWork:
type: object
required: [work_id, sessions, total_active_minutes, total_pages, current_progression, eta_seconds, last_read_at]
properties:
work_id: { type: string }
title: { type: string, description: Absent when unknown }
author: { type: string, description: Absent when unknown }
sessions: { type: integer }
total_active_minutes: { type: number }
total_pages: { type: number }
current_progression:
type: number
description: Latest position regardless of window; 0 when none is known.
eta_seconds:
type: [number, "null"]
description: |
Current remaining progression divided by measured pace in the
selected window. Null without usable pace or position, with
unknown legacy pace, or when progression is already 1.
last_read_at:
type: [string, "null"]
format: date-time
description: |
Latest contributing session end in the window, or local
midnight for a rollup whose raw timestamp is no longer kept.
InsightsDay:
type: object
required: [date, minutes, pages, sessions]
properties:
date: { type: string, format: date }
minutes: { type: number }
pages: { type: number }
sessions: { type: integer }
InsightsCandidate:
allOf:
- { $ref: "#/components/schemas/SessionInput" }
- type: object
required: [device_id]
properties:
device_id:
type: string
minLength: 1
maxLength: 64
description: Original upload device id, not the current token's; at most 64 UTF-8 bytes.
description: |
Full original native session payload, including original optional
duration and edition fields, plus its original device id.
Evidence only; the snapshot endpoint never stores this session.
InsightsSnapshotInput:
type: object
required: [snapshot_id, timezone]
oneOf:
- required: [range]
- required: [from, to]
description: |
Choose `range` or a complete `from`/`to` pair, not both.
Capture local totals and evidence together; do not truncate the
evidence to fit the limits and then treat the union as complete.
The body is also bounded by `ops.max_body_bytes`.
properties:
snapshot_id:
type: string
minLength: 1
maxLength: 128
description: Client-local capture identifier, echoed unchanged; at most 128 UTF-8 bytes.
timezone:
type: string
description: Account IANA timezone from capabilities; a mismatch returns 409.
range:
type: string
pattern: '^(all|[1-9][0-9]*d)$'
description: All history through today, or 1 through 3660 calendar days ending today.
example: all
from: { type: string, format: date, description: "First aggregate day, inclusive; requires to" }
to: { type: string, format: date, description: "Last aggregate day, inclusive; not before from" }
candidates:
type: array
maxItems: 10000
description: Original native sessions; session ids must be unique within this request.
items: { $ref: "#/components/schemas/InsightsCandidate" }
local_active_days:
type: array
maxItems: 10000
description: |
All-history account-local days with positive captured local
activity, including outside the aggregate window. Future dates
after server today are refused. Used only for combined streak.
items: { type: string, format: date }
calendar_from:
type: string
format: date
description: |
First daily-output day; requires calendar_to. At most 4000
inclusive days, within the aggregate window when bounded.
Without explicit calendar bounds, bounded requests use the
aggregate window. For all history, start at earliest positive
server activity, clipped to the latest 4000 days (today alone
when no positive activity exists).
calendar_to:
type: string
format: date
description: |
Last daily-output day, inclusive; requires calendar_from and
must not precede it. Defaults to aggregate end, or today for all.
Calendar bounds do not narrow summary, works or streaks.
comparison: { $ref: "#/components/schemas/InsightsComparisonInput" }
InsightsComparisonInput:
type: object
required: [current_from, current_to, previous_from, previous_to, through]
description: |
Optional current/previous comparison inside the same statistics
snapshot. Both spans are inclusive, ordered and at most 4000 days.
The previous span must end before the current span starts. The current
dates must equal the bounded headline dates and current_to must be
account-local today; range all cannot carry a comparison.
`through` is a local wall-clock cutoff with optional seconds and up
to millisecond precision. The response normalizes it to HH:mm:ss.SSS.
A repeated DST time uses its earlier occurrence; a skipped time moves
forward by the size of the gap.
properties:
current_from: { type: string, format: date }
current_to: { type: string, format: date }
previous_from: { type: string, format: date }
previous_to: { type: string, format: date }
through:
type: string
pattern: '^(?:[01][0-9]|2[0-3]):[0-5][0-9](?::[0-5][0-9](?:\.[0-9]{1,3})?)?$'
example: "08:48:42.375"
InsightsComparison:
type: object
required: [current_active_minutes, previous_active_minutes, current_from, current_to, previous_from, previous_to, through]
properties:
current_active_minutes: { type: number, minimum: 0 }
previous_active_minutes: { type: number, minimum: 0 }
current_from: { type: string, format: date }
current_to: { type: string, format: date }
previous_from: { type: string, format: date }
previous_to: { type: string, format: date }
through:
type: string
pattern: '^(?:[01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]\.[0-9]{3}$'
description: Millisecond-normalized account-local cutoff echoed from the request.
InsightsComparisonTotals:
type: object
required: [current_active_minutes, previous_active_minutes]
properties:
current_active_minutes: { type: number, minimum: 0 }
previous_active_minutes: { type: number, minimum: 0 }
InsightsOverlap:
type: object
required: [total_active_minutes, sessions, works, days]
description: |
Contributions of matching original candidates actually present in
this snapshot, including exact v2 archive proof. Absent candidates
contribute zero. There is no top-level total_pages; pages are in
works/days. Merge only additive metrics, not position or ETA fields.
properties:
total_active_minutes: { type: number }
sessions: { type: integer }
works:
type: array
description: |
Same row shape as server works, but current_progression is 0
and eta_seconds is null because overlap has no positions.
items: { $ref: "#/components/schemas/InsightsWork" }
days:
type: array
description: Daily overlap within the echoed calendar bounds, ordered by date.
items: { $ref: "#/components/schemas/InsightsDay" }
comparison:
allOf:
- { $ref: "#/components/schemas/InsightsComparisonTotals" }
description: |
Actual candidate overlap in the comparison's current and previous
spans. The bounds are the echoed top-level comparison bounds.
Present exactly when the top-level comparison is present.
InsightsSnapshot:
allOf:
- { $ref: "#/components/schemas/InsightsWindow" }
- { $ref: "#/components/schemas/InsightsRevision" }
- type: object
required: [version, account_id, snapshot_id, complete, first_activity_day, today, calendar_from, calendar_to, summary, works, days, combined_streak_days, overlap]
properties:
version: { type: integer, const: 1 }
account_id: { type: string, description: Stable account id of the authenticated caller }
snapshot_id: { type: string, description: Echoed client-local capture identifier }
complete:
type: boolean
description: |
False when legacy or timezone-incompatible rollups, a
candidate payload mismatch, or unknown archived proof
prevent an exact union. Check this before using overlap.
incomplete_reason:
type: string
description: Present only when complete is false; unknown reasons also require fallback.
enum:
- legacy_or_different_timezone_rollups
- candidate_payload_mismatch
- unknown_archived_contribution
- archived_timezone_mismatch
- archived_work_missing
comparison_incomplete_reason:
type: string
description: |
Present only when a comparison was requested but omitted.
It does not change `complete`, which describes the headline
union only. Unknown reasons also require local comparison
fallback.
enum:
- comparison_history_incomplete
- candidate_payload_mismatch
- unknown_archived_contribution
- archived_timezone_mismatch
- archived_work_missing
first_activity_day: { $ref: "#/components/schemas/InsightsFirstActivityDay" }
today: { type: string, format: date, description: Today in the account timezone }
calendar_from: { type: string, format: date }
calendar_to: { type: string, format: date }
summary: { $ref: "#/components/schemas/InsightsSummary" }
works:
type: array
description: |
Server works with contributions in the aggregate window,
including works the client does not hold. Sorted by active
minutes descending, then work id.
items: { $ref: "#/components/schemas/InsightsWork" }
days:
type: array
description: |
Server daily contributions within the echoed calendar
bounds, ordered by date. Missing dates contribute zero.
items: { $ref: "#/components/schemas/InsightsDay" }
combined_streak_days:
type: integer
description: |
All-history streak over server positive-activity days
unioned with local_active_days, ending today or yesterday.
Independent of aggregate and calendar bounds.
comparison:
allOf:
- { $ref: "#/components/schemas/InsightsComparison" }
description: |
Server totals for the requested current and previous spans.
Omitted with `comparison_incomplete_reason` when exact
proration cannot be proven. Rollups created before exact
millisecond comparison evidence was introduced are refused.
overlap: { $ref: "#/components/schemas/InsightsOverlap" }
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, "null"] }
started_at: { type: string, format: date-time }
ended_at: { type: string, format: date-time, description: Must not precede started_at }
start_progression: { type: number, minimum: 0, maximum: 1 }
end_progression: { type: number, minimum: 0, maximum: 1 }
idle_ms:
type: integer
format: int64
minimum: 0
default: 0
description: |
Idle milliseconds; must not exceed the wall-clock span even
when active_ms is supplied. Subtracted only when active_ms
is absent or null.
active_ms:
type: [integer, "null"]
format: int64
minimum: 0
maximum: 9007199254740991
description: |
Optional monotonic measured duration. Explicit 0 is zero
active time; absent or null uses wall-clock span minus idle.
Authoritative independently of the span and idle_ms, never
capped to the span or reduced by idle again. Negotiate support
through insights capabilities (active_ms) or token introspection
(session_active_ms). Presence/value participates in immutable
payload identity; do not change it on retries.
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 }
SettingEntry:
type: object
required: [updated_at]
properties:
value:
type: string
default: ""
description: |
Opaque value; the server never interprets it. Absent on the
way in is the same as an empty string, but an explicit JSON
null is refused, since a client that meant to send nothing
and a client whose serialiser dropped a value should not
silently clear a preference. There is no delete, so a client
that needs to represent "unset" does it with a value of its
own choosing.
updated_at:
type: string
format: date-time
description: |
When this key was last written, used for last-writer-wins.
Sent as RFC 3339 and stored to microsecond precision, so a
client can echo back exactly what it was given. Anything
finer is rounded down on the way in.
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.
WorkTranslationInput:
type: object
required: [key, translation]
properties:
key:
type: string
minLength: 64
maxLength: 64
description: |
The client's opaque content hash: SHA-256 over its
translation identity and the sentence. The server does not
interpret it; the sentence itself never travels.
translation:
type: string
description: The translated text, bounded by `ops.translation_max_value_bytes` (8 KiB by default).
WorkTranslation:
type: object
description: One cached sentence translation as the server holds it.
required: [key, translation]
properties:
key: { type: string, description: The client's opaque content hash. }
translation: { type: string }
device_id: { type: string, description: "The token's device, stamped by the server." }
updated_at: { type: string, format: date-time, description: Server-stamped; moves only when the text changes. }