Conventions
This reference is generated from the server's OpenAPI 3.1 description,
openapi.yaml, which you can load into any
OpenAPI tool or client generator. Paths are relative to wherever the
server is reachable: its host and port, or a reverse proxy in front of
it. The port is 10111 unless changed with LOWKEY_PORT or the
port config key.
Each endpoint lists its access level, parameters, request body and
responses with every field's type. A type name such as
Job links to its definition under
Schemas. Bodies are JSON unless a content type
says otherwise. Booleans in query strings are the literal strings
true and false. A path parameter written
{id} is one path segment.
Many older handlers do not check the HTTP method and accept any method
with the documented body; the method shown is the one the clients send.
Where it matters, the endpoint says so.
Code is coloured by role:
literal
--flag
{param}
/path
ENV_VAR
"value"
{ }
Access Levels
Every endpoint is marked with one of three access levels.
| Level | Requirement |
Public | No credential. |
Read | A credential, unless the allowPublicAccess setting is on, in which case anonymous requests are accepted. Write methods on a Read path still require a credential while public access is on; the endpoint notes this. |
Admin | A credential. Every authenticated user is an admin. |
A credential is one of: an Authorization: Bearer <token>
header carrying a JWT or an lk_ API key; an
X-API-Key: <key> header; or the
auth_token cookie set by POST /auth/login.
Headers are checked before the cookie. JWTs and the cookie are valid
for one year; API keys until revoked.
A request that fails authentication receives 401 when it
carries a Bearer or X-API-Key header, sends
exactly Accept: application/json, or targets a path under
/api/. Any other request is redirected with
302: to /setup until first-run setup is
complete, then to /login.
While the temporary default admin account is the only
user, requests authenticated as it receive 403 with
{"error": "setup_required", "message": "Please create a new user account"};
page requests are redirected to /login?setup=true.
Errors
There is no single error envelope. Most handlers write
{"error": "<message>"} (the Error
schema), often labelled Content-Type: text/plain because
they go through Go's http.Error; parse the body as JSON
regardless of the header. Older handlers write a bare plain-text
message. Each endpoint lists the statuses it returns and which form
the body takes. The status codes follow HTTP semantics:
| Status | Meaning |
400 | Malformed JSON, a missing required field, an invalid parameter, or a failed validation. |
401 | Missing or invalid credential (see above). |
403 | Path outside every configured storage root, setup required, or a cross-origin form post. |
404 | Unknown job, workflow, model, file, person, or scene. |
405 | Method not supported, on handlers that check the method. |
409 | Conflict: a move destination already in the library, a tag in another category. |
413 | Request body over its cap. |
422 | The input was valid but unusable, for example an image with no face to search by. |
500 | Database or filesystem failure. |
503 | A subsystem is unavailable: the auto scheduler is not running, or the event stream is at its connection limit. |
Media Paths
A media item is identified by its path: the absolute
filesystem path for local roots, or s3://bucket/key for
object storage roots. Paths are compared as stored. Endpoints that
serve or process a file refuse paths outside every configured storage
root with 403. Paths in query strings must be URL-encoded.
The Windows, macOS and Linux servers register the same routes with the
same access levels, with these exceptions:
POST /api/embedding/directml/install and the /events page exist only on Windows.
- The
/stats page exists only on macOS and Linux.
GET /tasks includes each task's options only on Windows.
POST /api/upload honours the destination field only on Windows; macOS and Linux always write to uploads/ in the default root.
/api/scene-dream endpoints exist only in builds made with the Dream feature (LOKI_DREAM=1).
Authentication
Every non-public route runs through the server's auth middleware. A request is authenticated by one of these credentials, checked in this order:
- an
X-API-Key: lk_... header; - an
Authorization: Bearer <token> header carrying either a JWT (from POST /auth/login) or an lk_ API key (anything starting with lk_ is checked against the API-key table, anything else as a JWT); - the
auth_token cookie set by POST /auth/login (HttpOnly, SameSite=Lax, expires with the JWT after 365 days).
A header credential that fails verification is not fatal by itself: the middleware falls through to the cookie. When nothing valid is found the response depends on the kind of request. It is treated as an API request, and answered 401 with the plain-text body Unauthorized, when any of these is true: the Accept header is exactly application/json, the request carries an X-API-Key or Bearer header, or the path starts with /api/. Every other request (a browser navigation) gets a 302 redirect to /login?redirect=<original URI>, or to /setup while first-run setup is not complete.
Before first-run setup, the server has a temporary admin/admin account. While that is the only account, a request authenticated as it is refused: API requests get 403 with the JSON body {"error":"setup_required","message":"Please create a new user account"}; page requests are redirected to /login?setup=true (or /setup). Creating the first real user (POST /auth/users or the setup wizard) deletes the temporary account.
Access levels on each operation (x-access): public needs no credential; read needs one unless Allow Public Access (allowPublicAccess in the config) is on, in which case anonymous requests pass, but write methods on mixed routes still require a credential and answer 401 {"error":"unauthorized"} without one; admin always needs a credential. Every authenticated user is an admin.
API keys live in the library database, so switching the server to another database (POST /config with a new dbPath) makes existing keys and sessions invalid there.
Exchange a username and password for a JWT.
On success the JWT is returned in the body and also set as the auth_token cookie (HttpOnly, SameSite=Lax, Path=/, expiring after 365 days, the same lifetime as the token). CORS on this route echoes a chrome-extension:// or moz-extension:// origin, otherwise allows http://localhost:1212, with credentials. OPTIONS answers 200 with no body.
Request body
application/json
Example
{
"username": "alice",
"password": "correct-horse"
}
Response 200
Logged in.
application/json
tokenstringrequired
The JWT. Use it as Authorization: Bearer <token>.
setup_requiredbooleanrequired
True when the login used the temporary admin account and it is still the only account. Requests made with this token are refused until a real user is created.
Example
{
"status": "ok",
"token": "eyJhbGciOiJIUzI1NiIs...",
"setup_required": false
}
Errors
- 400Body is not valid JSON. Plain text
Invalid JSON. text/plain - 401Wrong username or password (or a database error during lookup). The body is the text
{"error":"Invalid credentials"} sent with a text/plain content type. text/plain - 405Method other than POST or OPTIONS. Plain text. text/plain
Clear the session cookie.
Expires the auth_token cookie. It does not revoke the JWT itself: a copy of the token held elsewhere stays valid until it expires. Any method other than POST and OPTIONS (for example a browser GET /auth/logout) clears the cookie and answers 302 to /login.
Response 200
Cookie cleared.
application/json
Response 302
Returned for non-POST methods. Redirects to /login.
Report whether the request carries a valid credential.
Never fails: an invalid credential reports loggedIn: false. Header credentials are checked first. When the credential is the auth_token cookie, the response also carries the session's JWT in token, so the embedded web viewer can use it as a Bearer token. The handler does not check the method; every method except OPTIONS behaves like GET.
Response 200
Credential status.
application/json
usernamestring
Present when loggedIn is true.
publicAccessbooleanrequired
Whether Allow Public Access is on.
defaultStartPathstring
The configured default start path for the viewer. Present when loggedIn is true.
tokenstring
The cookie session's JWT. Present only when the credential was the cookie.
Example
{
"loggedIn": true,
"username": "alice",
"publicAccess": false,
"defaultStartPath": "D:\\Media"
}
List user accounts.
The route skips the auth middleware so the first account can be created before any credential exists (see POST); the handler itself requires a valid credential (header or cookie) to list users.
Response 200
Users, ordered by username.
application/json
usersarray | nullrequired
null when the users table is empty.
Example
{
"users": [
{
"id": 2,
"username": "alice",
"created_at": 1735689600
}
]
}
Errors
- 401No valid credential (header or cookie). Error
- 500Database error. Plain text. text/plain
Create a user account.
Open without a credential only while setup is required (the temporary admin account is the only account). After that the caller must present a valid credential (header or cookie). Creating a user also deletes the temporary admin account if it still exists. The username admin is reserved.
Request body
application/json
usernamestringrequired
Must not be admin or an existing username.
Example
{
"username": "bob",
"password": "s3cret-pass"
}
Response 201
Created. The body is {"status":"created"} (no Content-Type header is set).
application/json
Errors
- 400Invalid JSON, or
username/password empty. Plain text. text/plain - 403Setup is complete and the request has no valid credential. Body is the text
{"error":"unauthorized"} with a text/plain content type. text/plain - 500Username taken, reserved name
admin, or database error. Plain-text message. text/plain
Delete a user account and revoke its API keys.
Requires a valid credential (header or cookie). The last remaining user cannot be deleted. Deleting a username that does not exist still answers 200.
Response 200
Deleted. The body is {"status":"deleted"} (no Content-Type header is set).
application/json
Errors
- 400
username missing. Plain text. text/plain - 401No valid credential (header or cookie). Error
- 500
cannot delete the last user, or a database error. Plain text. text/plain
List API keys (without their secret values).
Response 200
Every key, for every user.
application/json
Example
{
"keys": [
{
"id": 3,
"username": "alice",
"name": "ci",
"prefix": "lk_ab12cd34",
"created_at": 1735689600,
"last_used_at": 1735776000
}
]
}
Errors
- 500Database error. Plain text. text/plain
Create an API key. The plaintext key is returned only once.
Request body
application/json
namestringrequired
Label for the key. Must not be blank.
usernamestring
User the key acts as. Defaults to the authenticated caller. Any existing user may be named.
Example
{
"name": "ci",
"username": "alice"
}
Response 201
Created.
application/json
keystringrequired
The full key: lk_ followed by 64 hex characters. Not retrievable later.
idinteger (int64)required
prefixstringrequired
First 11 characters of the key (lk_ + 8 hex), stored for identification.
Example
{
"status": "created",
"key": "lk_ab12cd34...",
"id": 3,
"name": "ci",
"username": "alice",
"prefix": "lk_ab12cd34"
}
Errors
- 400Invalid JSON, blank
name, no username resolvable, or the named user does not exist. Plain text. text/plain - 500Database error, or the user is the temporary
admin account (keys cannot be created for it). Plain text. text/plain
Revoke an API key.
Query parameters
idinteger (int64)required
Response 200
Revoked (also returned when no key had that id).
application/json
Errors
- 400
id missing or not an integer. Plain text Key id required. text/plain - 500Database error. Plain text. text/plain
GET/auth/cli/authorizePublic# Browser approval page for the CLI loopback login flow.
Step 1 of the RFC 8252 loopback flow with PKCE used by lokictl login and the viewer's "Sign in with browser". The client listens on 127.0.0.1:<port>, generates a PKCE verifier and opens this page. Only the auth_token cookie session counts here (header credentials are ignored). With no session the browser is redirected to /login?redirect=<this URL>. The page shows the key name and user, and posts the same parameters back to this URL with action=approve or action=deny.
Query parameters
portintegerrequired
Loopback port the client listens on.
min 1, max 65535.
statestringrequired
Opaque client state, echoed back on the callback.
code_challengestringrequired
base64url (no padding) SHA-256 of the PKCE verifier (S256).
namestring
Name for the API key that will be minted. Control characters are stripped; truncated to 64 bytes.
Default "lokictl".
Response 200
The approval page (Cache-Control: no-store).
text/html
string
Response 302
No browser session. Redirects to /login?redirect=....
Errors
- 400
invalid port, invalid state or invalid code_challenge. Plain text. text/plain - 403The session belongs to the temporary
admin account. Plain text. text/plain
POST/auth/cli/authorizePublic# Approve or deny the CLI login and redirect to the loopback callback.
Submitted by the approval page. The redirect target is always built server-side as http://127.0.0.1:<port>/callback. On approve, a one-time authorization code bound to the session user, the PKCE challenge and the key name is minted (single use, expires after 2 minutes) and the browser is sent to ...callback?code=<code>&state=<state>. On deny (any action other than approve), it is sent to ...callback?error=access_denied&state=<state>. If the browser sends an Origin header, its host must equal the request Host.
Request body
application/x-www-form-urlencoded
code_challengestringrequired
Response 303
Redirect to the loopback callback with code and state, or error=access_denied and state.
Errors
- 400Unparseable form or invalid
port/state/code_challenge. Plain text. text/plain - 401Approve without a valid
auth_token cookie. Plain text Not logged in. text/plain - 403Cross-origin post, or the session is the temporary
admin account. Plain text. text/plain - 500Could not create the code. Plain text. text/plain
POST/auth/cli/tokenPublic# Exchange a CLI authorization code and PKCE verifier for a new API key.
Step 2 of the loopback flow. The code is consumed on the first attempt whether or not the verifier matches. On success a new lk_ API key is minted for the approving user, named after the name given to the authorize page.
Request body
application/json
codestringrequired
The code from the callback.
code_verifierstringrequired
PKCE verifier; SHA-256 of it, base64url-encoded without padding, must equal the code_challenge.
Example
{
"code": "9f2c...e1",
"code_verifier": "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
}
Response 200
Key minted.
application/json
keystringrequired
The plaintext lk_ API key.
Example
{
"status": "ok",
"key": "lk_ab12cd34...",
"username": "alice",
"name": "lokictl@host"
}
Errors
- 400
Invalid JSON, Invalid code_verifier, Invalid or expired code, or code_verifier does not match. Error - 405Not POST. Error
- 500Key creation failed. Error
Jobs
Every piece of server work runs as a job in a persistent queue. A job names a task (its command, one of the ids listed by GET /tasks), a list of option tokens (arguments, parsed by the task as --name value or --name=value) and an input string (usually a path, a newline-separated path list, or a query).
Jobs start in pending and are claimed in FIFO order once every job in dependencies has completed and every concurrency bucket the job occupies (host plus resources) has a free slot. A job whose task id is not registered is still accepted; it fails with error when claimed and logs Task not found: <id>.
Live changes to the queue are pushed over GET /stream (create, update, delete, progress and stdout-<jobId> events). Pollers that cannot hold an event stream open can use GET /api/job/{id}.
All routes in this section require an admin credential (bearer JWT, lk_ API key in Authorization or X-API-Key, or the auth_token cookie). Without one, a request that looks like an API call (it sends Accept: application/json, sends a credential header, or its path starts with /api/) gets 401 with the plain-text body Unauthorized; any other request is redirected (302) to /login. While first-run account setup is still pending, a request authenticated as the built-in default admin gets 403 with {"error":"setup_required","message":"Please create a new user account"} (or a redirect to /login?setup=true for browsers).
Most handlers in this section are older ones: their error bodies are plain text (text/plain), not the JSON Error object.
Create a job from a command string.
Queues one job. The input string is split into tokens by these rules:
- Tokens are separated by the space character only (tabs and newlines are not separators and stay inside tokens).
- A double quote (
") toggles quoting; spaces inside quotes are kept. The quote characters themselves are removed. There is no escape syntax, so a token cannot contain a literal double quote. Quoting can start mid-token (a"b c"d is one token ab cd). - Empty tokens are discarded.
The first token is the task id. If there is more than one token, the last token is the job input and the tokens in between become arguments. A single-token string creates a job with no arguments and an empty input.
Each non-empty key/value pair of fields is then appended to arguments as two tokens, --<key> and <value>, verbatim (no splitting or unquoting), so values can contain spaces, quotes or newlines. Pairs with an empty key or empty value are skipped. Map order is not preserved, and any key is accepted. Task options are parsed with the last occurrence winning, so a fields entry overrides the same option given in input.
The task id is not validated here: an unknown task is accepted and the job fails when it runs. The job is assigned to a fresh single-node workflow (its workflow_id is set).
Request body
application/json
CreateJobRequest
inputstringrequired
Command string: <task-id> [option tokens...] <input>, split on spaces with double-quote grouping (see the operation description).
fieldsmap of string
Extra options appended verbatim as --<key> <value> token pairs. Empty keys or values are skipped.
Example
{
"input": "describe --overwrite \"D:/photos/2024 trip\"",
"fields": {
"prompt": "Describe the scene in one sentence."
}
}
Response 201
Job queued.
application/json
Example
{
"id": "3f0c8f5e-6a0e-4c41-9a7a-2f1f3d0b8c11"
}
Errors
- 400The body is not valid JSON (
bad json) or input contains no tokens (Invalid input). text/plain - 401Missing or invalid credential (API-style request). text/plain
- 405Method other than POST (
Use POST). text/plain - 500The queue rejected the job, or the
create broadcast failed to render (job may already be queued). text/plain
List every job in the queue.
Returns all jobs held in memory (every job not yet removed or cleared), newest first. There is no paging or filtering. Job log lines are not included; use GET /api/job/{id} or the stdout-<jobId> stream event.
Response 200
All jobs, newest first.
application/json
array of Job
A job in the queue.
argumentsarray of stringrequired
Option tokens passed to the task.
inputstringrequired
The job input after workflow substitution.
original_inputstringrequired
hoststringrequired
Concurrency bucket the job runs in.
resourcesarray of stringrequired
Additional concurrency buckets the job occupies.
dependenciesarray of stringrequired
Ids of jobs that must complete first.
One of "pending" "in_progress" "paused" "completed" "cancelled" "error".
created_atstring (date-time)required
claimed_atstring (date-time)required
Zero time (0001-01-01T00:00:00Z) until claimed.
completed_atstring (date-time)required
errored_atstring (date-time)required
output_filesarray of stringrequired
Files the job registered for downstream workflow steps.
source_filesarray of stringrequired
Parallel to output_files; the source of each output.
workflow_idstringrequired
Set when the job belongs to a workflow.
progress_doneintegerrequired
progress_totalintegerrequired
interrupt_countintegerrequired
Times the job was found still in progress at server startup (its run died: crash, out of memory, unclean shutdown). After a capped number of auto-resumes the job is failed instead.
Errors
- 401Missing or invalid credential (API-style request). Browser-style requests are redirected to
/login. text/plain - 405Method other than GET (
Use GET). text/plain
Job detail page (HTML).
Server-rendered admin page for one job, with its log and live output over /stream. This is a page for browsers, not a JSON API; use GET /api/job/{id} for machine-readable job state. The handler does not check the method, so any method renders the page.
Path parameters
idstringrequired
Job id (UUID, or the node id given to POST /workflow).
Response 200
The rendered page.
text/html
string
Response 302
Not signed in (browser request); redirects to /login?redirect=....
Errors
- 401Invalid credential header or
Accept: application/json without a credential. text/plain - 404No job with that id (
404 page not found). text/plain
POST/job/{id}/cancelAdmin# Cancel a job.
Cancels a pending, in_progress or paused job: its context is cancelled and its state becomes cancelled immediately. A running task keeps its concurrency slots until it actually returns. Dependents of the job are not cancelled.
The handler ignores the queue's result, so it answers 200 even when the id does not exist or the job is already in a terminal state (nothing changes in those cases).
Path parameters
idstringrequired
Job id (UUID, or the node id given to POST /workflow).
Response 200
Always Job cancelled successfully.
text/plain
string
Example
Job cancelled successfully
Errors
- 401Missing or invalid credential (API-style request). text/plain
- 405Method other than POST (
Use POST). text/plain
POST/job/{id}/pauseAdmin# Pause a job.
A pending job becomes paused at once. For an in_progress job a pause is requested: the task finishes the item it is working on, then the job parks in paused with its progress kept. Tasks that do not poll for pause requests run to completion.
Path parameters
idstringrequired
Job id (UUID, or the node id given to POST /workflow).
Response 200
Pause applied or requested (Job pause requested).
text/plain
string
Example
Job pause requested
Errors
- 400Unknown id (
job not found) or the job is not pending or in progress. text/plain - 401Missing or invalid credential (API-style request). text/plain
- 405Method other than POST (
Use POST). text/plain
POST/job/{id}/resumeAdmin# Resume a paused job.
Returns a paused job to pending so it is claimed again. The task re-resolves its input and skips items it already processed.
Path parameters
idstringrequired
Job id (UUID, or the node id given to POST /workflow).
Response 200
Job re-queued (Job resumed).
text/plain
string
Example
Job resumed
Errors
- 400Unknown id (
job not found) or the job is not paused. text/plain - 401Missing or invalid credential (API-style request). text/plain
- 405Method other than POST (
Use POST). text/plain
Clone a job into a new pending job.
Creates a new pending job with a fresh id and the same command, arguments, original input, dependencies, host, resources and workflow id. Progress, outputs, log and timestamps are reset.
Path parameters
idstringrequired
Job id (UUID, or the node id given to POST /workflow).
Response 201
Copy queued.
application/json
messagestringrequired
Always Job copied successfully.
Example
{
"id": "9b6a1d2e-0f43-4c1e-8e57-0d3c5b7e2a90",
"message": "Job copied successfully"
}
Errors
- 401Missing or invalid credential (API-style request). text/plain
- 405Method other than POST (
Use POST). text/plain - 500Unknown id (
job not found) or the broadcast failed. text/plain
POST/job/{id}/removeAdmin# Delete a job record.
Removes the job from the queue and the database in any state, and broadcasts a delete event. It does not cancel a running task (the task keeps running unobserved); cancel first.
Path parameters
idstringrequired
Job id (UUID, or the node id given to POST /workflow).
Response 200
Removed (Job removed successfully).
text/plain
string
Example
Job removed successfully
Errors
- 401Missing or invalid credential (API-style request). text/plain
- 405Method other than POST (
Use POST). text/plain - 500Unknown id (
job not found) or the broadcast failed. text/plain
Delete every finished job.
Removes every job in a terminal state (completed, cancelled, error) and broadcasts a delete event for each. pending, in_progress and paused jobs are kept.
Response 200
Jobs cleared.
application/json
cleared_countintegerrequired
messagestringrequired
Cleared <n> finished jobs.
Example
{
"cleared_count": 12,
"message": "Cleared 12 finished jobs"
}
Errors
- 401Missing or invalid credential (API-style request). text/plain
- 405Method other than POST (
Use POST). text/plain - 500A
delete broadcast failed part way (the jobs are already removed). text/plain
One job's state, progress, outputs and log tail.
A compact JSON snapshot for pollers (for example Lowkey Studio following a splat job cross-origin with an API key). Sent with Cache-Control: no-store.
Path parameters
idstringrequired
Job id (UUID, or the node id given to POST /workflow).
Query parameters
tailinteger
Number of most recent log lines to return. Values outside 0 to 500, or not an integer, fall back to the default.
Default 20. min 0, max 500.
Response 200
The job snapshot.
application/json
JobSnapshot
One of "pending" "in_progress" "paused" "completed" "cancelled" "error".
progress_doneintegerrequired
Items finished (including skips).
progress_totalintegerrequired
Total items; 0 until the input resolves.
output_filesarray | nullrequired
Files the job registered so far; null when none were ever registered.
created_atstring (date-time)required
completed_atstring (date-time)required
Zero time (0001-01-01T00:00:00Z) until completed.
logarray of stringrequired
The last tail log lines, oldest first.
Example
{
"id": "3f0c8f5e-6a0e-4c41-9a7a-2f1f3d0b8c11",
"command": "splat",
"state": "in_progress",
"progress_done": 2,
"progress_total": 5,
"output_files": [],
"created_at": "2026-10-31T09:12:44.512Z",
"completed_at": "0001-01-01T00:00:00Z",
"log": [
"colmap: registering frames",
"brush: step 1200/30000"
]
}
Errors
- 401Missing or invalid credential. text/plain
- 404No job with that id (
job not found). text/plain - 405Method other than GET (
Use GET). text/plain
GET/api/jobs/for-pathAdmin# Active jobs operating on one media file.
Answers from the queue's in-memory path index. Only active jobs (pending, in_progress, paused) are returned. Jobs whose input is a path list appear as soon as they are created; jobs whose input is a query appear once they resolve it when claimed.
Query parameters
pathstringrequired
Media file path, exactly as stored in the library.
Response 200
Matching jobs (possibly none).
application/json
pathstringrequired
The path that was asked about, echoed.
Example
{
"path": "D:/photos/2024/beach.mp4",
"jobs": [
{
"id": "3f0c8f5e-6a0e-4c41-9a7a-2f1f3d0b8c11",
"command": "transcribe",
"arguments": [],
"state": "in_progress",
"progress_done": 0,
"progress_total": 1,
"created_at": "2026-10-31T09:12:44.512Z"
}
]
}
Errors
- 400
path is missing or empty (missing path parameter). text/plain - 401Missing or invalid credential. text/plain
- 405Method other than GET (
Use GET). text/plain
Registered tasks and their option schemas.
Lists every task id that can be used as a job command, sorted by id. On Windows each task also carries its options (an empty array when it has none). The macOS and Linux builds return only id and name, with no options field.
Response 200
The task list.
application/json
Example
{
"tasks": [
{
"id": "describe",
"name": "Generate Descriptions",
"options": [
{
"name": "model",
"label": "Vision Model",
"type": "string",
"description": "Override the configured vision model for descriptions"
},
{
"name": "prompt",
"label": "Custom Prompt",
"type": "string",
"description": "Override the configured describe prompt for this run"
},
{
"name": "overwrite",
"label": "Overwrite Existing",
"type": "bool",
"description": "Reprocess items that already have this output (default: skip them)"
},
{
"name": "workers",
"label": "Worker Override",
"type": "number",
"description": "Override the number of parallel workers (0 = automatic)"
}
]
},
{
"id": "wait",
"name": "Wait",
"options": []
}
]
}
Errors
- 401Missing or invalid credential (API-style request). text/plain
- 405Method other than GET (
Use GET). text/plain
Workflows
A workflow is a DAG of task nodes (WorkflowTask). Each node becomes one job; a node's dependencies name other nodes in the same DAG, and the job only starts once those jobs have completed.
When a job with dependencies is claimed, its effective input is rebuilt as its original_input followed by, one per line, every output_files entry of each dependency (or, for tasks that register no output files, the dependency's log lines). When a job in a workflow ends in error, every pending job in the same workflow that depends on it (transitively) is cancelled. Cancelling a job by hand does not cascade: its dependents stay pending.
POST /workflow runs an ad-hoc DAG once. Saved workflows (/workflows*) are templates stored in the library database (workflows table, names unique) that can be run any number of times with a runtime input.
All routes require an admin credential; see the Jobs section for how unauthenticated requests are answered. Errors are plain text.
Run a DAG once without saving it.
Queues one job per node, in array order, all sharing a new workflow id. Unlike saved workflows, the DAG is not validated and node ids are used as the job ids verbatim (a node with an empty id gets a generated UUID, but then nothing can depend on it). Node ids must therefore be globally unique across the queue; a clash fails the request part way, leaving earlier nodes queued. A dependency on an id that never becomes a completed job leaves the dependent pending forever.
Request body
application/json
Example
{
"tasks": [
{
"id": "7d9a2a0c-1111-4a5e-9a0b-000000000001",
"command": "metadata",
"arguments": [],
"input": "D:/photos/2024",
"dependencies": []
},
{
"id": "7d9a2a0c-1111-4a5e-9a0b-000000000002",
"command": "autotag",
"arguments": [],
"input": "",
"dependencies": [
"7d9a2a0c-1111-4a5e-9a0b-000000000001"
]
}
]
}
Response 201
Jobs queued.
application/json
WorkflowRunResponse
idsarray | nullrequired
The created job ids, in node order. null when the DAG was empty (POST /workflow only).
Errors
- 400Body is not valid JSON (
bad json). text/plain - 401Missing or invalid credential (API-style request). text/plain
- 405Method other than POST (
Use POST). text/plain - 500A node id already exists as a job (
job with given ID already exists), or a broadcast failed. text/plain
List saved workflows.
Ids and names only, ordered by name. Empty library returns [].
Response 200
Saved workflow summaries.
application/json
array of SavedWorkflowSummary
Example
[
{
"id": "1c2e7d0a-5b8e-4f3e-9d61-2c4b8a7f0e13",
"name": "Ingest and tag"
}
]
Errors
- 401Missing or invalid credential (API-style request). text/plain
- 405Method other than GET (
Use GET). text/plain - 500Database error. text/plain
POST/workflows/createAdmin# Save a workflow template.
Validates the DAG (non-empty; every node has a non-empty id and command; node ids unique; every dependency names a node in the DAG) and stores it under a new UUID. Cycles are not detected. Names must be unique; name is not otherwise checked and may be empty.
Request body
application/json
SavedWorkflowInput
namestring
Workflow name; must be unique.
Non-empty DAG. Every node needs a non-empty, unique id and a command; every dependency must name a node in this DAG.
Example
{
"name": "Ingest and tag",
"dag": [
{
"id": "scan",
"command": "metadata",
"arguments": [],
"input": "",
"dependencies": [],
"pos_x": 40,
"pos_y": 80
},
{
"id": "tag",
"command": "autotag",
"arguments": [],
"input": "",
"dependencies": [
"scan"
],
"pos_x": 280,
"pos_y": 80
}
]
}
Errors
- 400Body is not valid JSON (
bad json). text/plain - 401Missing or invalid credential (API-style request). text/plain
- 405Method other than POST (
Use POST). text/plain - 500DAG validation failed (for example
DAG must not be empty, all tasks must have an id, all tasks must have a command, duplicate task id: <id>, dependency <id> not found in DAG), the name is already taken (SQLite UNIQUE constraint message), or another database error. text/plain
Get one saved workflow with its DAG.
Path parameters
idstringrequired
Saved workflow id (UUID).
Errors
- 401Missing or invalid credential (API-style request). text/plain
- 404Unknown id (
workflow not found: <id>) or any other load error (the handler maps every error to 404). text/plain
Replace a saved workflow's name and DAG.
Both name and dag are replaced (an omitted name becomes empty). The DAG is validated as for create.
Path parameters
idstringrequired
Saved workflow id (UUID).
Request body
application/json
SavedWorkflowInput
namestring
Workflow name; must be unique.
Non-empty DAG. Every node needs a non-empty, unique id and a command; every dependency must name a node in this DAG.
Response 200
The updated workflow, re-read from the database.
application/json
SavedWorkflow
Errors
- 400Body is not valid JSON (
bad json). text/plain - 401Missing or invalid credential (API-style request). text/plain
- 500DAG validation failed, the id does not exist (
workflow not found: <id>, returned as 500, not 404), the name is taken, or another database error. text/plain
DELETE/workflows/{id}Admin# Delete a saved workflow.
Jobs already started from the workflow are not affected.
Path parameters
idstringrequired
Saved workflow id (UUID).
Errors
- 401Missing or invalid credential (API-style request). text/plain
- 500The id does not exist (
workflow not found: <id>, returned as 500, not 404) or a database error. text/plain
POST/workflows/{id}/runAdmin# Instantiate a saved workflow as jobs.
Loads the template, gives every node a fresh UUID (dependencies are remapped to match), and queues one job per node in DAG order under a new workflow id. When input is non-empty it is injected into every root node (a node with no dependencies): appended after a space to the node's own input, or used as-is when the node's input is empty. Node pos_x / pos_y are dropped.
Path parameters
idstringrequired
Saved workflow id (UUID).
Request body
A JSON body is required (an empty body is rejected as bad json); input may be omitted or empty.
application/json
inputstring
Runtime input for the root nodes.
Example
{
"input": "D:/photos/2024"
}
Response 201
Jobs queued; ids in DAG order.
application/json
WorkflowRunResponse
idsarray | nullrequired
The created job ids, in node order. null when the DAG was empty (POST /workflow only).
Example
{
"ids": [
"0a7f3c2e-91b4-4d0e-8c55-3e1b2a9d7f01",
"5e2d8b1c-7a63-4f9e-b2d0-6c4a1e8f9b02"
]
}
Errors
- 400Body is not valid JSON (
bad json). text/plain - 401Missing or invalid credential (API-style request). text/plain
- 405Method other than POST (
Use POST). text/plain - 500Unknown workflow id (
workflow not found: <id>, returned as 500, not 404), or queuing failed. text/plain
Real-Time Events
GET /stream is a Server-Sent Events feed of queue and library changes, shared by every connected client (there is no per-client filtering). The SPA, the admin pages and the Electron viewer all subscribe to it.
Each message is written as event: <name> and data: <JSON>. The payload shapes are documented as component schemas (JobListEvent, StdoutEvent, ProgressEvent, PeopleUpdatedEvent, DuplicatesUpdatedEvent, MediaPathsEvent, SchedulerEvent, StatsEvent, StreamConnectedEvent).
Delivery is best effort: broadcasts go through a 2048-message hub buffer and a 256-message buffer per client, and a message that does not fit is dropped immediately (counted as dropped_broadcasts / dropped_client_msgs in GET /health). Clients should refetch state after reconnecting rather than rely on replay; there are no event ids and no Last-Event-ID support.
Server-Sent Events feed of job and library changes.
Opens a text/event-stream (Cache-Control: no-cache, Access-Control-Allow-Origin: *, never compressed). The route is registered without the auth middleware, so it is open to anyone who can reach the port regardless of the Allow Public Access setting; it carries job metadata and job output lines.
On connect the server first sends one unnamed message (a plain message event) whose data is StreamConnectedEvent. A comment line : keep-alive follows every 30 seconds. Then, for each broadcast, event: <name> plus data: <JSON>:
create, update, delete: a job was added (including copies), changed state, or was removed or cleared. Data: JobListEvent. For delete only job.id is meaningful; every other field is a zero value.stdout-<jobId>: one log line from that job. Data: StdoutEvent.progress: item progress of a job (throttled to about 4 per second per job; the first and final updates always go out). Data: ProgressEvent.people-updated: person groupings changed (face clustering finished a model, or a manual People edit). Data: PeopleUpdatedEvent; models is empty for manual edits. Refetch people.duplicates-updated: duplicate-candidate groups changed (the find-duplicates task, or a manual review action). Data: DuplicatesUpdatedEvent; counts are 0 for manual actions. Refetch duplicates.media-updated: the save task overwrote these files in place; bust caches. Data: MediaPathsEvent.media-created: the save task wrote these new files. Data: MediaPathsEvent.scheduler: the auto scheduler changed mode, state, reason or job. Data: SchedulerEvent.stats: library statistics changed (coalesced; at most one per second). Data: StatsEvent, the same object as GET /api/stats.
At most 5000 concurrent connections are accepted. Connections that see no traffic for about two minutes are reaped (keep-alives count as traffic).
Response 200
The event stream. It stays open until the client disconnects or the server shuts down.
text/event-stream
StreamConnectedEvent | JobListEvent | StdoutEvent | ProgressEvent | PeopleUpdatedEvent | DuplicatesUpdatedEvent | MediaPathsEvent | SchedulerEvent | StatsEvent
Server-Sent Events. Each frame carries an event name and one line of JSON data; the data is one of these payloads, chosen by the event name as described above.
Example
data: {"type":"connected","msg":"SSE connection established"}
event: progress
data: {"updateType":"progress","id":"3f0c8f5e-6a0e-4c41-9a7a-2f1f3d0b8c11","done":3,"total":10}
event: stdout-3f0c8f5e-6a0e-4c41-9a7a-2f1f3d0b8c11
data: {"updateType":"stdout","line":"processed D:/photos/a.jpg"}
: keep-alive
Errors
- 500The response writer cannot stream (
Streaming unsupported). text/plain - 503The 5000-connection limit is reached (
Server at capacity, please try again later). Rejections are counted as rejected_connections in GET /health. text/plain
File Serving
Byte-serving endpoints for media files, thumbnails, HLS renditions and face crops. These are plain GET URLs meant to be used directly in <img>, <video> and HLS players.
The path query parameter of /media/file and /media/thumbnail is read from the raw query string and decoded with path-unescaping, so a + stays a literal plus sign (encode spaces as %20). Only the first path= occurrence is used.
Local files are served by Go's http.ServeFile, which supports Range requests (206 Partial Content) and If-Modified-Since. Files in an S3-compatible root are not proxied: the server answers 302 Found with a presigned URL.
For anonymous callers under Allow Public Access, paths outside every configured storage root are refused with 403.
Serve a media file.
- Local files stream through
http.ServeFile with Range support, no size limit, Content-Type from the extension (unknown extensions are application/octet-stream), Content-Length, Cache-Control: public, max-age=3600 and an ETag of the form "<name>-<size>-<mtime>". A matching If-None-Match answers 304. - HEIC/HEIF files, which browsers cannot decode, are served as a cached JPEG rendering instead.
s3:// paths answer 302 to a presigned URL.http(s):// paths (admins only) are proxied through an SSRF-safe client: only the remote Content-Type, Content-Length, Cache-Control and ETag are copied (default Cache-Control: public, max-age=1800), and bodies are cut at 100 MB.
Query parameters
pathstringrequired
Absolute local path, s3:// URL, or http(s):// URL. Percent-encode it; + is not decoded as a space.
Headers
Rangestring
Byte range, for local files.
If-None-Matchstring
An ETag from a previous response.
Response 200
The file bytes.
application/octet-stream
string (binary)
image/*
string (binary)
video/*
string (binary)
audio/*
string (binary)
Response 206
Partial content for a Range request.
application/octet-stream
string (binary)
Response 302
s3:// path: redirect to a presigned URL.
Response 304
If-None-Match matched the current ETag.
Errors
- 400
path missing, empty or badly encoded, a local path that is not absolute, a directory, or an invalid remote URL. text/plain - 403Anonymous caller and the path is outside every storage root and not a library row, or is an
http(s):// URL. text/plain - 404Local file not found, or no storage backend for the
s3:// path. text/plain - 405The method is not
GET. text/plain - 413Remote (
http(s)://) file larger than 100 MB according to its Content-Length. text/plain - 415HEIC conversion failed. text/plain
- 500The file could not be stat'ed, or a presigned URL could not be generated. text/plain
- 502The remote URL could not be fetched. A remote non-200 status is passed through with a plain-text body. text/plain
Serve a thumbnail image, generating it on first request.
Lookup order: a thumbnail recorded in the item's cache column (an existing s3:// object is answered with a 302 to a presigned URL), then for s3:// media generation into the bucket followed by a 302, then for local media the recorded file, the deterministic cache path, and finally generation (throttled). Generated paths are stored on the item's row.
Local thumbnails are sent with Cache-Control: public, max-age=86400, immutable and an ETag of "<file name>-<size>"; a matching If-None-Match answers 304, and Range is supported. Video thumbnails are short .mp4 clips (video/mp4). Image thumbnails are PNG data in extensionless files, so they are labelled application/octet-stream.
Query parameters
pathstringrequired
Media path (absolute local path or s3:// URL). Read from the raw query string like /media/file.
Thumbnail size column. Any other value means thumbnail_path_600.
One of "thumbnail_path_100" "thumbnail_path_600" "thumbnail_path_1200".
tsnumber
Video frame time in seconds. Invalid values are ignored. A non-zero value gets its own cache file.
Default 0.
Response 200
Thumbnail bytes.
application/octet-stream
string (binary)
video/mp4
string (binary)
Response 302
S3 media or an S3 thumbnail; redirect to a presigned URL.
Response 304
If-None-Match matched.
Errors
- 400
path missing, empty or badly encoded, or an s3:// path whose root is not S3 storage. text/plain - 403Anonymous caller and the local path is outside every configured storage root. (Unlike
/media/file, a library row outside the roots is not enough.) text/plain - 404Source file not found, or no storage backend for the
s3:// path. text/plain - 405The method is not
GET. text/plain - 500No database configured, generation failed, or a presigned URL could not be generated. text/plain
HLS rendition status for a video, starting the transcode if needed.
Renditions are a stream-copy (no re-encode) VOD HLS stream with 6-second MPEG-TS segments, cached under the server's data directory keyed by the SHA-256 of path. One ffmpeg runs at a time; other requests queue.
Without check=true, a video that is neither cached nor in progress starts generating and the response is processing with the probed duration. Poll until ready, then load url in an HLS player. A failed generation is reported once as error and its partial output removed; the next request starts again.
Responses carry Access-Control-Allow-Origin: *.
Query parameters
checkstring
true reports idle instead of starting a transcode when nothing is cached.
One of "true".
Response 200
Current status.
application/json
HlsStatus
statusstringrequired
queued = waiting for the single ffmpeg slot; idle = nothing cached and check=true.
One of "ready" "processing" "queued" "idle" "error".
urlstring
Master playlist URL, when ready.
progressnumber
Fraction done while processing; omitted when 0.
min 0, max 1.
durationnumber
Source duration in seconds; only on the response that starts generation.
errorstring
Failure message, when error.
Example
{
"status": "ready",
"url": "/media/hls/6f1c...9a/master.m3u8"
}
Errors
- 400
path is missing. text/plain - 403Anonymous caller and the path is outside every configured storage root. text/plain
- 405Method other than
GET or DELETE. text/plain
Delete cached HLS renditions.
With path, removes that video's renditions; without it, removes the entire HLS cache. The route is read, but this method always needs admin credentials when Allow Public Access is on (anonymous callers get 401 JSON).
Query parameters
pathstring
Media path whose renditions to delete. Omit to clear everything.
Response 204
Deleted (also when nothing was cached).
Errors
- 401Allow Public Access is on and the caller is not an admin. Error
GET/media/hls/{hash}/{file}Read# Serve a top-level HLS playlist from the cache.
Served by the /media/hls/ prefix route. {hash} is the cache key from the url returned by GET /media/hls. The file must match master.m3u8 or stream.m3u8 at this level (segment names pass validation too, but are served with the playlist content type). Supports Range; carries Access-Control-Allow-Origin: *.
Path parameters
filestringrequired
master.m3u8 or stream.m3u8.
Response 200
The playlist.
application/vnd.apple.mpegurl
string
Errors
- 400Invalid hash or file name, or a path with the wrong number of segments. text/plain
- 404Not in the cache. text/plain
GET/media/hls/{hash}/{preset}/{file}Read# Serve a rendition playlist or segment from the cache.
Served by the /media/hls/ prefix route. Normally reached through the master playlist. The server currently only generates the passthrough preset. .ts files are sent as video/MP2T, playlists as application/vnd.apple.mpegurl. Supports Range; carries Access-Control-Allow-Origin: *.
Path parameters
presetstringrequired
Rendition name.
One of "passthrough" "480p" "720p" "1080p".
filestringrequired
stream.m3u8 or segment_NNN.ts (three or more digits).
Response 200
Playlist or segment bytes.
application/vnd.apple.mpegurl
string
video/MP2T
string (binary)
Errors
- 400Invalid hash, preset or file name. text/plain
- 404Not in the cache. text/plain
JPEG crop of one detected face.
Decodes the face's source media (for a video, the same midpoint frame the face scan used; the stored 600 px thumbnail is a last resort), cuts out the face box with a 35% margin, and scales it so the longer edge is size. Sent with Cache-Control: private, max-age=86400 (a face id's crop never changes; rescans mint new ids). JPEG quality 88.
Query parameters
sizeinteger
Longer edge in pixels. Values outside 32 to 1024, or not a number, use the default.
Default 160. min 32, max 1024.
Response 200
The crop.
image/jpeg
string (binary)
Errors
- 400
id missing or not an integer. Error - 403Anonymous caller and the face's media is outside every storage root and not a library row. Error
- 404No face with this id, or its media (and thumbnail) cannot be decoded. Error
- 405The method is not
GET. Error - 500The database failed. Error
Taxonomy
A category groups tags. An assignment attaches a tag to a media item, optionally at a video timestamp (0 means untimed) and with a weight used for ordering. Tag labels are unique across the library and each tag belongs to one category. An assignment's category always mirrors its tag's category.
Read endpoints are read access. Every mutation is admin.
Most handlers in this section do not check the HTTP method: they accept any method with the documented body, and the method shown is the one the web client sends. The exceptions are the method-dispatching paths (/api/tags, /api/tags/timestamp, /api/categories, /api/assignments), which act only on the listed methods and answer any other method with an empty 200, and GET /api/tags/list, which returns 405 for other methods.
Errors from these handlers carry a JSON body {"error": "..."} but are sent with Content-Type: text/plain; charset=utf-8. Mutations return {} on success unless noted.
Every category with all of its tags.
Returns an object keyed by category label (not an array), in the same shape as the desktop app's taxonomy. Keys are inserted in category weight order, and each category's tags are ordered by tag weight. On large libraries this is a big payload; the web client loads /api/taxonomy/categories and then /api/taxonomy/tags?category= per category instead. The method is not checked.
Response 200
Map of category label to category.
application/json
map of TaxonomyCategory
Example
{
"People": {
"label": "People",
"weight": 0,
"tagViewMode": "card",
"tags": [
{
"label": "Alice",
"category": "People",
"weight": 0
},
{
"label": "Bob",
"category": "People",
"weight": 1
}
]
},
"Places": {
"label": "Places",
"weight": 1,
"tagViewMode": "list",
"tags": []
}
}
GET/api/taxonomy/categoriesRead# The category list, without tags.
Ordered by weight. Pairs with /api/taxonomy/tags?category= so a client can load tags one category at a time. The method is not checked.
Response 200
Categories in weight order.
application/json
array of CategorySummary
descriptionstringrequired
tagViewModestringrequired
tagGroupingstringrequired
Whether the category's tags are listed under their groups.
One of "on" "off".
Example
[
{
"label": "People",
"weight": 0,
"description": "",
"tagViewMode": "card",
"tagGrouping": "on"
},
{
"label": "Suggested",
"weight": 5,
"description": "Autotagger output",
"tagViewMode": "list",
"tagGrouping": "off"
}
]
GET/api/taxonomy/tagsRead# Tags in one category, or every tag.
With category, returns that category's tags ordered by weight, each with its group. Without it, returns every tag in the library ordered by category then weight, in the thin shape without group (this is the search index on first focus and can be hundreds of thousands of rows). excludeCategory applies only to the all-tags form. The method is not checked.
Query parameters
categorystring
Return only this category's tags (adds group to each row).
excludeCategorystring
Comma-separated category labels to leave out of the all-tags list (the command palette passes Suggested). Tags with no category are always included. Ignored when category is set.
Response 200
Tag rows.
application/json
array of TaxonomyTag
categorystringrequired
Empty string for a tag with no category.
groupstring | null
Present only with ?category=: the tag's group, or null when ungrouped.
Example
[
{
"label": "Alice",
"category": "People",
"weight": 0,
"group": "Family"
},
{
"label": "Bob",
"category": "People",
"weight": 1,
"group": null
}
]
GET/api/taxonomy/tagRead# Full detail for a single tag.
Includes the description, preview thumbnail and group that the bulk tag lists leave out. The method is not checked.
Response 200
The tag.
application/json
TagDetail
categorystringrequired
Empty string for a tag with no category.
descriptionstringrequired
thumbnail_path_600string | nullrequired
Stored preview thumbnail path.
groupstring | nullrequired
Example
{
"label": "Alice",
"category": "People",
"weight": 0,
"description": "My sister",
"thumbnail_path_600": "D:\\Library\\.thumbnails\\abc_600.webp",
"group": "Family"
}
Errors
- 400
label missing. Error - 404No tag with that label. Error
- 500Database error. Error
GET/api/taxonomy/category-countRead# Number of distinct media items tagged in a category.
Returns a bare JSON integer. Counting the huge autotagger category exactly can take tens of seconds, so pass cap when an approximate "at least N" is enough. Database errors are not reported (the count is 0). The method is not checked.
Query parameters
categorystring
Category label. Missing or unknown yields 0.
capinteger
Stop counting at this many items. Non-numeric or non-positive values count exactly.
min 1.
Response 200
The count.
application/json
integer
Example
1342
Every tag with its usage count.
Ordered by category label then tag label. media_count counts distinct media paths carrying the tag (including paths whose media row no longer exists). Rows that fail to scan are skipped.
Query parameters
categorystring
Only this category's tags.
Response 200
Tags with counts.
application/json
Example
{
"tags": [
{
"label": "Alice",
"category": "People",
"weight": 0,
"media_count": 212
}
]
}
Errors
- 405Method other than GET. Error
- 500Database error. Error
Number of distinct media items carrying a tag.
The method is not checked. Database errors are not reported (the count is 0).
Request body
application/json
LabelBody
Example
{
"label": "Alice"
}
Response 200
The count.
application/json
Example
{
"count": 212
}
Errors
- 400Body is not valid JSON. Error
POST/api/tags/previewRead# The tag's preview thumbnail path.
Returns a bare JSON string (the stored thumbnail_path_600) or null when the tag has no preview or does not exist. Fetch the image through the media endpoints. The method is not checked.
Request body
application/json
LabelBody
Example
{
"label": "Alice"
}
Response 200
Thumbnail path or null.
application/json
string | null
Example
D:\Library\.thumbnails\abc_600.webp
Errors
- 400Body is not valid JSON. Error
Create a tag.
Inserts the tag as given; no field is validated. A label that already exists fails with 500 (unique constraint).
Request body
application/json
categoryLabelstring
Category to file it under.
weightnumber
Sort weight within the category. Default 0.
Example
{
"label": "Alice",
"categoryLabel": "People",
"weight": 3
}
Response 200
Created.
application/json
Example
{
"label": "Alice"
}
Errors
- 400Body is not valid JSON. Error
- 500Insert failed (for example the label already exists). Error
Delete a tag and all of its assignments.
When categoryLabel is given and the tag lives in a different category the request is refused with 409, so a client working from a stale view cannot delete a tag it thinks lives elsewhere. Deleting a tag that does not exist succeeds with assignments 0.
Request body
application/json
categoryLabelstring
Optional guard; must match the tag's category.
Example
{
"label": "Alice",
"categoryLabel": "People"
}
Response 200
Deleted.
application/json
assignmentsintegerrequired
Number of assignment rows removed.
Example
{
"label": "Alice",
"assignments": 212
}
Errors
- 400Body is not valid JSON or
label is empty. Error - 409The tag belongs to a different category than
categoryLabel. Error - 500Database error. Error
PUT/api/tags/renameAdmin# Rename a tag.
Renames the tag and its assignments in one transaction. The method is not checked.
Request body
application/json
RenameBody
Example
{
"label": "Alice",
"newLabel": "Alice Smith"
}
Response 200
Renamed (also when no tag matched).
application/json
EmptyObject
Errors
- 400Body is not valid JSON. Error
- 409
newLabel is already taken; nothing changed. Error - 500Database error; nothing changed. Error
Move a tag to another category.
The tag's assignments move with it, and the tag leaves its tag group (groups belong to a category). The method is not checked.
Request body
application/json
Example
{
"label": "Alice",
"categoryLabel": "Family"
}
Errors
- 400Body is not valid JSON. Error
- 409Unique-constraint conflict; nothing changed. Error
- 500Database error (for example a destination category that does not exist); nothing changed. Error
Set tag order by assigning ascending weights.
The tag at index i of labels gets weight i. Tags not listed keep their weight. The method is not checked.
Request body
application/json
labelsarray of string
Tag labels in the desired order.
Example
{
"labels": [
"Bob",
"Alice",
"Carol"
]
}
Errors
- 400Body is not valid JSON. Error
- 500Database error; nothing changed. Error
PUT/api/tags/weightAdmin# Set one tag's sort weight.
Database errors are not reported. The method is not checked.
Request body
application/json
WeightBody
Example
{
"label": "Alice",
"weight": 2.5
}
Response 200
Updated (also when no tag matched).
application/json
EmptyObject
Errors
- 400Body is not valid JSON. Error
PUT/api/tags/timestampAdmin# Move a timestamped assignment to a new time.
Updates the assignment of tagLabel on mediaPath whose time is exactly oldTimestamp. Database errors are not reported.
Request body
application/json
Example
{
"mediaPath": "D:\\Videos\\clip.mp4",
"tagLabel": "Alice",
"oldTimestamp": 12.5,
"newTimestamp": 14
}
Response 200
Updated (also when nothing matched).
application/json
EmptyObject
Errors
- 400Body is not valid JSON. Error
DELETE/api/tags/timestampAdmin# Remove the timestamp from a timestamped assignment.
If the item already has an untimed (0) assignment of the tag, the timestamped one is deleted. Otherwise the timestamped assignment is turned into the untimed one (its time becomes 0), so the item stays tagged. Database errors are not reported.
Request body
application/json
timestampnumber
Time in seconds of the assignment to change.
Example
{
"mediaPath": "D:\\Videos\\clip.mp4",
"tagLabel": "Alice",
"timestamp": 12.5
}
Response 200
Done (also when nothing matched).
application/json
EmptyObject
Errors
- 400Body is not valid JSON. Error
POST/api/categoriesAdmin# Create a category.
No field is validated. A label that already exists fails with 500 (unique constraint).
Request body
application/json
Example
{
"label": "Places",
"weight": 4
}
Response 200
Created.
application/json
Example
{
"label": "Places"
}
Errors
- 400Body is not valid JSON. Error
- 500Insert failed (for example the label already exists). Error
DELETE/api/categoriesAdmin# Delete a category with its tags, tag groups and their assignments.
Removes, in one transaction, the assignments of every tag filed under the category (plus leftover assignments labelled with the category whose tag no longer exists, but never assignments of a tag that now lives in another category), the tags, the category's tag groups and the category itself. Responds with what was removed, counted inside the same transaction; GET /api/categories/impact returns the same numbers without deleting. Deleting a category that does not exist succeeds with exists: false and zero counts.
Request body
application/json
Example
{
"label": "Suggested"
}
Response 200
Deleted; what was removed.
application/json
CategoryImpact
existsbooleanrequired
Whether the category exists.
tagsintegerrequired
Tags filed under the category.
groupsintegerrequired
Tag groups in the category.
assignmentsintegerrequired
Assignment rows removed with them.
mediaintegerrequired
Distinct media items those assignments are on.
Example
{
"label": "Suggested",
"exists": true,
"tags": 1800,
"groups": 0,
"assignments": 92000,
"media": 41000
}
Errors
- 400Body is not valid JSON or
label is empty. Error - 409Unique-constraint conflict; nothing changed. Error
- 500Database error; nothing changed. Error
GET/api/categories/impactRead# What deleting a category would remove, without removing it.
Same counts DELETE /api/categories returns. The method is not checked.
Response 200
Impact counts.
application/json
CategoryImpact
existsbooleanrequired
Whether the category exists.
tagsintegerrequired
Tags filed under the category.
groupsintegerrequired
Tag groups in the category.
assignmentsintegerrequired
Assignment rows removed with them.
mediaintegerrequired
Distinct media items those assignments are on.
Example
{
"label": "Suggested",
"exists": true,
"tags": 1800,
"groups": 0,
"assignments": 92000,
"media": 41000
}
PUT/api/categories/renameAdmin# Rename a category.
Renames the category and re-points its tags, tag groups and assignments in one transaction. The method is not checked.
Request body
application/json
RenameBody
Example
{
"label": "Places",
"newLabel": "Locations"
}
Response 200
Renamed (also when no category matched).
application/json
EmptyObject
Errors
- 400Body is not valid JSON. Error
- 409
newLabel is already taken; nothing changed. Error - 500Database error; nothing changed. Error
PUT/api/categories/tag-view-modeAdmin# Set how the viewer lists a category's tags.
mode is stored as given (the viewer uses card and list; an unset mode reads back as card). The method is not checked.
Request body
application/json
modestring
View mode, normally card or list.
Example
{
"label": "People",
"mode": "list"
}
Response 200
Updated (also when no category matched).
application/json
EmptyObject
Errors
- 400Body is not valid JSON or
label is empty. Error - 500Database error. Error
POST/api/assignmentsAdmin# Tag one or many media items.
mediaPaths takes precedence; mediaPath is used only when mediaPaths is empty.- The assignment is always filed under the tag's own category. Naming a different category than the tag's is a
409. - A tag that does not exist yet is created at the end of
categoryLabel, which must name an existing category (400 otherwise, or when categoryLabel is omitted). - Existing identical assignments (same path, tag and timestamp) are left alone. New ones get ascending weights after the tag's current assignment count.
- With
applyTagPreview, the first item's 600px thumbnail (generated if needed) becomes the tag's preview image. A thumbnail failure is only logged.
Request body
application/json
mediaPathsarray of string
Items to tag. One of mediaPaths or mediaPath is required.
mediaPathstring
Single item to tag, used when mediaPaths is empty.
categoryLabelstring
The tag's category. Required only when the tag does not exist yet.
timeStampnumber
Video time in seconds. Default 0 (untimed).
applyTagPreviewboolean
Make the first item the tag's preview image. Default false.
Example
{
"mediaPaths": [
"D:\\Photos\\a.jpg",
"D:\\Photos\\b.jpg"
],
"tagLabel": "Alice",
"categoryLabel": "People"
}
Errors
- 400Body is not valid JSON; no path or no
tagLabel; the tag does not exist and categoryLabel is missing or names a category that does not exist. Error - 409The tag belongs to a different category than
categoryLabel. Error - 500Database error; nothing was assigned. Error
DELETE/api/assignmentsAdmin# Remove a tag from one or many media items.
mediaPaths takes precedence over mediaPath.- A non-zero
tag.time_stamp removes only the assignment at that time. A time_stamp of 0 (or omitted) removes every assignment of the tag on the item, timed or not. - If the tag is a person's name and an item no longer carries it at any timestamp, that person's faces on the item are rejected (vetoed) so clustering cannot put them back.
- Nothing is validated and database errors are not reported.
Request body
application/json
mediaPathsarray of string
mediaPathstring
Single item, used when mediaPaths is empty.
time_stampnumber
Time in seconds of the one assignment to remove; 0 removes all of the tag's assignments on the item.
Example
{
"mediaPath": "D:\\Videos\\clip.mp4",
"tag": {
"tag_label": "Alice",
"time_stamp": 12.5
}
}
Errors
- 400Body is not valid JSON. Error
PUT/api/assignments/weightAdmin# Set the weight of an assignment.
A non-zero mediaTimeStamp targets the assignment at that time; 0 or omitted updates every assignment of the tag on the item. Database errors are not reported. The method is not checked.
Request body
application/json
mediaTimeStampnumber
Time in seconds of the assignment; default 0.
Example
{
"mediaPath": "D:\\Photos\\a.jpg",
"tagLabel": "Alice",
"weight": 0
}
Response 200
Updated (also when nothing matched).
application/json
EmptyObject
Errors
- 400Body is not valid JSON. Error
Tag Groups
Tag groups are a purely visual level between a category and its tags: a group is identified by its category and label, and a tag carries at most one group label. Search and query paths ignore groups. A tag leaves its group when it moves to another category, and deleting a group never deletes its tags (they become ungrouped). A category with tag grouping turned off lists its tags flat.
As in Taxonomy, these handlers do not check the method (except the method-dispatching /api/tag-groups), and errors are JSON {"error": "..."} sent as text/plain.
GET/api/taxonomy/groupsRead# A category's tag groups in order.
Ordered by weight, then label. Without category the result is an empty array. The method is not checked.
Response 200
Groups.
application/json
array of TagGroup
hiddenbooleanrequired
Left out of the taxonomy panel unless hidden groups are shown.
viewModestring | nullrequired
Override of the category's tag view mode; null follows the category.
One of "card" "list" null.
Example
[
{
"label": "Family",
"weight": 1,
"hidden": false,
"viewMode": null
},
{
"label": "Work",
"weight": 2,
"hidden": true,
"viewMode": "list"
}
]
POST/api/tag-groupsAdmin# Create a tag group.
The group goes to the end of the category's list. Creating a group that already exists is a no-op that still succeeds.
Request body
application/json
TagGroupRef
categoryLabelstringrequired
Example
{
"categoryLabel": "People",
"label": "Family"
}
Response 200
Created (or already present).
application/json
Example
{
"label": "Family"
}
Errors
- 400Body is not valid JSON, or
categoryLabel or label is empty. Error - 500Database error. Error
DELETE/api/tag-groupsAdmin# Delete a tag group; its tags become ungrouped.
Request body
application/json
TagGroupRef
categoryLabelstringrequired
Example
{
"categoryLabel": "People",
"label": "Family"
}
Response 200
Deleted (also when no group matched).
application/json
EmptyObject
Errors
- 400Body is not valid JSON, or
categoryLabel or label is empty. Error - 409Unique-constraint conflict; nothing changed. Error
- 500Database error; nothing changed. Error
PUT/api/tag-groups/renameAdmin# Rename a tag group.
Renames the group and re-points its tags in one transaction. An empty newLabel, or one equal to label, is a no-op that succeeds. The method is not checked.
Request body
application/json
any
Example
{
"categoryLabel": "People",
"label": "Family",
"newLabel": "Relatives"
}
Errors
- 400Body is not valid JSON, or
categoryLabel or label is empty. Error - 409A group with
newLabel already exists in the category; nothing changed. Error - 500Database error; nothing changed. Error
PUT/api/tag-groups/weightAdmin# Set a tag group's sort weight.
The method is not checked.
Request body
application/json
any
Example
{
"categoryLabel": "People",
"label": "Family",
"weight": 0
}
Response 200
Updated (also when no group matched).
application/json
EmptyObject
Errors
- 400Body is not valid JSON, or
categoryLabel or label is empty. Error - 500Database error. Error
PUT/api/tag-groups/hiddenAdmin# Hide or show a tag group.
A hidden group is left out of the taxonomy panel unless the viewer chooses to show hidden groups. The method is not checked.
Request body
application/json
any
Example
{
"categoryLabel": "People",
"label": "Work",
"hidden": true
}
Response 200
Updated (also when no group matched).
application/json
EmptyObject
Errors
- 400Body is not valid JSON, or
categoryLabel or label is empty. Error - 500Database error. Error
PUT/api/tag-groups/view-modeAdmin# Override the category's tag view mode for one group.
card or list sets the override; any other value (normally "") clears it so the group follows the category. The method is not checked.
Request body
application/json
any
Example
{
"categoryLabel": "People",
"label": "Work",
"viewMode": "list"
}
Response 200
Updated (also when no group matched).
application/json
EmptyObject
Errors
- 400Body is not valid JSON, or
categoryLabel or label is empty. Error - 500Database error. Error
Put a tag in a group, or ungroup it.
An empty groupLabel ungroups the tag. A group that does not yet exist in the tag's category is created at the end of its list, so a grouped tag always has a group to render under. The method is not checked.
Request body
application/json
groupLabelstring
Group within the tag's category; empty ungroups.
Example
{
"label": "Alice",
"groupLabel": "Family"
}
Errors
- 400Body is not valid JSON,
label is empty, or the tag has no category to group under. Error - 404No tag with that label. Error
- 409Unique-constraint conflict; nothing changed. Error
- 500Database error. Error
PUT/api/categories/tag-groupingAdmin# Turn tag grouping on or off for a category.
off turns grouping off; any other value turns it on. Groups and group labels are kept either way. The method is not checked.
Request body
application/json
modestring
off, or anything else for on.
Example
{
"label": "People",
"mode": "off"
}
Response 200
Updated (also when no category matched).
application/json
EmptyObject
Errors
- 400Body is not valid JSON or
label is empty. Error - 500Database error. Error
Embeddings Index
Vectors are stored per model in the media_embedding table and loaded into an exact in-memory cosine index for the active embedding model at startup and after each embed job. These endpoints inspect and maintain both. All are admin and enforce their HTTP method (405 otherwise). Errors are JSON {"error": "..."} sent as text/plain.
GET/api/index/statusAdmin# In-memory index state and per-model storage statistics.
Response 200
Index status.
application/json
IndexStatus
indexobjectrequired
The in-memory search index.
modelstringrequired
Model the index was built from; empty when none.
active_modelstringrequired
The configured embedding model.
media_totalintegerrequired
missing_active_modelintegerrequired
Media items with no embedding for the active model.
orphanedintegerrequired
Embedding rows whose media row is gone.
embeddingsarray of objectrequired
total_countintegerrequired
Embedding rows across all models.
total_bytesintegerrequired
Stored vector bytes across all models.
Example
{
"index": {
"installed": true,
"model": "siglip2-base-patch16-224",
"vectors": 51234
},
"active_model": "siglip2-base-patch16-224",
"media_total": 52000,
"missing_active_model": 766,
"orphaned": 12,
"embeddings": [
{
"model": "siglip2-base-patch16-224",
"count": 51234,
"dim": 768,
"bytes": 157390848
}
],
"total_count": 51234,
"total_bytes": 157390848
}
Errors
- 405Method other than GET. Error
- 500Database error. Error
GET/api/index/modelsAdmin# The embedding model registry.
Response 200
Models.
application/json
Example
{
"models": [
{
"id": "siglip2-base-patch16-224",
"display_name": "SigLIP 2 base (multimodal — image + text)",
"dim": 768,
"multimodal": true,
"active": true,
"indexed": true
}
]
}
Errors
- 405Method other than GET. Error
POST/api/index/rebuildAdmin# Rebuild the in-memory index for the active model.
Reloads every stored vector of the active embedding model into a fresh index and swaps it in. Blocks until done.
Response 200
Rebuilt.
application/json
vectorsintegerrequired
Vectors in the new index.
Example
{
"status": "ok",
"model": "siglip2-base-patch16-224",
"vectors": 51234
}
Errors
- 405Method other than POST. Error
- 500Loading vectors failed. Error
GET/api/index/missingAdmin# Media items without an embedding for a model.
Query parameters
modelstring
Model id from the registry. Default the active model.
limitinteger
Paths to return, sorted by path. Values above 10000 are clamped; 0 returns only the count.
Default 100. min 0, max 10000.
Response 200
Missing items.
application/json
total_missingintegerrequired
All items lacking an embedding, regardless of limit.
pathsarray of stringrequired
Example
{
"model": "siglip2-base-patch16-224",
"total_missing": 766,
"paths": [
"D:\\Photos\\new1.jpg"
]
}
Errors
- 400Unknown
model, or limit is not a non-negative integer. Error - 405Method other than GET. Error
- 500Database error. Error
Stored embedding rows for one media item.
One row per model unless model is given. Rows that fail to scan are skipped.
Query parameters
vectorstring
true includes the decoded float vector in each row.
One of "true".
Response 200
Embedding rows.
application/json
Example
{
"path": "D:\\Photos\\a.jpg",
"embeddings": [
{
"model": "siglip2-base-patch16-224",
"dim": 768,
"created_at": 0
}
]
}
Errors
- 400
path missing. Error - 405Method other than GET or DELETE (checked after
path). Error - 500Database error. Error
DELETE/api/embeddingsAdmin# Delete stored embedding rows for one media item.
Deletes every model's row, or only model's. The path is also dropped from the live index when the indexed model's vector is among those deleted.
Response 200
Deleted.
application/json
Example
{
"deleted": 2
}
POST/api/embeddings/pruneAdmin# Delete embeddings whose media row is gone.
Removes orphaned rows (for every model) left by out-of-band deletions and drops those paths from the live index.
Response 200
Pruned.
application/json
pruned_rowsintegerrequired
pruned_pathsintegerrequired
Distinct media paths they belonged to.
Example
{
"pruned_rows": 24,
"pruned_paths": 12
}
Errors
- 405Method other than POST. Error
- 500Database error. Error
DELETE/api/embeddings/allAdmin# Delete every stored embedding, for all models.
Also clears the in-memory index. Not undoable, though re-running the embed task rebuilds everything. Requires confirm=true.
Query parameters
confirmstringrequired
Must be true.
One of "true".
Response 200
Wiped.
application/json
Example
{
"deleted": 102468
}
Errors
- 400
confirm=true missing. Error - 405Method other than DELETE. Error
- 500Database error. Error
People and Faces
A face is one detection with an identity embedding, stored per media item by the face-scan task. A person is a cluster of faces, either named or auto-named Unknown #N; each person is also a tag in the People category, so person names work in library search, and rename, merge and delete cascade to those tag rows.
A face is assigned either by clustering (assignedBy: auto) or by hand (user). User assignments, rejections (vetoes and cannot-links) and group bans are permanent across re-clustering: locking or curating a group is how you make the clusterer's result stick. Grouping strictness is set with /api/faces/tuning.
Every manual change here broadcasts a people-updated event on /stream. Face crops are served by /media/facecrop?id=.
Most error bodies in this section are {"error": "..."} sent with Content-Type: text/plain.
List every person.
Returns every person with face and media counts, most faces first. Always an array (empty when there are no people).
Response 200
All people.
application/json
array of Person
A named (or auto-named Unknown #N) cluster of faces. Every person has a name so it can live in the taxonomy as a People-category tag.
idinteger (int64)required
namestringrequired
Stored name. May end in _cluster when a tag in another category owns the plain name; UIs strip the suffix for display.
coverFaceIdinteger (int64)
Face used as the preview crop (/media/facecrop?id=): the chosen cover when it still belongs to the person, else the best face (confidence times bbox area). Omitted when the person has no faces.
faceCountintegerrequired
Faces assigned to the person.
mediaCountintegerrequired
Distinct media items containing the person.
lockedCountintegerrequired
Faces that are user assignments (confirmed); these survive every recluster.
createdAtinteger (int64)
Unix seconds. Omitted when unknown.
modelsstring
Comma-separated recognizer id(s) of the person's faces (normally one, e.g. anime-ccip for a drawn-character cluster). Omitted when the person has no faces.
Example
[
{
"id": 12,
"name": "Ada",
"coverFaceId": 4031,
"faceCount": 87,
"mediaCount": 64,
"lockedCount": 20,
"createdAt": 1761900000,
"models": "buffalo_l"
},
{
"id": 13,
"name": "Unknown #4",
"faceCount": 6,
"mediaCount": 6,
"lockedCount": 0,
"createdAt": 1761900500,
"models": "buffalo_l"
}
]
Errors
- 405Method other than GET or POST. The body is
{"error": "..."} but is sent with Content-Type: text/plain. Error - 500Database or internal failure. The body is
{"error": "..."} but is sent with Content-Type: text/plain. Error
Create an empty named person.
The route is registered read-level, so the write is re-checked: while Allow Public Access is on an admin credential is required (401 JSON otherwise). With public access off the normal login requirement already applies. Broadcasts a people-updated event on /stream.
Request body
application/json
namestringrequired
Person name; trimmed. Must be non-empty and not already used by another person.
Example
{
"name": "Ada"
}
Response 200
Created.
application/json
idinteger (int64)required
namestringrequired
The trimmed requested name. The stored name may carry a _cluster suffix when a non-People tag already owns the plain name; this field does not reflect that.
Example
{
"id": 14,
"name": "Ada"
}
Errors
- 400Body is not valid JSON, the name is empty, or a person with that name already exists. The body is
{"error": "..."} but is sent with Content-Type: text/plain. Error - 401Allow Public Access is on and the request carries no admin credential (anonymous visitors may read but not write here). Error
- 500Database or internal failure. The body is
{"error": "..."} but is sent with Content-Type: text/plain. Error
POST/api/people/lock-allAdmin# Confirm every auto-assigned face of every named person.
Promotes every auto-assigned face of every NAMED person to a user assignment. Auto-named Unknown #N clusters are skipped. Locked faces survive every regroup. Broadcasts people-updated only when at least one face was locked. No request body.
Response 200
Counts.
application/json
peopleintegerrequired
Named people that had faces locked.
lockedintegerrequired
Faces promoted to user assignments.
Example
{
"people": 9,
"locked": 412
}
Errors
- 405Method other than POST. The body is
{"error": "..."} but is sent with Content-Type: text/plain. Error - 500Database or internal failure. The body is
{"error": "..."} but is sent with Content-Type: text/plain. Error
DELETE/api/people/{id}Admin# Delete a person.
Removes the person and their People-category tag rows. By default the faces are kept (unassigned, free to re-cluster) and the dissolved membership is recorded as a group ban so the same group does not simply re-form on the next clustering pass.
deleteFaces=true deletes the face rows too (and drops them from the search index); they stay gone until their media is rescanned. No ban is recorded in this mode.ban=false skips recording the ban.
Only DELETE is accepted on this path. Broadcasts a people-updated event on /stream.
Path parameters
idinteger (int64)required
Person id (positive integer).
min 1.
Query parameters
deleteFacesstring
Exactly true also deletes the person's faces. Any other value is ignored.
One of "true".
banstring
Exactly false skips recording the group ban. Ignored when deleteFaces=true.
One of "false".
Response 200
Deleted. With deleteFaces=true the body is {deleted, facesDeleted}; otherwise {deleted, banned}.
application/json
object | object
Example
{
"deleted": 13,
"banned": 6
}
Errors
- 400Invalid person id. The body is
{"error": "..."} but is sent with Content-Type: text/plain. Error - 404No person with that id. The body is
{"error": "..."} but is sent with Content-Type: text/plain. Error - 405Method other than DELETE. The body is
{"error": "..."} but is sent with Content-Type: text/plain. Error - 500Database or internal failure. The body is
{"error": "..."} but is sent with Content-Type: text/plain. Error
POST/api/people/{id}/renameAdmin# Rename a person.
Renames the person and cascades the new name to their People-category tag rows. Broadcasts a people-updated event on /stream.
Path parameters
idinteger (int64)required
Person id (positive integer).
min 1.
Request body
application/json
namestringrequired
New name; trimmed, must be non-empty.
Example
{
"name": "Ada Lovelace"
}
Response 200
Renamed.
application/json
idinteger (int64)required
namestringrequired
The resolved stored name, which may carry a _cluster suffix when a tag in another category owns the plain name. Clients use it to keep an active tag filter pointing at the person.
Example
{
"id": 12,
"name": "Ada Lovelace"
}
Errors
- 400Invalid id, invalid JSON, empty name, or another person already has the name (merge instead). The body is
{"error": "..."} but is sent with Content-Type: text/plain. Error - 404No person with that id. The body is
{"error": "..."} but is sent with Content-Type: text/plain. Error - 405Method other than POST. The body is
{"error": "..."} but is sent with Content-Type: text/plain. Error - 500Database or internal failure. The body is
{"error": "..."} but is sent with Content-Type: text/plain. Error
POST/api/people/{id}/mergeAdmin# Merge this person into another.
Moves every face and tag row of person {id} into intoId, then deletes {id}. Broadcasts a people-updated event on /stream.
Path parameters
idinteger (int64)required
Source person id (positive integer).
min 1.
Request body
application/json
intoIdinteger (int64)required
Example
{
"intoId": 12
}
Response 200
Merged.
application/json
mergedinteger (int64)required
The source (now deleted) person id.
intointeger (int64)required
Example
{
"merged": 13,
"into": 12
}
Errors
- 400Invalid id, missing/invalid
intoId (intoId required), or merging a person into itself. The body is {"error": "..."} but is sent with Content-Type: text/plain. Error - 404Either person does not exist. The body is
{"error": "..."} but is sent with Content-Type: text/plain. Error - 405Method other than POST. The body is
{"error": "..."} but is sent with Content-Type: text/plain. Error - 500Database or internal failure. The body is
{"error": "..."} but is sent with Content-Type: text/plain. Error
GET/api/people/{id}/mediaRead# List the media items containing a person.
Items in the flat renderer item shape, best detection first. score is a descending pseudo-score that only encodes that rank (N for the first of N items, down to 1).
Path parameters
idinteger (int64)required
Person id (positive integer).
min 1.
Response 200
Ranked items.
application/json
array of ScoredMediaItem
A ranked hit enriched with the item's row. Columns that are NULL, or the whole row when the path has no media row (an orphaned embedding), are omitted.
scorenumberrequired
Similarity score; items are sorted by it, highest first.
Example
[
{
"path": "D:/photos/ada-01.jpg",
"mtimeMs": 0,
"elo": 1512.4,
"battles": 3,
"width": 4032,
"height": 3024,
"score": 2
},
{
"path": "D:/photos/ada-02.jpg",
"mtimeMs": 0,
"width": 1920,
"height": 1080,
"score": 1
}
]
Errors
- 400Invalid person id. The body is
{"error": "..."} but is sent with Content-Type: text/plain. Error - 404No such person. The body is
{"error": "..."} but is sent with Content-Type: text/plain. Error - 405Method other than GET. The body is
{"error": "..."} but is sent with Content-Type: text/plain. Error - 500Database or internal failure. The body is
{"error": "..."} but is sent with Content-Type: text/plain. Error
GET/api/people/{id}/facesRead# List a person's faces for review, least typical first.
Each face carries a typicality score: cosine similarity to the group center. The center is the mean of the user-confirmed faces when the person has any (a confirmed face is measured against the other confirmed faces), otherwise the mean of all the person's faces excluding the face itself. Faces are sorted by ascending typicality so outliers come first.
Path parameters
idinteger (int64)required
Person id (positive integer).
min 1.
Response 200
Faces with typicality.
application/json
personIdinteger (int64)required
Example
{
"personId": 12,
"faces": [
{
"id": 5120,
"path": "D:/photos/group.jpg",
"frameTs": 0,
"detScore": 0.71,
"assignedBy": "auto",
"typicality": 0.18
},
{
"id": 4031,
"path": "D:/photos/ada-01.jpg",
"frameTs": 0,
"detScore": 0.93,
"assignedBy": "user",
"typicality": 0.82
}
]
}
Errors
- 400Invalid person id. The body is
{"error": "..."} but is sent with Content-Type: text/plain. Error - 404No such person. The body is
{"error": "..."} but is sent with Content-Type: text/plain. Error - 405Method other than GET. The body is
{"error": "..."} but is sent with Content-Type: text/plain. Error - 500Database or internal failure. The body is
{"error": "..."} but is sent with Content-Type: text/plain. Error
POST/api/people/{id}/lockAdmin# Confirm every auto-assigned face of one person.
Promotes every auto-assigned face of the person to a user assignment ("this whole group is right"). Locked faces survive every regroup, including --reset-all, and seed future clustering with extra weight. No request body. Broadcasts a people-updated event on /stream.
Path parameters
idinteger (int64)required
Person id (positive integer).
min 1.
Response 200
Locked.
application/json
personIdinteger (int64)required
Example
{
"personId": 12,
"locked": 67
}
Errors
- 400Invalid person id. The body is
{"error": "..."} but is sent with Content-Type: text/plain. Error - 404No person with that id. The body is
{"error": "..."} but is sent with Content-Type: text/plain. Error - 405Method other than POST. The body is
{"error": "..."} but is sent with Content-Type: text/plain. Error - 500Database or internal failure. The body is
{"error": "..."} but is sent with Content-Type: text/plain. Error
POST/api/people/{id}/curateAdmin# Keep the listed faces, reject the rest.
Whole-group review in one call: faces in keepFaceIds are locked as user assignments; every other face of the person is rejected (veto + cannot-links + unassign), with a permanent guarantee it never returns to this group. Broadcasts a people-updated event on /stream.
Path parameters
idinteger (int64)required
Person id (positive integer).
min 1.
Request body
application/json
keepFaceIdsarray of integer (int64)
Face ids to keep. An empty or missing list rejects every face of the person.
Example
{
"keepFaceIds": [
4031,
4032,
4107
]
}
Response 200
Curated.
application/json
personIdinteger (int64)required
Example
{
"personId": 12,
"kept": 3,
"rejected": 2
}
Errors
- 400Invalid id or invalid JSON body. The body is
{"error": "..."} but is sent with Content-Type: text/plain. Error - 404No person with that id. The body is
{"error": "..."} but is sent with Content-Type: text/plain. Error - 405Method other than POST. The body is
{"error": "..."} but is sent with Content-Type: text/plain. Error - 500Database or internal failure. The body is
{"error": "..."} but is sent with Content-Type: text/plain. Error
POST/api/people/{id}/coverAdmin# Pick a renderable cover face for a person.
Does not take a face id: the server walks the person's faces best-first (detection confidence times bbox area) and picks the first whose source media actually decodes (images directly, videos by re-extracting the scanned frame), trying at most 10 candidates. No request body. Broadcasts a people-updated event on /stream.
Path parameters
idinteger (int64)required
Person id (positive integer).
min 1.
Response 200
Cover set.
application/json
personIdinteger (int64)required
coverFaceIdinteger (int64)required
The face now used as the cover crop.
Example
{
"personId": 12,
"coverFaceId": 4031
}
Errors
- 400Invalid person id. The body is
{"error": "..."} but is sent with Content-Type: text/plain. Error - 404No such person. The body is
{"error": "..."} but is sent with Content-Type: text/plain. Error - 405Method other than POST. The body is
{"error": "..."} but is sent with Content-Type: text/plain. Error - 422The person has no faces, or none of the 10 best faces could be rendered. The body is
{"error": "..."} but is sent with Content-Type: text/plain. Error - 500Database or internal failure. The body is
{"error": "..."} but is sent with Content-Type: text/plain. Error
Stored faces for one media item.
A pure database read (no detection runs). For a scanned item model is the recognizer that scanned it; for a never-scanned item scanned is false, faces is empty and model is the recognizer routing would pick (from cache).
Response 200
Face scan status and faces.
application/json
scannedbooleanrequired
Whether the item has been face-scanned.
scannedAtinteger (int64)required
Scan time in unix seconds; 0 when not scanned or for legacy rows without a timestamp.
facesarray of FacerequiredExample
{
"model": "buffalo_l",
"scanned": true,
"scannedAt": 1761901234,
"faces": [
{
"id": 4031,
"x": 0.41,
"y": 0.18,
"w": 0.12,
"h": 0.17,
"score": 0.93,
"frameTs": 0,
"personId": 12,
"assignedBy": "user"
}
]
}
Errors
- 400
path missing. The body is {"error": "..."} but is sent with Content-Type: text/plain. Error - 405Method other than GET. The body is
{"error": "..."} but is sent with Content-Type: text/plain. Error - 500Database or internal failure. The body is
{"error": "..."} but is sent with Content-Type: text/plain. Error
GET/api/faces/{id}/similarRead# Media ranked by identity similarity to one face.
Searches the limit most similar stored faces, then collapses them to one item per media path (best face wins), so fewer than limit items may come back.
Path parameters
idinteger (int64)required
Query parameters
limitinteger
Faces to search. Values above 500 are clamped to 500; non-positive or non-numeric values use the default.
Default 100. max 500.
Response 200
Ranked items, each with the matched face.
application/json
array of FaceHitItem
A ScoredMediaItem (one per media path) with the best matching face attached. score is that face's cosine similarity.
Example
[
{
"path": "D:/photos/ada-07.jpg",
"mtimeMs": 0,
"width": 3000,
"height": 2000,
"score": 0.81,
"faceId": 4410,
"face": {
"x": 0.3,
"y": 0.2,
"w": 0.1,
"h": 0.14,
"frameTs": 0,
"personId": 12
}
}
]
Errors
- 400Face id is not an integer. The body is
{"error": "..."} but is sent with Content-Type: text/plain. Error - 404No face with that id. The body is
{"error": "..."} but is sent with Content-Type: text/plain. Error - 405Method other than GET. The body is
{"error": "..."} but is sent with Content-Type: text/plain. Error - 500Database or internal failure. The body is
{"error": "..."} but is sent with Content-Type: text/plain. Error
POST/api/faces/{id}/assignAdmin# Assign a face to a person (confirmed).
Records a user assignment. Choose the target in one of three ways:
personId: an existing person.name (with no personId): the person with that name (the _cluster-suffixed form also matches) is reused, or created when none exists.newPerson: true (with no personId or name): a new auto-named Unknown #N person seeded by this face.
A newly created person gets this face as its cover. Broadcasts a people-updated event on /stream.
Path parameters
idinteger (int64)required
Face id (positive integer).
min 1.
Request body
application/json
personIdinteger (int64)
Existing person id. Takes precedence when non-zero.
namestring
Person name to reuse or create (trimmed).
newPersonboolean
Mint a new auto-named person when neither personId nor name is given.
Example
{
"name": "Ada"
}
Response 200
Assigned.
application/json
faceIdinteger (int64)required
personIdinteger (int64)required
The person the face now belongs to.
createdbooleanrequired
True when a new person was created for this assignment (its only face becomes the cover).
namestringrequired
The name used when assigning by name / newPerson; empty when assigned by personId.
Example
{
"faceId": 5120,
"personId": 12,
"created": false,
"name": "Ada"
}
Errors
- 400Invalid face id, invalid JSON, or none of
personId, name, newPerson given. The body is {"error": "..."} but is sent with Content-Type: text/plain. Error - 404No face or no person with that id. The body is
{"error": "..."} but is sent with Content-Type: text/plain. Error - 405Method other than POST. The body is
{"error": "..."} but is sent with Content-Type: text/plain. Error - 500Database or internal failure. The body is
{"error": "..."} but is sent with Content-Type: text/plain. Error
POST/api/faces/{id}/unassignAdmin# Detach a face from its person.
No request body. Unlike reject, this records no veto, so clustering may place the face again. Broadcasts a people-updated event on /stream.
Path parameters
idinteger (int64)required
Face id (positive integer).
min 1.
Response 200
Unassigned.
application/json
faceIdinteger (int64)required
unassignedbooleanrequired
Example
{
"faceId": 5120,
"unassigned": true
}
Errors
- 400Invalid face id. The body is
{"error": "..."} but is sent with Content-Type: text/plain. Error - 404No face with that id. The body is
{"error": "..."} but is sent with Content-Type: text/plain. Error - 405Method other than POST. The body is
{"error": "..."} but is sent with Content-Type: text/plain. Error - 500Database or internal failure. The body is
{"error": "..."} but is sent with Content-Type: text/plain. Error
POST/api/faces/{id}/rejectAdmin# "Not them": permanently veto a face from a person.
Records a veto against the person plus cannot-links against the group's exemplar faces, and unassigns the face. No clustering pass will put the face back in that group, even after the group is dissolved and re-forms. personId defaults to the face's current person. A JSON body is required (send {} to use the default). Broadcasts a people-updated event on /stream.
Path parameters
idinteger (int64)required
Face id (positive integer).
min 1.
Request body
application/json
personIdinteger (int64)
Person to reject the face from. Required when the face is currently unassigned.
Example
{
"personId": 12
}
Response 200
Rejected.
application/json
faceIdinteger (int64)required
personIdinteger (int64)required
cannotLinksintegerrequired
Cannot-link constraints recorded.
Example
{
"faceId": 5120,
"personId": 12,
"cannotLinks": 8
}
Errors
- 400Invalid face id, invalid JSON, or the face is unassigned and no
personId was given. The body is {"error": "..."} but is sent with Content-Type: text/plain. Error - 404No face or no person with that id. The body is
{"error": "..."} but is sent with Content-Type: text/plain. Error - 405Method other than POST. The body is
{"error": "..."} but is sent with Content-Type: text/plain. Error - 500Database or internal failure. The body is
{"error": "..."} but is sent with Content-Type: text/plain. Error
DELETE/api/faces/allAdmin# Wipe all face data (privacy reset).
Deletes every face row, scan marker, person and People-category tag row, and clears the in-memory face index. Not undoable; rescanning rebuilds everything except manual labels. Broadcasts a people-updated event on /stream.
Query parameters
confirmstringrequired
Must be exactly true.
One of "true".
Response 200
Wiped.
application/json
Example
{
"deleted": true
}
Errors
- 400
confirm=true missing. The body is {"error": "..."} but is sent with Content-Type: text/plain. Error - 405Method other than DELETE. The body is
{"error": "..."} but is sent with Content-Type: text/plain. Error - 500Database or internal failure. The body is
{"error": "..."} but is sent with Content-Type: text/plain. Error
How much face data is stored.
Per-recognizer face counts and vector bytes, scan markers, people counts, and the live in-memory search index. Used by the Config page storage panel.
Response 200
Storage stats.
application/json
FaceStats
facesarray of objectrequired
Per recognizer, ordered by model id.
bytesinteger (int64)required
total_facesintegerrequired
total_bytesinteger (int64)required
scansintegerrequired
Face-scan markers (items scanned, including those with no faces).
namedintegerrequired
People with a non-empty name.
indexobjectrequired
The in-memory face search index.
modelstringrequired
Recognizer the index holds; empty when not built.
Example
{
"faces": [
{
"model": "buffalo_l",
"count": 51234,
"bytes": 104927232
}
],
"total_faces": 51234,
"total_bytes": 104927232,
"scans": 48810,
"people": {
"total": 340,
"named": 41,
"unnamed": 299
},
"index": {
"model": "buffalo_l",
"vectors": 51234
}
}
Errors
- 405Method other than GET. The body is
{"error": "..."} but is sent with Content-Type: text/plain. Error - 500Database or internal failure. The body is
{"error": "..."} but is sent with Content-Type: text/plain. Error
GET/api/faces/ungroupedRead# Count (and optionally page) faces not assigned to any person.
The count is the workload a "Group new faces" pass would pick up: unassigned faces from the recognizers that pass would process. With faces=1 the response also carries a page of those faces, best detections first.
Query parameters
facesstring
Exactly 1 to include the faces page.
One of "1".
limitinteger
Page size with faces=1. Values outside 1-500 use the default.
Default 120. min 1, max 500.
offsetinteger
Page offset with faces=1.
Default 0. min 0.
Response 200
Count, plus faces when requested.
application/json
Present only with faces=1.
Example
{
"count": 1873,
"faces": [
{
"id": 9001,
"path": "D:/photos/party.jpg",
"frameTs": 0,
"detScore": 0.97,
"model": "buffalo_l"
}
]
}
Errors
- 405Method other than GET. The body is
{"error": "..."} but is sent with Content-Type: text/plain. Error - 500Database or internal failure. The body is
{"error": "..."} but is sent with Content-Type: text/plain. Error
GET/api/faces/tuningAdmin# Read the face-grouping tuner.
Returns the effective values used by every clustering pass: saved config values, with built-in defaults (thresholdOffset 0, minCluster 3, minQuality 0.75) substituted where nothing valid is saved.
Response 200
Effective tuning.
application/json
FaceTuning
thresholdOffsetnumberrequired
Added to the clustering similarity threshold, -0.2 to 0.3. Default 0.
minClusterintegerrequired
Minimum faces for a new group, 1-50. Default 3.
minQualitynumberrequired
Minimum face quality, between 0 and 1 exclusive. Default 0.75.
Example
{
"thresholdOffset": 0,
"minCluster": 3,
"minQuality": 0.75
}
Errors
- 405Method other than GET or POST. The body is
{"error": "..."} but is sent with Content-Type: text/plain. Error
POST/api/faces/tuningAdmin# Save the face-grouping tuner.
Replaces all three values in the server config (it does not merge): every field is validated as sent, and an omitted field reads as 0, so minCluster and minQuality must always be supplied. The new values apply to every later clustering pass on every client. Returns the effective values.
Request body
application/json
thresholdOffsetnumber
Added to the clustering similarity threshold. Positive values make grouping stricter.
min -0.2, max 0.3.
minClusterintegerrequired
Minimum faces for a new group to form.
min 1, max 50.
minQualitynumberrequired
Minimum face quality for clustering.
Example
{
"thresholdOffset": 0.05,
"minCluster": 4,
"minQuality": 0.8
}
Response 200
Saved; the effective values.
application/json
FaceTuning
thresholdOffsetnumberrequired
Added to the clustering similarity threshold, -0.2 to 0.3. Default 0.
minClusterintegerrequired
Minimum faces for a new group, 1-50. Default 3.
minQualitynumberrequired
Minimum face quality, between 0 and 1 exclusive. Default 0.75.
Example
{
"thresholdOffset": 0.05,
"minCluster": 4,
"minQuality": 0.8
}
Errors
- 400Invalid JSON or a value out of range (
thresholdOffset must be between -0.2 and 0.3, minCluster must be between 1 and 50, minQuality must be between 0 and 1). The body is {"error": "..."} but is sent with Content-Type: text/plain. Error - 500Saving the config failed. The body is
{"error": "..."} but is sent with Content-Type: text/plain. Error
Duplicate Candidates
A duplicate group is a cluster of media whose visual embeddings are at least a threshold similar to the group's anchor item. Groups are stars around the anchor, not chains, and a media path belongs to at most one group. They are produced by the review-only find-duplicates task (unlike the exact-bytes dedupe task, it never merges or deletes) and reviewed here: merge the copies, dismiss look-alikes, or exclude a single member. The merge-duplicates task is the bulk accept.
A dismissed group, and an excluded member, keep their rows so later scans do not regroup them. Removing or merging media removes its member rows; a group left with fewer than two members is pruned, and one whose anchor is gone is re-anchored on its best remaining member. The dupe: query predicate filters the library to group members.
Every write here broadcasts duplicates-updated on /stream. Error bodies are {"error": "..."} sent with Content-Type: text/plain.
Page through duplicate-candidate groups.
Query parameters
statusstring
Which groups. Default pending. Case-insensitive.
One of "pending" "dismissed" "all".
sortstring
newest (default; most recently found first) or members (most active members first). Case-insensitive.
One of "newest" "members". Default "newest".
limitinteger
Page size. Values outside 1-500 use the default.
Default 50. min 1, max 500.
offsetinteger
Page offset.
Default 0. min 0.
Response 200
One page of groups.
application/json
totalintegerrequired
Groups matching status, unpaged.
Example
{
"groups": [],
"total": 0,
"limit": 50,
"offset": 0
}
Errors
- 400Unknown
status or sort value. The body is {"error": "..."} but is sent with Content-Type: text/plain. Error - 405Method other than GET. The body is
{"error": "..."} but is sent with Content-Type: text/plain. Error - 500Database or internal failure. The body is
{"error": "..."} but is sent with Content-Type: text/plain. Error
GET/api/duplicates/statsRead# Group and member counts.
Response 200
Counts.
application/json
DuplicateStats
membersintegerrequired
Member rows across all groups.
pendingItemsintegerrequired
Active members of pending groups: library items still awaiting a decision.
Example
{
"pending": 42,
"dismissed": 7,
"members": 131,
"pendingItems": 98
}
Errors
- 405Method other than GET. The body is
{"error": "..."} but is sent with Content-Type: text/plain. Error - 500Database or internal failure. The body is
{"error": "..."} but is sent with Content-Type: text/plain. Error
GET/api/duplicates/for-pathRead# The group a media item belongs to.
A path belongs to at most one group (as an active or excluded member).
Response 200
Group membership.
application/json
groupIdinteger (int64)required
0 when the item is in no group.
statusstring
Present when groupId is non-zero.
One of "pending" "dismissed".
activeCountinteger
Active members of the group. Present when groupId is non-zero.
Example
{
"groupId": 311,
"status": "pending",
"activeCount": 3
}
Errors
- 400
path missing (path required). The body is {"error": "..."} but is sent with Content-Type: text/plain. Error - 405Method other than GET. The body is
{"error": "..."} but is sent with Content-Type: text/plain. Error - 500Database or internal failure. The body is
{"error": "..."} but is sent with Content-Type: text/plain. Error
DELETE/api/duplicates/allAdmin# Forget every group (optionally of one status).
Forgotten items become eligible for grouping again on the next find-duplicates run. Broadcasts a duplicates-updated event on /stream.
Query parameters
confirmstringrequired
Must be exactly true.
One of "true".
statusstring
Narrow to one status. Omitted or all deletes every group. Case-insensitive.
One of "pending" "dismissed" "all".
Response 200
Deleted.
application/json
Example
{
"deleted": 49
}
Errors
- 400
confirm=true missing (confirm=true required), or unknown status. The body is {"error": "..."} but is sent with Content-Type: text/plain. Error - 405Method other than DELETE. The body is
{"error": "..."} but is sent with Content-Type: text/plain. Error - 500Database or internal failure. The body is
{"error": "..."} but is sent with Content-Type: text/plain. Error
GET/api/duplicates/{id}Read# One group, in the list shape.
Path parameters
idinteger (int64)required
Group id (positive integer).
min 1.
Response 200
The group.
application/json
DuplicateGroup
idinteger (int64)required
modelstringrequired
Embedding model the scan used.
thresholdnumberrequired
Similarity floor the scan used, 0-1.
anchorPathstringrequired
The item every member score is measured against.
keeperPathstringrequired
Default merge target: the highest-resolution active member, then the largest file, then the anchor, then the shortest path. The anchor when no member is active.
statusstringrequired
One of "pending" "dismissed".
createdAtinteger (int64)required
updatedAtinteger (int64)required
memberCountintegerrequired
Every member row, excluded ones included.
activeCountintegerrequired
minScorenumberrequired
Lowest active member similarity to the anchor (how tight the group is).
Anchor first, then by similarity, excluded members last.
Errors
- 400Invalid group id. The body is
{"error": "..."} but is sent with Content-Type: text/plain. Error - 404No such duplicate group. The body is
{"error": "..."} but is sent with Content-Type: text/plain. Error - 405Method other than GET or DELETE. The body is
{"error": "..."} but is sent with Content-Type: text/plain. Error - 500Database or internal failure. The body is
{"error": "..."} but is sent with Content-Type: text/plain. Error
DELETE/api/duplicates/{id}Admin# Forget one group without deciding.
Removes the group and its member rows; its items may be regrouped by a later scan. The route is registered read-level, so the write is re-checked: while Allow Public Access is on an admin credential is required (401 JSON otherwise). Broadcasts a duplicates-updated event on /stream.
Path parameters
idinteger (int64)required
Group id (positive integer).
min 1.
Response 200
Deleted.
application/json
deletedinteger (int64)required
Example
{
"deleted": 311
}
Errors
- 400Invalid group id. The body is
{"error": "..."} but is sent with Content-Type: text/plain. Error - 401Allow Public Access is on and the request carries no admin credential (anonymous visitors may read but not write here). Error
- 404No duplicate group with that id. The body is
{"error": "..."} but is sent with Content-Type: text/plain. Error - 500Database or internal failure. The body is
{"error": "..."} but is sent with Content-Type: text/plain. Error
POST/api/duplicates/{id}/mergeAdmin# Merge group members into one keeper.
Runs the same merge as POST /api/media/merge-metadata: the keeper gains the sources' tags, embeddings and transcript, and the sources are deleted from disk and the database (s3:// sources and files that fail to delete are reported in failed and keep their rows). Member rows follow the deletions, so a fully merged group disappears and a partially merged one stays pending with what is left.
keep defaults to the group's keeperPath (highest resolution, then largest file, then the anchor). It must be a member.sources defaults to every other active member. Excluded members are merged only when named. Blank entries and the keeper itself are skipped.
Also broadcasts people-updated when merged sources had faces. Broadcasts a duplicates-updated event on /stream.
Path parameters
idinteger (int64)required
Group id (positive integer).
min 1.
Request body
application/json
keepstring
Path of the member to keep.
sourcesarray of string
Member paths to merge into keep and delete.
Example
{
"keep": "D:/photos/IMG_0001.jpg",
"sources": [
"D:/photos/IMG_0001 (1).jpg"
]
}
Response 200
Merged.
application/json
The group after the merge, or null when it no longer exists (nothing left to review).
Example
{
"merge": {
"target": "D:/photos/IMG_0001.jpg",
"sources": [
"D:/photos/IMG_0001 (1).jpg"
],
"tags": 3,
"embeddings": 0,
"transcript": false,
"transcriptFile": "",
"deleted": [
"D:/photos/IMG_0001 (1).jpg"
],
"failed": null,
"facesRemoved": 1
},
"group": null
}
Errors
- 400Invalid id or JSON;
keep is not a member; a source is not a member; or nothing is left to merge. The body is {"error": "..."} but is sent with Content-Type: text/plain. Error - 404No such duplicate group. The body is
{"error": "..."} but is sent with Content-Type: text/plain. Error - 405Method other than POST. The body is
{"error": "..."} but is sent with Content-Type: text/plain. Error - 500Database failure or the merge failed before deleting anything. The body is
{"error": "..."} but is sent with Content-Type: text/plain. Error
POST/api/duplicates/{id}/dismissAdmin# Mark a group as not duplicates.
Sets the status to dismissed. The members keep their rows, so they are never regrouped by later scans. No request body. Broadcasts a duplicates-updated event on /stream.
Path parameters
idinteger (int64)required
Group id (positive integer).
min 1.
Response 200
Status changed.
application/json
idinteger (int64)required
Example
{
"id": 311,
"status": "dismissed"
}
Errors
- 400Invalid group id. The body is
{"error": "..."} but is sent with Content-Type: text/plain. Error - 404No duplicate group with that id. The body is
{"error": "..."} but is sent with Content-Type: text/plain. Error - 405Method other than POST. The body is
{"error": "..."} but is sent with Content-Type: text/plain. Error - 500Database or internal failure. The body is
{"error": "..."} but is sent with Content-Type: text/plain. Error
POST/api/duplicates/{id}/restoreAdmin# Return a group to pending.
Sets the status back to pending. No request body. Broadcasts a duplicates-updated event on /stream.
Path parameters
idinteger (int64)required
Group id (positive integer).
min 1.
Response 200
Status changed.
application/json
idinteger (int64)required
Example
{
"id": 311,
"status": "pending"
}
Errors
- 400Invalid group id. The body is
{"error": "..."} but is sent with Content-Type: text/plain. Error - 404No duplicate group with that id. The body is
{"error": "..."} but is sent with Content-Type: text/plain. Error - 405Method other than POST. The body is
{"error": "..."} but is sent with Content-Type: text/plain. Error - 500Database or internal failure. The body is
{"error": "..."} but is sent with Content-Type: text/plain. Error
POST/api/duplicates/{id}/excludeAdmin# Pull one member out of a group, or put it back.
An excluded member keeps its row (so it is never regrouped with the same partners) but is never merged by default. When fewer than two active members remain the group is dismissed automatically; when the anchor is excluded the group is re-anchored on an active member. Broadcasts a duplicates-updated event on /stream.
Path parameters
idinteger (int64)required
Group id (positive integer).
min 1.
Request body
application/json
excludedboolean
false puts the member back.
Default true.
Example
{
"path": "D:/photos/lookalike.jpg",
"excluded": true
}
Response 200
Updated group.
application/json
idinteger (int64)required
The group after the change; null if it no longer exists.
Errors
- 400Invalid id, invalid JSON, or
path missing (bad request: path required). The body is {"error": "..."} but is sent with Content-Type: text/plain. Error - 404The path is not a member of the group. The body is
{"error": "..."} but is sent with Content-Type: text/plain. Error - 405Method other than POST. The body is
{"error": "..."} but is sent with Content-Type: text/plain. Error - 500Database or internal failure. The body is
{"error": "..."} but is sent with Content-Type: text/plain. Error
GPU Engines
Where the loki-* GPU engines (loki-retouch, loki-reshoot, loki-dream, loki-segment) run. This is the engines section of the server config (EnginesConfig) plus the dispatcher's live state, and it backs the GPU Engines tab of the Config page.
- Strategy
local (the default) runs every engine on this machine's GPU; pool runs them on remote workers only; hybrid treats this machine's GPU as one more worker. - Pools are groups of interchangeable workers: kind
nodes (loki-worker HTTP servers this server calls directly) or kind runpod (a RunPod queue-based serverless endpoint; files travel through a bucket or inline). - Routes override the strategy per engine, for example
"loki-segment": "local". A route value is local, pool, pool:<name> or hybrid. - localOnly overrides the strategy and every route so all engines run here while the pools stay configured.
Worker tokens, RunPod API keys and transfer-bucket keys are never sent back: they read as <redacted>, and posting <redacted> back keeps the stored value.
Old Workers page; redirects to the Config page's GPU Engines tab.
Response 302
Redirect to /config#engines.
Get the engines config (secrets redacted) and the dispatcher's live status.
The status is the last known state; nothing is probed. Use POST /api/engines/check to probe now.
Query parameters
localstring
1 adds local, this machine's engine installs. Reading an engine's version runs it once (--version, cached per file).
One of "1".
Response 200
Config and status.
application/json
Only with ?local=1. One entry per engine in display order (loki-retouch, loki-reshoot, loki-dream in dream builds, loki-segment).
Example
{
"config": {
"strategy": "hybrid",
"pools": [
{
"name": "lab",
"kind": "nodes",
"nodes": [
{
"name": "gpu1",
"url": "http://10.0.0.5:8080",
"token": "<redacted>"
}
],
"maxJobs": 4
}
],
"routes": {
"loki-segment": "local"
},
"fallback": false,
"localOnly": false
},
"status": {
"strategy": "hybrid",
"routes": {
"loki-segment": "local"
},
"fallback": false,
"localOnly": false,
"placement": {
"loki-retouch": "hybrid",
"loki-reshoot": "hybrid",
"loki-dream": "hybrid",
"loki-segment": "local"
},
"targets": [
{
"key": "local",
"kind": "local",
"healthy": true,
"running": 0,
"slots": 1
},
{
"key": "lab/gpu1",
"pool": "lab",
"kind": "node",
"url": "http://10.0.0.5:8080",
"healthy": true,
"checked": "2026-10-31T12:00:00Z",
"running": 1,
"slots": 2
}
]
}
}
Replace the engines section of the config and apply it.
Replaces the whole engines section, saves it to the config file and applies it at once (dispatcher targets and the GPU bucket size), then probes every worker (up to 20 s) and returns the new state.
- A secret posted as
<redacted> keeps the stored value: pools are matched by name (case-insensitive), nodes by url (ignoring a trailing /). A placeholder with nothing to restore is saved as empty. - A pool posted without
provider keeps the stored one. - Routes present before and missing now are cleared (saved as
"", meaning follow the strategy). - The section is normalized: unknown
strategy becomes local; pools with a blank or duplicate name are dropped; any kind other than runpod becomes nodes; nodes without a url are dropped and an unnamed node is named after its URL; maxJobs <= 0 becomes 4; route keys are lowercased and prefixed loki- when needed. - Pools supplied by the environment (
fromEnv) are never written to the config file.
Request body
application/json
EnginesConfig
strategystring
Where engines run unless routed otherwise. Omitted when empty; anything unknown is saved as local.
One of "local" "pool" "hybrid". Default "local".
poolsarray | nullrequired
Worker pools; null when there are none.
routesobject | nullrequired
Per-engine override of the strategy, keyed by engine (loki-retouch, ...; retouch is accepted and prefixed). Values: local, pool, pool:<name>, hybrid, or "" to follow the strategy. null when empty.
fallbackbooleanrequired
With strategy pool, lets a run use this machine's GPU when no pool worker is reachable (hybrid always may).
localOnlybooleanrequired
Overrides the strategy and every route so all engines run on this machine while pools stay configured.
Example
{
"strategy": "pool",
"fallback": true,
"localOnly": false,
"routes": {
"segment": "local"
},
"pools": [
{
"name": "cloud",
"kind": "runpod",
"endpointId": "abc123xyz",
"apiKey": "<redacted>",
"maxJobs": 2,
"transfer": {
"root": "s3-media"
}
}
]
}
Response 200
The saved config (secrets redacted) and the freshly probed status.
application/json
Errors
- 400Body is not valid JSON (
bad json: ...). JSON Error body with a text/plain content type. Error - 500The config file could not be saved. JSON
Error body with a text/plain content type. Error
POST/api/engines/checkAdmin# Probe every worker now and return the status.
Probes every pool target (up to 30 s in total). No request body.
Response 200
The dispatcher status after probing.
application/json
EngineStatus
strategystringrequired
One of "local" "pool" "hybrid".
routesmap of string
The configured routes; omitted when empty.
placementmap of stringrequired
Effective rule per engine (loki-retouch, loki-reshoot, loki-dream, loki-segment): local, pool, pool:<name> or hybrid.
Every place engines can run; the first is always this machine (key local). Disabled pools and nodes are not listed.
POST/api/engines/warmAdmin# Preload engines' models on the workers.
Asks the workers of one pool (every pool when pool is empty) to fetch and load the engines' models ahead of the first job. Worker nodes get a warm request; RunPod endpoints get a warm job (which also downloads the models to their volume). The local GPU is not warmed. Times out after one minute.
Request body
application/json
poolstring
Pool name (case-insensitive); empty for every pool.
enginesarray of stringrequired
Engines to warm, for example loki-retouch. Must not be empty.
modelstring
Optional model to warm (passed to the worker).
Example
{
"pool": "lab",
"engines": [
"loki-retouch",
"loki-reshoot"
]
}
Response 200
One note per target, <key>: warming <engines> or <key>: <error>. null when no target matched.
application/json
notesarray | nullrequired
Example
{
"notes": [
"lab/gpu1: warming loki-retouch, loki-reshoot"
]
}
Errors
- 400Bad JSON or empty
engines. JSON Error body with a text/plain content type. Error
POST/api/engines/probeAdmin# Check a worker before adding it.
For kind: runpod, asks the RunPod endpoint endpointId for its health with apiKey; for any other kind, asks the loki-worker at url for its info with token. String fields are trimmed. Times out after 30 s.
Request body
application/json
kindstring
runpod probes an endpoint; anything else probes a node.
One of "nodes" "runpod".
tokenstring
Node bearer token (nodes).
endpointIdstring
RunPod endpoint id (runpod).
Example
{
"kind": "nodes",
"url": "http://10.0.0.5:8080",
"token": "s3cret"
}
Response 200
Reachable. info is a WorkerInfo for a node or a RunPodHealth for an endpoint.
application/json
Errors
- 400Body is not JSON (
bad json). JSON Error body with a text/plain content type. Error - 502The worker or endpoint could not be reached or refused the credentials. Error
Scenes
The Scene editor's API. A scene is a layered composition over one still (an image, or a frame of a video). Each layer replaces a masked region of it: a still layer with a loki-retouch edit of a crop, a motion layer with a loki-reshoot animation of a crop, or a paint layer the user paints by hand (never generated). The engines take no masks, so every generation crops the layer's region from the flattened stills below it, runs the engine on the crop with nolibrary, and composites the result back through the layer's full-frame alpha mask PNG.
- Each generation becomes a version of its layer;
active picks the one shown. Motion generations are chained into a scene-lock job that freezes non-moving pixels to the still so region edges stay static. - Generations, roto tracking (
scene-track) and exports (scene-render) are ordinary jobs on the queue. Finished jobs are folded into the document whenever the scene is read, so the editor polls GET /api/scenes/{id}. - The server owns the files under
<data>/scenes/<id>/. Exports are the only files written beside the source (<name>_scene.mp4 or <name>_scene.png) and are added to the library. - Selection (
/segment) keeps one loki-segment --serve session per scene, on this machine or on a worker node.
Scene errors are written by httpError: the body is the JSON Error shape but the Content-Type is text/plain; charset=utf-8. Unknown {action} segments answer a plain-text 404 page not found, and wrong methods a plain-text 405.
List scenes, optionally only those started from one source.
Query parameters
sourcestring
Source file path; compared case-insensitively after making it absolute. Omit for every scene.
Response 200
Scenes, most recently changed first (an empty array when none).
application/json
array of SceneSummary
A scene in a list (no masks).
updatedstring (date-time)required
Errors
- 405Method other than GET or POST. Plain text. text/plain
Open the latest scene for a source, or start a new one.
Unless fresh is true, returns the most recently changed scene started from source if there is one. Otherwise creates a scene: the base frame is the image (HEIC included) or, for a video, the frame at time seconds, normalized to PNG.
Request body
application/json
sourcestringrequired
Absolute path of an image or video.
timenumber
Video only: seconds into the clip (negative becomes 0). Ignored for images.
freshboolean
Always start a new scene.
Example
{
"source": "D:/photos/beach.jpg",
"fresh": false
}
Response 200
The scene document.
application/json
Scene
namestringrequired
Defaults to the source file name without extension.
sourcestringrequired
Absolute path of the source image or video.
sourceTimenumber
Video sources: seconds of the base frame.
basestringrequired
Path of the base frame PNG.
durationnumberrequired
Export length in seconds; 0 = the longest visible motion layer (5 s when none).
freeLoopsboolean
Each motion layer loops at its own length instead of being retimed so the export loops seamlessly.
stitchingboolean
Stitches are being made; poll until false.
createdstring (date-time)required
updatedstring (date-time)required
Errors
- 400Missing
source or bad JSON (needs a source path), source not found, not an image or video, or ffmpeg failed. Error
GET/api/scenes/{id}Admin# Get a scene, with finished jobs folded in.
Reconciles finished generation, track and export jobs into the document (saving it if anything changed) and derives version stitches, starting any that are due. Poll this while jobs run.
Path parameters
idstringrequired
Scene id (a UUID; ids not matching ^[a-f0-9-]{8,64}$ answer 404).
Response 200
The scene document.
application/json
Scene
namestringrequired
Defaults to the source file name without extension.
sourcestringrequired
Absolute path of the source image or video.
sourceTimenumber
Video sources: seconds of the base frame.
basestringrequired
Path of the base frame PNG.
durationnumberrequired
Export length in seconds; 0 = the longest visible motion layer (5 s when none).
freeLoopsboolean
Each motion layer loops at its own length instead of being retimed so the export loops seamlessly.
stitchingboolean
Stitches are being made; poll until false.
createdstring (date-time)required
updatedstring (date-time)required
Errors
- 404Unknown or malformed scene id. Error
- 405Method other than GET, PUT or DELETE. Plain text. text/plain
PUT/api/scenes/{id}Admin# Save the editor's fields of a scene.
Only editor-owned fields are taken; versions, jobs, errors and the export stay the server's, so a save can never drop a generation that finished meanwhile. Layers are replaced in the posted order (bottom to top). Layers missing from the body are deleted, their jobs cancelled and their files removed.
Path parameters
idstringrequired
Scene id (a UUID; ids not matching ^[a-f0-9-]{8,64}$ answer 404).
Request body
application/json
SceneUpdate
namestring
Blank keeps the current name.
durationnumber
Clamped to 0-60.
min 0, max 60.
layersarray of object
The complete layer list, bottom to top. Entries without an id, with an id over 64 characters, or repeating an id are skipped. Layers not listed are deleted.
kindstring
Anything else becomes still.
One of "still" "motion" "paint".
maskstring
Empty, or a data:image/png;base64, URL; any other value keeps the stored mask.
activeinteger
Ignored when out of range.
min -1.
versionsarray of object
Only each entry's mask is taken, matched to a stored version by path.
Example
{
"name": "Beach",
"duration": 0,
"freeLoops": false,
"layers": [
{
"id": "l1",
"name": "Waves",
"kind": "motion",
"region": {
"x": 0,
"y": 600,
"w": 1920,
"h": 480
},
"mask": "data:image/png;base64,iVBORw0...",
"feather": 24,
"prompt": "gentle waves roll in",
"options": {
"duration": 5,
"lock": true
},
"active": 0
}
]
}
Response 200
The saved scene.
application/json
Scene
namestringrequired
Defaults to the source file name without extension.
sourcestringrequired
Absolute path of the source image or video.
sourceTimenumber
Video sources: seconds of the base frame.
basestringrequired
Path of the base frame PNG.
durationnumberrequired
Export length in seconds; 0 = the longest visible motion layer (5 s when none).
freeLoopsboolean
Each motion layer loops at its own length instead of being retimed so the export loops seamlessly.
stitchingboolean
Stitches are being made; poll until false.
createdstring (date-time)required
updatedstring (date-time)required
Errors
- 400Bad JSON (
bad json) or the save failed. Error - 404Unknown scene. Error
DELETE/api/scenes/{id}Admin# Delete a scene and its files.
Cancels every layer's generations and removes the scene folder. Exports beside the source are kept.
Path parameters
idstringrequired
Scene id (a UUID; ids not matching ^[a-f0-9-]{8,64}$ answer 404).
Response 200
Deleted.
application/json
POST/api/scenes/{id}/renderAdmin# Export the scene beside its source.
Queues a scene-render job and records it in the scene's render. video writes <name>_scene.mp4 with every motion layer looping; still writes <name>_scene.png, a flattened PNG of the still edits and the first frame of the motion. The output goes beside the source (the scene folder if that folder is gone), with a numbered name on conflict, and is added to the library. The body is optional.
Path parameters
idstringrequired
Scene id (a UUID; ids not matching ^[a-f0-9-]{8,64}$ answer 404).
Request body optional
application/json
formatstring
Anything other than still means video.
One of "video" "still". Default "video".
Example
{
"format": "video"
}
Response 200
The scene, with render pending.
application/json
Scene
namestringrequired
Defaults to the source file name without extension.
sourcestringrequired
Absolute path of the source image or video.
sourceTimenumber
Video sources: seconds of the base frame.
basestringrequired
Path of the base frame PNG.
durationnumberrequired
Export length in seconds; 0 = the longest visible motion layer (5 s when none).
freeLoopsboolean
Each motion layer loops at its own length instead of being retimed so the export loops seamlessly.
stitchingboolean
Stitches are being made; poll until false.
createdstring (date-time)required
updatedstring (date-time)required
Errors
- 400An export is already pending or running, or the job could not be queued. Error
- 404Unknown scene. Error
- 405Method other than POST. Plain text. text/plain
POST/api/scenes/{id}/segmentAdmin# Find objects in the scene picture (or a motion version's frame) with loki-segment.
Answers one pick of the Select tool with SAM 3.1 masks the editor adds to a layer. Without layer and version it segments the picture as the editor shows it (every visible still and paint layer flattened over the base, cached until the layers change), in frame pixels. With layer + version it segments frame frame of that motion version's clip, in clip pixels.
Uses one loki-segment --serve session per scene (started on demand, closed when idle or when a GPU-heavy job needs the GPU). When loki-segment is routed to a pool, the session runs on a worker node; serverless endpoints cannot hold one.
Path parameters
idstringrequired
Scene id (a UUID; ids not matching ^[a-f0-9-]{8,64}$ answer 404).
Request body
application/json
SegmentRequest
textstring
Text prompt (find objects by description).
pointsarray of array of number
[x, y, 1|0] (1 = this, 0 = not this).
boxarray of number
[x, y, w, h]; used only with exactly 4 numbers.
multimaskboolean
Also return candidate readings (part / object / whole) for clicks and boxes.
layerstring
Motion layer id to segment a clip frame of.
frameinteger
Clip frame (with layer + version).
Example
{
"points": [
[
640,
360,
1
]
],
"multimask": true
}
Response 200
The objects found.
application/json
SegmentResult
widthintegerrequired
Width of what was segmented.
objectsarray | nullrequired
null when nothing was found.
msintegerrequired
Segmentation time in milliseconds.
Errors
- 400Bad JSON,
loki-segment unavailable (not installed, no reachable worker node), or the segmenter failed. Error - 404Unknown scene, or
layer/version is not a motion version of it. Error - 405Method other than POST. Plain text. text/plain
POST/api/scene-dreamAdmin# Generate a new picture to start a scene from (dream builds only).
Queues a dream job (loki-dream) with no input; the image lands in the library's Generated folder. Poll GET /api/scene-dream/{job}, then start a scene from its output. Registered only in servers built with -tags dream (LOKI_DREAM=1); otherwise this path is not served.
Request body
application/json
SceneDreamRequest
promptstringrequired
The picture to generate. Required (non-blank).
sizestring
WxH; ignored when it does not match.
Example
{
"prompt": "a lighthouse on a cliff at dusk",
"size": "1344x768"
}
Response 200
The queued job.
application/json
SceneDreamStatus
One of "pending" "in_progress" "paused" "completed" "cancelled" "error".
outputstring
The generated image, once completed.
Example
{
"jobId": "5f0c2f7e-1b2a-4c55-9d1e-0a6f3b9c2d11",
"state": "pending",
"done": 0,
"total": 0
}
Errors
- 400Bad JSON, empty
prompt (describe the picture first), or the job could not be queued. Error - 405Method other than POST. Plain text. text/plain
GET/api/scene-dream/{job}Admin# Report a scene-dream job (dream builds only).
The handler does not check the method; any method gets the same answer. Registered only in dream builds.
Path parameters
jobstringrequired
The jobId from POST /api/scene-dream.
Response 200
The job's state; output once completed, error (its last meaningful log line) once errored or cancelled.
application/json
SceneDreamStatus
One of "pending" "in_progress" "paused" "completed" "cancelled" "error".
outputstring
The generated image, once completed.
Errors
- 404No such job, or the job is not a
dream job (message scene not found). Error
POST/api/scenes/{id}/layers/{lid}/generateAdmin# Queue the layer's next generation.
Served by the /api/scenes/{id}/layers/{lid}/{action} route with action = generate.
Crops the layer's region from the stills below it (stitched, as the stage shows them) and queues the engine job, behind any generation of the layer already in flight (up to 8 running + queued). Still layers run retouch (or dream when options.engine is dream in dream builds; a colour finish runs scene-recolour first); motion layers run reshoot followed by scene-lock. Paint layers cannot be generated. The body is optional: mask generates with that mask instead of the layer's own (used only when it is a PNG data URL with a non-empty region).
Path parameters
idstringrequired
Scene id (a UUID; ids not matching ^[a-f0-9-]{8,64}$ answer 404).
lidstringrequired
Layer id (chosen by the editor).
Request body optional
application/json
Response 200
The scene, with the new generation as the layer's job or appended to queued.
application/json
Scene
namestringrequired
Defaults to the source file name without extension.
sourcestringrequired
Absolute path of the source image or video.
sourceTimenumber
Video sources: seconds of the base frame.
basestringrequired
Path of the base frame PNG.
durationnumberrequired
Export length in seconds; 0 = the longest visible motion layer (5 s when none).
freeLoopsboolean
Each motion layer loops at its own length instead of being retimed so the export loops seamlessly.
stitchingboolean
Stitches are being made; poll until false.
createdstring (date-time)required
updatedstring (date-time)required
Errors
- 400Paint layer, still layer without a prompt or finish (
describe the edit first), empty region (draw the area to change first), bad references, cropping failed, or the job could not be queued. Error - 404Unknown scene or layer. Error
- 409The layer already has 8 generations in flight. Error
POST/api/scenes/{id}/layers/{lid}/poseAdmin# Detect the body pose of the character in a box.
Served by the /api/scenes/{id}/layers/{lid}/{action} route with action = pose. Reads the pose (RTMPose, OpenPose's 18 joints) of the person in box in the picture as layer lid sees it (every layer under it flattened), for the mannequin of a repose layer. Starts the rtmpose-body model install if needed.
Path parameters
idstringrequired
Scene id (a UUID; ids not matching ^[a-f0-9-]{8,64}$ answer 404).
lidstringrequired
Layer id (chosen by the editor).
Request body
application/json
Example
{
"box": {
"x": 400,
"y": 120,
"w": 300,
"h": 700
}
}
Response 200
The joints.
application/json
ScenePose
jointsarray of objectrequired
OpenPose's 18 joints in its order (nose, neck, right shoulder, ... left ear), frame pixels.
Errors
- 400Missing or invalid body, box smaller than 8 px (
select the character first ...), model not installed yet, or detection failed. Error - 404Unknown scene. Error
POST/api/scenes/{id}/layers/{lid}/cancelAdmin# Cancel one of the layer's generations, or all of them.
Served by the /api/scenes/{id}/layers/{lid}/{action} route with action = cancel. Cancels every step of the generation whose SceneJob.id is job (running or queued), or every generation when job is empty or the body is omitted. Cancelling the running one starts the next queued. Clears the layer's error. An unknown layer id is not an error.
Path parameters
idstringrequired
Scene id (a UUID; ids not matching ^[a-f0-9-]{8,64}$ answer 404).
lidstringrequired
Layer id (chosen by the editor).
Request body optional
application/json
jobstring
The generation's SceneJob.id; empty for all.
Response 200
The scene.
application/json
Scene
namestringrequired
Defaults to the source file name without extension.
sourcestringrequired
Absolute path of the source image or video.
sourceTimenumber
Video sources: seconds of the base frame.
basestringrequired
Path of the base frame PNG.
durationnumberrequired
Export length in seconds; 0 = the longest visible motion layer (5 s when none).
freeLoopsboolean
Each motion layer loops at its own length instead of being retimed so the export loops seamlessly.
stitchingboolean
Stitches are being made; poll until false.
createdstring (date-time)required
updatedstring (date-time)required
DELETE/api/scenes/{id}/layers/{lid}/versionsAdmin# Drop every version of a layer but one.
Removes the other versions' files (and motion maps); the kept version becomes version 0 and active.
Path parameters
idstringrequired
Scene id (a UUID; ids not matching ^[a-f0-9-]{8,64}$ answer 404).
lidstringrequired
Layer id (chosen by the editor).
Query parameters
keepintegerrequired
Zero-based index of the version to keep.
min 0.
Response 200
The scene.
application/json
Scene
namestringrequired
Defaults to the source file name without extension.
sourcestringrequired
Absolute path of the source image or video.
sourceTimenumber
Video sources: seconds of the base frame.
basestringrequired
Path of the base frame PNG.
durationnumberrequired
Export length in seconds; 0 = the longest visible motion layer (5 s when none).
freeLoopsboolean
Each motion layer loops at its own length instead of being retimed so the export loops seamlessly.
stitchingboolean
Stitches are being made; poll until false.
createdstring (date-time)required
updatedstring (date-time)required
Errors
- 400
keep missing or not an integer (needs ?keep=<version index>), or out of range (no version N). Error - 404Unknown scene or layer. Error
- 405Method other than DELETE. Plain text. text/plain
DELETE/api/scenes/{id}/layers/{lid}/versions/{n}Admin# Drop one version of a layer.
Removes its file unless another version shares it. If it was active, the last remaining version becomes active (-1 when none).
Path parameters
idstringrequired
Scene id (a UUID; ids not matching ^[a-f0-9-]{8,64}$ answer 404).
lidstringrequired
Layer id (chosen by the editor).
nintegerrequired
Zero-based index into the layer's versions; a non-integer answers 400 (bad version).
min 0.
Response 200
The scene.
application/json
Scene
namestringrequired
Defaults to the source file name without extension.
sourcestringrequired
Absolute path of the source image or video.
sourceTimenumber
Video sources: seconds of the base frame.
basestringrequired
Path of the base frame PNG.
durationnumberrequired
Export length in seconds; 0 = the longest visible motion layer (5 s when none).
freeLoopsboolean
Each motion layer loops at its own length instead of being retimed so the export loops seamlessly.
stitchingboolean
Stitches are being made; poll until false.
createdstring (date-time)required
updatedstring (date-time)required
Errors
- 400
n is not an integer (bad version) or out of range (no version N). Error - 404Unknown scene or layer. Error
- 405Method other than DELETE. Plain text. text/plain
POST/api/scenes/{id}/layers/{lid}/versions/{n}/loopAdmin# Add a seamless-loop copy of a motion version.
Served by /api/scenes/{id}/layers/{lid}/versions/{n}/{action} with action = loop. Runs synchronously (ffmpeg) and appends the loop as a new active version (without the source's roto).
crossfade dissolves the end into the start (up to a second, never more than a quarter of the clip).natural finds the best loop inside the clip and hides its seam with a short dissolve.boomerang plays forward then backward.
Path parameters
idstringrequired
Scene id (a UUID; ids not matching ^[a-f0-9-]{8,64}$ answer 404).
lidstringrequired
Layer id (chosen by the editor).
nintegerrequired
Zero-based index into the layer's versions; a non-integer answers 400 (bad version).
min 0.
Request body
application/json
kindstringrequired
One of "crossfade" "natural" "boomerang".
Example
{
"kind": "crossfade"
}
Response 200
The scene with the new version.
application/json
Scene
namestringrequired
Defaults to the source file name without extension.
sourcestringrequired
Absolute path of the source image or video.
sourceTimenumber
Video sources: seconds of the base frame.
basestringrequired
Path of the base frame PNG.
durationnumberrequired
Export length in seconds; 0 = the longest visible motion layer (5 s when none).
freeLoopsboolean
Each motion layer loops at its own length instead of being retimed so the export loops seamlessly.
stitchingboolean
Stitches are being made; poll until false.
createdstring (date-time)required
updatedstring (date-time)required
Errors
- 400
n not an integer, unknown or missing kind (unknown loop kind ...), or ffmpeg failed. Error - 404Unknown scene, or
lid/n is not a motion version. Error
POST/api/scenes/{id}/layers/{lid}/versions/{n}/boomerangAdmin# Add a boomerang loop of a motion version.
Same as loop with kind = boomerang. No body.
Path parameters
idstringrequired
Scene id (a UUID; ids not matching ^[a-f0-9-]{8,64}$ answer 404).
lidstringrequired
Layer id (chosen by the editor).
nintegerrequired
Zero-based index into the layer's versions; a non-integer answers 400 (bad version).
min 0.
Response 200
The scene with the new version.
application/json
Scene
namestringrequired
Defaults to the source file name without extension.
sourcestringrequired
Absolute path of the source image or video.
sourceTimenumber
Video sources: seconds of the base frame.
basestringrequired
Path of the base frame PNG.
durationnumberrequired
Export length in seconds; 0 = the longest visible motion layer (5 s when none).
freeLoopsboolean
Each motion layer loops at its own length instead of being retimed so the export loops seamlessly.
stitchingboolean
Stitches are being made; poll until false.
createdstring (date-time)required
updatedstring (date-time)required
Errors
- 400
n not an integer, or ffmpeg failed. Error - 404Unknown scene, or
lid/n is not a motion version. Error
POST/api/scenes/{id}/layers/{lid}/versions/{n}/refinishAdmin# Redo a motion version's finish from the engine output.
Queues a scene-lock job over the version's raw reshoot output with the layer's current lock and detail options; the result arrives as a new version (re-finished vN). CPU only, no regeneration. No body.
Path parameters
idstringrequired
Scene id (a UUID; ids not matching ^[a-f0-9-]{8,64}$ answer 404).
lidstringrequired
Layer id (chosen by the editor).
nintegerrequired
Zero-based index into the layer's versions; a non-integer answers 400 (bad version).
min 0.
Response 200
The scene with the job queued on the layer.
application/json
Scene
namestringrequired
Defaults to the source file name without extension.
sourcestringrequired
Absolute path of the source image or video.
sourceTimenumber
Video sources: seconds of the base frame.
basestringrequired
Path of the base frame PNG.
durationnumberrequired
Export length in seconds; 0 = the longest visible motion layer (5 s when none).
freeLoopsboolean
Each motion layer loops at its own length instead of being retimed so the export loops seamlessly.
stitchingboolean
Stitches are being made; poll until false.
createdstring (date-time)required
updatedstring (date-time)required
Errors
- 400
n not an integer, not a motion version, or the engine output is no longer on disk (loops and older versions cannot be re-finished). Error - 404Unknown scene or layer. Error
- 409The layer already has 8 generations in flight. Error
POST/api/scenes/{id}/layers/{lid}/versions/{n}/healAdmin# Redraw where a still version's stitched seam still shows.
Queues a loki-dream inpaint of the band where the active still version's stitch seam still shows (its heal map), then merges only that band back; the result is a new version (healed vN). Needs a dream build: otherwise it answers 400. The version must be the active one and the layer stitched (options.stitch not false).
Path parameters
idstringrequired
Scene id (a UUID; ids not matching ^[a-f0-9-]{8,64}$ answer 404).
lidstringrequired
Layer id (chosen by the editor).
nintegerrequired
Zero-based index into the layer's versions; a non-integer answers 400 (bad version).
min 0.
Request body optional
application/json
strengthnumber
Inpaint strength; 0 or omitted uses the default (0.5).
Response 200
The scene with the job queued on the layer.
application/json
Scene
namestringrequired
Defaults to the source file name without extension.
sourcestringrequired
Absolute path of the source image or video.
sourceTimenumber
Video sources: seconds of the base frame.
basestringrequired
Path of the base frame PNG.
durationnumberrequired
Export length in seconds; 0 = the longest visible motion layer (5 s when none).
freeLoopsboolean
Each motion layer loops at its own length instead of being retimed so the export loops seamlessly.
stitchingboolean
Stitches are being made; poll until false.
createdstring (date-time)required
updatedstring (date-time)required
Errors
- 400Not a dream build, not a still layer, version not shown, layer not stitched, stitch not ready, or nothing to heal. Error
- 404Unknown scene or layer. Error
- 409The layer already has 8 generations in flight. Error
POST/api/scenes/{id}/layers/{lid}/versions/{n}/changeAdmin# Where a still version differs from the picture it was made from.
Used by "Fit mask to result". Returns a white PNG whose alpha is the change near the layer's mask, covering the version's region. No body.
Path parameters
idstringrequired
Scene id (a UUID; ids not matching ^[a-f0-9-]{8,64}$ answer 404).
lidstringrequired
Layer id (chosen by the editor).
nintegerrequired
Zero-based index into the layer's versions; a non-integer answers 400 (bad version).
min 0.
Response 200
The change map.
application/json
SceneRegionMap
dataUrlstringrequired
PNG data URL covering region; alpha = change or motion.
Errors
- 400
n not an integer, the source crop is no longer on disk, or the map could not be computed. Error - 404Unknown scene, or
lid/n is not a still version. Error
POST/api/scenes/{id}/layers/{lid}/versions/{n}/motionAdmin# Where a motion version moves.
Returns a PNG whose alpha is motion, covering the version's region. No body.
Path parameters
idstringrequired
Scene id (a UUID; ids not matching ^[a-f0-9-]{8,64}$ answer 404).
lidstringrequired
Layer id (chosen by the editor).
nintegerrequired
Zero-based index into the layer's versions; a non-integer answers 400 (bad version).
min 0.
Response 200
The motion map.
application/json
SceneRegionMap
dataUrlstringrequired
PNG data URL covering region; alpha = change or motion.
Errors
- 400
n not an integer, or the map could not be computed. Error - 404Unknown scene, or
lid/n is not a motion version. Error
POST/api/scenes/{id}/layers/{lid}/versions/{n}/trackAdmin# Queue the roto (tracked matte) of a motion version.
Queues a GPU-bucketed scene-track job (loki-segment) that writes one matte per clip frame plus an alpha webm for the stage; the export then multiplies the layer mask by the matte. Re-tracking cancels a pending or running track of the same version. The state appears on the version's track.
Path parameters
idstringrequired
Scene id (a UUID; ids not matching ^[a-f0-9-]{8,64}$ answer 404).
lidstringrequired
Layer id (chosen by the editor).
nintegerrequired
Zero-based index into the layer's versions; a non-integer answers 400 (bad version).
min 0.
Request body
application/json
promptsarray of objectrequired
loki-segment --prompts entries, in clip pixels: {obj, frame, text}, {obj, frame, points: [[x, y, 1|0]]} or {obj, frame, box: [x, y, w, h]}. Must be a non-empty array.
Example
{
"prompts": [
{
"obj": 1,
"frame": 0,
"points": [
[
320,
240,
1
]
]
}
]
}
Response 200
The scene with the version's track pending.
application/json
Scene
namestringrequired
Defaults to the source file name without extension.
sourcestringrequired
Absolute path of the source image or video.
sourceTimenumber
Video sources: seconds of the base frame.
basestringrequired
Path of the base frame PNG.
durationnumberrequired
Export length in seconds; 0 = the longest visible motion layer (5 s when none).
freeLoopsboolean
Each motion layer loops at its own length instead of being retimed so the export loops seamlessly.
stitchingboolean
Stitches are being made; poll until false.
createdstring (date-time)required
updatedstring (date-time)required
Errors
- 400
n not an integer, bad JSON, empty prompts (pick what to track first), or not a motion version. Error - 404Unknown scene or layer. Error
POST/api/scenes/{id}/layers/{lid}/versions/{n}/matteAdmin# One frame of a version's roto matte.
Path parameters
idstringrequired
Scene id (a UUID; ids not matching ^[a-f0-9-]{8,64}$ answer 404).
lidstringrequired
Layer id (chosen by the editor).
nintegerrequired
Zero-based index into the layer's versions; a non-integer answers 400 (bad version).
min 0.
Request body optional
application/json
frameinteger
Clip frame index (negative becomes 0).
Default 0. min 0.
Response 200
The matte frame as a mask piece, in clip pixels.
application/json
maskstringrequired
PNG data URL (white, alpha = kept) covering rect.
Errors
- 400
n not an integer, the version has no completed roto (vN has no roto yet), or the frame file is missing. Error - 404Unknown scene, layer or version. Error
POST/api/scenes/{id}/layers/{lid}/versions/{n}/untrackAdmin# Remove a version's roto.
Cancels the track job if it runs and deletes the matte folder and preview video. No body.
Path parameters
idstringrequired
Scene id (a UUID; ids not matching ^[a-f0-9-]{8,64}$ answer 404).
lidstringrequired
Layer id (chosen by the editor).
nintegerrequired
Zero-based index into the layer's versions; a non-integer answers 400 (bad version).
min 0.
Response 200
The scene.
application/json
Scene
namestringrequired
Defaults to the source file name without extension.
sourcestringrequired
Absolute path of the source image or video.
sourceTimenumber
Video sources: seconds of the base frame.
basestringrequired
Path of the base frame PNG.
durationnumberrequired
Export length in seconds; 0 = the longest visible motion layer (5 s when none).
freeLoopsboolean
Each motion layer loops at its own length instead of being retimed so the export loops seamlessly.
stitchingboolean
Stitches are being made; poll until false.
createdstring (date-time)required
updatedstring (date-time)required
Errors
- 400
n not an integer. Error - 404Unknown scene, layer or version. Error
Dependencies
The server's external pieces in one list: bundled binaries shipped next to the executable (FFmpeg, FFprobe, ExifTool, the ONNX tagger and embedder, ONNX Runtime, FFplay on Windows), optional command-line tools the user installs themselves (yt-dlp, gallery-dl, Ollama, DiscordChatExporter), and on-demand models and tools the server downloads into its deps/models folder (SigLIP, the WD tagger, face models, faster-whisper, the loki-* engines and their weights, COLMAP, Brush, ...).
Access: these routes are registered without the auth middleware. The read routes (GET /api/deps/status, the progress stream) are always open, with CORS for the Electron renderer. The write routes (download, cancel, verify, delete) are also open by design, because the first-run wizard and the desktop app call them unauthenticated, except while Allow Public Access is on: then they need an admin credential (Bearer JWT, lk_ API key or the auth_token cookie) and answer 401 {"error":"unauthorized"} without one. This does not depend on whether first-run setup is complete.
Download state lives in memory: an install started here (or by a job that needs a model on first use) is tracked until the server restarts, and every state change is broadcast on GET /api/deps/models/progress.
GET/api/deps/statusPublic# State of every bundled binary, optional tool and downloadable model.
Reads cached snapshots only and never starts an install or spawns a detection subprocess, so it is cheap to poll. Items come in this order: bundled, then optional, then manifest models and tools (Dream models are left out unless the server was built with the Dream feature).
For a model or tool with a tracked install in this server run, state is the install's state and detail is the install progress object. A model the user supplied themselves (a loki-* engine or third-party tool on PATH, weights next to such an engine, or a configured faster-whisper path) reports state: installed, source: user and detail: {"source":"path"}.
Response 200
Every dependency.
application/json
array of DepStatusItem
One dependency in GET /api/deps/status.
idstringrequired
Dependency id, e.g. ffmpeg, yt-dlp, siglip2-base-patch16-224, loki-retouch.
categorystringrequired
bundled ships with the server; optional is a user-installed PATH tool; model and tool are downloadable from the models manifest.
One of "bundled" "optional" "model" "tool".
featurestring
User-facing capability this unlocks (optional, model and tool items; omitted when empty).
descriptionstring
Longer explanation (optional, model and tool items; omitted when empty).
statestringrequired
Depends on category: - bundled: ready, missing or broken (the version command failed). - optional: installed or not_installed. - model / tool: installed or missing, or, while this server run tracks an install of it, the install state (queued, downloading, extracting, verifying, installed, failed, cancelled).
One of "ready" "missing" "broken" "installed" "not_installed" "queued" "downloading" "extracting" "verifying" "failed" "cancelled".
versionstring
First line of the tool's version output (bundled and optional items; omitted when unknown).
size_bytesinteger (int64)
Download size for this OS (model and tool items).
pathstring
Where it lives. Bundled and optional items when found; models when installed (the managed folder, or for a user copy the PATH binary or weights folder).
errorstring
Why a bundled item is missing or broken, or why a tracked install failed.
Extra data, varying by item: - optional: an OptionalToolHint (always present). - model / tool with a tracked install: the ModelInstall progress object. - model / tool supplied by the user: {"source": "path"}. Omitted otherwise.
sourcestring
user when the item is the user's own copy, used as-is and never downloaded, replaced or deleted by the server. Omitted for managed items.
One of "user".
Example
[
{
"id": "ffmpeg",
"category": "bundled",
"name": "FFmpeg",
"state": "ready",
"version": "ffmpeg version 8.0 Copyright (c) 2000-2025 the FFmpeg developers",
"path": "C:\\Program Files\\Lowkey Media Server\\ffmpeg.exe"
},
{
"id": "yt-dlp",
"category": "optional",
"name": "yt-dlp",
"feature": "Import from YouTube & video sites",
"description": "Video downloader. Required to import from YouTube, Twitch, and many other sites.",
"state": "not_installed",
"detail": {
"description": "Video downloader. Required to import from YouTube, Twitch, and many other sites.",
"commands": [
{
"os": "windows",
"label": "winget",
"command": "winget install yt-dlp"
}
],
"docs_url": "https://github.com/yt-dlp/yt-dlp#installation"
}
},
{
"id": "siglip2-base-patch16-224",
"category": "model",
"name": "SigLIP 2 base patch16 224",
"feature": "Visual similarity search",
"description": "Find visually similar images and search your library by text. ...",
"state": "downloading",
"size_bytes": 1505518412,
"detail": {
"id": "siglip2-base-patch16-224",
"state": "downloading",
"current_file": "image_model.onnx",
"bytes_done": 104857600,
"bytes_total": 371000000,
"started_at": "2026-10-31T10:00:00Z",
"updated_at": "2026-10-31T10:00:12Z"
}
}
]
GET/api/deps/models/progressPublic# Server-Sent Events stream of model install progress.
On connect the server sends one event for every install tracked in this server run (any state, including finished ones), then one event per state change of any install. Each event is a bare data: line holding a ModelInstall JSON object (no event: or id: fields). A comment line : ping is sent every 15 seconds to keep the connection open. Events are dropped, not queued, for a client that falls more than 16 updates behind, so treat the stream as "latest state" and re-read GET /api/deps/status after reconnecting.
Response 200
The event stream; stays open until the client disconnects.
text/event-stream
string
data: <ModelInstall JSON> events separated by blank lines, plus : ping comments.
Example
data: {"id":"yunet","state":"downloading","current_file":"model.onnx","bytes_done":131072,"bytes_total":232589,"started_at":"2026-10-31T10:00:00Z","updated_at":"2026-10-31T10:00:01Z"}
: ping
data: {"id":"yunet","state":"installed","current_file":"model.onnx","bytes_done":232589,"bytes_total":232589,"started_at":"2026-10-31T10:00:00Z","updated_at":"2026-10-31T10:00:02Z"}
Errors
- 500The response writer cannot stream. Plain-text
streaming unsupported. text/plain
POST/api/deps/models/{id}/downloadPublic# Start downloading a model or tool.
Starts the install in the background and returns at once. If an install of this id is already queued or running, that install is returned and no second download starts. Follow progress on GET /api/deps/models/progress. No request body.
Path parameters
idstringrequired
Model or tool id from the models manifest (the id of a model or tool item in GET /api/deps/status).
Response 200
Already installed. The body is the tracked install object when one exists in this server run, otherwise {"state":"installed"}.
application/json
ModelInstall | object
Example
{
"state": "installed"
}
Response 202
Install started (or already in progress); the current progress object.
application/json
ModelInstall
statestringrequired
extracting means archive members are being unpacked; byte counts then refer to the unpacked data, not the download.
One of "queued" "downloading" "extracting" "verifying" "installed" "failed" "cancelled".
current_filestring
File being processed; an archive being unpacked is reported as <name> (extracting). Omitted when empty.
bytes_doneinteger (int64)required
Bytes processed of the current step.
bytes_totalinteger (int64)required
Total bytes of the current step (0 when unknown).
errorstring
Failure message when state is failed. Omitted otherwise.
started_atstring (date-time)required
updated_atstring (date-time)required
Example
{
"id": "faster-whisper",
"state": "queued",
"bytes_done": 0,
"bytes_total": 0,
"started_at": "2026-10-31T10:00:00Z",
"updated_at": "2026-10-31T10:00:00Z"
}
Errors
- 401Only while Allow Public Access is on, when the request has no admin credential. Error
- 404Unknown model id (
unknown model id "<id>"). Error
POST/api/deps/models/{id}/cancelPublic# Cancel an in-flight model install.
Cancels the install's context; the install then moves to cancelled, which is reported on the progress stream. No request body.
Path parameters
idstringrequired
Model or tool id from the models manifest (the id of a model or tool item in GET /api/deps/status).
Response 202
Cancellation requested. Empty body.
Errors
- 401Only while Allow Public Access is on, when the request has no admin credential. Error
- 404No active install for this id (
no active install for "<id>"), including unknown ids. Error
POST/api/deps/models/{id}/verifyPublic# Check the files of an installed model or tool.
For a managed download, every file for this OS is SHA-256 checked against the manifest; a file that was extracted from an archive is only checked for presence (the checksum covers the discarded archive). For a copy the user supplied themselves (see GET /api/deps/status), only presence and, where the manifest knows it, size are checked, and source, location and note are set. The result is always 200; problems are reported per file. No request body.
Path parameters
idstringrequired
Model or tool id from the models manifest (the id of a model or tool item in GET /api/deps/status).
Response 200
Per-file results.
application/json
ModelVerifyResult
sourcestring
Set when the checked copy is the user's own. Omitted for managed downloads.
One of "user".
locationstring
Where the user's copy lives. Only with source.
notestring
Explanation that only presence and size are checked for a user copy. Only with source.
filesmap of stringrequired
Map of relative file path (or, for a user-supplied tool, the binary's file name) to a result: ok, missing, ok (extracted from checksum-verified archive), ok (on PATH), a checksum error message, or a size-mismatch note.
Example
{
"id": "wd-eva02-large-tagger-v3",
"files": {
"model.onnx": "ok",
"selected_tags.csv": "ok",
"config.json": "missing"
}
}
Errors
- 401Only while Allow Public Access is on, when the request has no admin credential. Error
- 404Unknown model id. Error
DELETE/api/deps/models/{id}Public# Delete a downloaded model or tool.
Removes the model's managed folder under deps/models and rebuilds the installed/missing cache. A missing folder is not an error. Copies the user supplied themselves are never touched (they do not live in the managed folder). An in-flight install is not cancelled first.
Path parameters
idstringrequired
Model or tool id from the models manifest (the id of a model or tool item in GET /api/deps/status).
Response 204
Deleted (or nothing was there). Empty body.
Errors
- 401Only while Allow Public Access is on, when the request has no admin credential. Error
- 404Unknown model id. Error
- 500The folder could not be removed. Error
Setup Wizard
The first-run wizard at /setup and its JSON endpoints under /setup/api/. The wizard picks the database file, configures storage roots, offers model downloads (through the Dependencies endpoints) and finally marks setup complete.
Gating: while the config key setupComplete is false every route here is open (there is no account to sign in with yet), and the auth middleware redirects unauthenticated page requests to /setup instead of /login. Once setupComplete is true the endpoints stay mounted but require a valid credential (Bearer JWT, lk_ API key, or the auth_token cookie): the JSON endpoints answer 403 and the page redirects to /login.
setupComplete is set by POST /setup/api/complete. It is also set automatically at startup for installs that already have a real (non-default) account, and when the account is provisioned from the LOWKEY_ADMIN_USER / LOWKEY_ADMIN_PASSWORD environment variables, so such installs never see the wizard.
Errors from these endpoints are written with http.Error: the body is JSON-shaped ({"error":"..."}) but the Content-Type is text/plain.
The first-run setup wizard page.
Server-rendered HTML. Open while setup is incomplete; afterwards it needs a valid credential and unauthenticated requests are redirected to /login.
Response 200
The wizard page.
text/html
string
Response 302
Setup is complete and the request carries no valid credential; redirects to /login.
Errors
- 500Template rendering failed. Plain-text message. text/plain
GET/setup/api/statePublic# Everything the wizard needs to render its steps.
Response 200
Wizard state.
application/json
SetupState
setupCompletebooleanrequired
Whether first-run setup has been completed.
defaultDBPathstringrequired
The database path the server uses when none is configured.
activeDBPathstringrequired
The configured dbPath (may be empty).
dataDirstringrequired
The server's data directory.
homeDirstringrequired
Home directory of the user the server runs as (empty if unknown).
osstringrequired
Go GOOS: windows, darwin, linux.
Configured storage roots (empty array when none), with any S3 secretKey replaced by •••.
hasRealUsersbooleanrequired
The active database has an account other than the bootstrap default admin.
authedbooleanrequired
The request carried a valid credential.
Same as GET /api/deps/status.
Example
{
"setupComplete": false,
"defaultDBPath": "C:\\ProgramData\\Lowkey Media Server\\media.db",
"activeDBPath": "C:\\ProgramData\\Lowkey Media Server\\media.db",
"dataDir": "C:\\ProgramData\\Lowkey Media Server",
"homeDir": "C:\\Users\\me",
"os": "windows",
"roots": [
{
"type": "local",
"path": "D:\\Media",
"label": "Media",
"default": true
},
{
"type": "s3",
"label": "archive",
"endpoint": "https://s3.example.com",
"bucket": "archive",
"accessKey": "AKIA...",
"secretKey": "•••"
}
],
"hasRealUsers": false,
"authed": false,
"deps": [],
"modelGroups": [
{
"id": "visual-search",
"title": "Visual & text search",
"desc": "Find media by describing it, or by visual similarity to another image.",
"recommended": true,
"sizeBytes": 1505518412,
"models": [
"siglip2-base-patch16-224"
]
}
]
}
Errors
- 403Setup is complete and the request carries no valid credential. Error
POST/setup/api/browsePublic# List folders on the server host for the wizard's folder picker.
Unlike /api/fs/list, this browses the whole machine, because its job is picking locations before any storage root exists. Only directories are listed, hidden (dot) folders are skipped, and entries are sorted case-insensitively.
An empty path returns the starting locations: Home, App data, and every drive letter that exists (Windows) or / (elsewhere). A path that does not exist is not an error: it returns exists: false so the picker can offer to create it. A folder that exists but cannot be listed returns exists: true with no entries.
Request body
A JSON body is required (send {} for the starting locations).
application/json
pathstring
Absolute folder path to list; empty or omitted for the starting locations. Trimmed and cleaned.
Example
{
"path": "D:\\"
}
Response 200
The folder listing.
application/json
SetupBrowseResult
pathstringrequired
The cleaned path listed (empty for the starting locations).
parentstring | nullrequired
Folder to go "up" to. Empty string at a drive or filesystem root (meaning the starting locations); null when listing the starting locations.
entriesarray of objectrequired
Subfolders (or starting locations), sorted by name.
existsbooleanrequired
The folder exists (always true for the starting locations).
writablebooleanrequired
A temp file could be created in the folder. False for the starting locations.
Example
{
"path": "D:\\",
"parent": "",
"entries": [
{
"name": "Media",
"path": "D:\\Media"
},
{
"name": "Photos",
"path": "D:\\Photos"
}
],
"exists": true,
"writable": true
}
Errors
- 400Body is not valid JSON (
bad request), the path is not absolute, or it names a file rather than a folder. Error - 403Setup is complete and the request carries no valid credential. Error
POST/setup/api/mkdirPublic# Create a folder (and any missing parents) on the server host.
Request body
application/json
pathstringrequired
Absolute path of the folder to create. Trimmed.
Example
{
"path": "D:\\Media\\Library"
}
Response 200
Created (or already present); the listing of the new folder, same shape as POST /setup/api/browse.
application/json
SetupBrowseResult
pathstringrequired
The cleaned path listed (empty for the starting locations).
parentstring | nullrequired
Folder to go "up" to. Empty string at a drive or filesystem root (meaning the starting locations); null when listing the starting locations.
entriesarray of objectrequired
Subfolders (or starting locations), sorted by name.
existsbooleanrequired
The folder exists (always true for the starting locations).
writablebooleanrequired
A temp file could be created in the folder. False for the starting locations.
Errors
- 400Body is not valid JSON,
path is empty or not absolute, or the folder could not be created (could not create folder: ...). Error - 403Setup is complete and the request carries no valid credential. Error
POST/setup/api/databasePublic# Open (or create) the SQLite library database and make it the active one.
If path is an existing folder, or ends with a path separator, the database goes inside it as media.db. The server switches to that database immediately (creating it if needed) and saves it as dbPath in the config. hasRealUsers in the response reflects the newly opened database, so the wizard can skip account creation for a library that already has an account.
Request body
application/json
pathstringrequired
Absolute path of the database file, or of a folder to put media.db in. Trimmed and cleaned.
Example
{
"path": "D:\\Lowkey\\"
}
Response 200
The database is open and saved in the config.
application/json
pathstringrequired
The database file actually opened (with media.db appended for a folder).
existedbooleanrequired
Whether the file was already there before this call.
hasRealUsersbooleanrequired
Whether the database has an account other than the bootstrap default admin.
Example
{
"path": "D:\\Lowkey\\media.db",
"existed": false,
"hasRealUsers": false
}
Errors
- 400Body is not valid JSON, or
path is empty or not absolute. Error - 403Setup is complete and the request carries no valid credential. Error
- 500The database could not be opened (
could not open database: ...), or it opened but the config could not be saved. Error
POST/setup/api/storage/test-s3Public# Test an S3-compatible bucket connection without saving anything.
Builds an S3 client from the body and pings the bucket, with a 10 second timeout.
Request body
application/json
endpointstring
S3 endpoint URL; empty for AWS.
bucketstringrequired
Bucket name (required, non-blank).
prefixstring
Key prefix inside the bucket.
Example
{
"endpoint": "http://localhost:9000",
"region": "us-east-1",
"bucket": "media",
"accessKey": "minio",
"secretKey": "minio123"
}
Response 200
The bucket is reachable.
application/json
Example
{
"ok": true
}
Errors
- 400Body is not valid JSON,
bucket is required, or connection failed: ... (client creation or ping failed). Error - 403Setup is complete and the request carries no valid credential. Error
POST/setup/api/storagePublic# Replace the configured storage roots.
Validates every root, then saves the list as the config's roots and swaps the live storage registry (no restart needed). The submitted list replaces the existing one entirely.
type local (or empty, which becomes local) needs an absolute path; the folder is created if missing.type s3 needs a bucket. A secretKey of ••• (the redaction placeholder from GET /setup/api/state) keeps the stored secret of the existing S3 root with the same bucket and endpoint.- An empty
label defaults to the bucket name (S3) or the folder's base name (local). - When no root has
default: true, the first root becomes the default.
Request body
application/json
Example
{
"roots": [
{
"type": "local",
"path": "D:\\Media",
"label": "Media",
"default": true
},
{
"type": "s3",
"endpoint": "http://localhost:9000",
"region": "us-east-1",
"bucket": "archive",
"accessKey": "minio",
"secretKey": "•••"
}
]
}
Response 200
Saved and applied.
application/json
Example
{
"status": "ok",
"roots": 2
}
Errors
- 400Body is not valid JSON; no roots (
add at least one storage
location); a local root without an absolute path or whose folder
could not be created; an S3 root without a bucket; an unknown
type; or the storage registry rejected the roots (messages
joined with ; ). Error - 403Setup is complete and the request carries no valid credential. Error
- 500The config could not be saved. Error
POST/setup/api/completePublic# Mark first-run setup complete.
Sets setupComplete in the config. Nothing is validated (it does not check that an account, database or storage root exists). From then on the wizard routes need a credential and unauthenticated page requests go to /login. No request body.
Response 200
Setup is complete.
application/json
Example
{
"status": "ok"
}
Errors
- 403Setup is complete and the request carries no valid credential. Error
- 500The config could not be saved. Error
HTML Pages
Server-rendered pages and embedded static files. Admin pages redirect an unauthenticated browser to /login (or /setup before first-run setup completes) instead of answering 401. The / pattern is the mux's catch-all, so any path that matches no other route renders the home page.
Configuration editor page.
Renders the Config page from the config file. S3 credentials are redacted in the page.
Response 200
HTML page.
text/html
string
Response 302
Browser without a session. Redirects to /login (or /setup).
Errors
- 500Config could not be loaded or the template failed. Plain text. text/plain
Home dashboard (library stats, metadata coverage, ingestion, job queue).
/ is the catch-all pattern: any path no other route matches also renders this page with 200. Every method except POST renders it.
Response 200
HTML page.
text/html
string
Response 302
Browser without a session. Redirects to /login (or /setup).
Job list page.
Response 200
HTML page.
text/html
string
Response 302
Browser without a session. Redirects to /login (or /setup).
Errors
- 405Not GET. Plain text. text/plain
Legacy media gallery page.
Renders the first 25 matching items; the page then pages through /media/api. The handler does not check the method.
Response 200
HTML page.
text/html
string
Response 302
Browser without a session. Redirects to /login (or /setup).
Errors
- 500Items could not be fetched. Plain text. text/plain
The Swipe app.
Anonymous visitors reach it only while Allow Public Access is on, and then get a view-only page (tagging hidden).
Response 200
HTML page.
text/html
string
Response 302
Browser without a session (public access off). Redirects to /login (or /setup).
Errors
- 405Not GET. Plain text. text/plain
Job composer page.
Response 200
HTML page.
text/html
string
Response 302
Browser without a session. Redirects to /login (or /setup).
Errors
- 405Not GET. Plain text. text/plain
Event log page.
Windows builds only; not registered on macOS or Linux.
Response 200
HTML page.
text/html
string
Response 302
Browser without a session. Redirects to /login (or /setup).
Errors
- 405Not GET. Plain text. text/plain
Sign-in page.
The page reads ?redirect= (where to go after signing in) and ?setup=true (prompt to create a real account) client-side. Until first-run setup is complete it redirects to /setup instead.
Response 200
HTML page.
text/html
string
Response 302
First-run setup not complete. Redirects to /setup.
Errors
- 405Not GET. Plain text. text/plain
The embedded web viewer (single-page application).
Registered as the prefix /app/ (bare /app gets a 301 to /app/). A path naming a file in the embedded bundle is served as that file; any other path (including the empty one) serves index.html so client-side routes work. Responses carry Cache-Control: no-cache, and bundle files carry a content-hash ETag, so revalidation answers 304.
Path parameters
pathstringrequired
File in the bundle, or a client route. May be empty and may contain slashes.
Response 200
The bundle file (content type from its extension) or index.html.
text/html
string
application/javascript
string
*/*
string (binary)
Response 302
Browser without a session while public access is off. Redirects to /login (or /setup).
Response 304
Not modified (If-None-Match matched the ETag).
Errors
- 500
index.html missing from the embedded bundle. Plain text. text/plain
Embedded static assets used by the server-rendered pages.
Registered as the prefix /static/ with no middleware at all (no auth, no CORS headers), served by Go's file server from the embedded client/static directory. Directory paths return a generated listing.
Path parameters
pathstringrequired
File path under the static root.
Response 200
The file, with a content type from its extension.
*/*
string (binary)
Errors
- 404No such file. Plain text. text/plain
Index of the visualization pages.
Registered as the prefix /viz/ (bare /viz gets a 301 to /viz/). /viz/embeddings and /viz/treemap are separate, more specific routes; every other path under /viz/ serves this index page.
Response 200
HTML page.
text/html
string
Response 302
Browser without a session. Redirects to /login (or /setup).
Errors
- 405Not GET. Body
{"error":"use GET"} sent as text/plain. text/plain
Metadata coverage page (macOS and Linux builds only).
Server-rendered page with library totals and how many items have a description, hash, size and tags. Registered only by the macOS and Linux servers; on Windows the path falls through to the home page. Other methods return 405 (plain text).
Response 200
The page.
text/html
string
Errors
- 405Method other than GET. text/plain