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