# Uchiyomi API
Everything the web app does, it does over this API, so anything you can do in the browser you can script.
This page covers how to authenticate and the endpoints worth scripting. It is not an exhaustive dump of
every route; the full list is at the bottom for reference, and the complete, browsable reference is served
by the app itself at **`/api/docs`** (Swagger UI, with "try it out") from
[`bff/openapi.yaml`](../bff/openapi.yaml). Both this list and that file are checked against the registered
routes in both directions by `bff/test/openapiCoverage.test.ts`, so neither can silently fall behind.
## Authenticating
There are two ways in, and for scripts you want the second one.
**Session tokens** are what the web app uses: `POST /auth/login` returns a JWT that expires after 15 minutes,
refreshed with a rotating cookie. Fine for a browser, miserable for a cron job.
**API tokens** are long-lived, revocable, and scoped. Create one under **Profile → Connections → API tokens → New token** (the form opens inline).
The token is shown once, so copy it then. It looks like `uy_` followed by random characters.
```bash
curl -H "Authorization: Bearer uy_your_token_here" https://your-server/api/home
```
### Scopes
| Scope | What it allows |
| --- | --- |
| `read` | `GET` requests, plus `POST /api/series/search` — a query whose input happens to be a body. Every token has this. |
| `write` | Anything that changes data: progress, favorites, adding series. |
| `admin` | The `/api/admin/*` endpoints. |
Scopes only ever *restrict*. An `admin`-scoped token belonging to a non-admin account still cannot reach the
admin API, and a token without `write` gets `403` on any non-`GET` request other than the library search:
```json
{ "error": "forbidden", "message": "This token is read-only." }
```
Give a token the least it needs. A backup script that only reads your library should be `read`, so that a
token accidentally committed to a repo cannot delete anything.
Tokens can be given an expiry, and revoking one takes effect on the next request. Both are managed in the
same panel as your active sessions.
### Images and OPDS
`/img/*` is authorised by the `yomi_img` cookie rather than a header, because `` tags can't send one — it
also accepts an OPDS token over HTTP Basic, so an OPDS reader can load covers and pages with the same
credentials it uses for the feed, **and, since v0.29.0, an API token as a Bearer**, so a third-party client
such as the Mihon extension needs one credential for the JSON and the pictures alike. A `read`-scoped token
is enough for images. `/opds/*` uses HTTP Basic with your OPDS token as the password
(**Profile → Connections → External readers**) and does not accept API tokens. A disabled account's OPDS token is refused (**401**) on the feed
and on `/img/*` alike, like every other credential of a disabled account, and works again once the account
is re-enabled — the token itself is not revoked.
### The Komga-compatible surface
`/api/v1/*` and `/api/v2/*` (since v0.38.0) speak Komga's API for Mihon's Komga extension and its Komga
tracker, and take an API token three ways: `X-API-Key: uy_…` on every request (the extension's API key
field), `Authorization: Bearer uy_…` (the way the rest of the API takes it), or `Authorization: Basic` with
the token as the **password** and any username (what the extension sends after a 401 when no key is set) —
in that order of precedence, and any presented credential outranks a remembered cookie. No credential is
**401** with `WWW-Authenticate: Basic` — the extension's Basic authenticator fires on a 401 and on nothing
else. Account passwords are refused there on purpose, right or wrong: the protocol has no channel for a
two-factor code, and a password path would have walked around 2FA and the lockout. OPDS tokens and session
JWTs are refused too. Ten failed credentials from one address in five minutes (the budget `/auth/login`
has) and every further request from that address that presents a credential is **429** `too_many_requests`
with `Retry-After` until the window ends; a request with no credential and a cookie-only request are
neither counted nor blocked, and a valid key never counts. A credentialed request also sets an
`UCHIYOMI-SESSION` cookie, honoured **only by those routes**, for the tracker's requests, which carry no
credential at all; the cookie names the token row, which is re-read on every use, so revoking the token,
letting it expire or disabling the account ends the cookie on the next request, and signing out of the
web app (`POST /auth/logout`) clears it from a browser alongside the other cookies. Details under
[Komga-compatible API](#komga-compatible-api-mihons-komga-extension-and-tracker) in the route list.
## Conventions
- Base URL is your server's origin. All paths below are absolute.
- Request and response bodies are JSON; send `Content-Type: application/json` when posting.
- List endpoints return `{ "content": [...] }`.
- Errors return a non-2xx status with `{ "error": "", "message": "" }`.
- IDs are strings. Series and book IDs are stable; don't parse them.
## Common tasks
**What am I in the middle of?**
```bash
curl -H "Authorization: Bearer $TOK" https://your-server/api/home
```
Returns the shelves the home screen is built from, including the on-deck books with their progress.
**Mark a chapter as read**
```bash
curl -X PUT -H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' \
-d '{"page": 20, "completed": true, "silent": true}' \
https://your-server/api/books/BOOK_ID/progress
```
`silent: true` means "this is an explicit action, not organic reading": it writes exactly what you say
(including marking something *unread*) and stays out of your reading history and streaks. Leave it off and
the write can only ever move a chapter forward to completed, never back.
If you have a tracker connected, finishing a chapter this way syncs it like any other.
**Add a series**
Two steps: find it, then add the result. Adding takes a source and that source's own id for the series, not a
URL.
```bash
# 1. find it — searches your enabled sources in order and returns {source, sourceId, title, ...}
curl -H "Authorization: Bearer $TOK" "https://your-server/api/sources/find?q=solo+leveling"
# 2. add it
curl -X POST -H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' \
-d '{"source":"mangadex","sourceId":"32d76d19-8a05-4db0-9fc2-e0b0648fe9d0","chapterCount":10,"autoUpdate":true}' \
https://your-server/api/sources/add
```
`chapterCount` limits how many chapters to grab (omit for all). It counts from the OLDEST unless
`chapterFrom: "newest"` is sent, and whichever end it counts from the selection is downloaded ascending, so
a partial add always reads as a coherent run. With `newest`, the chapters below the selection are left to
**Find missing chapters** rather than the updater: a chapter floor is set on the series so the scheduled
sweep fetches new releases only, instead of backfilling the whole back catalogue five per night with each
new chapter queued behind it. `autoUpdate` enrols it in the scheduled updater.
`chapterFrom: "none"` (since v0.34.0) adds the series with **no chapters at all** — the dialog's "Nothing
yet — pick chapters later". The row is created and followed, the listing is written so the series page can
show what the source has, nothing is downloaded, and the chapter floor is set just above the newest listed
number (`max + 0.001`: every number the source lists today is below it, the next release is not; no floor
when the source lists nothing), so auto-update takes only chapters released after the add. Older ones can
be fetched from the series page. The row is stamped as checked — the add just asked the source — so the
series' `checkedAt` and the source's chapter count are set from the start rather than after the first
sweep, and a series removed from the library and added back this way is revived under its old id (its
favourites, notes and read marks with it). `chapterCount` is ignored, and an empty listing is not
`no_chapters` — an announced title with nothing out yet is what this is for. The answer is `{ok, title,
folder, chapters: 0, started: false, nothing: true}`; `nothing` is what tells it from "already in library",
which also answers `chapters: 0`.
`GET /api/sources` lists what you can reach: each entry carries `id`, `name`, `lang` (null when the source
declares no single language, which means it belongs to every language group), `latest` (whether it can be
browsed without a query), `popular` (whether it can offer its own popularity ranking), `used` (how many
series in the library came from it), its health `status`, and `note`.
`status` is `ok`, `disabled`, or, while a cooldown is running, one of `rate_limited` / `blocked` / `down`.
It is also `quiet`, which means the source answers without error and returns nothing: a listing that has
stopped parsing never throws, so it never earns a cooldown, and before this existed such a source kept
reporting `ok` and kept being fetched first.
`note` is one sentence saying what is wrong, or `null` when nothing is. It is written for readers, so it
never contains a hostname, a component name or any part of the recorded error. The operator-facing half of
the diagnosis, which does name containers and config files, is only on the admin routes.
`POST /api/admin/sources/:id/test` (admin) probes a source right now: for a source that has a homepage of
its own it fetches that homepage directly, without the Cloudflare solver, and then exercises the adapter
(search, series, chapters, pages), returning per-step `checks`, the `probe` result and a `diagnosis`.
Extension sources have no homepage to ask (the engine talks to the site, not this server), so for them the
homepage step is skipped and `probe` carries no `httpStatus`: only the adapter's own result. It ignores any cooldown, which is the
point, and it deliberately writes no health of its own: a diagnostic that changed the diagnosis would let
repeated clicks drive a source's cooldown to its ceiling. A pass reports `canClear` rather than clearing the
block itself, because the smoke test stops at listing page URLs and never fetches an image byte. The
`probe` is always present: `{httpStatus?, finalUrl?, transport?, looksHtml?, adapterOk, needsSolver}`,
where `httpStatus` is absent when no homepage request was made and `0` when one was made and no HTTP answer
came back — and a live `adapterOk: true` outranks whatever the stored error says, so an extension source
that passes its checks no longer carries a Cloudflare verdict from an earlier afternoon (PR #56). The same
rule reaches the Health page's *Source health* check: a stored error older than the source's last success is
history, not a fix to go and apply, and such a row shows only its live finding. A stored `Cloudflare bypass
currently disabled` diagnoses as `cf_challenge` (public `reason` *This source is protected by a check we
could not get past.*) with an admin `fix` that names the engine's own switch rather than Uchiyomi's solver,
by the names the shipped compose files use, never the development stack's: *The extension engine's own
Cloudflare bypass is switched off. On the Suwayomi engine's container (uchiyomi-suwayomi in the shipped
compose files) set FLARESOLVERR_ENABLED=true and FLARESOLVERR_URL to the same solver address Uchiyomi uses
(http://uchiyomi-flaresolverr:8191 in the shipped files), then recreate it. The v0.37.0 compose files
already set both, so an upgrade that recreates the engine is the fix there.* Since v0.37.0 this route and
the scheduled source check also read the source's slow streak, so `diagnosis.code` can be `too_slow`
(*This source answers, but more slowly than it is given.*) from both, not only from Discover's health view;
its `fix` names the configured `SOURCE_LATEST_TIMEOUT_MS` budget in seconds (*longer than 8s*).
`POST /api/admin/sources/check` (admin) runs the source watchdog immediately instead of waiting for its
daily sweep. It probes every enabled source and smoke-tests its adapter, one at a time because they share a
single Cloudflare solver, then returns a verdict per source. It applies only the two fixes that are
verifiable: it follows a site to a new address **after** the new one passes a smoke test (rolling back if it
does not). Everything else is reported with a reason and a suggested fix, and admins get a push notification.
Answers **409** while a sweep is running. It no longer touches extensions -- that is its own scheduled task,
below, because the engine has to re-read its repositories before "an update is available" means anything.
`PATCH /api/admin/sources/custom/:id` (admin) changes a custom site's `base` address and nothing else. The
source id is derived from its name and the library is keyed on that id, so editing in place is the only way
to follow a site to a new domain without orphaning every series that came from it.
`POST /api/sources/add` answers as soon as the outcome is decided and downloads afterwards. It used to hold
the request until the first chapter had been fetched, measured at 15 to 59 seconds on a real install.
Everything that decides the answer still happens inline and still gets its own status code: **403** disabled,
**404** `no_chapters` (also the answer when every copy the source lists is from a group blocked
server-wide), **409** `duplicate` (with the "add anyway" message), and **200** with `chapters: 0`
for a title already in the library. A successful reply now carries `started: true`, which is what
distinguishes "downloading now" from "already had it" — previously only `chapters === 0` said so.
The body may also name `alsoFollow: [{source, sourceId}]` (at most six): other sources the add dialog
already found carrying the title, to be followed for the new series when they qualify. **Admins only**: a
member's `alsoFollow` is dropped before the add, which then proceeds exactly as with none given (no
judgement, no card) — following is an admin act, as `POST /api/admin/series/:id/sources` is. No search runs
for them. Each is judged on the server once the listing exists — at once for a `chapterFrom: "none"` add,
after the first chapter lands on a download — by two rules that stand in for the plan's human
confirmation. The candidate's own title must match this series' (exact or containing, after normalisation,
alt titles included), else `title_differs`. Then its numbering: the primary must list at least three
numbers (`too_few_listed` otherwise); with an **exact** title and a primary listing at least ten, the
candidate must list at least 90% of the primary's numbers (the fill-scan rule — a copy that runs on past
the primary still qualifies); with a containing title, or a primary listing fewer than ten, the numbering
must agree **both ways** — at least 90% of the primary's numbers listed by the candidate and at least 90%
of the candidate's listed by the primary — else `numbering_differs`, so a sequel that continues past the
primary ("Tokyo Ghoul:re" 1..60 for "Tokyo Ghoul" 1..20) is refused although it lists every number, while
"(Official)" 1..22 for 1..20 (20 of 22 = 0.91) follows. The `coverage` reported for a two-way judgement is
the lower of the two shares. At most two sources are followed per series, in the order given; a follower
this path wrote has no author and reads `auto: true` on the series' sources. The results are not in the
add's answer: they land on the series' job card (below), which a `none` add gets minted for the purpose.
One response is deliberately gone: the **429** `blocked` for a source refusing downloads. That can only be
known after the download is attempted, so it now arrives as a failed job carrying its reason. This is also
strictly better than before, where the 429 came back only after the whole chapter attempt had burned its
budget.
`GET /api/sources/jobs` lists downloads in progress. A finished job is swept a few minutes after it ends; a
**failed** one is never swept, because it is the only record that the download did not work, and it carries
a `reason` naming the source and how far it got. `DELETE /api/sources/jobs/` dismisses a job that
has stopped, and answers **409** `running` for one still downloading — or one whose auto-follow judgement
is still running (`autoFollow.done === false`), since the follows would still land while the report they
belong to was gone. A card whose add named `alsoFollow` candidates carries `autoFollow: {done, results}` —
`done: false` with no results while the other sources are asked, then one entry per candidate in the order
given, `{source, name, theirTitle, followed, coverage, why}`, with `why` one of `followed`,
`numbering_differs` (under 90% of the primary's numbers listed there or, when judged both ways, under 90%
of its numbers listed here — the rule above), `title_differs`, `unreachable` (threw or timed out — never
mistaken for "lists nothing"), `too_few_listed` (the primary lists under three numbers; nothing was
asked), `not_tried` (the 90-second wall ran out first, or the judgement itself failed before any source
was asked — every candidate then reads so, rather than the card finishing with an empty list), `cap`
(already following two) or `unavailable` (the primary itself, disabled, in a cooldown, not loaded, or
outside the caller's age cap). A `none` add with candidates gets a card with `total: 0, status: "done"`
just to carry this; it lives a few minutes after the judgement ends, so a closed dialog loses nothing.
`GET /api/sources/popular?source=&page=` is the same listing sorted by the source's OWN popularity,
not by anything this server computes: it is the page each site already publishes, reached with a different
sort. Every guard on the newest listing applies identically. A source that cannot offer one reports
`popular: false` and is simply not asked. Note the two listings are cached separately, so asking for one
never serves the other, and an empty *popular* page is deliberately not treated as evidence that a source's
parser has drifted, the way an empty *newest* page is.
`GET /img/sources/icon/` returns a source's own icon at 64px, resolved from the extension's declared
icon or, for a site added by URL, from the site's own favicon. A source with no findable icon gets a
lettered tile rendered here rather than a 404, so clients never need a fallback and a missing icon does not
log a console error in every visitor's browser. Either answer is cached, so a source without an icon costs
one lookup rather than one per page load.
`GET /api/sources/latest?source=&page=` is bounded at `SOURCE_LATEST_TIMEOUT_MS` (default 8000) per
source and cached server-side for ten minutes per source and page, with concurrent requests for the same page
collapsed into one outbound fetch. A source that times out is recorded against its health and earns a
cooldown, so it stops being picked first.
Responses worth handling: **200** with `message: "already in library"` if you have that exact series already,
and **409** `duplicate` if a series with the same title came from a *different* source — retry with
`"force": true` to add the second copy anyway.
**403 on the whole `/api/sources/*` surface.** Two account settings gate these routes, and both are enforced
server-side rather than only in the app:
* A non-admin whose `canDownload` permission is off is refused on **every** route in this group, not just
`add` — listing sources, searching, browsing newest, series detail and the job list all return `403`.
* An account whose `max_age_rating` is set below 18 cannot reach a source its extension declares adult. Such
a source is omitted from `GET /api/sources` entirely, refused with `403` by id on `latest`, `search`,
`detail` and `add`, and silently dropped from the `find` and `search-all` fan-outs (a fan-out has no single
source to refuse). Sources with no adult signal at all — built-ins, packs, custom sites — count as not
adult, the same way an unrated series stays visible.
Because these responses differ per account, do not cache them in anything shared. The app's service worker
explicitly excludes `/api/sources*` for that reason.
To add a whole *site* rather than one series, that is `POST /api/admin/sources/custom` (admin scope).
**Search everything at once**
```bash
curl -H "Authorization: Bearer $TOK" "https://your-server/api/sources/search-all?q=solo+leveling"
```
**Check the library for problems** (admin scope)
```bash
curl -H "Authorization: Bearer $TOK" https://your-server/api/admin/health
```
Returns the same checks as the admin Health tab: chapter gaps, truncated downloads, duplicate series,
impossible chapter numbers, and failing sources. Each check reports `status` (`ok`, `warn`, `problem`), a
one-line `summary`, and the individual `items`. Useful as a nightly cron that emails you only when
`status` isn't `ok`.
**Trigger a library scan** (admin scope)
```bash
curl -X POST -H "Authorization: Bearer $TOK" https://your-server/api/admin/library/scan
```
## 18+ libraries
A library whose `age_rating` is 18 or higher is left out of every **listing** endpoint by default: the home
rails, `POST /api/series/search`, genres, collections, favourites, updates, history, bookmarks, notes,
wrapped and the OPDS feeds. Add `?adult=1` to a request to include it. Admins are not exempt, because this is about what
appears unasked rather than about permission -- `max_age_rating` is the permission and is unrelated.
It is deliberately **not** applied to endpoints that resolve one id you already hold: the series page, its
chapter list, `GET /api/books/:id`, its pages, the offline manifest, next/previous, `PUT
/api/books/:id/progress` and `/opds/book/:id/file` all work whether or not the library is hidden. A filter
that refused to record what you read would lose data rather than tidy a screen.
OPDS feeds cannot pass the parameter, so the preference lives on the OPDS token instead: `PATCH
/api/opds/token { "showAdult": true }` (also a switch under **Profile → Connections → External readers**). Off by
default, per credential rather than per account, because the phone and the e-reader are different audiences.
Chapter downloads and page streaming work either way; the age cap is a permission and is unaffected.
`GET /api/libraries` reports `adult: true` for such a library so a client can offer the reveal, and drops
any library rated above the caller's own `max_age_rating` entirely.
The Komga-compatible API cannot pass the parameter either, so the same preference lives on the **API
token**: `POST /api/tokens { …, "showAdult": true }` (since v0.38.0; the *Include 18+ libraries* checkbox in
the mint dialog, off by default; `GET /api/tokens` rows carry `showAdult`). It decides whether 18+ libraries
appear in `/api/v1/libraries` and the series listings for that token; `/api/v1/series/:id`, its chapters,
pages and progress resolve by id whatever it says, and the age cap is a permission and is unaffected. The
flag changes nothing on `/api/*` proper, where `?adult=1` remains the reveal.
## Rate limiting
The API isn't rate-limited for authenticated users; the limits are on getting in. `POST /auth/login` takes
ten attempts per address per five minutes, `POST /api/setup` and `POST /auth/register` five per ten minutes,
and the Komga-compatible routes count failed credentials on the login budget — ten per address per five
minutes, then **429** `too_many_requests` `{message}` with `Retry-After` for every further request from that
address that presents a credential, until the window ends; requests with no credential, cookie-only
requests and valid keys are not counted. The *sources* the server fetches from are limited too: endpoints
that reach out to a manga site (`/api/sources/*`, `/api/admin/update`) queue behind a per-source limiter, so
a burst of requests will be slow rather than refused. Don't poll them in a tight loop.
## Full route list
Grouped by the module that serves them. Anything under `/api/admin/` needs an admin account **and** the
`admin` scope.
### Health
```
GET /livez GET /healthz
```
The two unauthenticated routes. Both answer before login exists, and they mean different things:
- **`/livez`** answers `{"ok":true}` unconditionally — is the process alive. This is what the container
healthcheck polls, so that a database blip does not mark the whole app unhealthy.
- **`/healthz`** runs `SELECT 1` and returns **503** when Postgres is unreachable — should traffic be sent
here. This is the one for a load balancer or an uptime monitor that should page you.
Point a reverse proxy's own health check at `/livez` if you want it to keep serving the shell during a
database outage, and at `/healthz` if you want it to take the app out of rotation instead.
### Authentication and setup
```
GET /api/setup/status POST /api/setup
GET /auth/config POST /auth/login
POST /auth/register POST /auth/refresh
POST /auth/logout POST /auth/logout-all
GET /auth/me POST /auth/password
GET /auth/sessions DELETE /auth/sessions/:id
POST /auth/totp/setup POST /auth/totp/enable
POST /auth/totp/disable
GET /auth/oidc/start GET /auth/oidc/callback
```
**Two-factor enrolment is one way.** `POST /auth/totp/setup` writes a *pending* secret and answers
`{secret, otpauth, qr}`; nothing is enforced until `POST /auth/totp/enable` `{code}` confirms a code from
the app and answers the recovery codes once. While two-factor is already on, setup is **409**
`{error: 'totp_enabled', message}` and the row is not touched (since v0.39.0): the secret in the row is
the one the authenticator holds, and rotating it from a stale *Set up 2FA* button left the flag on and the
app's codes wrong. `POST /auth/totp/disable` `{password}` is the way back (**401** `wrong_password`), and
setup works again after it.
### Library and reading
```
GET /api/home GET /api/featured
GET /api/foryou GET /api/trending
GET /api/random GET /api/genres
GET /api/genres/overview GET /api/libraries
GET /api/updates
POST /api/updates/seen POST /api/refresh
GET /api/series/:id GET /api/series/:id/books
GET /api/series/:id/similar GET /api/series/:id/color
POST /api/series/search GET /api/leaderboard
GET /api/books/:id GET /api/books/:id/pages
GET /api/books/:id/next PUT /api/books/:id/progress
PUT /api/books/:id/pages/:n/junk
GET /api/offline/plan GET /api/series/:id/listing
GET /api/series/:id/groups GET /api/series/:id/versions
```
**Where a series and its chapters came from.** `GET /api/series/:id` carries `sources`, primary first, then
any source the series has been followed on (`POST /api/admin/series/:id/sources`, below); each entry is
`{sourceId, name, sourceSeriesId, primary, checkedAt, chapters, registered, auto}`, where `registered` says
whether that adapter is loaded right now and `auto` whether the add-time auto-follow chose it rather than a
person (always `false` for the primary; a person confirming the same source through a plan turns it
`false`). Admins additionally get `scanlatorPrefs`: the series' own release
preferences, or `null` when it has none and the server-wide ones apply. Every chapter object (this route's
`books`, `GET /api/books/:id`, `next`, the home shelves) carries `scanlator` — the group that released the
file on disk, as the source showed it, a joint release reading `"A & B"` — and `sourceId`, the adapter it was
downloaded from. Both are `null` for a chapter the scanner found rather than the downloader wrote, which
includes everything downloaded before v0.31.0. The same group name is written into the file's
`ComicInfo.xml` as ``.
Two more flags on every chapter object since v0.32.0: `owned` — the file lives in the download directory,
so it is one this server fetched and could fetch again (the only chapters the delete and re-fetch actions
below will touch) — and `pruned` — the file was deleted by the read-chapter cleanup or by an admin, and the
row is a tombstone: reading progress is still attached, but there are no pages behind it. A pruned chapter
is listed by `GET /api/series/:id/books` (with the flag) and skipped everywhere a chapter is *served*:
`next`, Continue reading, the OPDS feed, the offline plan; its download manifest answers **410** `pruned`.
**Chapters the sources have that you don't.** `GET /api/series/:id/listing` answers
`{checkedAt, content: [Ghost]}`: every chapter number the series' sources listed at the last check (the
sweep, or **Check now**) that this server has no row for, each with the reason —
`Ghost = {number, title, publishedAt, scanlator, groups, sourceId, sourceName, why, attempts?, reason?,
waitingFor?, waitDaysLeft?}`, `why` one of `missing` (not fetched yet), `held` (waiting for a preferred
group under the release preferences), `failed` (the sweep gave up after the retry cap; `attempts` says how
many tries), `blocked` (only blocked groups have released it), `floor` (below the series' Latest-N floor).
A `held` ghost also carries `waitingFor` (since v0.34.0) — the effective first-choice group it is being
held for, the series' own priority over the global one, minus anything blocked — and `waitDaysLeft`, the
whole days until the patience window closes, counted as the sweep counts it: from the oldest hosted copy
that is not from a blocked group, under today's preferences (so it can read 0 on a row the last sweep held
before a preference change; both are absent when no priority group survives the blocklist). The listing is read from what the updater
persisted, never from the sources on a page open, so `checkedAt` is how old the answer is; a source that
failed to answer leaves the previous listing standing. `reason`, the downloader's last error text, is
present for admins only. A tombstone is a row, so it is never a ghost.
**Translated by.** `GET /api/series/:id/groups` answers `{checkedAt, content: [GroupStat]}`, one entry
per scanlation group, sorted by releases descending:
`GroupStat = {name, releases, first, last, lastReleaseAt, cadence: {kind, intervalDays, daysSince, quiet},
onDisk, chapters, langs, weeks}`. `releases` counts the chapters the group released — distinct numbers across every
followed source, so a second copy of a number (a follower listing it too, a re-upload) is not a second release,
while a joint release counts once for each of its groups; `chapters` are the numbers it released, ascending, with
`first`/`last` the ends of that list; `onDisk` is how many live chapters on this server are stamped with the
group; `langs` the languages its copies are in. `cadence.kind` comes from the median gap between the group's
last ten release *days* (a day with several chapters is one release day, so a group that ships two at a time
is weekly, not daily) — `daily` (≤ 1.5 days), `weekly` (≤ 9), `monthly` (≤ 40), `irregular`, or `unknown`
with fewer than two distinct dated days — and `quiet` is true when the silence since `lastReleaseAt` exceeds
three intervals (never less than 14 days), or 45 days when the rhythm is unknown. `weeks` (since v0.34.0)
is twelve booleans, oldest first, newest last — index 11 is the seven days ending now — true for each week
the group released in, from the same dates; the series page draws them as an activity strip. Groups are
merged by the same equality the release rules use; the name shown is the first spelling seen on disk, else
the first listed. Since v0.33.0 the listing keeps *every* copy the sources list, not only the chosen one,
and this is read from those copies plus the file stamps — never from the sources on a page open, so
`checkedAt` is how old the answer is. Any account that can open the series may read it; ranking and blocking
is the admin route below.
**Chapter versions.** `GET /api/series/:id/versions` answers `{checkedAt, content: [{number, copies: [Copy]}]}`
for every listed number, `Copy = {key, source, sourceName, groups, scanlator, lang, pages, publishedAt,
chosen, blocked, onDisk}` — `key` is `:`; `chosen` marks the copy the release rules picked
at the last check; `blocked` means every group on the copy is blocked by the effective preferences (a copy
with no groups is never blocked); `onDisk` is best effort — a live chapter for the number came from the same
source with the same group stamp, or, for a file with no stamp, this is the chosen copy. A `source` and
`sourceId` from here make a *pick* (below). A number listed before v0.33.0 has `copies: []` until its next
check.
### Sources
```
GET /api/sources GET /api/sources/find
GET /api/sources/detail GET /api/sources/search
GET /api/sources/search-all GET /api/sources/latest
GET /api/sources/jobs POST /api/sources/add
GET /api/discover/trending POST /api/sources/fill/scan
POST /api/sources/fill POST /api/sources/fetch
```
**Filling a series' gaps.** `POST /api/sources/fill/scan` takes `{seriesId, altTitle?}` and answers with what
is missing, a short-lived `planId`, and every source that was checked — including the ones it refused, with
the reason and the measured overlap. `POST /api/sources/fill` then takes
`{planId, source, sourceSeriesId, numbers[]}`.
The split is deliberate. Chapter URLs never leave the server: the client names chapter NUMBERS, and only ones
that the quoted plan actually offered for that source. A chapter fetched from the wrong series would land as
`Chapter .cbz` exactly where the right one belongs and look identical in every listing, so nothing is
fetched until a person has been shown which source, which title on it, and how many chapters.
The scan's answer also carries `following`: the source ids the series is already followed on, so a client
can mark a candidate as followed instead of offering to follow it twice (present on the `too_few_chapters`
early answer as well). Each candidate's `count`, `first` and `last` describe one copy per chapter number,
chosen under the series' release preferences with the patience switched off, so a fill of `[n]` lands one
file even when the source lists chapter `n` from three groups. `GET /api/sources/detail` counts the same
way, under the server-wide preferences, so the "120 chapters" the add dialog shows is the 120 the add would
land and not the 200 rows the source listed. Since v0.33.0 the detail also carries `groups: [GroupStat]`
(who translates it, from the same chapter list — no second source call, `onDisk` 0) and `versions`, how many
numbers the source lists in more than one copy. Its `summary` is plain text: HTML and Markdown are stripped
(MangaDex describes in Markdown), link text kept. The same strip is applied to the description an add
writes into the ComicInfo and, since v0.34.0, to every series' `metadata.summary` on the way out of
`GET /api/series/:id` and the listings — so a series added before v0.34.0 whose stored summary still holds
`**Year:** 1997 ---` reads clean without a migration.
**Fetching ghost chapters.** `POST /api/sources/fetch {seriesId, numbers?[], picks?[]}` (at least one of
the two, at most 300 combined; numbers 0–1,000,000) fetches chapters from the listing above. What authorises a fetch is the *listing*: a client
names chapter numbers, and only a number the sources list has anything to fetch from — the same footing
as the fill plan, and for the same reason (no chapter URL ever crosses the wire). The listing is refreshed
first (a check with no downloads), so what is fetched is the copy the release rules choose *now* — a group
ranked a minute ago counts; a source that does not answer leaves the last listing standing. A `held`
number is fetched regardless of patience, because a person clicking Fetch on a "waiting for group B" row
is saying they will take it, but the blocklist is never ignored — a `blocked` number has no copy to fetch;
unblock the group and check again. A manual fetch resets the chapter's retry cap. The answer is
`{ok, started, folder, total, skipped: [{number, reason}]}` with `reason` one of `not_listed` (run
**Check for new chapters** first), `blocked_group`, `already_here` (a live chapter, not a tombstone),
`source_unavailable` (adapter not loaded or disabled, or a source the series no longer follows), `cooldown`;
**409** `nothing_to_fetch` (with `skipped`) when nothing is fetchable, **409** `busy` while a download for
that series is running — since v0.37.0 that includes the series a bulk *Fetch newest* run is currently
inside, for that one series and only while the run is on it (the same test guards `/api/sources/fill` and
the admin `chapters/refetch`); the rest of the library is not locked — **404** for a series the caller
cannot see. Same permission gate as the fill:
`canDownload: false` is refused by the whole `/api/sources` surface, and a source outside the account's
age cap answers **403**. Progress is on `GET /api/sources/jobs` under the series' `folder`.
**Picks — fetching one specific version.** `picks: [{number, source, sourceId}]` names copies out of
`GET /api/series/:id/versions` instead of numbers. A pick is authorised by a matching entry among the
number's stored copies — `not_listed` otherwise, and the skipped entry then carries the `source` and
`sourceId` asked for — and its source must be followed and available (`source_unavailable`, `cooldown`)
exactly as for a number. What a pick does *not* go through is the group rules, **the blocklist included**:
the blocklist governs what the sweep takes on its own, the versions list labels a copy `blocked`, and a
person who taps Fetch on it anyway has chosen that one copy on purpose. What a pick never overrides is
`already_here`: a live chapter for the number is replaced by the admin's re-fetch, not by a member naming
another copy. A number named in both lists is fetched as its pick; a second pick for the same number is
skipped as `duplicate` (the first was handled, this one was not). The audit line (`series.chapters_fetch`)
carries `picks`.
### Bulk actions
```
POST /api/library/bulk/read POST /api/favorites/bulk
POST /api/collections/:id/items/bulk
POST /api/library/bulk/newest GET /api/library/bulk/newest
```
The first three take `{ seriesIds: [...] }`, up to 500. An id that no longer exists is reported in `skipped`
rather than failing the batch. Marking read deliberately writes no reading events, so importing a backlog
does not inflate streaks or the leaderboard.
**Fetch newest** (since v0.37.0) is the one bulk action that fetches bytes from sources, so it is a detached
job rather than a request that waits, and it carries the download gate the whole of `/api/sources` sits
behind. `POST /api/library/bulk/newest {ids: [...]}` — 1 to 500 series ids of 1–64 characters, duplicates
counted once — starts it and answers **202** `{ok: true, total}` at once (`total` = distinct ids asked). A
member whose `canDownload` permission is off (or whose account row cannot be read — it fails closed; admins
are exempt, an absent permission allows) gets **403** `forbidden` *You don't have permission to download
chapters.*; a body that is not `ids` (the old `seriesIds` key included) is **400** `bad_request` *ids: one to
five hundred series ids.*; while a run is going, **409** `busy` *A Fetch newest run is already going. Wait
for it to finish.* — one run per server, never queued. Ids the caller cannot see (hidden, merged, in a
library they are not granted, nonexistent) are not an error: they go into the run as `skipped` *Not in your
library.* and count towards `total`. One `download.bulk_newest` audit row per start, `{count, asked}`. The
run is not refused while the nightly sweep is going (a sweep can take hours); the downloader skips a file
already on disk, so the worst overlap is one listing asked twice.
The rule, per series: take the **newest release the series' sources list** (the maximum across every
followed source, chosen by the release preferences) and nothing else. If a live row holds it the series is
`up_to_date`; if the shelf holds it only as a tombstone the read-chapter cleanup, *Delete from server* or
*Delete files* left (`pruned_reason` NULL or `'deleted'`, no live row beside it) the series is `skipped` —
those bytes went by someone's decision, and the reason names the way back — while a `'missing'` tombstone
from the verify task is not held and is fetched; otherwise that one number is fetched with the series'
*latest N* floor ignored for it alone (the floor is never moved, and nothing below it is fetched, so a
caught-up Latest-N series answers up to date rather than back-filling), the retry-cap ledger for that number
cleared first as the series page's *Fetch* does. A number held for a preferred group is honoured, not
overridden (a bulk button is not a per-copy pick); a source the account's age limit excludes, a source in a
cooldown, and a source listing nothing are each skipped with their reason. A source the admin disabled is
never asked for its listing: a series whose every source is disabled is skipped at once with no network
call, and one that also follows a live source is asked on that one only. A file already on disk without a
row is scanned in and reads up to date. Only after a source was actually asked does the job pause the
sweep's 1.5 s before the next series — ids not in the library, disabled sources and cooldowns are not
paced; one library scan runs at the end, then dates and provenance are stamped on every landed chapter.
While the run is inside a series, that series' folder reads busy to `POST /api/sources/fetch`,
`/api/sources/fill` and the admin `chapters/refetch` (**409** `busy`), and to nothing else.
`GET /api/library/bulk/newest` always answers **200** `{running, done, total, startedAt, results:
[{id, title, outcome, reason?}]}` — the current run, or the last one (in memory: a restart forgets it; before
any run everything is zero, `startedAt` null, `results` empty). `outcome` is `downloaded`, `up_to_date`,
`skipped` or `failed`; `reason` is a sentence, present on every outcome but `downloaded` (*Chapter 12 is
already here.* — a live row holds it; *Chapter 12 was already on disk and is in the library now.*; *Chapter
12 was deleted from this server on purpose. Fetch again on the series page brings it back.* — `skipped`, the
newest chapter is a cleanup or Delete-files tombstone with no live row, nothing is fetched and the tombstone
is untouched; *Chapter 12 is being held for the preferred group. Pick a copy on the series page to take it
now.*, *Its source is disabled by the admin.*, *That source is not available on this account.*, *Its source
lists no chapters.*, *No source is installed for this series.*, *Its source is in a cooldown. Try again
later.*, *Not in your library.*, *A download for that series is already running.*, *The server is shutting
down.*, *Its source did not answer.*, *Chapter 12 could not be saved. The Health page has the details.*, *The
library disk is full.*); `title` is `""` for an id outside the caller's library. `results` are in the order
the ids were given, which is the order they were started, and they are returned only to the account that
started the run and to admins — every other member gets the counts with `results: []`, since a title from a
library they were not granted must not leak through someone else's selection. A server shutdown ends the run
between series; the rest read `skipped` *The server is shutting down.*
### Personal
```
GET /api/favorites POST /api/favorites
DELETE /api/favorites/:seriesId GET /api/history
GET /api/stats GET /api/wrapped
GET /api/settings PUT /api/settings
GET /api/collections POST /api/collections
GET /api/collections/:id PATCH /api/collections/:id
DELETE /api/collections/:id POST /api/collections/:id/items
PUT /api/collections/:id/items DELETE /api/collections/:id/items/:seriesId
GET /api/notes GET /api/notes/:seriesId
POST /api/notes
PATCH /api/notes/:id DELETE /api/notes/:id
PUT /api/ratings/:seriesId DELETE /api/ratings/:seriesId
GET /api/tokens POST /api/tokens
GET /api/bookmarks PUT /api/bookmarks/:bookId/:page
DELETE /api/bookmarks/:bookId/:page
DELETE /api/tokens/:id POST /api/opds/token
GET /api/opds/token DELETE /api/opds/token
PATCH /api/opds/token
GET /api/trackers POST /api/trackers/anilist
POST /api/trackers/:provider/connect
POST /api/trackers/anilist/backfill
POST /api/trackers/:provider/resync/:seriesId
DELETE /api/trackers/:provider
GET /api/push/key POST /api/push/subscribe
POST /api/push/unsubscribe
```
**Progress trackers.** `GET /api/trackers` is the caller's own connections, every provider listed connected
or not; `POST /api/trackers/:provider/connect` takes a pasted token and `DELETE /api/trackers/:provider`
drops it. A push goes out for the caller alone when they finish a chapter of a linked series, never below
the floor `tracker_progress` holds for them (see the reviewable import under Admin, `/run`). Only a **401**
from the service — or AniList's "Invalid token" **400** — is a verdict on the token, and disables the
connection with `last_error` = `the tracker rejected the saved token -- reconnect to resume syncing`; a
**403** (Cloudflare in front of AniList, MyAnimeList's request block, a forbidden Kitsu action) is recorded
as a plain sync error and retried on the next chapter, the connection kept. A token past its `expires_at`
is not sent: `last_error` reads `the access token has expired -- reconnect to resume syncing`, connection
kept. `POST /api/trackers/:provider/resync/:seriesId` is the one deliberate way **down**: it deletes the
caller's floor for that series on **that provider** only (`anilist`, `myanimelist` or `kitsu` — any other
name is **404** `unknown_provider`) and pushes the current local count at once, lower or not. No web
control calls it; the app's own repair for a floor the tracker has since corrected is to read the list
again. A local count that drops below a number this app already sent is refused with `last_error` =
`not syncing: this series now works out to chapter N, below the M already sent. Import your list again under
Admin → Import (From your tracker) to take the tracker's current number, or ask an admin to.`
### Offline downloads
```
GET /api/downloads POST /api/downloads
DELETE /api/downloads/:bookId GET /api/books/:id/download-manifest
```
### Admin
```
GET /api/admin/stats GET /api/admin/health
GET /api/admin/settings PATCH /api/admin/settings
GET /api/admin/install-ping/preview
GET /api/admin/users POST /api/admin/users
PATCH /api/admin/users/:id DELETE /api/admin/users/:id
GET /api/admin/sessions DELETE /api/admin/sessions/:id
GET /api/admin/audit GET /api/admin/tasks
POST /api/admin/tasks/:id/run POST /api/admin/library/scan
POST /api/admin/update POST /api/admin/update/:id
GET /api/sources/popular GET /img/sources/icon/:id
DELETE /api/sources/jobs/:folder
GET /api/admin/sources POST /api/admin/sources/:id/:action
POST /api/admin/sources/:id/test
POST /api/admin/sources/check
POST /api/admin/sources/reload GET /api/admin/sources/custom
POST /api/admin/sources/custom DELETE /api/admin/sources/custom/:id
PATCH /api/admin/sources/custom/:id
PUT /api/admin/series/:id/art PUT /api/admin/series/:id/meta
PATCH /api/admin/series/:id DELETE /api/admin/series/:id
POST /api/admin/series/bulk/hide
GET /api/admin/series/:id/scanlators GET /api/admin/scanlators
POST /api/admin/series/:id/sources DELETE /api/admin/series/:id/sources/:sourceId
GET /api/admin/libraries POST /api/admin/libraries
GET /api/admin/libraries/preview
GET /api/admin/libraries/folders
PATCH /api/admin/libraries/:id DELETE /api/admin/libraries/:id
POST /api/admin/series/:id/library
POST /api/admin/series/library
GET /api/admin/library/writable
POST /api/admin/series/:id/delete-files
POST /api/admin/series/:id/forget
POST /api/admin/series/:id/rename-folder
POST /api/admin/series/:id/chapters/delete POST /api/admin/series/:id/chapters/refetch
PUT /api/admin/books/:id/meta
POST /api/admin/series/:id/restore
POST /api/admin/series/:id/merge
GET /api/admin/series/deleted
POST /api/admin/series/:id/check
GET /api/admin/series/:id/check
GET /api/admin/art/overview GET /api/admin/art/candidates/:id
POST /api/admin/art/backfill GET /api/admin/art/backfill/status
POST /api/admin/trackers/relink GET /api/admin/trackers/relink/status
POST /api/admin/import POST /api/admin/import/parse
GET /api/admin/import/status
GET /api/admin/import/batches POST /api/admin/import/batches
GET /api/admin/import/batches/:id DELETE /api/admin/import/batches/:id
POST /api/admin/import/batches/:id/resume
POST /api/admin/import/batches/:id/run
PATCH /api/admin/import/candidates/:cid
```
**Server settings.** `GET /api/admin/settings` is the one row: `server_name`, `allow_registration`,
`updater_hours`, `extension_hours`, `extension_auto_update`, `update_check`, `install_ping`, `install_ping_last`,
`cleanup_read`, `cleanup_read_days`, `backup_hour`, `scanlator_prefs`, plus `extensions_configured` (computed). `PATCH
/api/admin/settings` takes any subset of `serverName` (1–64 chars), `allowRegistration`, `updaterHours`
(1–168), `extensionHours` (1–168), `extensionAutoUpdate`, `updateCheck`, `installPing`, `cleanupRead`,
`cleanupReadDays` (0–3650; 0 is a value, "at the next run"), `backupHour` (0–23, the local hour of the nightly
backup — the pending timer is re-armed at once, so the change applies to the next run rather than the one
after; `GET /api/admin/tasks` shows the backup's `schedule` as `daily at HH:00` from the same column) and
`scanlatorPrefs` (below). Each field is written on its own, an out-of-range value is a **400** and nothing is
written, and the audit row `settings.update` carries the body. The admin console's Settings tab sends one
row per PATCH as each row is changed (the read-chapter confirmation carries the day count with the switch).
The bulk importer's body takes `titles`, `autoUpdate`, `chapterCount` and `chapterFrom`, with the same
meaning as on `/api/sources/add` (`chapterFrom: "newest"` takes the latest N and floors the series; the
importer accepts `oldest` and `newest` only — `none` is the add dialog's).
**Reviewable import** (`/api/admin/import/batches*`, `/api/admin/import/candidates/:cid`) is the same idea
with a match-review step in between, and is what the admin UI uses — the plain importer above adds the
first cross-source hit with no review and stays for scripted callers. `POST .../batches` takes the same
`dataUrl`/`mangadexList`/`titles` intake as `/api/admin/import/parse`, starts matching in the background
(one batch resolves at a time server-wide) and returns `{batchId, total, truncated, skippedNovels}`. A
fourth intake, `{origin: 'tracker', tracker: 'anilist' | 'myanimelist' | 'kitsu', statuses?: ('reading' |
'plan_to_read' | 'completed' | 'on_hold' | 'dropped')[]}`, reads the requesting admin's OWN connected
account (the connection `GET /api/trackers` shows for them, never another member's; `statuses` defaults to
reading + plan_to_read) — up to 501 entries, of which the batch keeps 500 (`truncated` when the read hit the
cap); light novels are dropped and counted as `skippedNovels` — both figures are also stored on the batch
row, as `skippedNovels` and `truncated` on every batch `GET` returns; entries are deduped by their id and
by the normalised form of every name they go by, and each row carries `tracker`, `external_id`,
`alt_titles` (romaji and synonyms, at most three, never an abbreviation whose normalised form is shorter
than five characters — "AoT", "SnK", "MHA" — since such a term contains-matches almost any title) and
`progress`. The resolve pass searches the English search title on every source first, then the first
alternate on every source, and so on, so an exact hit for the title on a later source beats a weaker hit
for an alternate on an earlier one; a row matched under an alternate records it as `matched_via`. A
tracker row whose title the library already holds (under the search title or any alt — `matched_via` says
which alt, when one did) is linked at intake, for the requesting admin, and starts `decision: skip`,
`status: already`; a series that is deleted (`deleted_at`) never counts as held, for any intake, so such a
title resolves and `/run` puts the same series back — whereas a title merged into another **is** held,
under its survivor's id (since v0.37.0): the row reads `already`, and the tracker link and floor land on
the series that holds the chapters, not on the absorbed row. Merging is transitive — a title absorbed two
merges ago is re-pointed at the final survivor in the same transaction as the second merge — so a backup or
tracker entry with that spelling still reads `already` rather than being re-added via another source (a
survivor hidden since is the deleted case above under another name). Its errors: **404**
`not_connected` (no enabled connection to that tracker), **422** `token_expired` (the connection's
`expires_at` has passed: the service is not called, the connection stays enabled, and `last_error` on it
reads `the access token has expired -- reconnect to resume syncing`, the sentence a push leaves), **422**
`tracker_rejected` (the service refused the saved token; the connection is disabled with the same sentence
a rejected push leaves — 422 rather than 401 because a 401 is retried after a session refresh and would
then read `not_connected`; the same code, without disabling, when the stored token cannot be unsealed),
**502** `tracker_unavailable` (no answer; nothing changed — a 400 or a 403 from the service never disables
anything: only a 401, or AniList's "Invalid token" 400, is a verdict on the token).
`GET .../batches/:id` polls `{batch, items}` — each item's `decision` is `unresolved | auto | manual | skip`
and, once the batch leaves `resolving`, an `unresolved` row means "no match found" rather than "not looked
at yet"; a tracker row reads `linked: true` when a `series_trackers` row carries its id (read live, not
remembered). A row's
`confidence` is `same_source` only when the backup entry's own Mihon source is installed here (a Suwayomi
extension) and a hit's extension-relative path equals the url the backup stored -- Mihon's identity for a
manga, which survives a retitle -- otherwise `exact | contains | fuzzy` from the title alone; a title with no
confident hit anywhere stays `unresolved`, and the first search result is never taken. `PATCH
/api/admin/import/candidates/:cid` accepts `{decision:'manual', source, sourceId, title, coverUrl?}` to
override a pick — this clears `matched_via`, since a hand-picked match was found by nobody's alternate —
`{decision:'skip'}`, or `{decision:'auto'}` to restore the resolve pass's own suggestion after an override
(`matched_via` is left as it is, so an auto → manual → auto detour loses the note); skipping the last open
row of a batch a run has been through closes the batch (`done`).
`POST .../batches/:id/run` takes `{candidateIds?, autoUpdate?}` — with `candidateIds` it adds only those
rows (a skipped or still-unresolved id is silently left out rather than erroring; an entry that is not a
uuid, or more than 500 of them, is **400** `bad_request`), omitted means every eligible row in the batch.
Each row's `status` afterwards is `added`, `already` (the library has the title — the same folder, or the
same title from another source under a spelling the up-front `in_library` check missed) or the add's error
code (`no_title`, `no_chapters`, `disabled`, `blocked`, `undownloadable`, `disk_full`, `bad_request`, `error`);
`already` counts under the batch's `already`, an error code under `failed`. A tracker row that ends `added`
or `already` is linked to its tracker entry (`series_trackers`, `linked_by` = **the account whose list was
read**, the batch's `user_id` — batches are shared between admins, and whoever calls `/run`, the link and
the floor are the owner's; the caller appears only in the `import.batch.run` audit row, whose `detail`
carries `owner`) and the owner's `tracker_progress` for it is set to the entry's `progress` with
`pushed_at` NULL — set, not raised: every read of the list replaces the floor with the tracker's current
number and clears the stamp, whatever this app had pushed before, so re-reading the list is how a downward
correction made on the tracker reaches this app. A push then goes out once the local count **exceeds** the
floor; equal to an unstamped floor is skipped quietly (the tracker already holds that number, and a push
would say `CURRENT` over a `COMPLETED` entry), equal to a stamped one — a number this app sent — still
pushes. A `progress` of 0 seeds nothing. Every add is
"nothing yet": the
series is created and followed, no chapter is downloaded, matching the bulk-select UI's promise that "Import
selected" only moves titles into the library. Safe to call again later on the same batch — a row already
imported is never re-added, which is how importing the matched rows now and the rest (found by hand
afterwards) later both work; the batch state reads `review`, not `done`, while anything importable is still
waiting; the flip to `importing` is one conditional UPDATE, so two simultaneous calls import once (the other
answers **409** `busy`). `DELETE .../batches/:id` discards a batch outright and stops a resolve or add loop
still running for it before its next row; no batch is ever written as `cancelled`. A batch left `resolving`
by a server restart reads back with `stale: true`; `POST .../batches/:id/resume` restarts matching for
whatever is still unresolved (the progress counter restarts from the rows already settled) and takes the
one-batch guard before looking the batch up, so a `/resume` and a `POST .../batches` at the same instant
start one loop (the other answers **409** `busy`). One left `importing` by a restart is handed back on the
next GET -- `review` with its unreached rows still ready, or `done` when every row had been processed; a
`review` batch a run has been through with nothing left waiting (every remaining row skipped) is closed to
`done` on GET as well, while one nothing was ever imported through (every title already owned) stays
`review`. `GET /api/admin/import/batches` lists every batch newest first (`{content: [{id, origin, tracker,
state, total, resolved, added, already, failed, skippedNovels, truncated, created_at, updated_at, stale}]}`,
no rows; `tracker` names the service a tracker batch was read from, null otherwise; `skippedNovels` (the
novels a tracker read dropped, 0 for the other origins) and `truncated` (the intake kept 500 of a longer
list, or the tracker read hit its cap) are the intake's own answer, kept on the row so a later view of the
batch — `GET .../batches/:id` carries them on `batch` too — still shows them; `stale` as on the single
GET, so the "Open imports" card can call an interrupted batch interrupted rather than matching); a batch is
swept seven days after it last changed once `done`, thirty days while still open. A batch or candidate id
that is not a uuid answers **404**. A gzipped backup that inflates past 256 MB (no real one does) is
**422** `parse_failed` like any unreadable file.
**Scanlation groups.** When a source lists the same chapter from more than one group (MangaDex does, and
so do extension sources that carry Mihon's scanlator column), the server keeps one file per number and the
choice is made by the release preferences: `{priority: [...], blocked: [...], patienceDays}`, group names
compared case-insensitively with spaces and punctuation ignored. The server-wide set is
`scanlator_prefs` on `GET /api/admin/settings`, written whole through `PATCH /api/admin/settings
{scanlatorPrefs}` (`priority` up to 50 names, `blocked` up to 200, `patienceDays` an integer 0–30 or
`null`; the default is nothing ranked, nothing blocked, two days). A series can carry its own through
`PATCH /api/admin/series/:id`, whose body is now `{autoUpdate?, scanlatorPrefs?}` — at least one, no other
fields, each written on its own, and `scanlatorPrefs: null` clears the series' set. The two merge:
**blocked is the union**, a series **priority replaces** the global list, and a series `patienceDays` of
`null` **falls back** to the global one. A copy whose known groups are all blocked is dropped before the
choice is made — a joint release survives while any group on it is unblocked, a copy naming no group is
never blocked — so a number that only blocked groups have released is absent from the list altogether:
neither fetched nor counted as missing. A series only ever *waits* for a group when its effective priority
list is non-empty: with none, the best available copy is taken at once, so a series from a source that
names no groups is never held.
`GET /api/admin/series/:id/scanlators` is what the series page's editor reads: `{checkedAt, prefs, global,
effective: {priority, blocked, patienceDays}, groups: [GroupStat & {listed}]}`, the groups gathered from the
live files on disk, from every copy in the listing the updater persisted at the last check (primary and
followed sources alike — never from the sources themselves on a page open, so `checkedAt` is how old the
figures are), and from the names already in the preferences (so a blocked group that has vanished from the
listing can still be unblocked — as a row of zeros), sorted by `onDisk + listed` and then by name. Each entry
carries exactly the figures `GET /api/series/:id/groups` answers (one aggregator over the same rows, so the
editor is the panel with buttons); `listed` is kept and equals `releases`.
`GET /api/admin/scanlators` is the library-wide version the Settings page's group picker reads:
`{content: [{name, onDisk, listed, series}]}`, every group this server knows of, busiest first — the names
gathered from the files on disk and from the persisted listings of every source (not from the sources
themselves; this is one call for the whole library), merged by the same group equality the release rules
use, `series` counting the series the group appears on. Memoised for 30 seconds.
**Deleting a chapter from the server, and fetching it again.** Both are admin actions on chosen chapters,
and both touch the download directory only: a chapter the scanner found in the read library is never
touched (`owned: false` on the Book), on the same footing as the read-chapter cleanup. Neither takes a
typed confirmation; the client confirms with the count.
`POST /api/admin/series/:id/chapters/delete {bookIds[]}` (1–500) deletes the files and keeps the rows as
tombstones, so reading history survives and the updater does not re-download them; the cover moves to the
lowest live chapter. A chapter somebody has a bookmark in is skipped (`bookmarked`), as the cleanup skips
it: a bookmark names a page inside the file. A chapter whose file *and* folder are missing is skipped
(`unlink_failed`) rather than marked — that is the download volume not being mounted, not a deleted
chapter. It answers `{ok, applied, bytes, skipped: [{id, reason}]}` with `reason` one of `not_found`,
`not_owned`, `already_pruned`, `bookmarked`, `outside_root`, `unlink_failed`, or **409** `refused` (with
`message` and `fix`) when the download directory is not writable.
`POST /api/admin/series/:id/chapters/refetch {bookIds?[], picks?[]}` (at least one, at most 300 combined)
downloads the chapters again as the copy the release rules choose *now* — after a change of priority, or a follow, that may be another group's —
onto the **same rows**, so progress stays attached. Only a row under the download folder (`not_owned`
otherwise — a read-library chapter is not Uchiyomi's to re-fetch, so a *Delete files* tombstone there has no
way back but a hand copy) at exactly the path the downloader writes (`Chapter .cbz` in the series
folder; `not_ours` otherwise) is eligible, because only that path lands back on the same row. This is also
the only path back for a tombstoned chapter below a series' `chapter_floor`, which the sweep never wants
and *Fetch newest* takes only if it is the newest listed. The listing is refreshed first, as for `POST /api/sources/fetch`, so the copy is the
one the rules choose *now*, with the same `not_listed` / `blocked_group` / `source_unavailable` (including
a source the series no longer follows) / `cooldown` skips; the old file is set aside until
the new one lands and put back if the download fails, so a failed re-download never costs the chapter that
was there. A chapter the cleanup deleted is eligible: this is how it comes back. Answers
`{ok, started, folder, total, skipped}`, **409** `nothing_to_fetch` / `busy` / `refused` as above.
`picks: [{bookId, source, sourceId}]` replaces a row's file with *one named copy* out of the number's
versions instead of the rules' choice ("this chapter, but group B's version"): the row's number selects
the listing row, the pick selects the copy in it (`not_listed` when none matches), and as on
`POST /api/sources/fetch` a pick ignores the group rules including the blocklist — an explicit choice. A row
named in both lists is fetched as its pick, a second pick for the same row is skipped as `duplicate`; the
audit line carries `picks`.
**Removing a series, and what each step keeps.** `DELETE /api/admin/series/:id` hides: `deleted_at` is set,
the tracker link dropped, and every chapter row, file, progress row, favourite and rating stays; **400**
`already_deleted` for a hidden one, **400** `merged` for a series merged into another (a merge is one-way —
there is no un-merge, and the absorbed row can neither be hidden nor have its files deleted; see
`/merge`). `POST /api/admin/series/:id/restore` undoes it. `POST /api/admin/series/bulk/hide {ids: [...]}`
(since v0.37.0; 1 to 500 ids of 1–64 characters, duplicates once) is that same single delete once per id —
the Library page's *Remove from library* over a selection — and **only** that: it never touches files. It
answers `{ok: true, hidden, skipped: [{id, reason}]}` with `reason` one of `merged`, `already_hidden`,
`not_found`; an id that cannot be hidden is skipped with its reason and the rest still apply. One
`series.delete` audit row per hidden series, carrying `{id, title, books}`, exactly as the single route
writes it, nothing for skipped ids; **400** `bad_request` *Which series should be removed?* for a body that
is not `ids`. `GET /api/admin/series/deleted` lists the hidden ones, newest first, and since v0.37.0 each row
carries `live_books` and `pruned_books` (counted from the chapter rows; `books_count` is the scan's figure
and may be stale) — `live_books === 0 && pruned_books > 0` is how the panel knows the files are already gone.
`POST /api/admin/series/:id/delete-files {confirm}` — `confirm` is the series' title, compared trimmed and
NFC-normalised on both sides (a macOS-written NFD title is confirmed by an NFC keyboard), **400**
`confirm_mismatch` otherwise — is the irreversible step and only ever after the hide: it removes the series'
chapter files from every root it occupies (the read library included, which is why it takes the typed
title) and keeps every row. Since v0.37.0 each row whose file it actually removed is marked
pruned with `pruned_reason = 'deleted'` — the same tombstone `chapters/delete` and the cleanup leave — so a
restore afterwards lists those chapters as `pruned` (*Deleted from the server*, where `chapters/refetch`
brings one back onto the same row — for the rows under the download folder; a read-library row is
`not_owned` there and its file is the admin's to put back) instead of as openable chapters that 404, the updater's have-set keeps
counting them as held, and `files` in the answer `{ok, files, bytes}` counts real unlinks, not rows (a
second call reports 0). A row whose file is already absent is reconciled only when the root is provably
mounted — `stat(root)` works, at least one chapter file of any series is present under it (a present folder
is not proof: the downloader `mkdir -p`s series folders on a bare mount point), and no more than 90 % of
what was looked at is absent, the verify task's own rule; up to 200 other series' live rows on the root are
stat'ed when none of this series' own is present. Under that proof a live row with no file is marked
`pruned_reason = 'deleted'` and a `'missing'` tombstone becomes `'deleted'`; on an unproven root every row
is left as it was, so an unmounted share leaves live rows, which Forget refuses on. `files` still counts
unlinks only, so a series whose folder was removed by hand answers `files: 0` and its Removed row then
offers Forget (`live_books` 0). Delete files on a merge survivor also removes the folders of the rows
merged into it, and every folder is resolved on every root before anything is unlinked. It refuses,
**409** `refused` `{message, fix}`, rather than half-applying: the series is not hidden yet, it has no
chapter rows on any root (*That series has no files on disk.*), a folder resolves outside the library, or a
root is not writable (`PUID`/`PGID` unset; `fix` names it). Nothing here deletes a series row or a chapter
row: `read_progress.book_id` is `ON DELETE RESTRICT` on purpose. The one route that does is the third step
below.
**Forget: Remove → Delete files → Forget.** `POST /api/admin/series/:id/forget {confirm}` (since v0.38.0;
`confirm` is the series' title, compared trimmed and NFC-normalised on both sides — **400**
`confirm_mismatch` *Type the series title exactly to confirm.* otherwise, **400** `bad_request` without a
body) is the only call that hard-deletes a
series row. It erases every member's progress, reading events, bookmarks, notes, ratings, favourites, tracker
floors and collection entries on it, so stats, streaks, the leaderboard and Wrapped change retroactively, and
answers `{ok: true, books, absorbed, users}` — chapter rows erased, series rows that had been merged into
this one and went with it, and distinct members who lose history: progress, reading events, bookmarks,
notes, favourites, ratings, collection entries, or a tracker floor that is erased rather than carried to a
merge survivor (opening the series page, the NEW-badge counter, does not count). Rows the series absorbed
by merge are forgotten with it in the same transaction (leaving them would flip them live: `merged_into` is `ON DELETE
SET NULL`). History on chapters that moved to a merge survivor is kept under the survivor: every per-user
table is re-pointed to the chapter's current series first, deletes are keyed on the chapter ids this series
actually owns, and if a progress row or bookmark on another series' chapter is still filed here after that
the whole transaction rolls back — **409** `refused` `stranded`, nothing changed. It refuses, **409**
`refused` `{message, fix}`, in exactly four other cases: `live`, while the series is still in the library
(*Remove the series first. Forgetting it is a third, separate step.*, fix *Content → Library → Remove, then
Delete files, then Forget.*); `live_books`, while any chapter row still claims a file (*N chapter row(s)
still claim(s) a file on disk. Delete the files first, or the next scan brings the series back under a new
id with none of its history.*, fix *Delete files, then Forget.*); `missing_files`, only for a root that
cannot be stat'ed (* is not there right now, so nothing can be checked against it.*, fix *Mount the
library and delete the files first.*); and `folder_present`, while the folder still holds chapters under
any root (*The folder "…" still holds chapters under . Forgetting the series now would only have the
next scan bring it back under a new id, with none of its history.*, fix *Delete files first, or remove the
folder by hand and rescan.* — an empty folder does not refuse, since the scanner never turns one into a
series). A `'missing'` tombstone from the verify task does not refuse: nothing in Uchiyomi marks a chapter
row whose file it cannot see — verify refuses a root with no present file, and Delete files reconciles only
under the same proof — so an unmounted share leaves every row live and the `live_books` refusal is what
stops it, whereas a `'missing'` mark means verify proved the root was mounted and the file was not on it.
There is no Put back. Audit row
`series.forget {id, title, folder, books, absorbed, absorbedIds, users, rowsByTable}`. Since the same
release, a merge also carries **bookmarks** to the survivor (they used to keep the absorbed id) and the
tracker floor keeps the higher of the two counts, so a merge made now never leaves history for Forget to
strand.
**Verify chapter files.** `POST /api/admin/tasks/verify/run` (since v0.37.0; the Tasks panel's *Verify
chapter files*) is the repair for a database restored without its chapter files. It is **detached**, like
`update` and `cleanup`: one stat per row over a network share is minutes on a large library, and a request
held open that long dies at the reverse proxy while the walk keeps going. It answers **200** `{ok: true,
started: true}` at once, or `{ok: false, error: 'busy'}` while a walk is already going; the counts are not in
the answer — they land on `GET /api/admin/tasks` (the panel polls every 5 s) as the `verify` entry's
`lastResult`, and in the audit row `library.verify {checked, missing, readLibraryMissing, unmounted, ms}`
written when the walk ends, beside the usual `task.run {task: 'verify'}` at the press. Per root it stats
every un-pruned row (both roots are walked and counted), but **only rows under the download folder**
(`/library-dl`) are marked, as pruned with `pruned_reason = 'missing'` (rows never deleted; the cover moves to
the lowest live chapter): a re-fetch lands there on the same row, whereas a read-library (`/library`) row
marked missing would be "fetched again" into a different row, the tombstone would never clear and the
number would be listed twice. A read-library row whose file is gone is counted in `readLibraryMissing` and
never marked — those files are the engine's or the admin's to put back. Per root, the **whole-batch rule**
from the read-chapter cleanup decides what a missing root means: a root where no checked row's *file* is
present — an empty folder is not proof of a mount, the downloader creates folders while a share is down —
or that cannot be read at all, is a volume that is not mounted (or an empty disk, which looks identical
from inside the container), so it marks nothing and is reported in `unmounted` as its bare path; and by
the **90 % rule** a root where more than nine rows in ten have no file is refused the same way, reported as
`" (95 % of 20 chapter files missing)"`, so one stray download on a bare mount cannot turn "unmounted"
into "mark everything else" (exactly 90 % is still marked). `'missing'` is the one `pruned_reason` the
updater does **not** count as held (`pruned_at IS NULL OR pruned_reason IS DISTINCT FROM 'missing'`; NULL
is the cleanup and anything marked before v0.37.0, `'deleted'` is *Delete files*), so the next sweep — and
Fetch newest — download those chapters again onto the same rows and the scan clears the mark; a row below a
series' `chapter_floor` is outside the sweep's want-list and comes back through `chapters/refetch` only. A
row the cleanup already marked keeps its reason. It never runs at boot or on a schedule. `GET /api/admin/tasks`
always lists it: `{id: 'verify', name: 'Verify chapter files', schedule: 'on demand · after a database-only
restore', lastRun: number | null, lastResult: {ok: true, checked, missing, readLibraryMissing, unmounted:
string[], roots, ms, stopped?: 'shutdown'} | null, running}` — `checked` is rows whose file was looked for
under roots that were not skipped (both roots), `missing` the download-root rows marked this run. `lastRun`
and `lastResult` are persisted in `server_settings.verify_last_run` / `verify_last_result`, so a restart does
not turn the last run into "not run yet"; a run that threw stores a NULL result, so no stale healthy line
comes back. A shutdown stops it between batches; what it had marked stays marked, because it was true.
**Following a second source.** `POST /api/admin/series/:id/sources {planId, source, sourceSeriesId}` makes
the updater merge that source's chapter list with the primary's on every check; it answers `{ok, sources}`
with the series' full source list, primary first. The candidate must come from a `POST /api/sources/fill/scan`
plan for this series and the plan must have found it followable — at least 90% of the chapter numbers
already held listed there, with a verdict of `ok` or `nothing_to_fill`. There are two ways into a follow —
this route, and `alsoFollow` on `POST /api/sources/add`, both admin-only — and both make the "same series?"
judgement on the server, starting from that rule (`followable()` in `lib/fill.ts`): here from a plan, with
the admin looking at each candidate; there from the add's own listing plus the candidate's title, and the
numbering both ways unless the title is exact on a listing of at least ten (`lib/autoFollow.ts`, described
under the add route). Neither takes a bare pair on trust, which would let a client follow anything it
could name. Refusals: **409** `plan_stale` (scan again), `is_primary`, `source_unavailable` (adapter not
loaded or disabled); **400** `not_in_plan`, `not_followable` (with `reason` and `coverage`), or
`bad_request` when the plan belongs to another series; **404** for an unknown series. Following the same source again updates its
series id and coverage, and makes a follower the add-time path chose the confirming admin's (`auto:
false`). `DELETE /api/admin/series/:id/sources/:sourceId` stops following it (**404** when
the series was not) and answers the remaining list; chapters already downloaded from it stay, but the
listing rows it carried go at once, so its ghosts leave the series page and nothing can be fetched through
it before the next check.
With a follower in place, the updater takes each missing number from whichever followed source offers the
best copy — a ranked group first, then a hosted copy over an external link, then the primary over the
followers in the order they were added, then the earliest release — and a series whose primary is in a
cooldown still updates from a follower that answers; it is `blocked` only when every followed source is.
`GET /api/admin/series/:id/check` now reports `waiting` alongside `added`: the number of missing chapters
held back for a ranked group (omitted when none). The `frozen-series` health check lists a series whose
primary is gone but which still follows a live source as information rather than a warning.
### Admin — extensions (Mihon / Tachiyomi)
Present only when an extension engine is configured; see [extensions.md](extensions.md).
Installed extensions are kept current by a scheduled task, `extensions`, which appears in
`GET /api/admin/tasks` and can be started with `POST /api/admin/tasks/extensions/run` (answers
`{ ok: false, error: 'busy' }` while one is running, `not_configured` when there is no engine). Its interval
and kill switch are `extensionHours` and `extensionAutoUpdate` on `PATCH /api/admin/settings`, whose
response also carries `extensions_configured` -- not a column, and the only field that says whether there is
an engine at all (`extension_hours` has a default, so it is set on every install either way).
Its stored result -- `extension_last_result`, returned as the task's `lastResult` -- carries `refreshed`
(false when the repositories could not be read, with `refreshError`), `updated`, `failed`, `obsolete`,
`updatesAvailable`, `newUpstream`, `removedUpstream`, `reposRestored`, `reinstalled`, `removedOutside` and
`deferred`. A check that could not refresh reports nothing else: it deliberately does not fall back to the
stale catalogue.
`POST /api/admin/extensions/update-all` re-reads the repositories first and then applies everything, which is
the same work the scheduled check does with `forceUpdate`. It answers **409** while a check is running.
`POST /api/admin/extensions/sources/bulk` takes `{ ids?, langs?, enabled }` (at least one selector) and
switches every matching source in one statement and one registry reload, answering `changed` (rows that
actually flipped), `hiddenLangs`, `registered` and `skipped`. `langs` also records the standing preference:
a hidden language stays off when the next extension is installed, until it is shown again. `ids` do not --
turning one source back on by hand is an exception to the preference, not a change of it. A row whose
language is null is reachable only by id. `GET /api/admin/extensions/sources` carries the per-language
overview as `langs` (sources, enabled, series that came from them, hidden), unaffected by its `q`/`lang`
filters, and `GET /api/admin/extensions/status` reports `registered`, `skipped` and `cap` so the
`SUWAYOMI_MAX_SOURCES` overflow is visible rather than a line in the boot log.
```
GET /api/admin/extensions/status GET /api/admin/extensions/catalog
POST /api/admin/extensions/catalog/:pkgName
POST /api/admin/extensions/update-all
GET /api/admin/extensions/repos POST /api/admin/extensions/repos
DELETE /api/admin/extensions/repos POST /api/admin/extensions/refresh
GET /api/admin/extensions/sources POST /api/admin/extensions/sources/:id
POST /api/admin/extensions/sources/bulk
```
### Images and OPDS
Cookie and HTTP Basic respectively, as described above.
The OPDS catalogue is 1.2 (Atom). Two extensions ride on it, both ignorable by a reader that does not know
them:
- **Page streaming (OPDS-PSE 1.1).** Every chapter entry carries a
`rel="http://vaemendis.net/opds-pse/stream"` link whose `href` is a template,
`/opds/book/:id/page/{pageNumber}?maxWidth={maxWidth}`, with `pse:count` (pages), and, when this reader
has progress in the chapter, `pse:lastRead` and `pse:lastReadDate`. `{pageNumber}` is **zero-based**, per
the spec and the same base as `read_progress.page`. Panels, Chunky and KOReader read page by page over
this instead of downloading the CBZ; everything else keeps using the acquisition link. Without `maxWidth`
the original bytes are served (shared cache with the web reader); with it, a JPEG no wider than asked
(64–2000).
- **Facets (OPDS 1.2 §7).** `/opds/series` and `/opds/search` carry `rel="http://opds-spec.org/facet"`
links in four `opds:facetGroup`s -- Sort, Library, Genre, Status -- each with `thresholdCount` (how many
of *your* series it leaves) and `opds:activeFacet` on the one in force. The matching query parameters are
`sort` (`updated|title|added`), `library`, `genre` (case-insensitive) and `status`; they combine with `q`
and with each other, and `next` links carry them. Counts come from the same gated source as the listing,
so a genre that exists only in a library you cannot open is not listed.
`` is honest: a series carries its newest chapter's time, a chapter its own, and a feed the newest
of its entries. It used to be "now" on every fetch, which defeated readers' change detection.
**The cover proxy.** `GET /img/sources/cover?u=&source=&w=400|800|1600` fetches a remote cover
same-origin, resized to WebP, so a Discover tile never loads a third-party image in the browser. `u` is
caller-supplied and is fetched through the SSRF guard: a value that is not an `http(s)` URL, or that
resolves to a private, loopback, link-local or otherwise blocked address at any redirect hop (four at most),
is answered with the grey placeholder image (**200**, not cached) rather than an error, since a bad value
cannot be retried into working; a missing `u` is **400**; a genuine upstream failure is **502**, so the
client can retry the direct URL itself. The only URL fetched **without** that guard is the extension
engine's own thumbnail, `/api/v1/manga//thumbnail` — the engine's origin is a private
address on purpose, and its covers are proxied through it. Since v0.37.0 that exemption is one path shape,
not one origin: the URL on the wire is rebuilt from the configured engine base plus the numeric manga id,
and it is fetched only if it round-trips to exactly the origin and path the caller named, so no other path
on the engine (and nothing on any other host) is ever fetched with the engine's credentials, and a redirect
from it is refused rather than followed.
```
GET /img/series/:id/thumb GET /img/series/:id/backdrop
GET /img/extensions/icon/:pkgName
GET /img/books/:id/thumb GET /img/books/:id/page/:n
GET /img/lib/series/:id/thumb GET /img/lib/books/:id/thumb
GET /img/lib/books/:id/page/:n GET /img/sources/cover
GET /opds GET /opds/series
GET /opds/series/:id GET /opds/search
GET /opds/opensearch.xml GET /opds/book/:id/file
GET /opds/book/:id/page/:n
```
### Komga-compatible API (Mihon's Komga extension and tracker)
```
GET /api/v1/libraries GET /api/v1/series
GET /api/v1/series/latest GET /api/v1/series/:id
GET /api/v1/series/:id/books GET /api/v1/series/:id/thumbnail
GET /api/v1/books GET /api/v1/books/:id
GET /api/v1/books/:id/pages GET /api/v1/books/:id/pages/:n
GET /api/v1/books/:id/thumbnail
GET /api/v1/genres GET /api/v1/tags
GET /api/v1/publishers GET /api/v1/authors
GET /api/v1/collections GET /api/v1/collections/:id/series
GET /api/v1/readlists GET /api/v1/readlists/:id
GET /api/v1/readlists/:id/read-progress/tachiyomi
PUT /api/v1/readlists/:id/read-progress/tachiyomi
GET /api/v2/users/me
GET /api/v2/series/:id/read-progress/tachiyomi
PUT /api/v2/series/:id/read-progress/tachiyomi
```
Since v0.38.0. Enough of Komga's API for the keiyoushi **Komga** extension to browse and read this library
and for Mihon's **Komga tracker** to sync reading progress back — the set of `GET`s the current extension
actually calls (it uses none of Komga's newer `POST …/list` forms), plus the two tracker calls. How to set
the phone up, and what the sync can and cannot do, is in [USAGE.md](USAGE.md#the-other-direction-uchiyomi-inside-mihon-or-tachimanga)
and [extensions.md](extensions.md#komga-compatible-api); this is the wire contract.
**Authentication** is the one described under *The Komga-compatible surface* above: `X-API-Key`, then
`Authorization: Bearer`, then Basic with the token as the password, else the `UCHIYOMI-SESSION` cookie those
requests mint. Explicit beats remembered: a presented credential that does not resolve is **401** even
beside a valid cookie — `X-API-Key` *The API key is not a valid Uchiyomi API token.*, Bearer *The bearer
token is not a valid Uchiyomi API token. Account sessions are not accepted here.*, Basic *Use an Uchiyomi API
token as the password. Account passwords are not accepted here.*, any other `Authorization` scheme or no
credential *Send an Uchiyomi API token as X-API-Key, as a Bearer token, or as the HTTP Basic password.*; after
ten such failures from one address in five minutes every request from it that presents a credential is
**429** `too_many_requests` *Too many failed API keys from this address. Try again in a few minutes.* with
`Retry-After` until the window ends (no-credential and cookie-only requests are neither counted nor blocked).
A cookie that does not verify (a real Komga's `KOMGA-SESSION`, a tampered or expired value, any spelling other
than the one the server minted — `v1...`, decimal, no leading zeros) is simply "no
cookie", never a refusal on its own; a cookie that verifies but names a token that is gone — revoked,
expired, owner disabled — is **401** *This session's API token is no longer valid.* A token without `write`
browses and reads but gets **403** `forbidden` on the two `PUT`s, which is what a read-only token in the
extension looks like: nothing syncs in either direction, because Mihon retries a failed push a few times
with backoff, then gives up quietly until the next chapter read. Anything the token's account may not see
is **404**, never **403**. The cookie is set `HttpOnly; SameSite=Lax; Path=/`, `Secure` only over HTTPS (a
LAN install is plain HTTP and the WebView refuses a Secure cookie set over it), `Max-Age` = min(7 days, the
token's remaining life), no `Domain`; it is re-minted when absent, invalid, minted for another token (the
extension's key was changed to another account's — otherwise the tracker's credential-less `PUT` would keep
landing on the old account) or past half its life, and never on a 401. That re-mint needs a credentialed
request, which the API key field sends every time; with username/password the extension only presents the
password after a 401, so a changed password is not noticed while the previous cookie is valid (up to 7
days) — revoke the old token, or use the API key field. `POST /auth/logout` clears the cookie from a
browser too. Every JSON answer carries `Cache-Control: no-store`, because both clients send `max-age=600`
as a request directive against a disk cache and a cached progress `GET` could follow a `PUT`.
**Shapes.** Lists are Spring's page envelope with all nine keys — `content, empty, first, last, number`
(0-based)`, numberOfElements, size` (1–500, default 20)`, totalElements, totalPages` — because the Kotlin
client refuses a missing one, and `last` is true on the final page and on an empty one. Every DTO is padded
with every field Komga's classes declare without a default (the `*Lock` booleans, `titleSort`,
`fileLastModified`, `mediaProfile: 'DIVINA'`, …), and every string that could be null upstream is `''`:
one JSON `null` in a non-nullable field fails the decode of the whole list it sits in. On a chapter,
`sizeBytes` is the file's size on disk (`lib_books.size`; 0 when never stamped) and `size` is Komga's text
for it (`1.5 KiB`, `0 B`), which the extension shows in its default chapter name `{number} - {title}
({size})`.
Every date-time is `yyyy-MM-ddTHH:mm:ss` in UTC with no zone letter and no milliseconds — the extension
parses chapter dates strictly and a trailing `Z` or `.sss` would date every chapter at the epoch — a
required date that is not known is `1970-01-01T00:00:00`, and `releaseDate` is `yyyy-MM-dd` or null.
`metadata.status` maps the stored value, case-insensitively: *Completed*, *Complete*, *Finished*,
*Publishing finished* and *Ended* → `ENDED`; *Cancelled*, *Canceled*, *Dropped*, *Abandoned* → `ABANDONED`;
*On hiatus*, *Hiatus*, *Paused* → `HIATUS`; *Ongoing*, *Publishing*, *Releasing* → `ONGOING`; anything else
goes out upper-cased with spaces as underscores (empty stays empty), which the extension shows as *Unknown* —
never a guessed *Ongoing*. The `status` filter runs the same table the other way, so `status=ENDED` finds a
series stored as *Completed*. On a chapter, `number`, `metadata.numberSort` and
the progress endpoint's numbers are **one quantity**, the override-aware chapter number, unrounded: the
extension makes it the chapter number and Mihon compares and `PUT`s it back in that unit; `metadata.number`
is the display string. The scanlation group rides as an author with role `translator`, which the extension
turns back into the scanlator. `media.status` is `READY` for a chapter whose file is on the server and
`ERROR` for a tombstone. The full field lists are in [`openapi.yaml`](../bff/openapi.yaml) under
`KomgaSeries`, `KomgaBook`, `KomgaPageDto`, `KomgaReadProgressV2` and `KomgaUser`, and
`bff/test/komgaContract.test.ts` pins the required-field lists copied from the two clients' sources.
**Browsing.** `GET /api/v1/libraries` is the extension's log-in probe and its Libraries filter — the grants
and the age cap apply, and an 18+ library is listed only to a token minted with `showAdult` (`root` is empty,
`unavailable` false). `GET /api/v1/series` takes `search` (empty = none), `page` (0-based), `size`,
`unpaged`, `sort` (`metadata.titleSort | name | createdDate | lastModifiedDate | relevance | random`, then
`,asc|desc`; the extension's Popular is `metadata.titleSort,asc` and its Latest `lastModifiedDate,desc`;
`relevance` and anything unknown are title order), and the filters `library_id`, `status`, `genre`, `tag`,
`publisher` (the extension joins several values with commas in one parameter), `read_status`
(`UNREAD | IN_PROGRESS | READ`, repeated) and `author` (`name,role`, repeated) — both the comma-joined and
the repeated form are accepted for every one of them and flattened. `tag` filters genres and `publisher` the
author field, since that is where the DTO presents them; `status=ENDED` also matches *Completed* and
friends. A filter the query cannot express is **400** `unsupported_filter`, never silently widened.
`/api/v1/series/latest` is that list with the sort forced (PR #51 exposed it; the extension never calls it).
`GET /api/v1/series/:id` resolves by id — visible, not browsable, so a series in an 18+ library the token
does not list still answers — and carries the account's real `booksReadCount / booksUnreadCount /
booksInProgressCount`, which Mihon turns into *Unread / Reading / Completed*. `GET /api/v1/series/:id/books`
is the chapter list, ordered by the override-aware number then file; the extension asks
`unpaged=true&media_status=READY&deleted=false`, where `unpaged` is one page of everything — `size` is the
chapter count (at least 1), `number` 0, `first` and `last` true, `totalPages` 1 (0 for an empty series), and
no 500 cap, so a 600-chapter series comes back whole; the paged form (`page`, `size`) stays capped at 500 —
and `media_status=READY` leaves tombstones — chapters deleted from the server, whose page list is empty —
out of the **list** only; they still count for progress. `GET /api/v1/books/:id/pages`
numbers pages from **1**, as Komga does and as the extension puts them in the image URL, and
`GET /api/v1/books/:id/pages/:n` serves the original bytes (**400** `bad_page` below 1; `?convert=png` is
accepted and ignored), the same bytes and cache as `/img/lib/books/:id/page/:n`; the two thumbnails are the
same bytes as their `/img/lib/…/thumb` twins (`?w=800|1600` on the series cover). Every image is served
`Cache-Control: private` — the whole image cache is, since this release, because those bytes are authorised
per viewer and `public` told a shared proxy they were the same for everyone. `GET /api/v1/books` (the
extension's Books search type) is always an empty page: there is no chapter-level search. `genres` and
`authors` (each `{name, role: 'writer'}`) are real over the browsable series; `tags` and `publishers` are
`[]`; `collections`, `collections/:id/series` and `readlists` are **empty pages** on purpose — a collection
would name series this credential's cap or grants hide, and an id is a disclosure — and `readlists/:id` and
its progress are **404** in both directions. `GET /api/v2/users/me` is Komga's UserDto for the token's
account: `email` is the username, `roles` `['USER']` or `['ADMIN', 'USER']`, `sharedAllLibraries` /
`sharedLibrariesIds` from the grants, `ageRestriction` `{age, restriction: 'ALLOW_ONLY'}` from the cap or
null.
**Progress.** `GET /api/v2/series/:id/read-progress/tachiyomi` counts every chapter of the series (tombstones
included — members' history refers to them), reports `lastReadContinuousNumberSort`, the number of the last
chapter in the **leading** run of completed chapters ordered by the override-aware number (chapters 1, 2 and
4 read reports 2; nothing read reports 0, and so does a run that ends on — or starts with an unread —
number-0 chapter, which the protocol cannot express), and `maxNumberSort`, the highest chapter number;
**404** unless the series is visible to the token's account. `PUT` with `{"lastBookNumberSortRead": n}` (a
finite number from 0 to 1 000 000 000, else **400** `bad_request` *lastBookNumberSortRead must be a finite
number between 0 and 1000000000.* — the value is bound as a Postgres `real`, and 1e300 used to be a 500)
marks every not-yet-completed chapter whose number is ≤ n completed in one statement —
page moves to the chapter's page count and never backwards, chapters already completed are untouched and
nothing is ever un-marked, because the protocol has no unread — answers **204**, is idempotent, and pushes to
AniList / MyAnimeList / Kitsu once, only when something changed (Mihon `PUT`s on every bind and refresh, so a
library update of two hundred bound series would otherwise have been two hundred remote mutations). `n ≤ 0`
is a no-op **204**: a fresh bind sends `0.0`, and a chapter numbered 0 (*Extra*, *Oneshot* — any file without
a digit) must not be marked read by it; Mihon never reports a chapter it read as 0, so nothing is lost. The
first real sync (n ≥ 1) marks a number-0 chapter read on both sides, as Komga does; only the bind-time 0 is
ignored. No
reading event is written, so a sync from the phone does not count towards streaks, the leaderboard or
Wrapped, exactly like the app's own bulk mark-read. Needs the `write` scope.
---
# Single sign-on (OIDC)
Uchiyomi can sign people in through an identity provider you already run: Authentik, Authelia, Keycloak,
Pocket ID, Zitadel, or any other OpenID Connect provider.
SSO is **additional**, never a replacement. Local accounts, 2FA, lockout and session revocation all keep
working exactly as before, so you are not locked out if the identity provider is down.
## Setting it up
In your identity provider, create an OAuth2/OpenID Connect application with:
- **Redirect URI**: `https://your-server/auth/oidc/callback`
- **Grant type**: authorization code (PKCE is used automatically)
- **Scopes**: `openid profile email`
Then set these on the `uchiyomi` container and restart it (`yomi-bff` if you run the development stack):
```yaml
environment:
OIDC_ISSUER: https://auth.example.com/application/o/uchiyomi/
OIDC_CLIENT_ID: your-client-id
OIDC_CLIENT_SECRET: your-client-secret
OIDC_NAME: Authentik # the name shown on the button
```
`OIDC_ISSUER` is the base URL that serves `/.well-known/openid-configuration`. If SSO doesn't appear on the
login screen, that URL is usually the reason: fetch it yourself and check it returns JSON.
A **Continue with …** button appears on the login screen once the issuer and client id are set. Nothing else
changes until someone uses it.
## Who is allowed in
By default, signing in through the identity provider only works for people who already have a linked account
here, which is the safe default but means nobody can get in yet. Pick one of these:
```yaml
OIDC_LINK_BY_USERNAME: "true" # adopt the existing local account with the same username
OIDC_ALLOW_SIGNUP: "true" # create an account the first time someone signs in
```
`OIDC_LINK_BY_USERNAME` is what you usually want on a server whose users already exist. The first time
someone signs in through SSO, their existing account is adopted: same account, same reading progress,
favorites and history, now reachable through the identity provider as well as their password. An account
already linked to a different SSO identity is never taken over.
Optionally map admin rights from a group:
```yaml
OIDC_ADMIN_GROUP: uchiyomi-admins
```
When set, roles follow the identity provider on every sign-in: in the group means admin here, out of it means
an ordinary user. Leave it unset to keep managing roles in the admin panel.
## Notes
- Boolean settings read the actual word, so `"false"` means false.
- The ID token's signature is verified against the issuer's published keys on every sign-in, along with its
issuer, audience, expiry and nonce.
- SSO sessions appear in **Profile → Account → Active sessions** as a device named "SSO" and can be revoked like any other.
- Signing in through SSO does not ask for a second factor here; your identity provider is responsible for
that. Local password logins still use Uchiyomi's own 2FA.