All endpoints are served under /api. Requests and responses use JSON unless otherwise noted. The backend is async (FastAPI); all endpoints support concurrent requests.
Authentication: All endpoints except /api/setup/* and /api/auth/* require a valid session token. Pass it as:
Authorization: Bearer <token>header, orotaki_sessioncookie
Obtain a token via POST /api/auth/login. Requests without a valid token return 401 Unauthorized.
- Setup
- Auth
- Search
- Requests (Comics)
- Settings
- Sources
- Watermark Templates
- Quality
- Common Types
- Error Responses
- Limitations
The setup wizard walks through first-time configuration. Steps 2–5 are guarded: once SETUP_COMPLETE=True is written to .env (at the end of step 5), they return 409. The status and completion-check endpoints have no guard and are always callable.
Exempt from the auth middleware — no token required, except GET /api/setup/status which requires auth.
Lightweight public check used by the frontend on load to decide whether to show the setup wizard or the normal app.
Response 200
{ "complete": false }Full setup state, used by the wizard to prefill fields on re-entry. Requires auth — call after step 1 completes and a token is available.
Headers
| Header | Required | Description |
|---|---|---|
Authorization |
yes | Bearer <token> |
Response 200
{
"complete": false,
"admin_created": true,
"suwayomi_url": "http://suwayomi:4567",
"suwayomi_username": "admin",
"download_path": null,
"library_path": null
}Error Cases
401 Unauthorized— missing or invalid token.
Create the first admin user (step 1). Not guarded by SETUP_COMPLETE.
Request Body
{ "username": "admin", "password": "mypassword" }Response 200 — no body.
Error Cases
409 Conflict— an admin user already exists.
Connect to Suwayomi (step 2). Validates connectivity and writes credentials to .env.
Request Body
{ "url": "https://suwayomi.example.com", "username": "user", "password": "pass" }Response 200 — no body.
Error Cases
400 Bad Request— could not reach Suwayomi with the supplied credentials.409 Conflict— setup already complete (SETUP_COMPLETE=True).
List sources installed in Suwayomi for priority ordering (step 3).
Response 200
[
{ "id": "1998944621602222888", "name": "MangaDex", "lang": "en", "icon_url": "https://..." }
]Error Cases
409 Conflict— setup already complete.
Save source priority order (step 3). First item = highest priority.
Request Body
{
"sources": [
{ "id": "1998944621602222888", "name": "MangaDex", "lang": "en", "icon_url": "https://..." }
]
}Response 200 — no body. Idempotent — replaces all existing Source rows.
Error Cases
409 Conflict— setup already complete.
Set filesystem paths (step 4). On success, writes SETUP_COMPLETE=True to .env, locking all guarded setup endpoints.
Request Body
{
"download_path": "/data/suwayomi/downloads",
"library_path": "/data/library",
"create": false
}| Field | Type | Default | Description |
|---|---|---|---|
download_path |
string | — | Suwayomi's download staging directory |
library_path |
string | — | Final library directory |
create |
bool | false |
If true, create missing directories with mkdir -p |
Response 200 — no body.
Error Cases
400 Bad Request(directories missing) — one or more paths do not exist andcreate=false. Response body identifies which paths are missing:The frontend shows a confirmation screen; resubmit with{ "detail": { "code": "directories_missing", "missing": [ { "field": "download_path", "path": "/data/suwayomi/downloads" } ] } }create=trueto create them.400 Bad Request(permission denied) —create=truebut the directory could not be created.409 Conflict— setup already complete.
Exchange credentials for a JWT session token.
Request Body
{ "username": "admin", "password": "mypassword" }Response 200
{ "access_token": "eyJ...", "token_type": "bearer" }Include the token on subsequent requests:
Authorization: Bearer eyJ...
Error Cases
401 Unauthorized— username not found or password incorrect (response does not distinguish between the two).
Response 200 — no body. Stateless: the token is not invalidated server-side; the client should discard it.
Return the current user's profile.
Headers
| Header | Required | Description |
|---|---|---|
Authorization |
yes | Bearer <token> |
Response 200
{ "id": 1, "username": "admin" }Error Cases
401 Unauthorized— missing, malformed, or expired token.
Returns overall system health plus detailed status for each component. Unauthenticated — safe for Docker health checks and external monitors.
Response 200
{
"status": "healthy",
"database": "ok",
"suwayomi": {
"status": "ok",
"url": "https://suwayomi.example.com",
"sources": [
{ "name": "MangaDex", "enabled": true, "reachable": true },
{ "name": "BrokenSource", "enabled": true, "reachable": false }
]
},
"workers": {
"download_listener": {
"running": true,
"uptime_seconds": 3600.0
},
"scheduler": {
"running": true,
"uptime_seconds": 3600.0,
"jobs": [
{
"comic_id": 1,
"title": "One Piece",
"next_poll_at": "2026-04-08T10:00:00+00:00",
"next_upgrade_at": "2026-04-08T10:00:00+00:00"
}
]
}
}
}Overall status rules
| Condition | status |
|---|---|
| DB unreachable | unhealthy |
| Suwayomi unreachable or a worker not running (DB ok) | degraded |
| All components ok | healthy |
suwayomi.status values: "ok" — reachable and responding; "unreachable" — ping failed or URL not configured; "error" — unexpected error during check.
suwayomi.sources — cross-references enabled sources in the Otaki DB with the live Suwayomi source list. Only populated when Suwayomi is reachable.
Worker uptime_seconds — seconds since the worker started; null if not yet started.
Search for a manga title across all enabled sources. Results are not deduplicated — the same series may appear multiple times with different titles across sources. The user selects which results belong to the same series when submitting a request.
Query Parameters
| Name | Type | Required | Description |
|---|---|---|---|
q |
string | yes | Title search query |
Response 200
{
"results": [
{
"title": "One Piece",
"cover_url": "https://...",
"cover_display_url": "/api/search/thumbnail?url=...",
"synopsis": "...",
"source_id": 1,
"source_name": "MangaDex",
"url": "https://source-url/manga/one-piece",
"suwayomi_manga_id": "42"
},
{
"title": "ワンピース",
"cover_url": "https://...",
"cover_display_url": "/api/search/thumbnail?url=...",
"synopsis": "...",
"source_id": 2,
"source_name": "MangaPlus",
"url": "https://source2-url/manga/wan-piisu",
"suwayomi_manga_id": "43"
}
],
"source_errors": [
{ "source_name": "BrokenSource", "reason": "connection timed out" }
]
}results fields:
| Field | Type | Notes |
|---|---|---|
title |
string | Title as returned by this source |
cover_url |
string | null | Absolute Suwayomi cover URL — submitted to POST /api/requests |
cover_display_url |
string | null | Proxied /api/search/thumbnail?url=… — used by <img> tags |
synopsis |
string | null | Short description, may be empty |
source_id |
int | Otaki source ID |
source_name |
string | Human-readable source label |
url |
string | Source-specific manga URL, passed back when submitting a request |
suwayomi_manga_id |
string | Suwayomi's internal manga ID for this source — pass back when creating or updating source pins |
source_errors fields:
| Field | Type | Notes |
|---|---|---|
source_name |
string | Name of the source that failed |
reason |
string | Human-readable failure reason: "connection timed out", "connection refused or DNS failure", "authentication failed (401)", "unexpected HTTP <N>", or "unexpected error" |
Notes
- Fans out to all enabled sources in parallel; slow sources are waited on up to a timeout.
- No deduplication — the frontend shows all results and lets the user select which ones represent the same series.
resultsis empty andsource_errorsis populated when all sources fail.- Does not require a
Comicrow to exist; this is purely a live Suwayomi query.
Same as GET /api/search but streams results as SSE events — one event per source as it responds, rather than waiting for all sources to finish.
Auth: Bearer token via Authorization header. Use fetch + ReadableStream, not EventSource (which cannot send custom headers).
Query Parameters
| Name | Type | Required | Description |
|---|---|---|---|
q |
string | yes | Title search query |
Response 200 text/event-stream
Each SSE event has the shape data: <JSON>\n\n. There are three event types:
data: {"source_name": "MangaDex", "results": [...]}
data: {"source_name": "BrokenSource", "error": "connection timed out"}
data: [DONE]
- result event —
source_nameplusresultsarray (same shape asGET /api/searchresults). - error event —
source_namepluserrorstring; emitted when a source fails. Other sources continue streaming. [DONE]sentinel — literal string (not JSON); signals that all sources have responded.
Notes
- HTTP status is always
200(headers are sent before any source responds). Errors are surfaced as error events, not HTTP error codes. - No deduplication — same semantics as
GET /api/search.
Track a new comic. Triggers source selection and enqueues all available chapter downloads.
Request Body
{
"primary_title": "One Piece",
"library_title": "One Piece",
"cover_url": "https://source1-url/cover.jpg",
"poll_override_days": 7.0,
"upgrade_override_days": null,
"aliases": ["ワンピース", "One Piece (Viz)"],
"source_pins": [{"source_id": 1, "suwayomi_manga_id": "42"}]
}| Field | Type | Required | Notes |
|---|---|---|---|
primary_title |
string | yes | Display name for the comic in the Otaki UI |
library_title |
string | no | Name used for the library folder path and ComicInfo.xml <Series> tag. Defaults to primary_title if omitted. |
cover_url |
string | null | no | Cover image URL; downloaded and stored at request time. Injected as cover.{ext} into each chapter CBZ during relocation. |
poll_override_days |
float | null | no | Days between new-chapter polls; null (default) = use inferred cadence, falling back to DEFAULT_POLL_DAYS |
upgrade_override_days |
float | null | no | Days between upgrade checks; null = use DEFAULT_POLL_DAYS |
aliases |
string[] | no | Alternative titles for this comic. Saved as ComicAlias rows and used during source searches to find the comic under its alternative names. |
source_pins |
SourcePinInput[] |
no | Pin specific manga IDs per source. Each entry skips title search for that source and fetches chapters directly. Optional; defaults to []. |
Response 201
{
"id": 1,
"title": "One Piece",
"library_title": "One Piece",
"cover_url": null,
"status": "tracking",
"poll_override_days": 7.0,
"upgrade_override_days": null,
"inferred_cadence_days": 6.5,
"next_poll_at": "2025-03-22T09:00:00Z",
"next_upgrade_check_at": "2025-03-22T09:00:00Z",
"last_upgrade_check_at": null,
"created_at": "2025-03-15T09:00:00Z",
"aliases": [
{ "id": 1, "title": "ワンピース" }
],
"source_errors": [
{ "source_name": "BrokenSource", "reason": "connection timed out" }
]
}source_errors is an empty array on full success. A non-empty array means one or more sources could not be reached during chapter discovery — the comic was still created and chapters from reachable sources were enqueued. Call POST /api/requests/{id}/discover to retry failed sources.
Side Effects
- Creates a
Comicrow withtitle = primary_title. - Creates
ComicAliasrows for each entry inaliases. 3a. CreatesComicSourcePinrows for each entry insource_pins. Pins causesource_selectorto bypass title search for that source and fetch chapters directly using the pinned manga ID. - Calls
source_selector.build_chapter_source_map()— searches all enabled sources usingprimary_titleand all alias titles, assigning each chapter to the highest-priority source that has it. - Calls
suwayomi.fetch_chapters()per source group. - Calls
suwayomi.enqueue_downloads()grouped by source. - Creates one
ChapterAssignmentrow per chapter withdownload_status=queued,is_active=True. - Registers an APScheduler poll job for this comic.
Error Cases
409 Conflict— aComicwith the same title is already being tracked.422 Unprocessable Entity— missing or invalid fields.
List tracked comics with pagination, search, filter, and sort.
Query Parameters
| Name | Type | Default | Description |
|---|---|---|---|
page |
int | 1 | Page number (≥ 1) |
per_page |
int | 25 | Items per page (1–100) |
search |
string | — | Case-insensitive substring match on title |
status |
string | — | Filter by comic status (tracking or complete) |
source_id |
int | — | Filter to comics that have at least one chapter assignment from this source |
sort_by |
string | id |
Sort column: id, title, library_title, status, source |
sort_dir |
string | asc |
Sort direction: asc or desc |
Response 200
{
"items": [
{
"id": 1,
"title": "One Piece",
"library_title": "One Piece",
"status": "tracking",
"chapter_counts": {
"total": 120,
"done": 118,
"downloading": 1,
"queued": 1,
"failed": 0
},
"poll_override_days": null,
"upgrade_override_days": null,
"inferred_cadence_days": 6.5,
"next_poll_at": "2025-03-22T09:00:00Z",
"next_upgrade_check_at": "2025-03-22T09:00:00Z",
"last_upgrade_check_at": null
}
],
"total": 42,
"page": 1,
"per_page": 25
}| Field | Type | Notes |
|---|---|---|
items |
array | Comics for this page |
total |
int | Total matching comics (before pagination) |
page |
int | Current page number |
per_page |
int | Items per page |
chapter_counts |
object | Counts by download_status: total, done, downloading, queued, failed |
poll_override_days |
float | null | User override for poll interval; null = use inferred cadence / DEFAULT_POLL_DAYS |
upgrade_override_days |
float | null | User override for upgrade interval; null = use poll interval |
inferred_cadence_days |
float | null | Median inter-chapter gap in days, inferred from chapter_published_at; null until ≥ 2 chapters are available |
next_poll_at |
datetime | null | When the next new-chapter poll will run |
next_upgrade_check_at |
datetime | null | When the next upgrade check will run |
last_upgrade_check_at |
datetime | null | When upgrade checks last ran |
Full detail for one comic: all chapter assignments with download and relocation status.
Path Parameters
| Name | Type | Description |
|---|---|---|
id |
int | Comic ID |
Response 200
{
"id": 1,
"title": "One Piece",
"library_title": "One Piece",
"cover_url": null,
"status": "tracking",
"poll_override_days": 7.0,
"upgrade_override_days": null,
"inferred_cadence_days": 6.5,
"next_poll_at": "2025-03-22T09:00:00Z",
"next_upgrade_check_at": "2025-03-22T09:00:00Z",
"last_upgrade_check_at": "2025-03-15T10:00:00Z",
"created_at": "2025-03-15T09:00:00Z",
"aliases": [
{ "id": 1, "title": "ワンピース" }
]
}Notes
- Chapters are no longer embedded in this response. Use
GET /api/requests/{id}/chaptersfor paginated chapter data.
Error Cases
404 Not Found— no comic with this ID.
Update one or more settings for a tracked comic. All fields are optional — only fields present in the request body are applied; omitted fields are left unchanged.
Path Parameters
| Name | Type | Description |
|---|---|---|
id |
int | Comic ID |
Request Body (any subset)
{
"library_title": "One Piece Vol. 1+",
"poll_override_days": 3.0,
"upgrade_override_days": null,
"status": "complete"
}| Field | Type | Notes |
|---|---|---|
library_title |
string | Folder name and ComicInfo.xml <Series> tag for future relocations. Does not rename existing library files. |
poll_override_days |
float | null | New poll interval. null = clear override (reverts to inferred cadence / default). Reschedules the APScheduler poll job immediately. |
upgrade_override_days |
float | null | New upgrade interval. null = clear override (reverts to inferred cadence / poll override / default). Reschedules the upgrade job. |
status |
"tracking" | "complete" |
"complete" stops all scheduled jobs; "tracking" re-registers them. |
Response 200 — ComicResponse (same shape as POST /api/requests).
Error Cases
404 Not Found— no comic with this ID.
Re-run source discovery for a comic and queue any chapters not yet assigned. Safe to call at any time — only creates assignments for chapter numbers not already tracked with is_active=True. Intended for comics that ended up with 0 (or partial) assignments due to a connectivity failure at request time.
Path Parameters
| Name | Type | Description |
|---|---|---|
id |
int | Comic ID |
Response 200
{
"new_chapters": 3,
"source_errors": [
{ "source_name": "BrokenSource", "reason": "connection timed out" }
]
}| Field | Type | Notes |
|---|---|---|
new_chapters |
int | Number of new ChapterAssignment rows created |
source_errors |
array | Sources that could not be reached during this discovery run |
Error Cases
404 Not Found— no comic with this ID.
Walk every active chapter through whatever pipeline stage it is currently stuck or incomplete in. Idempotent — safe to call multiple times.
For each active ChapterAssignment:
| Condition | Action |
|---|---|
relocation_status=done, library file exists |
Re-pack CBZ, update ComicInfo.xml (library_title) and cover, move to correct path if library_title changed |
download_status=queued|downloading, staging file found |
Treat as done — run relocate / replace-in-library pipeline (recovers missed FINISHED events) |
download_status=queued|downloading, no staging, chapter in live Suwayomi queue |
Skip — genuinely still in progress |
download_status=queued|downloading, no staging, absent from live queue |
Re-enqueue download |
download_status=failed |
Re-enqueue download |
download_status=done, staging file found |
Run relocate / replace-in-library pipeline |
download_status=done, no staging, library file exists |
Re-pack and update as above |
| No staging, no library file | Re-enqueue download |
Path Parameters
| Name | Type | Description |
|---|---|---|
id |
int | Comic ID |
Auth: Bearer token via Authorization header. Use fetch + ReadableStream, not EventSource.
Response 200 text/event-stream
Streams SSE events as each chapter is processed. Three event types:
data: {"type": "chapter", "chapter_number": 3, "action": "processed"}
data: {"type": "chapter", "chapter_number": 4, "action": "queued"}
data: {"type": "chapter", "chapter_number": 5, "action": "skipped"}
data: {"type": "done", "queued": 2, "processed": 5, "skipped": 1}
data: [DONE]
chapterevent — emitted for each active chapter as it is handled.actionvalues:"processed"(ran pipeline),"queued"(re-enqueued download),"skipped"(in-progress).
doneevent — final summary with aggregate counts.[DONE]sentinel — literal string (not JSON); signals stream end.errorevent —{"type": "error", "detail": "..."}emitted instead ofdoneif the comic is not found or Suwayomi is unreachable.
| Field | Type | Notes |
|---|---|---|
queued |
int | Chapters re-enqueued for download (failed or missing) |
processed |
int | Chapters that ran through the relocate / update pipeline |
skipped |
int | Chapters already in progress (queued/downloading) |
Notes
- HTTP status is always
200. Errors (comic not found, Suwayomi unreachable) are surfaced as{"type": "error"}events.
Immediately run an upgrade check for all active chapters of a comic, queuing a new ChapterAssignment (with is_active=False) for every chapter where a higher-priority source is now available. The actual swap happens when the upgrade download completes (handled by chapter_event_handler).
Path Parameters
| Name | Type | Description |
|---|---|---|
id |
int | Comic ID |
Auth: Bearer token via Authorization header. Use fetch + ReadableStream, not EventSource.
Response 200 text/event-stream
data: {"type": "chapter", "chapter_number": 3, "old_source": "Source A", "new_source": "Source B"}
data: {"type": "done", "queued": 1}
data: [DONE]
chapterevent — emitted for each chapter where an upgrade was queued.doneevent — final summary;queuedis 0 if no better sources were found.errorevent —{"type": "error", "detail": "..."}if the comic is not found or Suwayomi is unreachable.
Same as the bulk force-upgrade above, but scoped to a single active ChapterAssignment. Returns queued: 0 if no better source exists for that chapter.
Path Parameters
| Name | Type | Description |
|---|---|---|
id |
int | Comic ID |
assignment_id |
int | Active ChapterAssignment ID |
Auth: Bearer token via Authorization header.
Response 200 text/event-stream
Same event shape as the bulk endpoint. At most one chapter event is emitted.
Error Cases (in-stream)
{"type": "error"}— comic not found, assignment not found or not active, or Suwayomi unreachable.
Stop tracking a comic. Removes APScheduler jobs, all ChapterAssignment rows, and the Comic row. Optionally deletes library files.
Path Parameters
| Name | Type | Description |
|---|---|---|
id |
int | Comic ID |
Query Parameters
| Name | Type | Default | Description |
|---|---|---|---|
delete_files |
bool | false |
If true, deletes any files referenced by library_path on assignments |
Response 204 No Content
Error Cases
404 Not Found— no comic with this ID.
Paginated list of chapter assignments for one comic, with optional status filter.
Path Parameters
| Name | Type | Description |
|---|---|---|
id |
int | Comic ID |
Query Parameters
| Name | Type | Default | Description |
|---|---|---|---|
page |
int | 1 | Page number (≥ 1) |
per_page |
int | 50 | Items per page (1–200) |
status |
string | — | High-level status filter: available, queued, downloading, relocating, failed |
Status mapping:
available—relocation_status == donequeued—download_status == queueddownloading—download_status == downloadingrelocating—download_status == doneANDrelocation_status != donefailed—download_status == failedORrelocation_status == failed
Response 200
{
"items": [
{
"assignment_id": 55,
"chapter_number": 12.5,
"volume_number": 2,
"source_id": 2,
"source_name": "MangaDex",
"download_status": "done",
"is_active": true,
"downloaded_at": "2025-03-15T09:30:00Z",
"library_path": "/library/One Piece/One Piece - Ch.0012.5.cbz",
"relocation_status": "done"
}
],
"total": 203,
"page": 1,
"per_page": 50
}Notes
itemsis ordered bychapter_numberascending. All assignments are returned regardless ofis_active.
Error Cases
404 Not Found— no comic with this ID.
Serves the comic's stored cover image.
Response 200 — image file (image/jpeg or image/png depending on stored format).
Error Cases
404— no comic with this ID, or no cover has been set.
Set or replace the cover image for a comic. Accepts either a URL to download from or a direct file upload.
Option A — URL (application/json):
{ "url": "https://source-url/cover.jpg" }Option B — Upload (multipart/form-data):
| Field | Type | Description |
|---|---|---|
file |
image file | PNG or JPG cover image |
Response 200
{ "cover_url": "/api/requests/1/cover" }Side Effects
- Downloads or saves the image to
COVERS_PATH/{comic_id}.{ext}, replacing any existing cover. - All future chapter downloads will have the new cover injected as
cover.png. Already-relocated chapters are not retroactively updated — usePOST /api/quality/{assignment_id}/relocateto re-process individual chapters if needed.
Error Cases
404— no comic with this ID.415 Unsupported Media Type— file is not a recognised image format.
Remove the cover image. Future chapter CBZs will not have cover.png injected.
Response 204 No Content
List all aliases for a comic.
Response 200
[
{ "id": 1, "title": "ワンピース" },
{ "id": 2, "title": "One Piece (Viz)" }
]Add a new alias to a comic. The alias title is used as a fallback search query when source_selector searches for this comic on sources.
Request Body
{ "title": "ワンピース" }Response 201
{ "id": 3, "title": "ワンピース" }Error Cases
404 Not Found— no comic with this ID.
Remove an alias from a comic.
Response 204 No Content
Error Cases
404 Not Found— alias does not exist or does not belong to this comic.
List all source-manga ID pins for a comic.
Path Parameters
| Name | Type | Description |
|---|---|---|
id |
int | Comic ID |
Response 200
[
{
"id": 1,
"source_id": 2,
"source_name": "MangaDex",
"suwayomi_manga_id": "42",
"pinned_at": "2025-03-15T09:00:00Z"
}
]Returns an empty array if no pins have been set.
Error Cases
404 Not Found— no comic with this ID.
Bulk-replace all source-manga ID pins for a comic. Deletes all existing pins and inserts the new set. Send an empty array to clear all pins.
When pins are set, source_selector bypasses title search for those sources and fetches chapters directly using the pinned manga IDs. Call POST /api/requests/{id}/discover after updating pins to pick up any newly discoverable chapters.
A comic may have multiple pins for the same source (e.g. a series split across several manga IDs on the same source).
Path Parameters
| Name | Type | Description |
|---|---|---|
id |
int | Comic ID |
Request Body
{
"pins": [
{ "source_id": 2, "suwayomi_manga_id": "42" },
{ "source_id": 2, "suwayomi_manga_id": "43" }
]
}Response 200 — same shape as GET /api/requests/{id}/pins.
Error Cases
404 Not Found— no comic with this ID.
List all enabled sources with their global priority and, if overridden for this comic, their effective (comic-local) priority.
Required role: Requestor or Admin
Path Parameters
| Name | Type | Description |
|---|---|---|
id |
int | Comic ID |
Response 200
[
{
"source_id": 1,
"source_name": "MangaDex",
"global_priority": 1,
"effective_priority": 2,
"is_overridden": true
},
{
"source_id": 2,
"source_name": "Webtoons",
"global_priority": 2,
"effective_priority": 1,
"is_overridden": true
}
]Entries are sorted by effective_priority ascending. If no overrides exist for this comic, effective_priority == global_priority and is_overridden == false for all entries.
Error Cases
404 Not Found— no comic with this ID.
Replace the comic-local source priority order. The caller provides the full ordered list of all enabled source IDs; the backend assigns positions 1, 2, 3… in that order.
Passing sources in a different order than the global ranking creates per-comic overrides. All existing overrides for this comic are deleted and replaced atomically.
The list must contain every enabled source exactly once — passing a partial list or an unknown source ID returns 422.
Required role: Requestor or Admin
Path Parameters
| Name | Type | Description |
|---|---|---|
id |
int | Comic ID |
Request Body
{ "source_ids": [2, 1, 3] }source_ids must contain every enabled source ID exactly once, in the desired priority order (index 0 = highest priority).
Response 200 — same shape as GET /api/requests/{id}/source-overrides, reflecting the new priorities.
Error Cases
404 Not Found— no comic with this ID.422 Unprocessable Entity— list is incomplete, contains duplicates, or references an unknown source ID.
Remove all comic-local source priority overrides for this comic, reverting it to the global source priority order.
Idempotent — returns 204 even if no overrides exist.
Required role: Requestor or Admin
Path Parameters
| Name | Type | Description |
|---|---|---|
id |
int | Comic ID |
Response 204 No Content
Error Cases
404 Not Found— no comic with this ID.
Return the current application settings. Any authenticated user may call this endpoint.
Response 200
{
"suwayomi_url": "https://suwayomi.example.com",
"suwayomi_username": "admin",
"suwayomi_password": "**masked**",
"suwayomi_download_path": "/data/suwayomi/downloads",
"library_path": "/data/library",
"default_poll_days": 7,
"chapter_naming_format": "{title}/{title} - Ch.{chapter}.cbz",
"relocation_strategy": "auto"
}| Field | Type | Notes |
|---|---|---|
suwayomi_url |
string | null | Suwayomi server URL |
suwayomi_username |
string | null | Suwayomi login username |
suwayomi_password |
"**masked**" | null |
"**masked**" if a password is set; null if unset |
suwayomi_download_path |
string | null | Suwayomi's download staging directory |
library_path |
string | null | Final library directory |
default_poll_days |
int | Default poll interval in days |
chapter_naming_format |
string | Template for chapter file paths |
relocation_strategy |
string | "auto" / "hardlink" / "copy" / "move" |
Error Cases
401 Unauthorized— missing or invalid token.
Update one or more settings. All fields are optional; omitted fields are left unchanged. Any authenticated user may call this endpoint.
Request Body — all fields optional:
{
"suwayomi_url": "https://suwayomi.example.com",
"suwayomi_username": "admin",
"suwayomi_password": "newpassword",
"suwayomi_download_path": "/data/suwayomi/downloads",
"library_path": "/data/library",
"default_poll_days": 14,
"chapter_naming_format": "{title}/{title} - Ch.{chapter}.cbz",
"relocation_strategy": "hardlink"
}| Field | Type | Notes |
|---|---|---|
suwayomi_url |
string | null | New Suwayomi URL |
suwayomi_username |
string | null | New username |
suwayomi_password |
string | null | New password; null = leave unchanged |
suwayomi_download_path |
string | null | Must be an existing directory |
library_path |
string | null | Must be an existing directory |
default_poll_days |
int | null | New default poll interval |
chapter_naming_format |
string | null | New naming format string |
relocation_strategy |
"auto" | "hardlink" | "copy" | "move" | null |
New relocation strategy |
Response 200 — same schema as GET /api/settings, with the updated values (password still masked).
Behaviour
- If any of
suwayomi_url,suwayomi_username, orsuwayomi_passwordis provided, Otaki pings Suwayomi with the resulting credentials before saving. If connectivity fails, the request is rejected and no settings are changed. - Path fields (
suwayomi_download_path,library_path) are validated to be existing directories before saving. - Values are persisted to
.envand applied to the in-memorysettingssingleton immediately.
Error Cases
400 Bad Request— Suwayomi ping failed (when connection fields are provided), or a path field is not a valid directory.401 Unauthorized— missing or invalid token.422 Unprocessable Entity— invalidrelocation_strategyvalue.
Download a backup of the current Otaki state.
Query Parameters
| Name | Type | Default | Description |
|---|---|---|---|
format |
"otaki" | "json" | "csv" |
"otaki" |
Export format |
include_all_assignments |
bool | false |
Include inactive assignments (upgrade candidates). Ignored for csv. |
Formats
otaki— zip archive (otaki-backup-<date>.zip) containingbackup.json(full DB snapshot) andcovers/(cover image files). Fully re-importable.json—backup.jsononly (no covers), returned inline asapplication/json. Useful for DB inspection or scripting.csv— one row per activeChapterAssignment. Not re-importable. Columns:comic_title,library_title,chapter_number,volume_number,source_name,download_status,relocation_status,library_path,chapter_published_at.
backup.json structure
{
"version": 1,
"exported_at": "2026-04-08T12:00:00+00:00",
"include_all_assignments": false,
"sources": [{"_id": 1, "suwayomi_source_id": "en.mangadex", "name": "MangaDex", "priority": 1, "enabled": true}],
"comics": [{"_id": 1, "title": "One Piece", "library_title": "One Piece", "status": "tracking",
"poll_override_days": null, "upgrade_override_days": null, "inferred_cadence_days": 7.0,
"created_at": "...", "cover_file": "covers/1.jpg"}],
"comic_aliases": [{"comic_id": 1, "title": "ワンピース"}],
"comic_source_pins": [{"comic_id": 1, "source_id": 1, "suwayomi_manga_id": "abc123"}],
"chapter_assignments": [{"comic_id": 1, "source_id": 1, "chapter_number": 1.0, "volume_number": null,
"suwayomi_manga_id": "abc123", "suwayomi_chapter_id": "ch-1",
"download_status": "done", "is_active": true, "chapter_published_at": "...",
"downloaded_at": "...", "library_path": "/library/...", "relocation_status": "done",
"source_chapter_name": null, "source_manga_title": null, "retry_count": 0}]
}_id values are backup-internal sequential integers used to link child records. They are not DB surrogate keys and are discarded on import.
Auth: Bearer token via Authorization header. Use fetch + blob download in the browser (native <a href> cannot send Authorization headers).
Response — 200 with appropriate content type and Content-Disposition: attachment header.
Error Cases
401 Unauthorized
Parse a backup file and return a diff against the current DB without writing anything.
Request — multipart/form-data:
| Field | Type | Description |
|---|---|---|
file |
file | Backup zip (or JSON) to upload |
path |
string | Alternative: path on the server to load from |
Exactly one of file or path must be provided.
Response 200
{
"source_conflicts": [
{"backup_id": 1, "suwayomi_source_id": "en.mangadex", "name": "MangaDex",
"import_priority": 2, "import_enabled": true,
"existing_priority": 1, "existing_enabled": true}
],
"comic_conflicts": [
{"backup_id": 1, "title": "Bleach", "existing_id": 7,
"import_chapters": 366, "import_aliases": 1, "import_pins": 2,
"existing_has_cover": true, "import_has_cover": true}
],
"new_sources": [{"backup_id": 2, "suwayomi_source_id": "en.webtoons", "name": "Webtoons"}],
"new_comics": [{"backup_id": 3, "title": "Vinland Saga", "import_chapters": 200,
"import_aliases": 0, "import_pins": 1, "import_has_cover": false}],
"totals": {"sources": 3, "comics": 12, "chapters": 1840, "covers": 8}
}source_conflicts— sources wheresuwayomi_source_idalready exists butpriorityorenableddiffer.comic_conflicts— comics whosetitlealready exists in the DB. One entry per(backup comic, existing comic)pair (multiple matches possible if titles collide).new_sources/new_comics— records that will be created without conflict.
Error Cases
401 Unauthorized422 Unprocessable Entity— not a valid zip or JSON file, or neitherfilenorpathprovided.
Apply a backup with user-supplied conflict resolutions. All changes are committed in a single transaction.
Request — multipart/form-data:
| Field | Type | Description |
|---|---|---|
file |
file | Backup zip (re-upload from preview) |
path |
string | Alternative: server-side path |
source_resolutions |
JSON string | List of source resolution objects |
comic_resolutions |
JSON string | List of comic resolution objects |
source_resolutions — one entry per source in source_conflicts; new sources are always created:
[{"backup_id": 1, "action": "overwrite"}]action: "overwrite" (update priority/enabled from backup) or "skip" (keep existing).
comic_resolutions — one entry per comic in both comic_conflicts and new_comics:
[
{"backup_id": 1, "action": "merge", "target_id": 7, "replace_cover": false},
{"backup_id": 3, "action": "create", "title_override": null},
{"backup_id": 5, "action": "create", "title_override": "Bleach (Remaster)"},
{"backup_id": 9, "action": "skip"}
]| Field | Required for | Description |
|---|---|---|
action |
all | "merge" / "create" / "skip" |
target_id |
merge |
DB id of the existing comic to merge into |
title_override |
create (optional) |
Rename the imported comic on creation |
replace_cover |
merge (optional) |
If true, overwrite existing cover with imported cover. Default false. |
Merge behaviour: adds any aliases, pins, and chapter assignments (by suwayomi_chapter_id) not already present on the target comic. Never duplicates. Cover: written only if target has none (unless replace_cover=true).
Response 200
{"comics": 3, "chapters": 1840, "covers": 2, "skipped": 12}Error Cases
401 Unauthorized422 Unprocessable Entity— malformed zip/JSON or invalid resolution objects.
List all configured sources in priority order.
Response 200
[
{
"id": 1,
"suwayomi_source_id": "1998944621602222888",
"name": "MangaDex",
"priority": 1,
"enabled": true,
"created_at": "2025-03-01T00:00:00Z"
}
]Add a new source.
Request Body
{
"suwayomi_source_id": "1998944621602222888",
"name": "MangaDex",
"priority": 1,
"enabled": true
}| Field | Type | Required | Notes |
|---|---|---|---|
suwayomi_source_id |
string | yes | The source ID string from Suwayomi |
name |
string | yes | Human-readable label |
priority |
int | yes | 1 = highest priority (most preferred) |
enabled |
bool | no | Defaults to true |
Response 201 — the created source object.
Error Cases
409 Conflict—suwayomi_source_idalready exists.
Update a source (e.g. change priority or toggle enabled).
Request Body — all fields optional:
{
"name": "MangaDex EN",
"priority": 2,
"enabled": false
}Response 200 — the updated source object.
Remove a source from the priority list.
Response 204 No Content
Notes
- Does not affect already-downloaded chapters or existing
ChapterAssignmentrows. - Comics currently using this source will retain it until an upgrade or manual change.
Upload a sample page and crop coordinates to extract a new watermark template.
Request — multipart/form-data
| Field | Type | Required | Description |
|---|---|---|---|
file |
image file | yes | Full page image containing the watermark (PNG/JPG) |
name |
string | yes | Identifier for this template |
x |
int | yes | Left edge of crop region (pixels) |
y |
int | yes | Top edge of crop region (pixels) |
w |
int | yes | Width of crop region (pixels) |
h |
int | yes | Height of crop region (pixels) |
source_id |
int | no | Associate with a specific source |
match_threshold |
float | no | Detection threshold 0.0–1.0; defaults to 0.8 |
Response 201
{
"id": 3,
"name": "MangaDex Logo",
"source_id": 1,
"file_path": "mangadex_logo.png",
"match_threshold": 0.8,
"enabled": true
}Side Effects
- Saves cropped region as PNG to
WATERMARKS_PATH/{name}.png. - Inserts a
WatermarkTemplaterow. - Invalidates the in-memory template cache in
quality_scannerso the new template is used immediately.
Notes
- The crop coordinates are relative to the uploaded image, not to the original manga page dimensions.
- If a file already exists at the target path, the upload will be rejected with
409.
List all watermark templates.
Response 200 — array of template objects (same schema as POST response).
Remove a watermark template. Deletes the PNG file and the DB row.
Response 204 No Content
Notes
- Does not retroactively change existing
QualityScanrecords that referenced this template.
All quality scan results for a comic, grouped by chapter.
Path Parameters
| Name | Type | Description |
|---|---|---|
comic_id |
int | Comic ID |
Response 200
[
{
"chapter_number": 1.0,
"assignment_id": 10,
"scan": {
"id": 5,
"scanned_at": "2025-03-15T09:31:00Z",
"watermark_count": 1,
"watermark_templates_matched": [3],
"has_header": false,
"has_footer": true,
"severity": "moderate",
"auto_fixed": true
}
}
]Notes
- Only active assignments (
is_active=true) are returned. scanisnullfor chapters not yet scanned.
Re-run the quality scanner on an existing downloaded chapter.
Path Parameters
| Name | Type | Description |
|---|---|---|
assignment_id |
int | ChapterAssignment ID |
Response 200 — the new QualityScan object.
Notes
- Requires
download_status=doneand the CBZ file to exist on disk. - Creates a new
QualityScanrow; does not delete the previous one. - Does not automatically trigger auto-fix or relocation.
Error Cases
404— assignment not found.409— chapter is not indonestate or CBZ file is missing.
Manually run the image processor to crop banners from a chapter.
Response 200
{
"assignment_id": 10,
"fixed": true,
"backup_path": "/suwayomi/downloads/One Piece/Ch.001.cbz.orig"
}Notes
- Requires a
QualityScanrow to exist (so the scanner knows what to crop). - Renames the original CBZ to
*.cbz.origbefore writing the cropped version. - Sets
quality_scan.auto_fixed=trueon the associated scan row. - If
auto_fixedis alreadytrue, this is a no-op and returnsfixed: false.
Error Cases
404— assignment not found.409— no scan result exists; run/rescanfirst.
Manually re-trigger relocation for a settled chapter.
Response 200
{
"assignment_id": 10,
"library_path": "/library/One Piece/One Piece - Ch.0001.0.cbz",
"relocation_status": "done"
}Notes
- Use this if relocation previously failed (
relocation_status=failed) or was skipped. - If the chapter is already relocated (
relocation_status=done), this is a no-op. - Uses the same hardlink/copy logic as automatic relocation.
Error Cases
404— assignment not found.409— chapterdownload_statusis notdone.
"clean" – no watermarks or banners detected
"minor" – 1 watermark, no banners
"moderate" – watermarks or a banner on one end
"severe" – watermarks and banners on both ends
"queued" – waiting in Suwayomi's download queue
"downloading" – actively downloading
"done" – download complete; file present on disk
"failed" – download failed; may be retried by Suwayomi
"pending" – waiting to be relocated (not yet settled)
"done" – file moved/linked to library path
"failed" – relocation attempted but errored
"skipped" – relocation was not applicable (e.g. chapter superseded by upgrade)
"tracking" – ongoing series; new chapters will be downloaded automatically
"complete" – series finished; no further updates expected
All errors return a JSON body:
{
"detail": "Comic with this title is already being tracked."
}| Status | Meaning |
|---|---|
400 Bad Request |
Malformed request body |
401 Unauthorized |
Missing, invalid, or expired session token |
404 Not Found |
Resource does not exist |
409 Conflict |
State conflict (duplicate, wrong status, etc.) |
422 Unprocessable Entity |
Validation error (missing required fields, wrong types) |
503 Service Unavailable |
Setup not complete, or Suwayomi is unreachable |
- No pagination —
GET /api/requestsandGET /api/quality/{comic_id}return all rows. For personal use this is acceptable; at large scale (thousands of tracked titles) this will become slow. - No real-time push — the frontend polls via TanStack Query; there is no WebSocket or SSE endpoint for live UI updates beyond what polling provides.
- Single Suwayomi instance — only one
SUWAYOMI_URLis supported. Multiple Suwayomi instances require separate deployments. - Filesystem coupling — image processing and relocation require this app to share the same download path as Suwayomi (Docker volume or same host). Remote or NFS-mounted paths may cause relocation to fall back to the slower copy+delete path.
- CBZ only — the scanner and image processor assume Suwayomi downloads in CBZ format. Other archive formats (CBR, PDF) are not supported.
- First/last page scanning only — the quality scanner checks only the first and last images in a CBZ. Per-page watermarks on inner pages are not detected.
- Upgrade replaces, does not merge — when a chapter is upgraded to a better source, the old file is replaced entirely. There is no diff or partial-page merge.