HTTP API Reference

Every endpoint served by Lowkey Media Server. All paths are relative to your server's address.

Version 2.38.0

OpenAPI 3.1 spec lokictl reference →

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.

LevelRequirement
PublicNo credential.
ReadA 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.
AdminA 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:

StatusMeaning
400Malformed JSON, a missing required field, an invalid parameter, or a failed validation.
401Missing or invalid credential (see above).
403Path outside every configured storage root, setup required, or a cross-origin form post.
404Unknown job, workflow, model, file, person, or scene.
405Method not supported, on handlers that check the method.
409Conflict: a move destination already in the library, a tag in another category.
413Request body over its cap.
422The input was valid but unusable, for example an image with no face to search by.
500Database or filesystem failure.
503A 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.

Platform Differences

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.

POST/auth/loginPublic#

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

usernamestring
passwordstring
Example
{
"username": "alice",
"password": "correct-horse"
}

Response 200

Logged in.

application/json

statusstringrequired

One of "ok".

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
POST/auth/logoutPublic#

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

statusstringrequired

One of "ok".

Response 302

Returned for non-POST methods. Redirects to /login.

GET/auth/statusPublic#

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

loggedInbooleanrequired
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"
}
GET/auth/usersAdmin#

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
POST/auth/usersPublic#

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.

passwordstringrequired
Example
{
"username": "bob",
"password": "s3cret-pass"
}

Response 201

Created. The body is {"status":"created"} (no Content-Type header is set).

application/json

statusstringrequired

One of "created".

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/auth/usersAdmin#

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.

Query parameters

usernamestringrequired

Response 200

Deleted. The body is {"status":"deleted"} (no Content-Type header is set).

application/json

statusstringrequired

One of "deleted".

Errors

  • 400username missing. Plain text. text/plain
  • 401No valid credential (header or cookie). Error
  • 500cannot delete the last user, or a database error. Plain text. text/plain
GET/auth/keysAdmin#

List API keys (without their secret values).

Response 200

Every key, for every user.

application/json

keysarray of APIKeyrequired
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
POST/auth/keysAdmin#

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

statusstringrequired

One of "created".

keystringrequired

The full key: lk_ followed by 64 hex characters. Not retrievable later.

idinteger (int64)required
namestringrequired
usernamestringrequired
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
DELETE/auth/keysAdmin#

Revoke an API key.

Query parameters

idinteger (int64)required

Key id from the listing.

Response 200

Revoked (also returned when no key had that id).

application/json

statusstringrequired

One of "deleted".

Errors

  • 400id 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

  • 400invalid 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

portintegerrequired

min 1, max 65535.

statestringrequired
code_challengestringrequired
namestring

Default "lokictl".

actionstringrequired

One of "approve" "deny".

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

statusstringrequired

One of "ok".

keystringrequired

The plaintext lk_ API key.

usernamestringrequired
namestringrequired

The key's name.

Example
{
"status": "ok",
"key": "lk_ab12cd34...",
"username": "alice",
"name": "lokictl@host"
}

Errors

  • 400Invalid JSON, Invalid code_verifier, Invalid or expired code, or code_verifier does not match. Error
  • 405Not POST. Error
  • 500Key creation failed. Error

System

Server health, configuration, and host actions. Configuration is read as JSON with GET /api/config (secrets redacted) and written with POST /config, which merges the posted fields into the stored configuration rather than replacing it.

GET/healthPublic#

Liveness, server version, SSE connection stats and job counts.

Registered without the auth middleware, so it needs no credential even though it reports job counts. Sets fully permissive CORS headers (Access-Control-Allow-Origin: *); OPTIONS answers 200. Paused jobs are counted in jobs.total but in no per-state bucket.

Response 200

Health report.

application/json

HealthStatus

statusstringrequired

Always healthy when the server answers.

One of "healthy".

versionstringrequired

Server version.

timestampinteger (int64)required

Server time, Unix seconds.

streamobjectrequired

/stream SSE statistics.

active_connectionsintegerrequired
total_messagesintegerrequired
max_connectionsintegerrequired
dropped_broadcastsintegerrequired
dropped_client_msgsintegerrequired
rejected_connectionsintegerrequired
jobsobjectrequired

Job counts by state. Paused jobs are in total only.

totalintegerrequired
pendingintegerrequired
in_progressintegerrequired
completedintegerrequired
cancelledintegerrequired
errorintegerrequired
Example
{
"status": "healthy",
"version": "2.37.0",
"timestamp": 1767225600,
"stream": {
"active_connections": 2,
"total_messages": 150,
"max_connections": 1000,
"dropped_broadcasts": 0,
"dropped_client_msgs": 0,
"rejected_connections": 0
},
"jobs": {
"total": 10,
"pending": 2,
"in_progress": 1,
"completed": 5,
"cancelled": 1,
"error": 1
}
}

Errors

  • 405Method other than GET or OPTIONS. Plain text. text/plain
GET/api/configAdmin#

The active configuration as JSON, with secrets redacted.

Reloads the config file and returns it. Every non-empty secret is replaced by the literal <redacted>: jwtSecret, discordToken, runpodApiKey, lmstudioApiKey, llamacppApiKey, strataApiKey, storage-root accessKey/secretKey, and engine pool API keys and node tokens. Empty secrets stay empty (and storage-root credentials, being omitempty, are then absent). POST /config treats <redacted> as "keep the stored value", so a read-modify-write round trip is safe.

Response 200

The configuration.

application/json

ServerConfig

dbPathstring

Active SQLite library database.

portinteger

HTTP port (default 10111).

downloadPathstring
extractorsPathstring
inferenceProviderstring

One of "off" "ollama" "runpod" "lmstudio" "llamacpp" "strata".

ollamaBaseUrlstring
ollamaModelstring
describePromptstring
runpodEndpointstring
runpodApiKeystring
lmstudioBaseUrlstring
lmstudioModelstring
lmstudioApiKeystring
llamacppBaseUrlstring
llamacppModelstring
llamacppApiKeystring
strataBaseUrlstring
strataModelstring
strataApiKeystring
inferenceConcurrencyInferenceConcurrency
autoProcessOpsstring

Comma-separated ops for the auto scheduler's job; empty = all.

localComputeConcurrencyinteger
onnxTaggerobject
modelPathstring
labelsPathstring
configPathstring
ortSharedLibraryPathstring
generalThresholdnumber
characterThresholdnumber
embeddingModelstring
embeddingProviderstring

One of "cpu" "directml".

embeddingPerformancestring

One of "low" "balanced" "max" "custom".

embeddingWorkersinteger
embeddingThreadsPerWorkerinteger
autotagModelstring
autotagProviderstring
autotagPerformancestring
autotagWorkersinteger
autotagThreadsPerWorkerinteger
faceModelstring
faceProviderstring
facePerformancestring
faceWorkersinteger
faceThreadsPerWorkerinteger
faceRoutingstring

One of "auto" "single".

faceClusterThresholdOffsetnumber
faceClusterMinClusterinteger
faceClusterMinQualitynumber
byoFaceModelsarray of ByoFaceModel
onnxFileTimeoutSecondsinteger

Per-file timeout for local ONNX tasks; <= 0 disables.

transcriptionProviderstring
transcriptionModelstring
transcriptionLanguagestring
transcriptionVadFilterboolean
transcriptionInitialPromptstring
transcriptionHotwordsstring
transcriptionVocalExtractboolean
dreamModelstring
reshootModelsarray of object
filestring
labelstring
recipestring

One of "fast" "h3".

fasterWhisperPathstring
discordTokenstring
jwtSecretstring

Always <redacted> when set.

setupCompleteboolean
defaultStartPathstring
allowPublicAccessboolean
swipeFeedobject

Swipe For You feed tuning.

enginesobject

GPU engine placement (see the GPU Engines section).

strategystring

One of "local" "pool" "hybrid".

poolsarray | null
routesobject | null
fallbackboolean
localOnlyboolean
rootsarray of StorageRoot
rootPathsarray of string

Errors

  • 405Not GET. Body {"error":"Use GET"} sent as text/plain. text/plain
  • 500Config file could not be loaded. Body {"error":"failed to load config"} sent as text/plain. text/plain
POST/openAdmin#

Open a file or folder in the server host's default application.

Runs on the machine the server runs on. Only useful when the server is on the same desktop as the user.

Request body

application/json

pathstringrequired

Absolute path on the server host. Trimmed.

Example
{
"path": "D:\\Media\\clip.mp4"
}

Response 200

Opened.

application/json

statusstringrequired

One of "ok".

Errors

  • 400bad json, path cannot be empty, or path must be absolute. Plain text. text/plain
  • 405Not POST. Plain text. text/plain
  • 500The OS could not open the path. Plain text failed to open path. text/plain
POST/configAdmin#

Merge settings into the configuration, save it and apply it.

Not a full replacement: the server starts from its in-memory configuration and overwrites only the fields below, mostly only when they are present and non-empty (strings) or positive (numbers), so partial posts are safe. Exceptions:

  • dbPath is required on every call.
  • onnxModelPath, onnxLabelsPath, onnxConfigPath and onnxOrtSharedLibPath are assigned unconditionally, so omitting them clears them.
  • Pointer-style fields (transcriptionLanguage, transcriptionVadFilter, transcriptionInitialPrompt, transcriptionHotwords, transcriptionVocalExtract, allowPublicAccess, defaultStartPath) and the arrays byoFaceModels and roots apply whenever present, including empty values; omitted means unchanged.
  • Secret fields equal to <redacted> keep the stored value; storage roots posted with <redacted> credentials recover them from the stored root with the same bucket+endpoint+prefix, else the same label.

Keys not accepted here (for example engines, swipeFeed, reshootModels, dreamModel, autoProcessOps, setupComplete, jwtSecret) are ignored. After saving, the server switches databases if dbPath changed (clearing the auth_token cookie, since users and keys live in the library database), rebuilds the embedding or face index in the background when the active model changed, rebuilds storage backends, and re-applies concurrency caps. A port change takes effect on restart.

Request body

application/json

ConfigUpdateRequest

dbPathstringrequired

Required, non-blank. A different value switches the library database.

portinteger

Applied on restart. Out-of-range or 0 is ignored.

min 1, max 65535.

downloadPathstring
ollamaBaseUrlstring
ollamaModelstring
describePromptstring
inferenceProviderstring

Lower-cased.

One of "off" "ollama" "runpod" "lmstudio" "llamacpp" "strata".

runpodEndpointstring
runpodApiKeystring

<redacted> keeps the stored key.

lmstudioBaseUrlstring
lmstudioModelstring
lmstudioApiKeystring

<redacted> keeps the stored key.

llamacppBaseUrlstring
llamacppModelstring
llamacppApiKeystring

<redacted> keeps the stored key.

strataBaseUrlstring
strataModelstring
strataApiKeystring

<redacted> keeps the stored key.

inferenceConcurrencyInferenceConcurrency
localComputeConcurrencyinteger
onnxModelPathstring

Always applied; omitting it clears it.

onnxLabelsPathstring

Always applied; omitting it clears it.

onnxConfigPathstring

Always applied; omitting it clears it.

onnxOrtSharedLibPathstring

Always applied; omitting it clears it.

onnxGeneralThresholdnumber
onnxCharacterThresholdnumber
embeddingModelstring
embeddingProviderstring
embeddingPerformancestring
embeddingWorkersinteger
embeddingThreadsPerWorkerinteger
autotagModelstring
autotagProviderstring
autotagPerformancestring
autotagWorkersinteger
autotagThreadsPerWorkerinteger
onnxFileTimeoutSecondsinteger

Non-zero values apply; negative disables the timeout.

faceModelstring
faceRoutingstring

Other values are ignored.

One of "auto" "single".

faceProviderstring
facePerformancestring
faceWorkersinteger
faceThreadsPerWorkerinteger
byoFaceModelsarray of ByoFaceModel

Replaces the list when present; [] clears it.

transcriptionProviderstring
transcriptionModelstring
transcriptionLanguagestring

Applied when present; empty means auto-detect.

transcriptionVadFilterboolean
transcriptionInitialPromptstring
transcriptionHotwordsstring
transcriptionVocalExtractboolean
allowPublicAccessboolean
defaultStartPathstring
fasterWhisperPathstring
discordTokenstring

<redacted> keeps the stored token.

rootsarray of StorageRoot

Replaces the storage roots when present.

Example
{
"dbPath": "C:\\ProgramData\\Lowkey Media Server\\media.db",
"embeddingModel": "siglip2-base-patch16-224",
"allowPublicAccess": false
}

Response 200

Saved and applied.

application/json

statusstringrequired

One of "ok".

configPathstringrequired

Path of the config file written.

activeDBPathstringrequired
changedbooleanrequired

Whether any configuration value differs from before.

dbChangedbooleanrequired
logoutRequiredbooleanrequired

Equal to dbChanged; the session cookie was cleared.

storageWarningsarray of stringrequired

Storage backends that failed to initialise, a default root that fell back to another, or a note that LOWKEY_ROOTS/LOWKEY_ROOT_* environment variables override the saved roots.

Example
{
"status": "ok",
"configPath": "C:\\ProgramData\\Lowkey Media Server\\config.json",
"activeDBPath": "C:\\ProgramData\\Lowkey Media Server\\media.db",
"changed": true,
"dbChanged": false,
"logoutRequired": false,
"storageWarnings": []
}

Errors

  • 400bad json, or dbPath cannot be empty. Plain text. text/plain
  • 405Method other than GET or POST. Plain text. text/plain
  • 500Config file could not be saved, or the database switch failed. Plain text. text/plain
POST/Admin#

Legacy single-task job launch.

Queues a one-task workflow. The struct has no JSON tags, so the canonical keys are Command and Arguments; Go's case-insensitive decoding also accepts command and arguments. The last element of Arguments is the task input; the rest are the task's option tokens. Prefer POST /create.

Request body

application/json

Commandstring

Task id.

Argumentsarray of stringrequired

Option tokens followed by the input as the final element.

Example
{
"Command": "metadata",
"Arguments": [
"--type=hash",
"D:\\Media\\clip.mp4"
]
}

Response 201

Queued.

application/json

idstringrequired

Id of the created job (empty string if none was created).

Errors

  • 400bad json, or Arguments empty. Plain text. text/plain
  • 500The workflow could not be queued. Plain text. text/plain

Library Statistics

Library totals and per-metadata-type coverage counts, used by the home page's coverage cards. The counts come from a snapshot, never computed inline in the request: a request that finds the snapshot stale (older than 15 seconds on small libraries, up to 5 minutes on very large ones, scaled by how long the last count took) starts a background recount and is answered from the current snapshot. Running tasks add their completed items on top of the snapshot as they finish, and the merged view is also broadcast over the /stream Server-Sent Events channel as a stats event at most once per second.

GET/api/statsRead#

Library totals and metadata coverage counts.

Serves the latest snapshot with live task progress applied, and starts a background recount when the snapshot is stale. Until the first snapshot after startup (or after a database switch) exists, the response is just {"ready": false}; poll until ready is true.

Coverage counts ignore rows whose media item no longer exists. Transcript coverage is counted against videos and audio only, embedding coverage against the active embedding model, and face-scan coverage against items scanned by any face model. Between recounts, live progress can make a count drift slightly; it never exceeds its denominator.

Response 200

A snapshot, or {"ready": false} before the first count.

application/json

LibraryStats | object

Example
{
"ready": true,
"generatedAt": 1761900000,
"totalMedia": 52000,
"totalVideos": 4100,
"totalAudio": 300,
"totalImages": 47600,
"totalTranscribable": 4400,
"withDescription": 31000,
"withHash": 51800,
"withSize": 52000,
"withTags": 40100,
"withDimensions": 51900,
"withTranscript": 2100,
"withEmbedding": 51234,
"withFaceScan": 30000,
"autotagModel": {
"id": "wd-eva02-large-tagger-v3",
"name": "WD EVA02 Large v3 (anime / illustration)"
},
"embedModel": {
"id": "siglip2-base-patch16-224",
"name": "SigLIP 2 base (multimodal — image + text)"
},
"faceModel": {
"id": "sface",
"name": "SFace (photos — OpenCV Zoo, shipped default)"
}
}

Errors

  • 405Method other than GET. 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.

POST/createAdmin#

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

idstringrequired

The new job's id (UUID).

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
GET/jobs/listAdmin#

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.

idstringrequired

Job id (UUID).

commandstringrequired

Task id.

argumentsarray of stringrequired

Option tokens passed to the task.

inputstringrequired

The job input after workflow substitution.

original_inputstringrequired

The input as submitted.

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.

stateJobStaterequired

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
GET/job/{id}Admin#

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
POST/job/{id}/copyAdmin#

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

idstringrequired

The new job's id.

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
POST/jobs/clearAdmin#

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

Number of jobs removed.

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
GET/api/job/{id}Admin#

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

idstringrequired

Job id.

commandstringrequired

Task id.

stateJobStaterequired

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.

jobsarray of JobForPathSummaryrequired
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

  • 400path is missing or empty (missing path parameter). text/plain
  • 401Missing or invalid credential. text/plain
  • 405Method other than GET (Use GET). text/plain
GET/tasksAdmin#

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

tasksarray of TaskInforequired
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.

POST/workflowAdmin#

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

tasksarray of WorkflowTask

The DAG nodes.

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
GET/workflowsAdmin#

List saved workflows.

Ids and names only, ordered by name. Empty library returns [].

Response 200

Saved workflow summaries.

application/json

array of SavedWorkflowSummary

idstringrequired

Workflow id (UUID).

namestringrequired

Unique workflow name.

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.

dagarray of WorkflowTaskrequired

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
}
]
}

Response 201

The saved workflow.

application/json

SavedWorkflow

idstringrequired

Workflow id (UUID).

namestringrequired

Unique workflow name.

dagarray of WorkflowTaskrequired

The DAG nodes.

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/workflows/{id}Admin#

Get one saved workflow with its DAG.

Path parameters

idstringrequired

Saved workflow id (UUID).

Response 200

The saved workflow.

application/json

SavedWorkflow

idstringrequired

Workflow id (UUID).

namestringrequired

Unique workflow name.

dagarray of WorkflowTaskrequired

The DAG nodes.

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
PUT/workflows/{id}Admin#

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.

dagarray of WorkflowTaskrequired

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

idstringrequired

Workflow id (UUID).

namestringrequired

Unique workflow name.

dagarray of WorkflowTaskrequired

The DAG nodes.

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).

Response 204

Deleted.

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.

GET/streamPublic#

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

Media Library

The JSON API the web viewer uses to list, search and edit library items. A media item is identified by its path: an absolute local path or an s3://bucket/key URL inside a configured storage root.

Most of these handlers report errors through a shared helper that writes a JSON body ({"error": "..."}) but with Content-Type: text/plain; charset=utf-8, because it is built on Go's http.Error. Clients should parse the body as JSON regardless of the content type. Those responses are documented here as text/plain with the Error schema.

Several handlers in this section do not check the HTTP method: they decode a JSON body from whatever method is used. The documented method is the one the clients use.

List endpoints (/api/media, /api/media/query, /api/media/search) stream a JSON array of MediaListItem objects in chunks, so a million-item result never sits in server memory. If the database fails after the first chunk has been sent, the connection is aborted instead of ending the array, so a truncated list is never mistaken for a complete one.

With Allow Public Access on, read routes are open to anonymous visitors. Routes that read a file or run ffprobe/ffmpeg on a path then restrict anonymous callers to paths inside a configured storage root or rows already in the library, and never http(s):// URLs; admins are unrestricted.

POST/api/media/queryRead#

Run a composable predicate query, with optional similarity ranking.

The main library query. Predicates are combined left to right: the first predicate with a non-empty value is the base, and each later one is connected to the running result by its own join, or by mode when join is missing or not AND/OR. The expression is parenthesised left-associatively, so a OR b AND c means (a OR b) AND c. Predicates with an empty value are ignored. With no usable predicates the whole library is returned.

The four visual types (similar, visual, clip, face) are resolved through the embedding index into a candidate set of at most 1000 paths, which then takes part in the boolean expression like any other predicate. When every connector is AND and the query mixes visual and non-visual predicates, evaluation is filter-first: the non-visual predicates run first and similarity is scored only inside that set, so the result is the top 1000 most similar items within the filter. If that filter matches nothing, the model is not called at all.

Ordering: - Without a visual predicate the server emits no ORDER BY; the order is whatever SQLite returns (the viewer sorts client side). Every item omits score. - With a visual predicate every item carries score and items are sorted by it, highest first. When several visual predicates match the same item the highest score is kept; items that matched only a non-visual branch of an OR get score 0.

An unknown predicate type produces invalid SQL and a 500.

The handler does not check the method; clients send POST.

Request body

application/json

MediaQueryRequest

modestring

Connector for predicates without their own join. Anything other than exactly OR means AND.

One of "AND" "OR". Default "AND".

predicatesarray of MediaQueryPredicate

Evaluated left to right. Missing or empty returns the whole library.

Example
{
"mode": "AND",
"predicates": [
{
"type": "tag",
"value": "sunset"
},
{
"type": "tag",
"value": "blurry",
"exclude": true
},
{
"type": "path",
"value": "/2024/"
},
{
"type": "orientation",
"value": "landscape",
"join": "OR"
}
]
}

Response 200

Matching items, streamed as one JSON array.

application/json

array of MediaListItem

One item in a list result. Nullable database columns are omitted when NULL (a real zero is still sent).

pathstringrequired

Media path.

mtimeMsintegerrequired

The driving tag assignment's created_at (milliseconds) when a tag predicate drives the row, otherwise 0. Not the file's modification time.

elonumber

Battle Mode rating.

battlesinteger

Battle Mode bout count.

heightinteger

Pixel height.

widthinteger

Pixel width.

weightnumber

Weight of the driving tag assignment.

tagLabelstring

Label of the driving tag assignment.

timeStampnumber

Video timestamp (seconds) of the driving tag assignment.

scorenumber

Similarity score; present on every item, and only then, when the query had a visual predicate.

Example
[
{
"path": "D:/photos/a.jpg",
"mtimeMs": 0,
"elo": 1532.4,
"battles": 7,
"height": 1080,
"width": 1920,
"score": 0.83
},
{
"path": "D:/photos/b.jpg",
"mtimeMs": 0,
"height": 3000,
"width": 2000,
"score": 0.79
}
]

Errors

  • 400The body is not valid JSON. Error
  • 500A similarity search failed (no embedding model, undecodable clip data URL, unknown blend node kind, no face in a face image), or the SQL query failed. Error
POST/api/mediaRead#

List items by tag intersection or union (predecessor of the predicate query).

With an empty or missing tags array, returns every item in the library ordered by path (tag fields are empty strings and 0).

With tags, mode (case-insensitive, default EXCLUSIVE) chooses the semantics: - EXCLUSIVE: items that carry every listed tag, one row per item, ordered by path. The tag fields come from one of the item's matching assignments. - Any other value: one row per matching tag assignment, ordered by path. An item carrying two of the tags, or one tag at several video timestamps, appears more than once.

Only POST is handled. Any other method gets an empty 200 response with no body.

Request body

application/json

tagsarray of string

Tag labels to match exactly.

modestring

EXCLUSIVE (default) requires every tag; anything else (for example INCLUSIVE) matches any tag.

Example
{
"tags": [
"sunset",
"beach"
],
"mode": "EXCLUSIVE"
}

Response 200

Matching items, streamed as one JSON array.

application/json

array of MediaListItem

One item in a list result. Nullable database columns are omitted when NULL (a real zero is still sent).

pathstringrequired

Media path.

mtimeMsintegerrequired

The driving tag assignment's created_at (milliseconds) when a tag predicate drives the row, otherwise 0. Not the file's modification time.

elonumber

Battle Mode rating.

battlesinteger

Battle Mode bout count.

heightinteger

Pixel height.

widthinteger

Pixel width.

weightnumber

Weight of the driving tag assignment.

tagLabelstring

Label of the driving tag assignment.

timeStampnumber

Video timestamp (seconds) of the driving tag assignment.

scorenumber

Similarity score; present on every item, and only then, when the query had a visual predicate.

Example
[
{
"path": "D:/photos/a.jpg",
"mtimeMs": 1717171717000,
"elo": 1500,
"battles": 0,
"height": 1080,
"width": 1920,
"weight": 1,
"tagLabel": "sunset",
"timeStamp": 0
}
]

Errors

  • 400The body is not valid JSON. Error
  • 500The query failed. Error
POST/api/media/searchRead#

Substring search over item descriptions.

Returns every item whose description contains the given text (SQL LIKE '%text%', case-insensitive for ASCII; % and _ in the text act as wildcards). Ordered by path. An empty description matches every item that has a non-NULL description.

tags and filteringMode are accepted in the body but ignored.

The handler does not check the method; clients send POST.

Request body

application/json

descriptionstring

Text to look for in item descriptions.

tagsarray of string

Accepted for compatibility; ignored.

filteringModestring

Accepted for compatibility; ignored.

Example
{
"description": "red car"
}

Response 200

Matching items, streamed as one JSON array. Tag fields are empty strings and 0.

application/json

array of MediaListItem

One item in a list result. Nullable database columns are omitted when NULL (a real zero is still sent).

pathstringrequired

Media path.

mtimeMsintegerrequired

The driving tag assignment's created_at (milliseconds) when a tag predicate drives the row, otherwise 0. Not the file's modification time.

elonumber

Battle Mode rating.

battlesinteger

Battle Mode bout count.

heightinteger

Pixel height.

widthinteger

Pixel width.

weightnumber

Weight of the driving tag assignment.

tagLabelstring

Label of the driving tag assignment.

timeStampnumber

Video timestamp (seconds) of the driving tag assignment.

scorenumber

Similarity score; present on every item, and only then, when the query had a visual predicate.

Errors

  • 400The body is not valid JSON. Error
  • 500The query failed. Error
GET/api/media/similarRead#

Items visually similar to a library item.

Ranks the library by cosine similarity to the item's embedding in the active embedding model. When the item has no stored vector it is embedded on the fly (and the vector is saved). The item itself is usually the top hit.

The handler does not check the method.

Query parameters

pathstringrequired

Media path of the anchor item.

limitinteger

Maximum hits. Values that are not a positive integer fall back to the default. No upper bound is enforced.

Default 50. min 1.

Response 200

Hits, best first.

application/json

array of SimilarHit

pathstringrequired

Media path.

scorenumberrequired

Cosine similarity in the active embedding model's space.

Example
[
{
"path": "D:/photos/x.jpg",
"score": 1
},
{
"path": "D:/photos/y.jpg",
"score": 0.91
}
]

Errors

  • 400path is missing. Error
  • 500Embedding or search failed (for example no embedding model installed, or the file cannot be embedded). Error
GET/api/media/search/visualRead#

Text-to-image search.

Embeds the text with the active embedding model's text encoder and ranks the library by cross-modal similarity.

The handler does not check the method.

Query parameters

qstringrequired

Free-text description of what to find.

limitinteger

Maximum hits. Values that are not a positive integer fall back to the default. No upper bound is enforced.

Default 50. min 1.

Response 200

Hits, best first.

application/json

array of SimilarHit

pathstringrequired

Media path.

scorenumberrequired

Cosine similarity in the active embedding model's space.

Example
[
{
"path": "D:/photos/dog.jpg",
"score": 0.31
}
]

Errors

  • 400q is missing. Error
  • 500The text could not be embedded or the search failed. Error
POST/api/media/search/imageRead#

Image-to-image search with an uploaded image.

The request body is the raw bytes of an image in any common format (no multipart, no JSON). Returns the 50 most similar items; the count is fixed.

The handler does not check the method.

Request body

application/octet-stream

string (binary)

Raw image bytes, at most 32 MB.

image/*

string (binary)

Response 200

Up to 50 items, best first.

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.

pathstringrequired
mtimeMsintegerrequired

Always 0.

elonumber
battlesinteger
heightinteger
widthinteger
scorenumberrequired

Similarity score; items are sorted by it, highest first.

Example
[
{
"path": "D:/photos/x.jpg",
"mtimeMs": 0,
"elo": 1500,
"battles": 2,
"height": 1080,
"width": 1920,
"score": 0.88
}
]

Errors

  • 400The body is empty or could not be read. Error
  • 413The body is larger than 32 MB. Error
  • 500The image could not be embedded or the search failed. Error
POST/api/media/search/faceRead#

Find media containing the person whose face is in an uploaded image.

The request body is the raw bytes of an image. Faces are detected in it and matched against stored face embeddings. limit counts faces searched; hits are then collapsed to one item per media path (best face wins), so the result can hold fewer items than limit.

Query parameters

limitinteger

Number of face hits to search. Non-positive or invalid values use the default; values above 500 are capped to 500.

Default 100. min 1, max 500.

Request body

application/octet-stream

string (binary)

Raw image bytes, at most 32 MB.

image/*

string (binary)

Response 200

Media items, best first, each with the best-matching face attached.

application/json

array of FaceSearchItem

Example
[
{
"path": "D:/photos/x.jpg",
"mtimeMs": 0,
"height": 1080,
"width": 1920,
"score": 0.72,
"faceId": 4411,
"face": {
"x": 0.41,
"y": 0.18,
"w": 0.12,
"h": 0.17,
"frameTs": 0,
"personId": 12
}
}
]

Errors

  • 400The body is empty or could not be read. Error
  • 405The method is not POST. Error
  • 413The body is larger than 32 MB. Error
  • 422No face was detected in the uploaded image. Error
  • 500Face detection or search failed. Error
POST/api/media/metadataRead#

Stored metadata for one item, plus any AI-generation text embedded in the file.

Reads the item's row (width, height, size, description, transcript, hash). A path with no row still answers 200 with the defaults. For local files the handler also extracts generator text chunks (for example a ComfyUI workflow or WebUI parameters) into textChunks.

The handler does not check the method; clients send POST.

Request body

application/json

MediaPathRequest

pathstringrequired

Media path.

Example
{
"path": "D:/photos/x.png"
}

Response 200

The item's metadata.

application/json

MediaMetadata

fileMetadataobjectrequired
sizestringrequired

Human-readable size from the stored byte count, such as 2.41 MB; 0 Bytes when unknown.

modifiedstringrequired

Always an empty string.

widthintegerrequired

Stored width; 0 when unknown.

heightintegerrequired

Stored height; 0 when unknown.

hashstringrequired

Stored content hash; empty when unknown.

descriptionstring

Present when the item has a description.

transcriptstring

Present when the item has a transcript.

textChunksmap of string

Generator text embedded in a local file (for example PNG text chunks), keyed by chunk name. Omitted when there are none.

Example
{
"fileMetadata": {
"size": "2.41 MB",
"modified": "",
"width": 1920,
"height": 1080
},
"hash": "9f2c1d0e",
"description": "A red car parked by the sea.",
"textChunks": {
"parameters": "a red car, sunset, Steps: 30"
}
}

Errors

  • 400The body is not valid JSON. Error
  • 403Anonymous caller and the path is outside every storage root and not a library row (or is an http(s):// URL). Error
POST/api/media/tagsRead#

Tag assignments of one item.

Every row of the item's tag assignments, one per tag and video timestamp. An unknown path returns an empty list.

The handler does not check the method; clients send POST.

Request body

application/json

MediaPathRequest

pathstringrequired

Media path.

Example
{
"path": "D:/photos/x.jpg"
}

Response 200

The item's tags.

application/json

tagsarray of MediaTagAssignmentrequired
Example
{
"tags": [
{
"tag_label": "sunset",
"category_label": "Scene",
"weight": 1,
"time_stamp": 0
}
]
}

Errors

  • 400The body is not valid JSON. Error
  • 500The query failed. Error
POST/api/media/gif-metadataRead#

Frame count and duration of an animated image or video, via ffprobe.

Runs ffprobe -count_frames on the first video stream. When ffprobe fails or reports no stream the response is JSON null with status 200.

The handler does not check the method; clients send POST.

Request body

application/json

MediaPathRequest

pathstringrequired

Media path.

Example
{
"path": "D:/gifs/loop.gif"
}

Response 200

The probe result, or null.

application/json

GifMetadata | null

Example
{
"frameCount": 48,
"duration": 1.92
}

Errors

  • 400The body is not valid JSON. Error
  • 403Anonymous caller and the path is outside every storage root and not a library row (or is an http(s):// URL). Error
POST/api/media/previewRead#

Server-side path of an item's thumbnail, generating it if missing.

Returns the thumbnail's location as a JSON string: a local filesystem path on the server, or an s3:// URL for media in a bucket. To fetch the image bytes use GET /media/thumbnail instead.

Lookup order: the path recorded in the item's thumbnail column (if the file or object still exists), then for local media the deterministic cache path on disk, then generation (throttled). Results are written back to the item's row.

cache that is not a string, or an empty string, returns null without doing anything. A string that is not one of the three column names is treated as thumbnail_path_600. Without a configured database path, local media returns null.

The handler does not check the method; clients send POST.

Request body

application/json

pathstringrequired

Media path.

cacheThumbnailCache | boolean

Thumbnail column, or false to skip.

timeStampnumber

Video frame time in seconds. 0 (default) uses the default frame; a non-zero value gets its own cache file.

Default 0.

Example
{
"path": "D:/videos/clip.mp4",
"cache": "thumbnail_path_600",
"timeStamp": 12.5
}

Response 200

The thumbnail path, or null.

application/json

string | null

Example
C:\ProgramData\Lowkey Media Server\thumbnail_path_600\3b5d...e1.mp4

Errors

  • 400The body is not valid JSON. Error
  • 403Anonymous caller and the path is outside every storage root and not a library row (or is an http(s):// URL). Error
  • 500Thumbnail generation failed. Error
POST/api/media/descriptionAdmin#

Set an item's description.

Writes description to the item's row. An empty string stores an empty description. A path with no row changes nothing and still returns 200.

The handler does not check the method; clients send POST.

Request body

application/json

pathstring

Media path.

descriptionstring

New description text.

Example
{
"path": "D:/photos/x.jpg",
"description": "A red car parked by the sea."
}

Response 200

Always an empty object.

application/json

object

Example
{}

Errors

  • 400The body is not valid JSON. Error
  • 500The update failed. Error
POST/api/media/transcriptAdmin#

Set or clear an item's transcript.

Request body

application/json

pathstringrequired

Media path.

transcriptstring

New transcript (for example WebVTT text). An empty or missing value stores an empty string.

Example
{
"path": "D:/videos/talk.mp4",
"transcript": "WEBVTT\n\n00:00.000 --> 00:02.000\nHello"
}

Response 200

Saved.

application/json

statusstringrequired

One of "ok".

Errors

  • 400Invalid JSON or path missing. Error
  • 404No library item has this path. Error
  • 405The method is not POST. Error
  • 500The update failed. Error
POST/api/media/ratingAdmin#

Read or update an item's rating counters.

Only fields present in the body are written; a body with just path reads. The response always reflects the row after the update.

Request body

application/json

pathstringrequired

Media path.

elonumber

New Elo rating.

viewsinteger

New view count.

winsinteger

New win count.

lossesinteger

New loss count.

Example
{
"path": "D:/photos/x.jpg",
"views": 12
}

Response 200

The item's current rating columns. Each is null when unset.

application/json

pathstringrequired
elonumber | nullrequired
viewsinteger | nullrequired
winsinteger | nullrequired
lossesinteger | nullrequired
Example
{
"path": "D:/photos/x.jpg",
"elo": 1532.4,
"views": 12,
"wins": 4,
"losses": 3
}

Errors

  • 400Invalid JSON or path missing. Error
  • 404No library item has this path. Error
  • 405The method is not POST. Error
  • 500The database failed. Error
POST/api/media/battleAdmin#

Record a Battle Mode vote and update both Elo ratings.

The Elo math runs server side in one transaction, and the vote is appended to the battle log. Ratings start at 1500. The K-factor depends on how many battles the item has fought: 48 under 10, 24 under 30, 12 after that. A win adds 1 to the winner's wins and the loser's losses; a draw changes neither. battles is reset from the battle-log count. A path with no library row gets one created.

Request body

application/json

winnerPathstringrequired

Path of the item that won.

loserPathstringrequired

Path of the item that lost. Must differ from winnerPath.

outcomenumber

The winner's score. 1 is a win, 0.5 a draw.

One of 1 0.5. Default 1.

Example
{
"winnerPath": "D:/photos/a.jpg",
"loserPath": "D:/photos/b.jpg"
}

Response 200

The new ratings and battle counts.

application/json

winnerPathstringrequired
winnerElonumberrequired

Winner's rating after the vote.

winnerMatchesintegerrequired

Winner's battle count including this one.

loserPathstringrequired
loserElonumberrequired
loserMatchesintegerrequired
Example
{
"winnerPath": "D:/photos/a.jpg",
"winnerElo": 1524,
"winnerMatches": 1,
"loserPath": "D:/photos/b.jpg",
"loserElo": 1476,
"loserMatches": 1
}

Errors

  • 400Invalid JSON, a path missing, both paths equal, or outcome not 1 or 0.5. Error
  • 405The method is not POST. Error
  • 500The database failed. Error
POST/api/media/deleteAdmin#

Remove an item from the library (the file is not touched).

Erases every database reference to the path: the media row, tag assignments, embeddings, faces and their curation assertions (vetoes, cannot-links), person cover pointers, face-scan markers and battle-log rows, and evicts the path from the in-memory vector and face indexes. The file on disk is never deleted. Identical to /api/media/forget.

The handler does not check the method; clients send POST.

Request body

application/json

MediaPathRequest

pathstringrequired

Media path.

Example
{
"path": "D:/photos/x.jpg"
}

Response 200

Rows removed per table.

application/json

MediaEraseResult

pathstringrequired
mediaintegerrequired

Media rows removed (0 or 1).

tagsintegerrequired

Tag assignment rows removed.

embeddingsintegerrequired

Embedding rows removed.

facesintegerrequired

Face rows removed.

battlesintegerrequired

Battle-log rows removed.

Example
{
"path": "D:/photos/x.jpg",
"media": 1,
"tags": 4,
"embeddings": 2,
"faces": 1,
"battles": 3
}

Errors

  • 400Invalid JSON or path empty. Error
  • 500The database failed. Error
POST/api/media/forgetAdmin#

Erase every database reference to a path (the file is not touched).

Same implementation and response as /api/media/delete; meant for media the viewer can no longer load (file gone or unreachable).

The handler does not check the method; clients send POST.

Request body

application/json

MediaPathRequest

pathstringrequired

Media path.

Example
{
"path": "D:/photos/missing.jpg"
}

Response 200

Rows removed per table.

application/json

MediaEraseResult

pathstringrequired
mediaintegerrequired

Media rows removed (0 or 1).

tagsintegerrequired

Tag assignment rows removed.

embeddingsintegerrequired

Embedding rows removed.

facesintegerrequired

Face rows removed.

battlesintegerrequired

Battle-log rows removed.

Errors

  • 400Invalid JSON or path empty. Error
  • 500The database failed. Error
POST/api/media/moveAdmin#

Re-point every database reference after moving a file or folder on disk.

The bookkeeping half of a move: the filesystem is never touched, the caller has already moved the file(s). All references are rewritten in one transaction: media.path, media_tag_by_category.media_path, media_embedding.media_path, face.media_path, face_scan.media_path, battle.winner_path, battle.loser_path, duplicate_member.media_path and duplicate_group.anchor_path. Tables that do not exist in the library are skipped (their key is absent from rows).

from and to are trimmed of whitespace and trailing / or \. With prefix, both are folders and every item under from moves under to keeping its sub-path. Matching is segment-aligned (/a/foo never drags /a/foobar) and case-sensitive.

With dryRun the whole move runs and is rolled back, so the counts are exactly what a real run would change.

If any destination path already belongs to another library item that is not itself moving, nothing changes and the answer is 409. This check also applies to dry runs.

After a real move the vector and face indexes are re-keyed for the paths listed in paths. When truncated is true only the first 100 were re-keyed; rebuild the index to refresh the rest.

The handler does not check the method; clients send POST.

Request body

application/json

fromstringrequired

Current path (or folder with prefix). Required.

tostringrequired

New path (or folder with prefix). Required, must differ from from, and with prefix must not be inside from.

prefixboolean

Treat from and to as folders and move the whole subtree.

Default false.

dryRunboolean

Compute the result and roll back.

Default false.

Example
{
"from": "C:/pics/2023",
"to": "D:/archive/2023",
"prefix": true,
"dryRun": true
}

Response 200

What the move changed (or would change, for a dry run).

application/json

MediaMoveResult

fromstringrequired

Normalised source (trimmed of whitespace and trailing separators).

tostringrequired

Normalised destination.

prefixbooleanrequired
dryRunbooleanrequired
itemsintegerrequired

Number of media rows whose path changed.

rowsmap of integerrequired

Rows updated per table.column key (for example media.path, media_tag_by_category.media_path), including zero entries. Tables missing from the library have no key.

totalintegerrequired

Sum of rows.

pathsarray of object

Up to 100 sample rewrites, ordered by old path. Omitted when nothing matched.

fromstringrequired
tostringrequired
truncatedboolean

True when more than 100 items moved; omitted otherwise.

Example
{
"from": "C:/pics/2023",
"to": "D:/archive/2023",
"prefix": true,
"dryRun": true,
"items": 2,
"rows": {
"media.path": 2,
"media_tag_by_category.media_path": 9,
"media_embedding.media_path": 2,
"face.media_path": 1,
"face_scan.media_path": 2,
"battle.winner_path": 0,
"battle.loser_path": 0,
"duplicate_member.media_path": 0,
"duplicate_group.anchor_path": 0
},
"total": 16,
"paths": [
{
"from": "C:/pics/2023/a.jpg",
"to": "D:/archive/2023/a.jpg"
},
{
"from": "C:/pics/2023/b.jpg",
"to": "D:/archive/2023/b.jpg"
}
]
}

Errors

  • 400Invalid JSON, from or to empty, both the same, to inside from with prefix, or a database error during the move. Error
  • 409One or more destination paths already belong to another library item. Nothing was changed. Unlike the other errors, this body is sent as application/json. application/json
POST/api/media/merge-metadataAdmin#

Merge several items into the first one, then delete the others.

The first non-blank path is the target; every other distinct path is a source. The merge is additive: tag rows and per-model embedding rows the target lacks are copied in (the target's own rows win), an empty transcript is filled from the first source that has one, and that source's .vtt sidecar is moved next to the target. The sources are then deleted from disk (plus leftover sidecars) and every database reference to them is erased. s3:// sources and files that fail to delete keep their file and rows and are listed in failed.

The handler does not check the method; clients send POST.

Request body

application/json

pathsarray of stringrequired

Target first, then the sources. Blank entries and repeats of the target are skipped.

Example
{
"paths": [
"D:/photos/keep.jpg",
"D:/photos/dupe1.jpg",
"D:/photos/dupe2.jpg"
]
}

Response 200

What the merge changed.

application/json

MediaMergeResult

targetstringrequired
sourcesarray of stringrequired
tagsintegerrequired

Tag rows the target gained.

embeddingsintegerrequired

Embedding rows the target gained.

transcriptbooleanrequired

True when the target's transcript was filled or a .vtt sidecar was moved next to it.

transcriptFilestringrequired

Path of the moved sidecar; empty when none.

deletedarray | nullrequired

Sources removed from disk and database. null when none.

failedarray | nullrequired

Sources that kept their file and rows (s3://, or deletion failed). null when none.

facesRemovedintegerrequired

Face rows erased with the sources.

Example
{
"target": "D:/photos/keep.jpg",
"sources": [
"D:/photos/dupe1.jpg",
"D:/photos/dupe2.jpg"
],
"tags": 3,
"embeddings": 0,
"transcript": false,
"transcriptFile": "",
"deleted": [
"D:/photos/dupe1.jpg",
"D:/photos/dupe2.jpg"
],
"failed": null,
"facesRemoved": 0
}

Errors

  • 400Invalid JSON, or no target plus at least one source. Error
  • 500A database error before anything was deleted. Error
POST/api/media/assign-personAdmin#

Assign a face in a media item to a person, or found a new person from it.

Assigns one face of the item: when the item has several faces, the one most similar to the person's existing faces wins; for a person with no faces yet (always the case with newPerson) the largest face wins. The item is face-scanned on the fly when it has no stored faces yet.

With newPerson: true a new person is created (named name, or the next automatic "Unknown" name) only after a face has been found, so a face-less item never leaves an empty group behind; setCover is then implied. The assignment is recorded as a user action.

Request body

application/json

pathstringrequired

Media path. Required.

personIdinteger

Existing person id. Required unless newPerson is true.

setCoverboolean

Also make the chosen face the person's cover crop.

Default false.

newPersonboolean

Create a new person seeded by this item's face.

Default false.

namestring

Name for the new person (with newPerson). Blank picks an automatic name.

Example
{
"path": "D:/photos/x.jpg",
"personId": 12,
"setCover": true
}

Response 200

The face that was assigned.

application/json

faceIdintegerrequired

Id of the assigned face.

personIdintegerrequired

The person it was assigned to (the new id with newPerson).

facesintegerrequired

Number of faces found in the item.

setCoverbooleanrequired

Whether the face became the cover.

createdbooleanrequired

True when a new person was created.

namestringrequired

The new person's name; empty when assigning to an existing person.

Example
{
"faceId": 4411,
"personId": 31,
"faces": 2,
"setCover": true,
"created": true,
"name": "Alex"
}

Errors

  • 400Invalid JSON, path empty, or neither a positive personId nor newPerson; also person-creation or assignment errors whose message says something is already so, required, or must be. Error
  • 404personId does not exist. Error
  • 405The method is not POST. Error
  • 422No face was detected in the media item. Error
  • 500Face scan or database failure. Error
POST/api/media/reject-personAdmin#

Reject every face of a person on one media item.

The inverse of assign-person: each of the person's faces on the item gets a permanent veto plus cannot-links and is unassigned, so the group actually loses those faces. Used by the Electron viewer when a person tag is removed from an item. The person is identified by personId, or by display name when personId is missing or not positive.

Request body

application/json

pathstringrequired

Media path. Required.

personIdinteger

Person id. Required unless name is given.

namestring

Person display name, used when personId is not given.

Example
{
"path": "D:/photos/x.jpg",
"personId": 12
}

Response 200

The faces that were rejected (possibly none).

application/json

personIdintegerrequired
rejectedFaceIdsarray | nullrequired

Ids of the rejected faces. May be null when nothing was rejected.

Example
{
"personId": 12,
"rejectedFaceIds": [
4411
]
}

Errors

  • 400Invalid JSON, path empty, or neither personId nor name. Error
  • 404No person has the given name. Error
  • 405The method is not POST. Error
  • 500The database failed. Error
POST/api/thumbnailsRead#

Recorded thumbnail files of one item.

Looks at the item's thumbnail_path_600 and thumbnail_path_1200 columns and reports each one that is set, with whether the file exists on local disk and its size. The 100 px cache is not reported. s3:// thumbnails always report exists: false.

The handler does not check the method; clients send POST.

Request body

application/json

MediaPathRequest

pathstringrequired

Media path.

Example
{
"path": "D:/photos/x.jpg"
}

Response 200

Zero, one or two entries.

application/json

array of object

cachestringrequired

One of "thumbnail_path_600" "thumbnail_path_1200".

pathstringrequired

Thumbnail file path on the server.

existsbooleanrequired

Whether the file is on local disk.

sizeintegerrequired

File size in bytes; 0 when missing.

Example
[
{
"cache": "thumbnail_path_600",
"path": "C:\\ProgramData\\Lowkey Media Server\\thumbnail_path_600\\3b5d...e1",
"exists": true,
"size": 48211
}
]

Errors

  • 400The body is not valid JSON. Error
POST/api/thumbnails/regenerateAdmin#

Delete and regenerate one thumbnail of a local item.

Removes the cached file at the deterministic cache path, generates a new one, stores it in the item's column and returns its path. Only local media is supported (there is no S3 branch). An unrecognised cache is treated as thumbnail_path_600.

The handler does not check the method; clients send POST.

Request body

application/json

pathstring

Media path.

One of "thumbnail_path_100" "thumbnail_path_600" "thumbnail_path_1200".

timeStampnumber

Video frame time in seconds.

Default 0.

Example
{
"path": "D:/videos/clip.mp4",
"cache": "thumbnail_path_1200",
"timeStamp": 30
}

Response 200

The new thumbnail's path on the server, as a JSON string.

application/json

string

Errors

  • 400The body is not valid JSON. Error
  • 500No database configured, or generation failed. Error

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.

GET/media/fileRead#

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

  • 400path 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
GET/media/thumbnailRead#

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.

Headers

If-None-Matchstring

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

  • 400path 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
GET/media/hlsRead#

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

pathstringrequired

Media path of the video.

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

  • 400path 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/media/hlsAdmin#

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

hashstringrequired

Lowercase hex cache key.

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

hashstringrequired

Lowercase hex cache key.

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
GET/media/facecropRead#

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

idintegerrequired

Face id.

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

  • 400id 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.

GET/api/taxonomyRead#

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": []
}
}

Errors

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

labelstringrequired
weightnumberrequired

Sort weight.

descriptionstringrequired

Empty string when unset.

tagViewModestringrequired

card when unset.

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"
}
]

Errors

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

labelstringrequired
categorystringrequired

Empty string for a tag with no category.

weightnumberrequired
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
}
]

Errors

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.

Query parameters

labelstringrequired

Tag label.

Response 200

The tag.

application/json

TagDetail

labelstringrequired
categorystringrequired

Empty string for a tag with no category.

weightnumberrequired
descriptionstringrequired

Empty string when unset.

thumbnail_path_600string | nullrequired

Stored preview thumbnail path.

groupstring | nullrequired

The tag's group.

Example
{
"label": "Alice",
"category": "People",
"weight": 0,
"description": "My sister",
"thumbnail_path_600": "D:\\Library\\.thumbnails\\abc_600.webp",
"group": "Family"
}

Errors

  • 400label 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
GET/api/tags/listRead#

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

tagsarray of TagWithCountrequired
Example
{
"tags": [
{
"label": "Alice",
"category": "People",
"weight": 0,
"media_count": 212
}
]
}

Errors

  • 405Method other than GET. Error
  • 500Database error. Error
POST/api/tags/countRead#

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

labelstring

Tag label.

Example
{
"label": "Alice"
}

Response 200

The count.

application/json

countintegerrequired
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

labelstring

Tag label.

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
POST/api/tagsAdmin#

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

labelstring

New tag label.

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

labelstringrequired
Example
{
"label": "Alice"
}

Errors

  • 400Body is not valid JSON. Error
  • 500Insert failed (for example the label already exists). Error
DELETE/api/tagsAdmin#

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

labelstringrequired

Tag to delete.

categoryLabelstring

Optional guard; must match the tag's category.

Example
{
"label": "Alice",
"categoryLabel": "People"
}

Response 200

Deleted.

application/json

labelstringrequired
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

labelstring

Current label.

newLabelstring

New label.

Example
{
"label": "Alice",
"newLabel": "Alice Smith"
}

Response 200

Renamed (also when no tag matched).

application/json

EmptyObject

Errors

  • 400Body is not valid JSON. Error
  • 409newLabel is already taken; nothing changed. Error
  • 500Database error; nothing changed. Error
PUT/api/tags/moveAdmin#

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

labelstring

Tag to move.

categoryLabelstring

Destination category.

Example
{
"label": "Alice",
"categoryLabel": "Family"
}

Response 200

Moved.

application/json

EmptyObject

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
PUT/api/tags/orderAdmin#

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"
]
}

Response 200

Reordered.

application/json

EmptyObject

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

labelstring

Tag label.

weightnumber

Sort weight.

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

mediaPathstring
tagLabelstring
oldTimestampnumber

Current time in seconds.

newTimestampnumber

New time in seconds.

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

mediaPathstring
tagLabelstring
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

labelstring

New category label.

weightnumber

Sort weight. Default 0.

Example
{
"label": "Places",
"weight": 4
}

Response 200

Created.

application/json

labelstringrequired
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

labelstringrequired

Category to delete.

Example
{
"label": "Suggested"
}

Response 200

Deleted; what was removed.

application/json

CategoryImpact

labelstringrequired
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.

Query parameters

labelstringrequired

Category label.

Response 200

Impact counts.

application/json

CategoryImpact

labelstringrequired
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

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

labelstring

Current label.

newLabelstring

New label.

Example
{
"label": "Places",
"newLabel": "Locations"
}

Response 200

Renamed (also when no category matched).

application/json

EmptyObject

Errors

  • 400Body is not valid JSON. Error
  • 409newLabel 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

labelstringrequired

Category label.

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.

tagLabelstringrequired

Tag to assign.

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"
}

Response 200

Assigned.

application/json

EmptyObject

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

Items to untag.

mediaPathstring

Single item, used when mediaPaths is empty.

tagobject
tag_labelstring

Tag to remove.

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
}
}

Response 200

Done.

application/json

EmptyObject

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

mediaPathstring
tagLabelstring
weightnumber
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.

Query parameters

categorystring

Category label.

Response 200

Groups.

application/json

array of TagGroup

labelstringrequired
weightnumberrequired

Sort weight.

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"
}
]

Errors

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

The group's category.

labelstringrequired

Group label.

Example
{
"categoryLabel": "People",
"label": "Family"
}

Response 200

Created (or already present).

application/json

labelstringrequired
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

The group's category.

labelstringrequired

Group label.

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"
}

Response 200

Renamed.

application/json

EmptyObject

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/api/tags/groupAdmin#

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

labelstringrequired

Tag label.

groupLabelstring

Group within the tag's category; empty ungroups.

Example
{
"label": "Alice",
"groupLabel": "Family"
}

Response 200

Updated.

application/json

EmptyObject

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

labelstringrequired

Category label.

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.

installedbooleanrequired

An index is loaded.

modelstringrequired

Model the index was built from; empty when none.

vectorsintegerrequired

Vectors in the index.

active_modelstringrequired

The configured embedding model.

media_totalintegerrequired

Rows in the media table.

missing_active_modelintegerrequired

Media items with no embedding for the active model.

orphanedintegerrequired

Embedding rows whose media row is gone.

embeddingsarray of objectrequired

Stored rows per model.

modelstringrequired
countintegerrequired
dimintegerrequired
bytesintegerrequired

Stored vector bytes.

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

modelsarray of EmbedModelInforequired
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

statusstringrequired

One of "ok".

modelstringrequired

Active model id.

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

modelstringrequired
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
GET/api/embeddingsAdmin#

Stored embedding rows for one media item.

One row per model unless model is given. Rows that fail to scan are skipped.

Query parameters

pathstringrequired

Media path.

modelstring

Only this model's row.

vectorstring

true includes the decoded float vector in each row.

One of "true".

Response 200

Embedding rows.

application/json

pathstringrequired
embeddingsarray of StoredEmbeddingRowrequired
Example
{
"path": "D:\\Photos\\a.jpg",
"embeddings": [
{
"model": "siglip2-base-patch16-224",
"dim": 768,
"created_at": 0
}
]
}

Errors

  • 400path 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.

Query parameters

pathstringrequired

Media path.

modelstring

Only this model's row.

Response 200

Deleted.

application/json

deletedintegerrequired

Rows removed.

Example
{
"deleted": 2
}

Errors

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

Embedding rows deleted.

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

deletedintegerrequired

Rows removed.

Example
{
"deleted": 102468
}

Errors

  • 400confirm=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.

GET/api/peopleRead#

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
POST/api/peopleAdmin#

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

New person id.

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

Target person id.

min 1.

Example
{
"intoId": 12
}

Response 200

Merged.

application/json

mergedinteger (int64)required

The source (now deleted) person id.

intointeger (int64)required

The target person id.

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.

pathstringrequired
mtimeMsintegerrequired

Always 0.

elonumber
battlesinteger
heightinteger
widthinteger
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
facesarray of PersonReviewFacerequired
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
lockedintegerrequired

Faces promoted.

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
keptintegerrequired

Faces locked.

rejectedintegerrequired

Faces rejected.

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
GET/api/facesRead#

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).

Query parameters

pathstringrequired

Media path.

Response 200

Face scan status and faces.

application/json

modelstringrequired

Recognizer id.

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 Facerequired
Example
{
"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

  • 400path 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

Face id.

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

deletedbooleanrequired
Example
{
"deleted": true
}

Errors

  • 400confirm=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
GET/api/faces/statsRead#

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.

modelstringrequired

Recognizer id.

countintegerrequired

Stored faces.

bytesinteger (int64)required

Total vector blob bytes.

total_facesintegerrequired
total_bytesinteger (int64)required
scansintegerrequired

Face-scan markers (items scanned, including those with no faces).

peopleobjectrequired
totalintegerrequired
namedintegerrequired

People with a non-empty name.

unnamedintegerrequired
indexobjectrequired

The in-memory face search index.

modelstringrequired

Recognizer the index holds; empty when not built.

vectorsintegerrequired

Vectors in the index.

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

countintegerrequired

Unassigned faces.

facesarray of UngroupedFace

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.

GET/api/duplicatesRead#

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

groupsarray of DuplicateGrouprequired
totalintegerrequired

Groups matching status, unpaged.

limitintegerrequired

Effective page size.

offsetintegerrequired

Effective offset.

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

pendingintegerrequired

Pending groups.

dismissedintegerrequired

Dismissed groups.

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).

Query parameters

pathstringrequired

Media path (trimmed).

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

  • 400path 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

deletedintegerrequired

Groups deleted.

Example
{
"deleted": 49
}

Errors

  • 400confirm=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

Unix seconds.

updatedAtinteger (int64)required

Unix seconds.

memberCountintegerrequired

Every member row, excluded ones included.

activeCountintegerrequired

Members not excluded.

minScorenumberrequired

Lowest active member similarity to the anchor (how tight the group is).

membersarray of DuplicateMemberrequired

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

The deleted group id.

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

groupDuplicateGroup | nullrequired

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
statusstringrequired
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
statusstringrequired
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

pathstringrequired

Member path (trimmed).

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
groupDuplicateGroup | nullrequired

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

Swipe

Swipe is a mobile-first progressive web app served at /swipe for flicking through the library one item at a time. /swipe/api feeds it pages of items in several modes (shuffle, more like this, a personalized For You feed, text search, same person), and /swipe/manifest.json makes it installable. Errors from /swipe/api are plain-text messages.

GET/swipe/apiRead#

A page of items for the Swipe app.

The mode parameter picks how items are chosen. Any other value (or none) uses the shuffle.

  • Omitted: shuffle. With no q, a seeded sample of the whole library (seed 0 or omitted means fresh randomness). With q, the library search query filters the pool: pure tag queries (tag:"X", OR/AND of tags) shuffle with seed; other queries are ordered by SQL RANDOM() on every request, so their pages are not stable.
  • similar: ranked by visual-embedding similarity to anchor (active embedding model), nearest first.
  • feed: the personalized "For You" feed built from the user's swipe favorites (the tag configured in the swipeFeed config section). The server keeps one feed sequence per session (and per orientation) so pages compose. exploit, fresh, bridge and wildcard override the configured lane weights for this request.
  • text: the library ranked by SigLIP text-to-image similarity to q (required). Query vectors are cached for 15 minutes so paging does not re-encode.
  • face: ranked by face similarity to the largest face in anchor (scanned on the fly when it has no stored faces), one entry per media item.

In similar, text and face modes the ranking is deterministic, the anchor is excluded, and missing-media and off-orientation items are filtered before paging, so offsets index a stable list.

Query parameters

modestring

Selection mode; see the description. Unrecognized values fall back to the shuffle.

One of "similar" "feed" "text" "face".

qstring

Shuffle mode: library search query filter. text mode: the search text (required, trimmed).

anchorstring

Media path. Required for similar and face; excluded from its own results.

seedinteger (int64)

Shuffle mode only. Same seed, same order (for unfiltered and tag-filtered pools). 0 means fresh randomness.

Default 0.

sessionstring

feed mode: client session id keying the server-side feed sequence.

Default "default".

exploitnumber

feed mode: weight override for the nearest-neighbours-of-your-taste lane. Must be >= 0.

min 0.

freshnumber

feed mode: weight override for the neighbours-of-the-latest-like lane. Must be >= 0.

min 0.

bridgenumber

feed mode: weight override for the between-interests "surprising" lane. Must be >= 0.

min 0.

wildcardnumber

feed mode: weight override for the uniform-random lane. Must be >= 0.

min 0.

orientationstring

Restrict to portrait (width <= height) or landscape (width >= height); items without dimensions are excluded when set. Any other value (including both) means unrestricted. Case-insensitive.

One of "portrait" "landscape".

offsetinteger

Page offset.

Default 0. min 0.

limitinteger

Page size. Values outside 1-50 use the default.

Default 20. min 1, max 50.

Response 200

One page of items.

application/json

SwipeResponse

itemsarray | nullrequired

The page. The shuffle mode returns null (not an empty array) when the pool is empty.

has_morebooleanrequired

More items exist past this page.

total_countintegerrequired

Not populated by this endpoint; always 0.

Example
{
"items": [
{
"path": "D:/photos/beach.jpg",
"description": "A beach at sunset.",
"size": 2483112,
"hash": "a1b2c3",
"width": 4032,
"height": 3024,
"tags": [
{
"label": "beach",
"category": "Places"
}
],
"exists": true,
"duplicateCount": 0
}
],
"has_more": true,
"total_count": 0
}

Errors

  • 400anchor missing for similar/face, or q empty for text. Plain-text message. text/plain
  • 405Method other than GET (Use GET). text/plain
  • 500Search, feed or database failure, including no face found in the face anchor and the text model not being installed. Plain-text message. text/plain
GET/swipe/manifest.jsonPublic#

PWA manifest for the Swipe app.

A fixed web app manifest. Registered without any auth middleware, so it is served to anyone, and it answers every HTTP method the same way.

Response 200

The manifest.

application/manifest+json

SwipeManifest

namestringrequired
short_namestringrequired
descriptionstringrequired
start_urlstringrequired
displaystringrequired
orientationstringrequired
background_colorstringrequired
theme_colorstringrequired
iconsarray of objectrequired
srcstringrequired
sizesstringrequired
typestringrequired
purposestringrequired
Example
{
"name": "Lowkey Media Server Swipe",
"short_name": "Swipe",
"description": "Swipe through your media library",
"start_url": "/swipe",
"display": "standalone",
"orientation": "portrait",
"background_color": "#000000",
"theme_color": "#000000",
"icons": [
{
"src": "/static/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/static/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
]
}

Database

Direct access to the active library database: read-only SQL for trusted clients such as lokictl, and the web client's boot handshake.

POST/api/db/queryAdmin#

Run one read-only SQL statement.

Built for lokictl and other trusted API clients. Two layers keep it read-only: - The statement must be a single SELECT or WITH (leading -- and /* */ comments and one trailing ; are allowed). Any other ; rejects the query, including one inside a string literal, so pass data through args. - It runs on a connection with PRAGMA query_only=ON, so SQLite refuses any write however it is phrased.

BLOB cells are returned as {"blob_bytes": n} instead of their contents. All errors, including 405, are application/json.

Request body

application/json

sqlstringrequired

One SELECT or WITH statement.

argsarray of any

Positional values for ? placeholders. JSON numbers bind as floating point.

limitinteger

Row cap. Zero or negative means the default; values above 10000 are clamped.

Default 1000. max 10000.

timeout_msinteger

Statement timeout in milliseconds. Zero or negative means the default; values above 30000 are clamped.

Default 5000. max 30000.

Example
{
"sql": "SELECT path, size FROM media WHERE path LIKE ? ORDER BY size DESC",
"args": [
"%/2024/%"
],
"limit": 100,
"timeout_ms": 5000
}

Response 200

Query result.

application/json

DBQueryResult

columnsarray of stringrequired
rowsarray of array of anyrequired

One array per row, cells in column order. A BLOB cell is {"blob_bytes": n}.

row_countintegerrequired

Rows returned.

truncatedbooleanrequired

More rows existed beyond limit.

elapsed_msintegerrequired

Query and read time in milliseconds.

Example
{
"columns": [
"path",
"size"
],
"rows": [
[
"D:\\Photos\\2024\\big.mov",
4831838208
],
[
"D:\\Photos\\2024\\a.jpg",
5242880
]
],
"row_count": 2,
"truncated": false,
"elapsed_ms": 3
}

Errors

  • 400Body is not valid JSON (bad json); the statement is not a single SELECT/WITH; or SQLite rejected or aborted the query (syntax error, write attempt, timeout), with SQLite's message. Error
  • 405Method other than POST. Error
  • 500No database connection available, read-only mode could not be set, or reading the result failed. Error
POST/api/db/loadRead#

Web client boot handshake (no-op).

The desktop viewer's "load database" call, mirrored for the web client. On the server it does nothing: the body is parsed and {} is returned, and the server keeps using its configured database. It is read access so anonymous boots work in public view-only mode. The method is not checked.

Request body

application/json

pathstring

Database path the client asked for. Ignored.

Example
{
"path": "D:\\Library\\dream.sqlite"
}

Response 200

Acknowledged.

application/json

EmptyObject

Errors

  • 400Body missing or not valid JSON. Error

Storage and Transfer

Browse and scan the configured storage roots (local folders and S3-compatible buckets), upload files into them, and move a curated library between servers with export and import archives.

POST/api/fs/listRead#

List a directory inside a storage root.

An empty path lists the configured storage roots themselves (each with type local or s3, and parent null). Otherwise lists the directory's entries through the backend that owns it. parent is null at a root. roots is always an empty array. The method is not checked.

Request body

application/json

pathstring

Directory to list; empty for the roots.

Example
{
"path": "D:\\Photos"
}

Response 200

Directory listing.

application/json

entriesarray of FSEntryrequired
parentstring | nullrequired

Parent directory, or null at a root.

rootsarray of stringrequired

Always empty.

Example
{
"entries": [
{
"name": "2024",
"path": "D:\\Photos\\2024",
"isDir": true,
"mtimeMs": 1704067200000,
"size": 0
},
{
"name": "a.jpg",
"path": "D:\\Photos\\a.jpg",
"isDir": false,
"mtimeMs": 1704067200000,
"size": 5242880
}
],
"parent": "D:\\",
"roots": []
}

Errors

  • 400Body is not valid JSON. Error
  • 403path is not within any configured storage root. Error
  • 500Listing failed. Error
POST/api/fs/scanRead#

List the media files in a directory.

Returns the media files under path (only direct children unless recursive). When path is a file, its parent directory is scanned and cursor is that file's index in library (otherwise 0). For S3 paths a trailing / marks a folder.

For admin callers, every scanned file is also added to the library (new media rows, and missing sizes backfilled). Anonymous public-access visitors get the listing without any write. The method is not checked.

Request body

application/json

pathstringrequired

Directory to scan, or a file to scan the directory of.

recursiveboolean

Include subdirectories. Default false.

Example
{
"path": "D:\\Photos\\a.jpg",
"recursive": false
}

Response 200

Media files.

application/json

libraryarray of objectrequired
pathstringrequired
mtimeMsnumberrequired

Modification time in milliseconds since the epoch.

sizeintegerrequired

Bytes.

cursorintegerrequired

Index of the requested file when path was a file, else 0.

Example
{
"library": [
{
"path": "D:\\Photos\\a.jpg",
"mtimeMs": 1704067200000,
"size": 5242880
},
{
"path": "D:\\Photos\\b.jpg",
"mtimeMs": 1704067300000,
"size": 4194304
}
],
"cursor": 0
}

Errors

  • 400Body is not valid JSON. Error
  • 403path is empty or not within any configured storage root. Error
  • 500Scan failed. Error
POST/api/uploadAdmin#

Upload files into a storage root.

Stores each files part under the destination directory, keeping its base name and adding _1, _2, ... before the extension when the name is taken. Parts that fail to open or store are skipped (logged); the request fails only when none succeed. Unless autoIngest is false, an ingest workflow is then queued for the destination directory. The request body is capped at 10 GB.

Platform difference: on Linux and macOS the destination field is ignored and files always go to uploads/ (a relative key) in the default storage root, and the ingest job's input is uploads/.

Request body

multipart/form-data

filesarray of string (binary)required

One or more file parts.

destinationstring

Directory inside a configured storage root (local path or s3:// prefix). Default: uploads/ under the default root. Windows only.

autoIngeststring

false to skip the ingest job. Any other value, or omitted, queues it.

Response 200

At least one file stored.

application/json

UploadResult

successbooleanrequired
filesarray | nullrequired

Stored paths; null on failure.

messagestring

Present on success.

errorstring

Present on failure.

Example
{
"success": true,
"files": [
"D:\\Library/uploads/a.jpg",
"D:\\Library/uploads/a_1.jpg"
],
"message": "Uploaded 2 file(s)"
}

Errors

  • 400The multipart form could not be parsed (including over 10 GB), destination is outside every storage root, or no files parts were sent. UploadResult
  • 405Method other than POST. text/plain
  • 500No storage root is configured, or no file could be stored. UploadResult
GET/api/exportAdmin#

Download a .lokiexport archive of the tagged library.

Streams a gzip-compressed tar, sent as an attachment named lowkey-export-YYYYMMDD-HHMMSS.lokiexport. It holds every media item that has at least one tag, plus the whole taxonomy and the embeddings, faces and people referencing those items: - manifest.json: format version, creation time, the source's storage roots and the item count. - library.db: a SQLite database with only the selected rows, each media path replaced by a flat, collision-free key (the file's base name, disambiguated with -1, -2, ...). - files/<key>: each item's bytes. Files that cannot be read are skipped, but their rows stay.

Thumbnails are not included; they regenerate after import. Errors after streaming has begun cannot change the status and leave a truncated archive.

Response 200

The archive.

application/gzip

string (binary)

Errors

  • 400No tagged media to export. text/plain
  • 405Method other than GET. text/plain
  • 500Selecting items or building the export database failed. text/plain
POST/api/importAdmin#

Merge a .lokiexport archive into this library.

Extracts the archive (rejecting members that escape the extraction folder), rebases every file key onto the destination root, copies the file bytes there, and merges by media item: an item already present is skipped whole; a new item brings its media row, tags, embeddings, faces and file. Taxonomy and tag groups merge without overwriting what exists. Face and person ids are remapped so curation references stay valid. The embedding and face search indexes are rebuilt afterwards; failures there become warnings. The request body is capped at 50 GB.

Request body

multipart/form-data

filestring (binary)required

The .lokiexport archive.

destRootstring

Storage root to import into, matched against each root's path or name. Omitted or unmatched: the default root.

Response 200

Imported.

application/json

ImportResult

importedintegerrequired

New media items merged in.

skippedintegerrequired

Items already present, skipped whole.

filesintegerrequired

File bytes copied into the destination root.

dest_rootstringrequired

Path of the destination storage root.

warningsarray of string

Non-fatal problems; omitted when empty.

Example
{
"imported": 1200,
"skipped": 34,
"files": 1198,
"dest_root": "s3://media/library"
}

Errors

  • 400The form could not be parsed (including over 50 GB), file is missing, no storage root is configured, or the archive is not a valid tar.gz or contains unsafe member paths. text/plain
  • 405Method other than POST. text/plain
  • 500The merge failed, including an archive newer than this server supports or one without library.db. text/plain

Transform and Models

Helpers for the Transform flow and the model pickers in the web UI: a dry-run preview of the prompt a retouch or reshoot job will run, the extra video models configured for reshoot, the active description prompt, the models of the local Ollama install, and the optional DirectML runtime used by the embed task.

All of these require an admin credential.

POST/api/transform/promptAdmin#

Preview the prompt a retouch or reshoot job will run.

Takes exactly the body POST /create takes and queues nothing. The job's arguments go through the same planning as the task, and the engine itself expands the prompt (--show-prompt, which loads no models), so presets, --append, living-photo expansion and reference tokens read exactly as they will run. When the engine is routed to a worker, the worker's engine answers.

input is parsed like /create: the first token is the task id (retouch or reshoot), the last token is the input (newline-separated file paths; quote it if it contains spaces), and the tokens between are options. Each non-empty fields entry is appended as --<key> <value>.

If the engine is not installed, a free-form prompt is returned as it will be sent (fromEngine: false, with a note); preset prompts cannot be expanded without the engine. A batch is shown for its first image (runs > 1).

Request body

application/json

inputstringrequired

<task> [options...] <input>, as for POST /create. Needs at least two tokens.

fieldsmap of string

Extra options appended as --<key> <value>; empty keys or values are skipped.

Example
{
"input": "retouch --preset=restore \"D:/photos/a.jpg\"",
"fields": {
"prompt": "Remove the scratches."
}
}

Response 200

The expanded prompt of the job's first run.

application/json

PromptPreview

enginestringrequired

One of "loki-retouch" "loki-reshoot".

promptstringrequired

The instruction as the engine expands it (empty when a preset needs the engine and it is not installed).

modelInputstring

Retouch only: the whole text-encoder turn, every reference announced by its token, then the prompt. Omitted when empty.

referencesarray of PromptReferencerequired
runsintegerrequired

Engine runs the job makes (one per image in a retouch batch; reshoot is always 1).

fromEnginebooleanrequired

False when the engine is not installed and the prompt was assembled by the server.

notestring

Explanation when the prompt is partial or shown for the first of several images. Omitted when empty.

Example
{
"engine": "loki-retouch",
"prompt": "Remove the scratches.",
"modelInput": "<|im_start|>system\nComprehend and analyze the provided prompt.<|im_end|>\n...",
"references": [
{
"token": "<image1>",
"path": "D:\\photos\\a.jpg",
"kind": "image"
}
],
"runs": 1,
"fromEngine": true
}

Errors

  • 400Body is not JSON (bad json) or input has fewer than two tokens (Invalid input). Plain text. text/plain
  • 405Method other than POST. Plain text. text/plain
  • 422The request cannot be previewed (unknown task, a query input instead of files, no input files, no prompt or preset, no image to edit, missing reference inputs). Error
  • 500The engine failed (for example --show-prompt exited with an error or a video frame could not be sampled). Error
GET/api/reshoot/modelsAdmin#

List the extra loki-reshoot diffusion models a job can pick.

The config's reshootModels list, cleaned up: blank and duplicate files (case-insensitive) dropped, recipe is h3 unless configured as fast, and label defaults to the file name without its extension. The built-in H3 and FastH3 Omni models are always available and are not listed. A job selects one with the dit option (the file).

Response 200

The configured models (an empty array when none).

application/json

modelsarray of ReshootModelChoicerequired
Example
{
"models": [
{
"file": "10eros_beta7.safetensors",
"label": "10Eros beta7",
"recipe": "fast"
}
]
}

Errors

  • 405Method other than GET. Plain text. text/plain
GET/api/prompts/describeAdmin#

Get the configured description prompt.

Returns describePrompt from the config, used by the web UI as the placeholder of the custom-prompt panel. Sent with Cache-Control: no-store.

Response 200

The active prompt.

application/json

promptstringrequired

The configured describe prompt (may be empty).

Example
{
"prompt": "Describe this image in detail."
}

Errors

  • 405Method other than GET. Plain text. text/plain
GET/ollama/modelsAdmin#

List the models of the local Ollama install.

Runs ollama ls --quiet on the server host (falling back to parsing the first column of ollama ls) and returns the model names, in order, de-duplicated. It does not query a remote Ollama URL: the ollama executable must be on the server's PATH.

Response 200

Model references such as llama3:latest (an empty array when none).

application/json

modelsarray of stringrequired
Example
{
"models": [
"llama3:latest",
"qwen2.5vl:7b"
]
}

Errors

  • 405Method other than GET. Plain text. text/plain
  • 500ollama ls failed or is not installed (failed to list ollama models). Plain text. text/plain
GET/api/embedding/directml/installAdmin#

Report whether the DirectML ONNX runtime is installed.

Windows builds only; the route is not registered on Linux or macOS.

Response 200

Install state.

application/json

DirectMLInstallResult

installedbooleanrequired

Whether the DirectML runtime is present in the data directory.

errorstring

POST only: why the install failed.

Example
{
"installed": false
}
POST/api/embedding/directml/installAdmin#

Download and install the DirectML ONNX runtime.

Downloads and installs the optional GPU (DirectML) ONNX Runtime into the data directory so the embed task can use provider=directml. Runs synchronously. A failed install still answers 200, with error set. Windows builds only.

Response 200

The install state afterwards, plus error when the install failed.

application/json

DirectMLInstallResult

installedbooleanrequired

Whether the DirectML runtime is present in the data directory.

errorstring

POST only: why the install failed.

Example
{
"installed": true
}

Errors

  • 405Method other than GET or POST (method not allowed). Plain text. text/plain

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.

GET/workersAdmin#

Old Workers page; redirects to the Config page's GPU Engines tab.

Response 302

Redirect to /config#engines.

GET/api/enginesAdmin#

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

configEnginesConfigrequired
statusEngineStatusrequired
localarray of LocalEngine

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
}
]
}
}
PUT/api/enginesAdmin#

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

configEnginesConfigrequired
statusEngineStatusrequired

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.

fallbackbooleanrequired
localOnlybooleanrequired
placementmap of stringrequired

Effective rule per engine (loki-retouch, loki-reshoot, loki-dream, loki-segment): local, pool, pool:<name> or hybrid.

targetsarray of EngineTargetStatusrequired

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".

urlstring

Node base URL (nodes).

tokenstring

Node bearer token (nodes).

endpointIdstring

RunPod endpoint id (runpod).

apiKeystring

RunPod API key (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

okbooleanrequired
infoWorkerInfo | RunPodHealthrequired

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.

GET/api/scenesAdmin#

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).

idstringrequired
namestringrequired
sourcestringrequired
basestringrequired
layersintegerrequired

Number of layers.

motionintegerrequired

Number of motion layers.

updatedstring (date-time)required

Errors

  • 405Method other than GET or POST. Plain text. text/plain
POST/api/scenesAdmin#

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

idstringrequired
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.

widthintegerrequired
heightintegerrequired
layersarray of SceneLayerrequired

Bottom to top.

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

idstringrequired
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.

widthintegerrequired
heightintegerrequired
layersarray of SceneLayerrequired

Bottom to top.

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.

freeLoopsboolean
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.

idstringrequired
namestring
kindstring

Anything else becomes still.

One of "still" "motion" "paint".

hiddenboolean
regionSceneRect
maskstring

Empty, or a data:image/png;base64, URL; any other value keeps the stored mask.

shapesany

Any JSON.

maskOptsany

Any JSON.

feathernumber

Clamped to 0-512.

promptstring
optionsobject
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.

pathstring
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

idstringrequired
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.

widthintegerrequired
heightintegerrequired
layersarray of SceneLayerrequired

Bottom to top.

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

okbooleanrequired

Errors

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

idstringrequired
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.

widthintegerrequired
heightintegerrequired
layersarray of SceneLayerrequired

Bottom to top.

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.

everythingboolean

Segment every object.

multimaskboolean

Also return candidate readings (part / object / whole) for clicks and boxes.

layerstring

Motion layer id to segment a clip frame of.

versioninteger

Version index of layer.

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.

heightintegerrequired
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).

negativestring
sizestring

WxH; ignored when it does not match.

turboboolean
seedinteger

Used when > 0.

Example
{
"prompt": "a lighthouse on a cliff at dusk",
"size": "1344x768"
}

Response 200

The queued job.

application/json

SceneDreamStatus

jobIdstringrequired
stateJobStaterequired

One of "pending" "in_progress" "paused" "completed" "cancelled" "error".

doneintegerrequired
totalintegerrequired
outputstring

The generated image, once completed.

errorstring
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

jobIdstringrequired
stateJobStaterequired

One of "pending" "in_progress" "paused" "completed" "cancelled" "error".

doneintegerrequired
totalintegerrequired
outputstring

The generated image, once completed.

errorstring

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

idstringrequired
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.

widthintegerrequired
heightintegerrequired
layersarray of SceneLayerrequired

Bottom to top.

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

boxSceneRectrequired
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.

xnumberrequired
ynumberrequired
scorenumberrequired

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

idstringrequired
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.

widthintegerrequired
heightintegerrequired
layersarray of SceneLayerrequired

Bottom to top.

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

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

idstringrequired
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.

widthintegerrequired
heightintegerrequired
layersarray of SceneLayerrequired

Bottom to top.

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

  • 400keep 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

idstringrequired
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.

widthintegerrequired
heightintegerrequired
layersarray of SceneLayerrequired

Bottom to top.

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

  • 400n 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

idstringrequired
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.

widthintegerrequired
heightintegerrequired
layersarray of SceneLayerrequired

Bottom to top.

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

  • 400n 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

idstringrequired
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.

widthintegerrequired
heightintegerrequired
layersarray of SceneLayerrequired

Bottom to top.

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

  • 400n 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

idstringrequired
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.

widthintegerrequired
heightintegerrequired
layersarray of SceneLayerrequired

Bottom to top.

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

  • 400n 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

idstringrequired
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.

widthintegerrequired
heightintegerrequired
layersarray of SceneLayerrequired

Bottom to top.

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.

regionSceneRectrequired

Errors

  • 400n 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.

regionSceneRectrequired

Errors

  • 400n 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

idstringrequired
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.

widthintegerrequired
heightintegerrequired
layersarray of SceneLayerrequired

Bottom to top.

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

  • 400n 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.

rectSceneRectrequired
widthintegerrequired

Clip width in pixels.

heightintegerrequired

Clip height in pixels.

Errors

  • 400n 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

idstringrequired
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.

widthintegerrequired
heightintegerrequired
layersarray of SceneLayerrequired

Bottom to top.

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

  • 400n 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".

namestringrequired

Display name.

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

idstringrequired

Model id.

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

idstringrequired
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

Settings and Session

Free-form key/value stores held in server memory for the web viewer. Neither store is written to disk: both are empty after a server restart, and the session store is also cleared when the server switches library databases. The current web viewer keeps its settings and session in browser localStorage and no longer calls these endpoints; they remain for compatibility.

Error bodies from these handlers are JSON ({"error":"bad request"}) but are sent with a text/plain content type.

GET/api/settingsRead#

The whole settings object.

Response 200

Settings (empty object after a restart).

application/json

object

PUT/api/settingsRead#

Set one setting or merge several.

If the body has a string key property it is a single update: settings[key] = value. Otherwise every property of the body is merged into the settings. A batch that happens to contain a string key property is therefore treated as a single update. Requires a credential even when Allow Public Access is on. Methods other than GET and PUT answer 200 with an empty body.

Request body

application/json

object | object

A body with a string key sets that one setting to value; any other object is merged key by key.

Example
{
"key": "theme",
"value": "dark"
}

Response 200

Saved. Body is {}.

application/json

object

Errors

  • 400Body is not a JSON object. Body {"error":"bad request"} sent as text/plain. text/plain
  • 401Allow Public Access is on and the request has no valid credential. Error
GET/api/sessionRead#

The whole session object.

Response 200

Session (empty object after a restart or database switch).

application/json

object

PUT/api/sessionRead#

Merge an object into the session.

Requires a credential even when Allow Public Access is on. Methods other than GET, PUT and DELETE answer 200 with an empty body.

Request body

application/json

object

Example
{
"query": "tag:sunset",
"cursor": 12
}

Response 200

Merged. Body is {}.

application/json

object

Errors

  • 400Body is not a JSON object. Body {"error":"bad request"} sent as text/plain. text/plain
  • 401Allow Public Access is on and the request has no valid credential. Error
DELETE/api/sessionRead#

Clear the whole session.

Requires a credential even when Allow Public Access is on.

Response 200

Cleared. Body is {}.

application/json

object

Errors

  • 401Allow Public Access is on and the request has no valid credential. Error
GET/api/session/{key}Read#

One session value.

Path parameters

keystringrequired

Session key. Registered as the prefix /api/session/; everything after it (slashes included) is the key. An empty key (/api/session/) addresses the whole session. The key keys is unreachable here because /api/session/keys is its own route.

Response 200

The stored JSON value, null when the key is not set, or the whole session object when the key is empty.

application/json

any

PUT/api/session/{key}Read#

Set one session value to the request body.

The body is any JSON value. With an empty key the body must be an object and is merged into the session. Requires a credential even when Allow Public Access is on. Methods other than GET and PUT (including DELETE) answer 200 with an empty body and change nothing.

Path parameters

keystringrequired

Session key. Registered as the prefix /api/session/; everything after it (slashes included) is the key. An empty key (/api/session/) addresses the whole session. The key keys is unreachable here because /api/session/keys is its own route.

Request body

application/json

any

Example
tag:sunset

Response 200

Saved. Body is {}.

application/json

object

Errors

  • 400Body is not valid JSON. Body {"error":"bad request"} sent as text/plain. text/plain
  • 401Allow Public Access is on and the request has no valid credential. Error
POST/api/session/keysAdmin#

Delete the named keys from the session.

The handler does not check the method; any method with this body behaves the same. POST is what clients send.

Request body

application/json

keysarray of stringrequired
Example
{
"keys": [
"library",
"previous"
]
}

Response 200

Deleted. Body is {}.

application/json

object

Errors

  • 400Body is not valid JSON. Body {"error":"bad request"} sent as text/plain. text/plain

Auto Scheduler

The auto scheduler keeps metadata generation caught up by running the combined per-item process job (with a --scheduled marker) whenever the machine is idle, and pausing it the moment the machine is needed: user requests to media, search and job routes, a queued job that shares its buckets, a fullscreen app, battery power, or CPU load above a threshold. Its mode (off or auto) is runtime-only and not persisted: every server start comes up off. Status changes are also broadcast on the /stream SSE channel as scheduler events.

GET/api/schedulerAdmin#

Current scheduler status.

The last status computed by the scheduler loop, which ticks every 15 seconds.

Response 200

Status.

application/json

SchedulerStatus

modestringrequired

One of "off" "auto".

forcedbooleanrequired

A one-shot forced run (POST /api/scheduler/run) is active.

statestringrequired

One of "disabled" "waiting" "running" "yielding" "complete".

reasonstringrequired

Human-readable reason for the state, e.g. the app is in use, session locked.

jobIdstring

The scheduled job's id, when there is one. Omitted otherwise.

doneintegerrequired

Items processed by the scheduled job.

totalintegerrequired

Items the scheduled job will process.

opsstringrequired

Comma-separated per-item ops the job runs (default hash,dimensions,describe,transcribe,autotag,embed,faces).

signalsobjectrequired

The idle signals behind the decision.

cpuPercentnumberrequired

Total system CPU percent; -1 when unknown.

sessionLockedstringrequired

One of "yes" "no" "unknown".

fullscreenBusystringrequired

One of "yes" "no" "unknown".

onBatterystringrequired

One of "yes" "no" "unknown".

inputIdleSecondsnumberrequired

Seconds since the last keyboard/mouse input.

appQuietSecondsnumberrequired

Seconds since the last user-intent request to the server.

Errors

  • 405Not GET. Plain text. text/plain
  • 503The scheduler has not been started. Plain text scheduler not running. text/plain
POST/api/scheduler/modeAdmin#

Turn the scheduler on (auto) or off for this server session.

Applies immediately. Turning it off pauses a running scheduled job. Either direction cancels a forced run. The mode is not persisted across restarts.

Request body

application/json

modestringrequired

One of "off" "auto".

Example
{
"mode": "auto"
}

Response 200

Status after the change.

application/json

SchedulerStatus

modestringrequired

One of "off" "auto".

forcedbooleanrequired

A one-shot forced run (POST /api/scheduler/run) is active.

statestringrequired

One of "disabled" "waiting" "running" "yielding" "complete".

reasonstringrequired

Human-readable reason for the state, e.g. the app is in use, session locked.

jobIdstring

The scheduled job's id, when there is one. Omitted otherwise.

doneintegerrequired

Items processed by the scheduled job.

totalintegerrequired

Items the scheduled job will process.

opsstringrequired

Comma-separated per-item ops the job runs (default hash,dimensions,describe,transcribe,autotag,embed,faces).

signalsobjectrequired

The idle signals behind the decision.

cpuPercentnumberrequired

Total system CPU percent; -1 when unknown.

sessionLockedstringrequired

One of "yes" "no" "unknown".

fullscreenBusystringrequired

One of "yes" "no" "unknown".

onBatterystringrequired

One of "yes" "no" "unknown".

inputIdleSecondsnumberrequired

Seconds since the last keyboard/mouse input.

appQuietSecondsnumberrequired

Seconds since the last user-intent request to the server.

Errors

  • 400Invalid JSON, or mode is not off or auto. Plain text. text/plain
  • 405Not POST. Plain text. text/plain
  • 503The scheduler has not been started. Plain text. text/plain
POST/api/scheduler/runAdmin#

Start or resume the scheduled job now, ignoring idle signals until it completes.

Also clears a manual hold (a scheduled job the user paused by hand). No request body.

Response 200

Status after the forced tick.

application/json

SchedulerStatus

modestringrequired

One of "off" "auto".

forcedbooleanrequired

A one-shot forced run (POST /api/scheduler/run) is active.

statestringrequired

One of "disabled" "waiting" "running" "yielding" "complete".

reasonstringrequired

Human-readable reason for the state, e.g. the app is in use, session locked.

jobIdstring

The scheduled job's id, when there is one. Omitted otherwise.

doneintegerrequired

Items processed by the scheduled job.

totalintegerrequired

Items the scheduled job will process.

opsstringrequired

Comma-separated per-item ops the job runs (default hash,dimensions,describe,transcribe,autotag,embed,faces).

signalsobjectrequired

The idle signals behind the decision.

cpuPercentnumberrequired

Total system CPU percent; -1 when unknown.

sessionLockedstringrequired

One of "yes" "no" "unknown".

fullscreenBusystringrequired

One of "yes" "no" "unknown".

onBatterystringrequired

One of "yes" "no" "unknown".

inputIdleSecondsnumberrequired

Seconds since the last keyboard/mouse input.

appQuietSecondsnumberrequired

Seconds since the last user-intent request to the server.

Errors

  • 405Not POST. Plain text. text/plain
  • 503The scheduler has not been started. Plain text. text/plain

Onboarding

A single flag that tells the web UI whether the first-run tour has been dismissed. It is stored as onboarding.json in the server's data directory. Access follows the Dependencies routes: the read is always open, and the two writes are open unless Allow Public Access is on, when they need an admin credential (401 otherwise).

GET/api/onboarding/statePublic#

Whether the first-run tour has been dismissed.

A missing or unreadable onboarding.json reads as {"shown": false}.

Response 200

The tour state.

application/json

OnboardingState

shownbooleanrequired

The tour has been shown and dismissed.

dismissed_atstring (date-time)

When it was dismissed (UTC). Omitted when not dismissed.

Example
{
"shown": true,
"dismissed_at": "2026-10-31T10:00:00Z"
}
POST/api/onboarding/dismissPublic#

Record that the tour was dismissed (now).

Writes {"shown": true, "dismissed_at": <now, UTC>}. No request body.

Response 204

Saved. Empty body.

Errors

  • 401Only while Allow Public Access is on, when the request has no admin credential. Error
  • 500The state file could not be written. Error
POST/api/onboarding/resetPublic#

Re-arm the tour.

Deletes the state file so the tour shows again. Never fails (a missing file is fine). No request body.

Response 204

Reset. Empty body.

Errors

  • 401Only while Allow Public Access is on, when the request has no admin credential. 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.

GET/setupPublic#

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.

rootsarray of SetupStorageRootrequired

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.

depsarray of DepStatusItemrequired

Same as GET /api/deps/status.

modelGroupsarray of SetupModelGrouprequired
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.

namestringrequired
pathstringrequired

Absolute path.

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.

namestringrequired
pathstringrequired

Absolute path.

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.

regionstring
bucketstringrequired

Bucket name (required, non-blank).

prefixstring

Key prefix inside the bucket.

accessKeystring
secretKeystring
Example
{
"endpoint": "http://localhost:9000",
"region": "us-east-1",
"bucket": "media",
"accessKey": "minio",
"secretKey": "minio123"
}

Response 200

The bucket is reachable.

application/json

okbooleanrequired

One of true.

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

rootsarray of SetupStorageRootrequired

At least one root.

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

statusstringrequired

One of "ok".

rootsintegerrequired

Number of roots saved.

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

statusstringrequired

One of "ok".

Example
{
"status": "ok"
}

Errors

  • 403Setup is complete and the request carries no valid credential. Error
  • 500The config could not be saved. Error

Visualizations

Admin-only diagnostic pages served by the server itself, and the JSON APIs that feed them: a 3D point cloud of the embedding space and a folder treemap showing where items live and how much of each folder the indexes cover. The index page /viz/ links to both.

GET/viz/embeddingsAdmin#

3D embedding point cloud page.

A WebGL scatter of the embedding space, built from GET /api/embeddings/projection. Orbit and zoom, hover points for previews, and switch models to compare how each clusters the library.

Response 200

HTML page.

text/html

string

Errors

  • 405Method other than GET. Error
GET/api/embeddings/projectionAdmin#

PCA projection of a model's embeddings to 3D.

Vectors are normalized and projected onto their first three principal components. Rows whose dimension differs from the first row's are dropped. When more rows remain than limit, a deterministic stride sample is taken so reloads show the same cloud. paths[i] is the item for points[i]. Any model with stored rows works, including ones no longer in the registry. A model with no rows returns empty arrays.

Query parameters

modelstring

Model id. Default the active embedding model.

limitinteger

Maximum points. Values above 20000 are clamped.

Default 4000. min 1, max 20000.

Response 200

The projection.

application/json

modelstringrequired
dimintegerrequired

Vector dimension (0 when there are no rows).

totalintegerrequired

Rows considered before sampling.

countintegerrequired

Points returned.

variancearray of numberrequired

Fraction of total variance explained by each of the three axes.

pathsarray of stringrequired
pointsarray of array of numberrequired

[x, y, z] per point.

Example
{
"model": "siglip2-base-patch16-224",
"dim": 768,
"total": 51234,
"count": 2,
"variance": [
0.081,
0.054,
0.039
],
"paths": [
"D:\\Photos\\a.jpg",
"D:\\Photos\\b.jpg"
],
"points": [
[
0.12,
-0.03,
0.2
],
[
-0.08,
0.15,
0.01
]
]
}

Errors

  • 400limit is not a positive integer. Error
  • 405Method other than GET. Error
  • 500Loading embeddings failed. Error
GET/viz/treemapAdmin#

Library treemap page.

A squarified treemap of the library by folder, sized by any metric from GET /api/treemap/metrics, with drill-down through GET /api/treemap.

Response 200

HTML page.

text/html

string

Errors

  • 405Method other than GET. Error
GET/api/treemapAdmin#

Per-folder row counts for one metric.

Every metric is a row count, not bytes: media items, tag assignments, or one model's embedding or face rows, aggregated over each folder on the item's path (both / and \ separate segments). The tree is cached per metric for 2 minutes. For metrics other than items, each node also carries itemCount (items in that folder) so coverage can be computed.

Query parameters

metricstring

items, tags, embed:<model> or face:<model>.

Default "items".

pathstring

Folder to root the response at, exactly as a node's path (default the library root).

depthinteger

Levels of children to include. Values above 8 are clamped.

Default 3. min 1, max 8.

limitinteger

Children per node, largest first; the rest collapse into otherCount/otherDirs. Values above 2000 are clamped.

Default 100. min 1, max 2000.

Response 200

The subtree.

application/json

metricstringrequired
pathstringrequired
totalintegerrequired

Rows in the whole library for this metric.

totalItemsinteger

Items in the whole library. Absent for the items metric.

nodeTreemapNoderequired
Example
{
"metric": "embed:siglip2-base-patch16-224",
"path": "D:\\Photos",
"total": 51234,
"totalItems": 52000,
"node": {
"name": "Photos",
"path": "D:\\Photos",
"count": 30100,
"covered": 30100,
"itemCount": 30500,
"children": [
{
"name": "2024",
"path": "D:\\Photos\\2024",
"count": 12000,
"covered": 12000,
"direct": 12000,
"itemCount": 12400
}
],
"otherCount": 40,
"otherDirs": 3
}
}

Errors

  • 400Unknown metric, or depth/limit not a positive integer. Error
  • 404path is not a folder in the tree. Error
  • 405Method other than GET. Error
  • 500Database error. Error
GET/api/treemap/metricsAdmin#

Metrics a treemap can be drawn by, with library-wide counts.

Always items and tags, then one embed:<model> per model with stored embeddings and one face:<model> per model with stored faces.

Response 200

Metrics.

application/json

metricsarray of objectrequired
idstringrequired

Value for GET /api/treemap?metric=.

labelstringrequired

Display label.

countintegerrequired

Rows for this metric.

Example
{
"metrics": [
{
"id": "items",
"label": "Items",
"count": 52000
},
{
"id": "tags",
"label": "Tags",
"count": 310000
},
{
"id": "embed:siglip2-base-patch16-224",
"label": "Embeddings · siglip2-base-patch16-224",
"count": 51234
}
]
}

Errors

  • 405Method other than GET. Error
  • 500Database error. Error

Legacy Media Browser

Endpoints behind the older server-rendered gallery at /media and the browser extensions. New integrations should use the Media Library endpoints instead.

Errors from these handlers are plain text (text/plain), not JSON.

GET/media/apiAdmin#

Paged items for the legacy gallery, or one item by path.

With path and single=true, returns the one item with that path (items is null when there is none).

Otherwise returns a page of items ordered by path. q uses the gallery's search language: key:value terms (keys from GET /media/suggest?kind=filters, such as tag, category, path, description, width, tagcount, exists, orientation), comparisons like width>1000 or tagcount:>2, * wildcards, AND, OR, NOT, parentheses, and implicit AND between terms. A query that fails to parse (for example a bare word with no key:) is ignored and every item is returned.

Query parameters

offsetinteger

Items to skip. Invalid values mean 0.

Default 0.

limitinteger

Page size. Values outside 1 to 100 use the default.

Default 25. min 1, max 100.

qstring

Search expression.

pathstring

Media path for a single-item lookup (read from the raw query string and path-unescaped). Ignored unless single=true.

singlestring

true together with path looks up one item.

One of "true".

Response 200

A page of items.

application/json

itemsarray | nullrequired
has_morebooleanrequired

More items exist after this page.

total_countintegerrequired

Total matching items (for a single lookup, 0 or 1).

Example
{
"items": [
{
"path": "D:/photos/x.jpg",
"description": "A red car.",
"size": 2527291,
"hash": "9f2c1d0e",
"width": 1920,
"height": 1080,
"tags": [
{
"label": "sunset",
"category": "Scene"
}
],
"exists": true,
"duplicateCount": 0
}
],
"has_more": true,
"total_count": 5210
}

Errors

  • 400path is badly encoded. text/plain
  • 405The method is not GET. text/plain
  • 500The query failed. text/plain
GET/media/suggestRead#

Typeahead suggestions for the gallery search box.

Query parameters

kindstringrequired

What to suggest (case-insensitive): - filters: the search keys (ignores prefix and limit). - tag: tags whose label contains prefix, with their categories. - category: category labels. - path: media paths. - pathdir: folders.

One of "filters" "tag" "category" "path" "pathdir".

prefixstring

Text typed so far.

limitinteger

Maximum suggestions. Values outside 1 to 200 use the default.

Default 25. min 1, max 200.

Response 200

Suggestions. kind=tag adds tags.

application/json

suggestionsarray | nullrequired

Suggested strings. For kind=tag, the distinct labels of tags (null when there are none).

tagsarray | null

Only for kind=tag.

Example
{
"tags": [
{
"label": "sunset",
"category": "Scene"
}
],
"suggestions": [
"sunset"
]
}

Errors

  • 400Missing or unknown kind. text/plain
  • 405The method is not GET. text/plain
  • 500The query failed. text/plain
POST/media/tagAdmin#

Add or remove one tag on one item.

Request body

application/json

media_pathstringrequired

Media path.

tag_labelstringrequired

Tag label.

category_labelstringrequired

The tag's category.

actionstringrequired

One of "add" "remove".

Example
{
"media_path": "D:/photos/x.jpg",
"tag_label": "sunset",
"category_label": "Scene",
"action": "add"
}

Response 200

Done.

application/json

statusstringrequired

One of "ok".

Errors

  • 400Invalid JSON, a required field empty, or action not add/remove. text/plain
  • 405The method is not POST. text/plain
  • 500The update failed. text/plain
GET/media/has-tagRead#

Whether an item carries a tag.

Query parameters

media_pathstringrequired

Media path.

tag_labelstringrequired

Tag label.

category_labelstringrequired

The tag's category.

Response 200

The answer.

application/json

has_tagbooleanrequired
Example
{
"has_tag": true
}

Errors

  • 400A parameter is missing. text/plain
  • 405The method is not GET. text/plain
  • 500The query failed. text/plain

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.

GET/configAdmin#

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
GET/Admin#

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).

GET/jobsAdmin#

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
GET/mediaAdmin#

Legacy media gallery page.

Renders the first 25 matching items; the page then pages through /media/api. The handler does not check the method.

Query parameters

qstring

Search query.

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
GET/swipeRead#

The Swipe app.

Anonymous visitors reach it only while Allow Public Access is on, and then get a view-only page (tagging hidden).

Query parameters

qstring

Initial search query.

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
GET/editorAdmin#

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
GET/eventsAdmin#

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
GET/loginPublic#

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.

Query parameters

redirectstring
setupstring

One of "true".

Response 200

HTML page.

text/html

string

Response 302

First-run setup not complete. Redirects to /setup.

Errors

  • 405Not GET. Plain text. text/plain
GET/app/{path}Read#

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

  • 500index.html missing from the embedded bundle. Plain text. text/plain
GET/static/{path}Public#

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
GET/viz/{path}Admin#

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.

Path parameters

pathstringrequired

Usually empty.

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
GET/statsAdmin#

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

Schemas

Shared object shapes referenced above. Every type name in an endpoint links here.

APIKeyobject#

An API key's metadata. The secret is never returned after creation.

idinteger (int64)required
usernamestringrequired

User the key acts as.

namestringrequired
prefixstringrequired

First 11 characters of the key (lk_ + 8 hex).

created_atinteger (int64)required

Unix seconds.

last_used_atinteger (int64)required

Unix seconds; 0 if never used.

AuthUserobject#

A user account.

idinteger (int64)required
usernamestringrequired
created_atinteger (int64)required

Unix seconds.

ByoFaceModelobject#

A bring-your-own face recognizer (user-supplied ONNX file).

idstringrequired

Unique id; face vectors are keyed by it.

namestring
modelPathstringrequired

Absolute path to the ONNX file.

dimintegerrequired

Embedding dimension.

inputNamestring

Input tensor name (default data).

outputNamestring

Output tensor name (default fc1).

meanarray of number

Per-channel RGB mean on the 0..255 scale.

stdarray of number

Per-channel RGB std on the 0..255 scale.

CategoryImpactobject#

What deleting a category removes (or removed).

labelstringrequired
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.

CategorySummaryobject#
labelstringrequired
weightnumberrequired

Sort weight.

descriptionstringrequired

Empty string when unset.

tagViewModestringrequired

card when unset.

tagGroupingstringrequired

Whether the category's tags are listed under their groups.

One of "on" "off".

ConfigUpdateRequestobject#

Fields accepted by POST /config. Only dbPath is required; see the operation description for which fields are skipped when empty.

dbPathstringrequired

Required, non-blank. A different value switches the library database.

portinteger

Applied on restart. Out-of-range or 0 is ignored.

min 1, max 65535.

downloadPathstring
ollamaBaseUrlstring
ollamaModelstring
describePromptstring
inferenceProviderstring

Lower-cased.

One of "off" "ollama" "runpod" "lmstudio" "llamacpp" "strata".

runpodEndpointstring
runpodApiKeystring

<redacted> keeps the stored key.

lmstudioBaseUrlstring
lmstudioModelstring
lmstudioApiKeystring

<redacted> keeps the stored key.

llamacppBaseUrlstring
llamacppModelstring
llamacppApiKeystring

<redacted> keeps the stored key.

strataBaseUrlstring
strataModelstring
strataApiKeystring

<redacted> keeps the stored key.

inferenceConcurrencyInferenceConcurrency
localComputeConcurrencyinteger
onnxModelPathstring

Always applied; omitting it clears it.

onnxLabelsPathstring

Always applied; omitting it clears it.

onnxConfigPathstring

Always applied; omitting it clears it.

onnxOrtSharedLibPathstring

Always applied; omitting it clears it.

onnxGeneralThresholdnumber
onnxCharacterThresholdnumber
embeddingModelstring
embeddingProviderstring
embeddingPerformancestring
embeddingWorkersinteger
embeddingThreadsPerWorkerinteger
autotagModelstring
autotagProviderstring
autotagPerformancestring
autotagWorkersinteger
autotagThreadsPerWorkerinteger
onnxFileTimeoutSecondsinteger

Non-zero values apply; negative disables the timeout.

faceModelstring
faceRoutingstring

Other values are ignored.

One of "auto" "single".

faceProviderstring
facePerformancestring
faceWorkersinteger
faceThreadsPerWorkerinteger
byoFaceModelsarray of ByoFaceModel

Replaces the list when present; [] clears it.

transcriptionProviderstring
transcriptionModelstring
transcriptionLanguagestring

Applied when present; empty means auto-detect.

transcriptionVadFilterboolean
transcriptionInitialPromptstring
transcriptionHotwordsstring
transcriptionVocalExtractboolean
allowPublicAccessboolean
defaultStartPathstring
fasterWhisperPathstring
discordTokenstring

<redacted> keeps the stored token.

rootsarray of StorageRoot

Replaces the storage roots when present.

CreateJobRequestobject#
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.

DBQueryResultobject#
columnsarray of stringrequired
rowsarray of array of anyrequired

One array per row, cells in column order. A BLOB cell is {"blob_bytes": n}.

row_countintegerrequired

Rows returned.

truncatedbooleanrequired

More rows existed beyond limit.

elapsed_msintegerrequired

Query and read time in milliseconds.

DepStatusItemobject#

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".

namestringrequired

Display name.

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".

DirectMLInstallResultobject#
installedbooleanrequired

Whether the DirectML runtime is present in the data directory.

errorstring

POST only: why the install failed.

DuplicateGroupobject#

A cluster of media whose visual embeddings are at least threshold similar to the anchor item.

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

Unix seconds.

updatedAtinteger (int64)required

Unix seconds.

memberCountintegerrequired

Every member row, excluded ones included.

activeCountintegerrequired

Members not excluded.

minScorenumberrequired

Lowest active member similarity to the anchor (how tight the group is).

membersarray of DuplicateMemberrequired

Anchor first, then by similarity, excluded members last.

DuplicateMemberobject#

A group member in the flat renderer item shape plus review flags. elo, battles, width, height and size are omitted when unknown.

pathstringrequired
mtimeMsintegerrequired

Always 0.

elonumber
battlesinteger
widthinteger
heightinteger
sizeinteger (int64)

File size in bytes; present only when known and non-zero.

scorenumberrequired

Cosine similarity to the anchor (the anchor scores 1).

excludedbooleanrequired

Pulled out of the group ("not a duplicate").

anchorbooleanrequired

This member is the anchor.

keeperbooleanrequired

This member is the default merge target.

DuplicateMergeResultobject#

What a merge changed. The same shape as the POST /api/media/merge-metadata response.

targetstringrequired

The kept path.

sourcesarray of stringrequired

Paths merged in.

tagsinteger (int64)required

Tag rows the target gained.

embeddingsinteger (int64)required

Embedding rows the target gained.

transcriptbooleanrequired

The target's transcript was filled from a source, or a source's .vtt sidecar was moved next to it.

transcriptFilestringrequired

The moved .vtt path; empty when none.

deletedarray | nullrequired

Sources removed from disk and database. null when none.

failedarray | nullrequired

Sources that could not be deleted (s3:// paths, delete errors); they keep their rows. null when none.

facesRemovedinteger (int64)required

Face rows erased with the sources.

DuplicateStatsobject#
pendingintegerrequired

Pending groups.

dismissedintegerrequired

Dismissed groups.

membersintegerrequired

Member rows across all groups.

pendingItemsintegerrequired

Active members of pending groups: library items still awaiting a decision.

DuplicatesUpdatedEventobject#

Data of the duplicates-updated event. Counts are informational; clients refetch.

groupsintegerrequired

Groups written so far by the scan (0 after a manual action).

itemsintegerrequired

Items in those groups (0 after a manual action).

EmbedModelInfoobject#
idstringrequired
display_namestringrequired
dimintegerrequired

Vector dimension.

multimodalbooleanrequired

Supports text queries in the same space.

activebooleanrequired

The configured embedding model.

indexedbooleanrequired

The model the in-memory index holds.

EmptyObjectobject#

An empty object, {}.

EngineDependencyItemobject#

A dependency (tool or model) and its install state, as on the Dependencies page.

idstringrequired
categorystringrequired
namestringrequired
featurestring
descriptionstring
statestringrequired

Install state, for example installed or missing.

versionstring
size_bytesinteger
pathstring
errorstring
detailany

Extra state detail (any JSON).

sourcestring

user when it is the user's own copy; omitted when managed.

EnginesConfigobject#

The engines section of the server config.

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.

EngineStatusobject#

The dispatcher's state.

strategystringrequired

One of "local" "pool" "hybrid".

routesmap of string

The configured routes; omitted when empty.

fallbackbooleanrequired
localOnlybooleanrequired
placementmap of stringrequired

Effective rule per engine (loki-retouch, loki-reshoot, loki-dream, loki-segment): local, pool, pool:<name> or hybrid.

targetsarray of EngineTargetStatusrequired

Every place engines can run; the first is always this machine (key local). Disabled pools and nodes are not listed.

EngineTargetStatusobject#

One place engines run.

keystringrequired

local, <pool>/<node name> for a node, or <pool> for a RunPod endpoint.

poolstring

Pool name; omitted for local.

kindstringrequired

One of "local" "node" "runpod".

urlstring

Node base URL (nodes only).

healthybooleanrequired

Last probe succeeded; always true for local.

checkedstring (date-time)

Last probe time; omitted until probed.

errorstring

Last probe error.

runningintegerrequired

Jobs this server has in flight on the target.

slotsintegerrequired

Jobs it runs at once: 1 for local, maxJobs for RunPod, the worker's reported slots (default 1) for a node.

enginesarray of string

The pool's engines restriction, when set.

transferstring

How files reach a RunPod worker: storage root <label>, bucket <name> or inline (files up to 7 MB).

Errorobject#

Error body returned by handlers written for the web client. Older handlers return a plain-text message instead.

errorstringrequired

Human-readable message.

Faceobject#

One stored face detection. Bounding-box coordinates are relative (0-1 of the image size). Crops are served by /media/facecrop?id=.

idinteger (int64)required
xnumberrequired

Left edge (relative).

ynumberrequired

Top edge (relative).

wnumberrequired

Width (relative).

hnumberrequired

Height (relative).

scorenumberrequired

Detection confidence.

frameTsnumberrequired

Video frame time in seconds (0 for images).

personIdinteger (int64)required

Assigned person; 0 when unassigned.

assignedBystringrequired

auto (clustering), user (confirmed by hand), or empty when unassigned.

One of "auto" "user" "".

FaceHitItemobject#

A ScoredMediaItem (one per media path) with the best matching face attached. score is that face's cosine similarity.

FaceSearchItemany#
FaceStatsobject#
facesarray of objectrequired

Per recognizer, ordered by model id.

modelstringrequired

Recognizer id.

countintegerrequired

Stored faces.

bytesinteger (int64)required

Total vector blob bytes.

total_facesintegerrequired
total_bytesinteger (int64)required
scansintegerrequired

Face-scan markers (items scanned, including those with no faces).

peopleobjectrequired
totalintegerrequired
namedintegerrequired

People with a non-empty name.

unnamedintegerrequired
indexobjectrequired

The in-memory face search index.

modelstringrequired

Recognizer the index holds; empty when not built.

vectorsintegerrequired

Vectors in the index.

FaceTuningobject#

Face-grouping tuner (the People panel Tune sliders), stored in the server config.

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.

FSEntryobject#
namestringrequired
pathstringrequired
isDirbooleanrequired
mtimeMsnumberrequired

Modification time in milliseconds since the epoch (0 for roots).

sizeintegerrequired

Bytes; 0 for directories.

typestring

Present only when listing the roots.

One of "local" "s3".

GifMetadataobject#
frameCountintegerrequired

Frames counted by ffprobe; 0 when unreported.

durationnumberrequired

Duration in seconds; 0 when unreported.

HealthStatusobject#
statusstringrequired

Always healthy when the server answers.

One of "healthy".

versionstringrequired

Server version.

timestampinteger (int64)required

Server time, Unix seconds.

streamobjectrequired

/stream SSE statistics.

active_connectionsintegerrequired
total_messagesintegerrequired
max_connectionsintegerrequired
dropped_broadcastsintegerrequired
dropped_client_msgsintegerrequired
rejected_connectionsintegerrequired
jobsobjectrequired

Job counts by state. Paused jobs are in total only.

totalintegerrequired
pendingintegerrequired
in_progressintegerrequired
completedintegerrequired
cancelledintegerrequired
errorintegerrequired
HlsStatusobject#
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.

ImportResultobject#
importedintegerrequired

New media items merged in.

skippedintegerrequired

Items already present, skipped whole.

filesintegerrequired

File bytes copied into the destination root.

dest_rootstringrequired

Path of the destination storage root.

warningsarray of string

Non-fatal problems; omitted when empty.

IndexStatusobject#
indexobjectrequired

The in-memory search index.

installedbooleanrequired

An index is loaded.

modelstringrequired

Model the index was built from; empty when none.

vectorsintegerrequired

Vectors in the index.

active_modelstringrequired

The configured embedding model.

media_totalintegerrequired

Rows in the media table.

missing_active_modelintegerrequired

Media items with no embedding for the active model.

orphanedintegerrequired

Embedding rows whose media row is gone.

embeddingsarray of objectrequired

Stored rows per model.

modelstringrequired
countintegerrequired
dimintegerrequired
bytesintegerrequired

Stored vector bytes.

total_countintegerrequired

Embedding rows across all models.

total_bytesintegerrequired

Stored vector bytes across all models.

InferenceConcurrencyobject#

Per-provider concurrency caps for vision inference.

ollamainteger
runpodinteger
lmstudiointeger
llamacppinteger
stratainteger
Jobobject#

A job in the queue.

idstringrequired

Job id (UUID).

commandstringrequired

Task id.

argumentsarray of stringrequired

Option tokens passed to the task.

inputstringrequired

The job input after workflow substitution.

original_inputstringrequired

The input as submitted.

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.

stateJobStaterequired

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.

JobForPathSummaryobject#
idstringrequired

Job id.

commandstringrequired

Task id.

argumentsarray | nullrequired

Option tokens.

stateJobStaterequired

One of "pending" "in_progress" "paused" "completed" "cancelled" "error".

progress_doneintegerrequired
progress_totalintegerrequired
created_atstring (date-time)required
JobListEventobject#

Data of the create, update and delete events.

updateTypestringrequired

Same as the event name.

One of "create" "update" "delete".

jobJobrequired
htmlstringrequired

The job rendered as a table row for the admin Jobs page.

JobSnapshotobject#

Response of GET /api/job/{id}. Every field is always present.

idstringrequired

Job id.

commandstringrequired

Task id.

stateJobStaterequired

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.

JobStatestring#

completed, cancelled and error are terminal. paused holds progress and can be resumed.

One of "pending" "in_progress" "paused" "completed" "cancelled" "error".

LabelBodyobject#
labelstring

Tag label.

LegacyMediaItemobject#
pathstringrequired
descriptionstring | nullrequired
sizeinteger | nullrequired

Bytes.

hashstring | nullrequired
widthinteger | nullrequired
heightinteger | nullrequired
tagsarray of LegacyMediaTagrequired
existsbooleanrequired

Whether the file currently exists.

duplicateCountintegerrequired

Declared but never populated by this endpoint; always 0.

LegacyMediaTagobject#
labelstringrequired
categorystringrequired
LibraryStatsobject#
readybooleanrequired

One of true.

generatedAtintegerrequired

Unix seconds when the snapshot was computed.

totalMediaintegerrequired
totalVideosintegerrequired

By extension: .mp4 .mov .avi .mkv .wmv .webm.

totalAudiointegerrequired

By extension: .mp3 .wav .aac .ogg .m4a .wma .ape .flac .opus .aiff.

totalImagesintegerrequired

Everything that is not video or audio.

totalTranscribableintegerrequired

Videos plus audio; the transcript denominator.

withDescriptionintegerrequired
withHashintegerrequired
withSizeintegerrequired
withTagsintegerrequired
withDimensionsintegerrequired
withTranscriptintegerrequired

Among videos and audio only.

withEmbeddingintegerrequired

For the active embedding model.

withFaceScanintegerrequired

Scanned by any face model.

autotagModelStatsModelInforequired
embedModelStatsModelInforequired
faceModelStatsModelInforequired
LocalEngineobject#

One loki-* engine as installed on this machine.

enginestringrequired

Engine executable name.

versionstring

The binary's --version, when installed.

modelsarray | nullrequired

The model dependencies it runs with.

MediaEraseResultobject#

Rows removed per table by delete/forget.

pathstringrequired
mediaintegerrequired

Media rows removed (0 or 1).

tagsintegerrequired

Tag assignment rows removed.

embeddingsintegerrequired

Embedding rows removed.

facesintegerrequired

Face rows removed.

battlesintegerrequired

Battle-log rows removed.

MediaListItemobject#

One item in a list result. Nullable database columns are omitted when NULL (a real zero is still sent).

pathstringrequired

Media path.

mtimeMsintegerrequired

The driving tag assignment's created_at (milliseconds) when a tag predicate drives the row, otherwise 0. Not the file's modification time.

elonumber

Battle Mode rating.

battlesinteger

Battle Mode bout count.

heightinteger

Pixel height.

widthinteger

Pixel width.

weightnumber

Weight of the driving tag assignment.

tagLabelstring

Label of the driving tag assignment.

timeStampnumber

Video timestamp (seconds) of the driving tag assignment.

scorenumber

Similarity score; present on every item, and only then, when the query had a visual predicate.

MediaMergeResultobject#
targetstringrequired
sourcesarray of stringrequired
tagsintegerrequired

Tag rows the target gained.

embeddingsintegerrequired

Embedding rows the target gained.

transcriptbooleanrequired

True when the target's transcript was filled or a .vtt sidecar was moved next to it.

transcriptFilestringrequired

Path of the moved sidecar; empty when none.

deletedarray | nullrequired

Sources removed from disk and database. null when none.

failedarray | nullrequired

Sources that kept their file and rows (s3://, or deletion failed). null when none.

facesRemovedintegerrequired

Face rows erased with the sources.

MediaMetadataobject#
fileMetadataobjectrequired
sizestringrequired

Human-readable size from the stored byte count, such as 2.41 MB; 0 Bytes when unknown.

modifiedstringrequired

Always an empty string.

widthintegerrequired

Stored width; 0 when unknown.

heightintegerrequired

Stored height; 0 when unknown.

hashstringrequired

Stored content hash; empty when unknown.

descriptionstring

Present when the item has a description.

transcriptstring

Present when the item has a transcript.

textChunksmap of string

Generator text embedded in a local file (for example PNG text chunks), keyed by chunk name. Omitted when there are none.

MediaMoveResultobject#
fromstringrequired

Normalised source (trimmed of whitespace and trailing separators).

tostringrequired

Normalised destination.

prefixbooleanrequired
dryRunbooleanrequired
itemsintegerrequired

Number of media rows whose path changed.

rowsmap of integerrequired

Rows updated per table.column key (for example media.path, media_tag_by_category.media_path), including zero entries. Tables missing from the library have no key.

totalintegerrequired

Sum of rows.

pathsarray of object

Up to 100 sample rewrites, ordered by old path. Omitted when nothing matched.

fromstringrequired
tostringrequired
truncatedboolean

True when more than 100 items moved; omitted otherwise.

MediaPathRequestobject#
pathstringrequired

Media path.

MediaPathsEventobject#

Data of the media-updated and media-created events (never sent with an empty list).

pathsarray of stringrequired

Affected file paths.

MediaQueryBlendNodeobject#
kindstringrequired

image = a library media path, clip = an image data URL or base64, text = free text. Any other kind fails the query with 500.

One of "image" "clip" "text".

valuestringrequired

Path, data URL or text. Blank text nodes are skipped.

weightnumber

Magnitude, clamped to 0 to 1. Missing means 1; 0 drops the node.

min 0, max 1.

negativeboolean

Subtract this term, steering results away from it.

Default false.

MediaQueryPredicateobject#

One filter or ranking term. value semantics by type: - tag: exact tag label; the item has that tag. - category: exact category label; the item has any tag in that category. - path, description, hash: substring match (LIKE '%value%', case-insensitive for ASCII; % and _ in the value are wildcards). - orientation: landscape, portrait or square (case-insensitive), comparing stored width and height. Items without dimensions never match an include and are kept by an exclude. Other values match nothing. - faces: only ungrouped: items with at least one detected face and no face assigned to a person. Other values match nothing. - dupe: a duplicate group id (active, non-excluded members of that group), pending (members of pending groups) or any/all (every grouped item, dismissed groups included). Other values match nothing. - similar (visual): a library media path; ranks by image-embedding similarity to it, embedding the file on the fly if it has no vector. - visual (visual): free text; text-to-image search. - clip (visual): an image as a base64 data URL (data:image/png;base64,...) or bare base64; image-to-image search. - face (visual): a library media path or an image data URL; face-identity search, one hit per media item. Ignores text and nodes.

For an exclude of a "matches nothing" value (unknown orientation, faces or dupe value, or a visual predicate with no hits), nothing is removed.

typestringrequired

One of "tag" "category" "path" "description" "hash" "orientation" "faces" "dupe" "similar" "visual" "clip" "face".

valuestringrequired

Operand; meaning depends on type. An empty value removes the predicate from the query.

excludeboolean

Negate the predicate (NOT EXISTS, NOT LIKE, NOT IN).

Default false.

joinstring

Connector to the running result of the predicates before it. Missing, empty or any other value falls back to mode. Ignored on the first predicate.

One of "AND" "OR" "".

textstring

Legacy blend, similar and clip only. Free text mixed into the image query as one combined vector. Without nodes it is a two-way image/text blend; with nodes it is folded in as one more text term weighted by textWeight.

textWeightnumber

Share of text in the blend, clamped to 0 to 1 (0 = pure image, 1 = pure text). Missing means 0.5.

Default 0.5. min 0, max 1.

nodesarray of MediaQueryBlendNode

Composite similarity for similar, clip and visual. The predicate's own value is the base term with weight 1; each node adds a signed weighted term. Nodes with weight 0 are skipped.

blendModestring

How a predicate with nodes is scored. blend (or empty, or any value other than shared) combines all terms into one centroid vector. shared scores candidates against every positive term at once (must match all), finding what the positive terms have in common.

One of "blend" "shared" "". Default "blend".

MediaQueryRequestobject#

Body of POST /api/media/query. There is no paging, limit or cursor; the full result is returned.

modestring

Connector for predicates without their own join. Anything other than exactly OR means AND.

One of "AND" "OR". Default "AND".

predicatesarray of MediaQueryPredicate

Evaluated left to right. Missing or empty returns the whole library.

MediaTagAssignmentobject#
tag_labelstringrequired
category_labelstringrequired
weightnumberrequired
time_stampnumberrequired

Video timestamp in seconds; 0 for an untimed assignment.

ModelInstallobject#

Snapshot of one model or tool install attempt (also each progress SSE event).

idstringrequired

Model id.

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
ModelVerifyResultobject#

Result of POST /api/deps/models/{id}/verify.

idstringrequired
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.

OnboardingStateobject#
shownbooleanrequired

The tour has been shown and dismissed.

dismissed_atstring (date-time)

When it was dismissed (UTC). Omitted when not dismissed.

OptionalToolHintobject#

How to install an optional tool.

descriptionstring
commandsarray of object

Per-OS install commands (omitted when none).

osstringrequired

One of "windows" "darwin" "linux".

labelstringrequired

Installer name, e.g. winget, Homebrew.

commandstringrequired

Command line or instruction.

docs_urlstring

Installation docs link.

PeopleUpdatedEventobject#

Data of the people-updated event.

modelsarray | nullrequired

Face model ids whose clustering ran; empty after a manual edit.

Personobject#

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.

PersonReviewFaceobject#

One face in the person review payload.

idinteger (int64)required

Face id.

pathstringrequired

Media path.

frameTsnumberrequired

Video frame time in seconds (0 for images).

detScorenumberrequired

Detection confidence.

assignedBystringrequired

auto (clustering) or user (confirmed).

One of "auto" "user".

typicalitynumberrequired

Cosine similarity to the group center (see the operation). Low values are the outliers worth reviewing first; 0 when it cannot be computed (single face or mixed-dimension group).

PoolProviderobject#

What lokictl pool create provisioned, so lokictl pool destroy can take it down.

namestringrequired

Provider, for example runpod.

podIdsarray of string
templateIdstring
endpointIdstring
PoolTransferobject#

The bucket serverless jobs pass files through: a configured S3 storage root by label (root), or a bucket of its own. Nodes ignore it. Without it, RunPod jobs carry files inline (up to 7 MB).

rootstring

Label of an S3 storage root to use.

endpointstring

S3 endpoint URL.

regionstring
bucketstring
prefixstring

Key prefix inside the bucket.

accessKeystring

Redacted in responses.

secretKeystring

Redacted in responses.

ProgressEventobject#

Data of the progress event.

updateTypestringrequired
idstringrequired

Job id.

doneintegerrequired

Items finished.

totalintegerrequired

Total items.

PromptPreviewobject#

The expanded prompt of the first run of a job.

enginestringrequired

One of "loki-retouch" "loki-reshoot".

promptstringrequired

The instruction as the engine expands it (empty when a preset needs the engine and it is not installed).

modelInputstring

Retouch only: the whole text-encoder turn, every reference announced by its token, then the prompt. Omitted when empty.

referencesarray of PromptReferencerequired
runsintegerrequired

Engine runs the job makes (one per image in a retouch batch; reshoot is always 1).

fromEnginebooleanrequired

False when the engine is not installed and the prompt was assembled by the server.

notestring

Explanation when the prompt is partial or shown for the first of several images. Omitted when empty.

PromptReferenceobject#

One image, clip or audio file the model sees, with the token the prompt uses for it.

tokenstringrequired

For example <image1> (retouch) or <Picture 1>, <Video 1>, <Audio 1> (reshoot).

pathstringrequired
kindstringrequired

One of "image" "video-frame" "video" "audio".

RenameBodyobject#
labelstring

Current label.

newLabelstring

New label.

ReshootModelChoiceobject#
filestringrequired

Model file name; pass it as the job's dit option.

labelstringrequired

Display name.

recipestringrequired

Sampling recipe the model uses.

One of "h3" "fast".

RunPodHealthobject#

A RunPod endpoint's queue and worker counts.

jobsobjectrequired
completedintegerrequired
failedintegerrequired
inProgressintegerrequired
inQueueintegerrequired
retriedintegerrequired
workersobjectrequired
idleintegerrequired
initializingintegerrequired
readyintegerrequired
runningintegerrequired
throttledintegerrequired
unhealthyintegerrequired
SavedWorkflowobject#
idstringrequired

Workflow id (UUID).

namestringrequired

Unique workflow name.

dagarray of WorkflowTaskrequired

The DAG nodes.

SavedWorkflowInputobject#

Body of POST /workflows/create and PUT /workflows/{id}.

namestring

Workflow name; must be unique.

dagarray of WorkflowTaskrequired

Non-empty DAG. Every node needs a non-empty, unique id and a command; every dependency must name a node in this DAG.

SavedWorkflowSummaryobject#
idstringrequired

Workflow id (UUID).

namestringrequired

Unique workflow name.

Sceneobject#

A scene document.

idstringrequired
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.

widthintegerrequired
heightintegerrequired
layersarray of SceneLayerrequired

Bottom to top.

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
SceneDreamRequestobject#
promptstringrequired

The picture to generate. Required (non-blank).

negativestring
sizestring

WxH; ignored when it does not match.

turboboolean
seedinteger

Used when > 0.

SceneDreamStatusobject#
jobIdstringrequired
stateJobStaterequired

One of "pending" "in_progress" "paused" "completed" "cancelled" "error".

doneintegerrequired
totalintegerrequired
outputstring

The generated image, once completed.

errorstring
SceneGenMaskobject#

The mask to generate with instead of the layer's own (the layer's mask without what only shapes the shown version). Used only when mask is a PNG data URL and region is non-empty.

shapesany

Editor strokes (any JSON).

maskOptsany

Editor mask settings (any JSON).

feathernumber

Clamped to 0-512.

maskstringrequired

Full-frame PNG data URL (data:image/png;base64,...); alpha = coverage.

regionSceneRectrequired
SceneJobobject#

A layer's generation in flight (server-owned).

idstringrequired

The last job of the chain, whose output is the result.

stateJobStaterequired

One of "pending" "in_progress" "paused" "completed" "cancelled" "error".

doneintegerrequired
totalintegerrequired
regionSceneRectrequired
promptstringrequired
inputstringrequired

The crop sent to the engine.

chainarray of string

Every job of the generation in order (motion: reshoot, then scene-lock).

requeuedinteger

Times a later step was queued again after being cancelled before it ran.

stepstring

Command of the step running now, for example reshoot or scene-lock.

notestring
rawstring
linestring

The job's latest log line.

belowarray of string
refsarray of string

Reference files the engine was given.

SceneLayerobject#

One masked edit. The editor owns name, kind, hidden, region, mask, shapes, maskOpts, feather, prompt, options and active; versions, job, queued and error are server-owned.

idstringrequired
namestringrequired
kindstringrequired

One of "still" "motion" "paint".

hiddenboolean
regionSceneRectrequired
maskstringrequired

Full-frame PNG data URL (long edge <= 2048) whose alpha is the coverage, feather included. For paint layers, the painting itself (RGBA).

shapesany

The editor's strokes (any JSON).

maskOptsany

The editor's whole-mask settings (any JSON).

feathernumberrequired
promptstringrequired
optionsobject

Editor-owned settings. Keys the server reads include: - seed, steps (still, default 25, 4-80), engine (dream in dream builds), finish (sketch, colour, repose), paint (id of the paint layer a finish uses), grounding, strength, stitch (false turns off edge blending), guide (paint layer kept out of the picture) - refs: array of {layer} or {path} references with an optional note - motion: duration (seconds, default 5, 1-15), megapixels, model (fast), dit (a file from GET /api/reshoot/models), describe, sound, soundPrompt, voices, dialogue (array of {speaker, lang, text}), voiceRef, lock and detail (default true), matte, speed (0.25-4), offset, volume (0-2)

versionsarray of SceneVersionrequired
activeintegerrequired

Index into versions of the one shown; -1 = none.

min -1.

queuedarray of SceneJob

Generations started while job was in flight, oldest first.

errorstring
SceneMaskobject#

A mask as the editor draws it (opaque to the server).

shapesany

The editor's strokes (any JSON).

maskOptsany

The editor's whole-mask settings (invert, expand, opacity; any JSON).

feathernumberrequired

Feather radius in pixels.

ScenePoseobject#
jointsarray of objectrequired

OpenPose's 18 joints in its order (nose, neck, right shoulder, ... left ear), frame pixels.

xnumberrequired
ynumberrequired
scorenumberrequired
SceneRectobject#

A rectangle in pixels (base-frame pixels unless stated otherwise).

xintegerrequired
yintegerrequired
wintegerrequired
hintegerrequired
SceneRegionMapobject#
dataUrlstringrequired

PNG data URL covering region; alpha = change or motion.

regionSceneRectrequired
SceneRenderobject#

The latest export (server-owned).

jobIdstringrequired
formatstringrequired

One of "video" "still".

stateJobStaterequired

One of "pending" "in_progress" "paused" "completed" "cancelled" "error".

doneintegerrequired
totalintegerrequired
outputstring

The exported file, once completed.

errorstring
SceneStitchobject#

The active still version blended into the picture under it (derived on every read).

pathstringrequired

The stitched patch.

seamstring

Image drawing the seam for the editor.

healstring

Where the seam still shows (input for heal).

keystringrequired

Hash of the version, mask and everything below it.

reportStitchReportrequired
SceneSummaryobject#

A scene in a list (no masks).

idstringrequired
namestringrequired
sourcestringrequired
basestringrequired
layersintegerrequired

Number of layers.

motionintegerrequired

Number of motion layers.

updatedstring (date-time)required
SceneTrackobject#

A motion version's roto (server-owned).

jobIdstringrequired

The scene-track job.

stateJobStaterequired

One of "pending" "in_progress" "paused" "completed" "cancelled" "error".

doneintegerrequired
totalintegerrequired
errorstring
promptsarray of objectrequired

The loki-segment --prompts entries it was tracked from.

dirstring

Folder of matte frames 00000.png, ... (set once completed).

framesinteger

Number of matte frames (set once completed).

videostring

The clip with the matte as alpha (VP9 webm), for the stage.

SceneUpdateobject#

The fields PUT /api/scenes/{id} reads (a full Scene may be sent; other fields are ignored).

namestring

Blank keeps the current name.

durationnumber

Clamped to 0-60.

min 0, max 60.

freeLoopsboolean
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.

idstringrequired
namestring
kindstring

Anything else becomes still.

One of "still" "motion" "paint".

hiddenboolean
regionSceneRect
maskstring

Empty, or a data:image/png;base64, URL; any other value keeps the stored mask.

shapesany

Any JSON.

maskOptsany

Any JSON.

feathernumber

Clamped to 0-512.

promptstring
optionsobject
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.

pathstring
SceneVersionobject#

One generation of a layer. Server-owned except mask.

pathstringrequired

The file the engine produced (PNG for stills, MP4 for motion).

regionSceneRectrequired
promptstringrequired
durationnumber

Motion: seconds.

audioboolean

Motion: has a soundtrack.

notestring

For example boomerang of v2, re-finished v1, healed v3.

rawstring

The engine's own output a finished (scene-lock) version was made from.

jobIdstring
belowarray of string

The stills the version was generated from, so the editor can tell when the stack under it changed.

inputsarray of string

Images the engine was given (the crop, then references).

createdstring (date-time)required
SchedulerEventobject#

Data of the scheduler event; the auto scheduler's status. Sent only when mode, state, reason or jobId changes.

modestringrequired

One of "off" "auto".

forcedbooleanrequired

A one-shot run-now override is active.

statestringrequired

One of "disabled" "waiting" "running" "yielding" "complete".

reasonstringrequired

Human-readable explanation of the state.

jobIdstring

The scheduler's current job. Omitted when none.

doneintegerrequired
totalintegerrequired
opsstringrequired

Operations the scheduler runs.

signalsobjectrequired

System signals the scheduler reads.

cpuPercentnumberrequired
sessionLockedstringrequired

One of "yes" "no" "unknown".

fullscreenBusystringrequired

One of "yes" "no" "unknown".

onBatterystringrequired

One of "yes" "no" "unknown".

inputIdleSecondsnumberrequired
appQuietSecondsnumberrequired
SchedulerStatusobject#

Auto-scheduler status. Fields may be zero values before the first tick.

modestringrequired

One of "off" "auto".

forcedbooleanrequired

A one-shot forced run (POST /api/scheduler/run) is active.

statestringrequired

One of "disabled" "waiting" "running" "yielding" "complete".

reasonstringrequired

Human-readable reason for the state, e.g. the app is in use, session locked.

jobIdstring

The scheduled job's id, when there is one. Omitted otherwise.

doneintegerrequired

Items processed by the scheduled job.

totalintegerrequired

Items the scheduled job will process.

opsstringrequired

Comma-separated per-item ops the job runs (default hash,dimensions,describe,transcribe,autotag,embed,faces).

signalsobjectrequired

The idle signals behind the decision.

cpuPercentnumberrequired

Total system CPU percent; -1 when unknown.

sessionLockedstringrequired

One of "yes" "no" "unknown".

fullscreenBusystringrequired

One of "yes" "no" "unknown".

onBatterystringrequired

One of "yes" "no" "unknown".

inputIdleSecondsnumberrequired

Seconds since the last keyboard/mouse input.

appQuietSecondsnumberrequired

Seconds since the last user-intent request to the server.

ScoredMediaItemobject#

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.

pathstringrequired
mtimeMsintegerrequired

Always 0.

elonumber
battlesinteger
heightinteger
widthinteger
scorenumberrequired

Similarity score; items are sorted by it, highest first.

SegmentObjectobject#
idintegerrequired
labelstringrequired
scorenumberrequired
bboxSceneRectrequired
maskstringrequired

PNG data URL (white, alpha = coverage) covering rect.

rectSceneRectrequired
candidatesarray of SegmentObject

Alternative readings, with multimask.

SegmentRequestobject#

One pick. Coordinates are in the pixels of what is segmented: the picture (frame size), or with layer + version the clip.

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.

everythingboolean

Segment every object.

multimaskboolean

Also return candidate readings (part / object / whole) for clicks and boxes.

layerstring

Motion layer id to segment a clip frame of.

versioninteger

Version index of layer.

frameinteger

Clip frame (with layer + version).

SegmentResultobject#
widthintegerrequired

Width of what was segmented.

heightintegerrequired
objectsarray | nullrequired

null when nothing was found.

msintegerrequired

Segmentation time in milliseconds.

ServerConfigobject#

The server configuration as returned by GET /api/config (secrets redacted). Only commonly used keys are described; see the Config page for the meaning of each setting.

dbPathstring

Active SQLite library database.

portinteger

HTTP port (default 10111).

downloadPathstring
extractorsPathstring
inferenceProviderstring

One of "off" "ollama" "runpod" "lmstudio" "llamacpp" "strata".

ollamaBaseUrlstring
ollamaModelstring
describePromptstring
runpodEndpointstring
runpodApiKeystring
lmstudioBaseUrlstring
lmstudioModelstring
lmstudioApiKeystring
llamacppBaseUrlstring
llamacppModelstring
llamacppApiKeystring
strataBaseUrlstring
strataModelstring
strataApiKeystring
inferenceConcurrencyInferenceConcurrency
autoProcessOpsstring

Comma-separated ops for the auto scheduler's job; empty = all.

localComputeConcurrencyinteger
onnxTaggerobject
modelPathstring
labelsPathstring
configPathstring
ortSharedLibraryPathstring
generalThresholdnumber
characterThresholdnumber
embeddingModelstring
embeddingProviderstring

One of "cpu" "directml".

embeddingPerformancestring

One of "low" "balanced" "max" "custom".

embeddingWorkersinteger
embeddingThreadsPerWorkerinteger
autotagModelstring
autotagProviderstring
autotagPerformancestring
autotagWorkersinteger
autotagThreadsPerWorkerinteger
faceModelstring
faceProviderstring
facePerformancestring
faceWorkersinteger
faceThreadsPerWorkerinteger
faceRoutingstring

One of "auto" "single".

faceClusterThresholdOffsetnumber
faceClusterMinClusterinteger
faceClusterMinQualitynumber
byoFaceModelsarray of ByoFaceModel
onnxFileTimeoutSecondsinteger

Per-file timeout for local ONNX tasks; <= 0 disables.

transcriptionProviderstring
transcriptionModelstring
transcriptionLanguagestring
transcriptionVadFilterboolean
transcriptionInitialPromptstring
transcriptionHotwordsstring
transcriptionVocalExtractboolean
dreamModelstring
reshootModelsarray of object
filestring
labelstring
recipestring

One of "fast" "h3".

fasterWhisperPathstring
discordTokenstring
jwtSecretstring

Always <redacted> when set.

setupCompleteboolean
defaultStartPathstring
allowPublicAccessboolean
swipeFeedobject

Swipe For You feed tuning.

enginesobject

GPU engine placement (see the GPU Engines section).

strategystring

One of "local" "pool" "hybrid".

poolsarray | null
routesobject | null
fallbackboolean
localOnlyboolean
rootsarray of StorageRoot
rootPathsarray of string
SetupBrowseResultobject#

A folder listing for the wizard's picker.

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.

namestringrequired
pathstringrequired

Absolute path.

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.

SetupModelGroupobject#

A feature card in the wizard's model step, mapping to downloadable model ids.

idstringrequired

visual-search, autotag, faces, transcription everywhere; ai-image-editing, ai-video, ai-select on Windows and Linux; ai-generate on Windows and Linux in Dream builds; splats on Windows only.

titlestringrequired
descstringrequired
recommendedbooleanrequired

Pre-selected in the wizard.

sizeBytesinteger (int64)required

Sum of the models' download sizes for this OS, from the manifest.

modelsarray of stringrequired

Model ids to download via POST /api/deps/models/{id}/download.

SetupStateobject#

Response of GET /setup/api/state.

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.

rootsarray of SetupStorageRootrequired

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.

depsarray of DepStatusItemrequired

Same as GET /api/deps/status.

modelGroupsarray of SetupModelGrouprequired
SetupStorageRootobject#

A storage root as saved in the config (roots).

typestring

Empty is treated as local on save.

One of "local" "s3".

pathstring

Absolute folder (local roots).

labelstring

Display name.

defaultboolean

Destination for uploads and downloads.

endpointstring

S3 endpoint URL.

regionstring
bucketstring

Bucket name (required for S3 roots).

prefixstring

Key prefix inside the bucket.

accessKeystring
secretKeystring

Redacted to ••• in GET /setup/api/state.

thumbnailPrefixstring

Key prefix for thumbnails.

SimilarHitobject#
pathstringrequired

Media path.

scorenumberrequired

Cosine similarity in the active embedding model's space.

StatsEventobject#

Data of the stats event; the same object GET /api/stats returns (snapshot plus live progress deltas). Only the counters are listed here; see that endpoint for the full shape.

readyboolean
generatedAtinteger

Unix seconds the snapshot was computed.

totalMediainteger
totalVideosinteger
totalAudiointeger
totalImagesinteger
totalTranscribableinteger

Videos plus audio.

withDescriptioninteger
withHashinteger
withSizeinteger
withTagsinteger
withDimensionsinteger
withTranscriptinteger
withEmbeddinginteger
withFaceScaninteger
autotagModelobject

Active autotag model info.

embedModelobject

Active embedding model info.

faceModelobject

Active face model info.

StatsModelInfoobject#
idstringrequired
namestringrequired

Display name.

StdoutEventobject#

Data of the stdout-<jobId> event.

updateTypestringrequired
linestringrequired

One log line.

StitchReportobject#

Measurements of a stitch.

bandintegerrequired
driftnumberrequired

Mean displacement corrected near the edge, px.

driftMaxnumberrequired

95th percentile of the drift, px.

tonenumberrequired

Overall colour shift corrected (0-255 RGB distance).

grainAddednumberrequired

Noise added (0-255 standard deviation).

seamLengthintegerrequired

px.

residualnumberrequired

What the seam still adds (0-255 excess gradient, mean along it).

healPixelsintegerrequired

Pixels where the seam still shows.

skippedstring

Why the stitch was skipped.

StorageRootobject#

A storage root (local folder or S3-compatible bucket).

typestringrequired

One of "local" "s3".

pathstring

Local filesystem path (local).

labelstringrequired

Display name.

defaultboolean

Destination for uploads and downloads.

endpointstring
regionstring
bucketstring
prefixstring
accessKeystring

<redacted> on read when set.

secretKeystring

<redacted> on read when set.

thumbnailPrefixstring
StoredEmbeddingRowobject#
modelstringrequired
dimintegerrequired
created_atintegerrequired

Stored creation time; the embed tasks currently write 0.

vectorarray of number

Present only with vector=true.

StreamConnectedEventobject#

Data of the unnamed message sent once when a /stream connection opens.

typestringrequired
msgstringrequired
SwipeManifestobject#

Web app manifest (fixed content).

namestringrequired
short_namestringrequired
descriptionstringrequired
start_urlstringrequired
displaystringrequired
orientationstringrequired
background_colorstringrequired
theme_colorstringrequired
iconsarray of objectrequired
srcstringrequired
sizesstringrequired
typestringrequired
purposestringrequired
SwipeMediaItemobject#

A media row as the Swipe app receives it.

pathstringrequired
descriptionstring | nullrequired

AI description; null when none.

sizeinteger | nullrequired

File size in bytes.

hashstring | nullrequired

Content hash.

widthinteger | nullrequired
heightinteger | nullrequired
tagsarray | nullrequired

May be null when the item has no tags.

existsbooleanrequired

Whether the file exists on disk.

duplicateCountinteger (int64)required

Other items with the same hash.

SwipeMediaTagobject#
labelstringrequired
categorystringrequired
SwipeResponseobject#
itemsarray | nullrequired

The page. The shuffle mode returns null (not an empty array) when the pool is empty.

has_morebooleanrequired

More items exist past this page.

total_countintegerrequired

Not populated by this endpoint; always 0.

TagDetailobject#
labelstringrequired
categorystringrequired

Empty string for a tag with no category.

weightnumberrequired
descriptionstringrequired

Empty string when unset.

thumbnail_path_600string | nullrequired

Stored preview thumbnail path.

groupstring | nullrequired

The tag's group.

TagGroupobject#
labelstringrequired
weightnumberrequired

Sort weight.

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.

TagGroupRefobject#
categoryLabelstringrequired

The group's category.

labelstringrequired

Group label.

TagWithCountobject#
labelstringrequired
categorystringrequired

Empty string for a tag with no category.

weightnumberrequired
media_countintegerrequired

Distinct media paths carrying the tag.

TaskInfoobject#
idstringrequired

Task id, used as a job's command and as the first token of POST /create input.

namestringrequired

Display name.

optionsarray of TaskOption

Option schema. Always present (possibly empty) on Windows; absent on macOS and Linux builds.

TaskOptionobject#

One option a task accepts. Passed to the job as --<name> <value> or --<name>=<value> in arguments (or as a POST /create fields entry). Unknown option tokens are ignored by the task.

namestringrequired

Option name (the flag without --).

labelstringrequired

Display label.

typestringrequired

How the value is parsed: - bool: --name alone means true; --name=true|1|yes or a following true/false/1/0/yes/no token sets it explicitly. - number: parsed as a float; an unparsable value leaves the default. - string, enum, multi-enum: the following token (unless it starts with --) or the = value, as a string.

One of "string" "bool" "enum" "multi-enum" "number".

choicesarray of string

Allowed values for enum and multi-enum. Omitted when empty.

defaultany

Value used when the option is absent (string, boolean, number or array depending on the task). Omitted when the option has no default, in which case it defaults to false, 0 or an empty string by type.

requiredboolean

Marked as required in the UI. Omitted when false.

descriptionstring

Help text. Omitted when empty.

TaxonomyCategoryobject#
labelstringrequired
weightnumberrequired

Sort weight.

tagViewModestringrequired

How the viewer lists the tags (card when unset).

tagsarray of objectrequired
labelstringrequired
categorystringrequired
weightnumberrequired
TaxonomyTagobject#
labelstringrequired
categorystringrequired

Empty string for a tag with no category.

weightnumberrequired
groupstring | null

Present only with ?category=: the tag's group, or null when ungrouped.

ThumbnailCachestring#

Thumbnail size column (100, 600 or 1200 px).

One of "thumbnail_path_100" "thumbnail_path_600" "thumbnail_path_1200".

TreemapNodeobject#
namestringrequired

Folder name (empty at the library root).

pathstringrequired

Folder path exactly as stored; pass it back as path to drill down.

countintegerrequired

Metric rows in the subtree.

coveredintegerrequired

Distinct items in the subtree that have rows.

directinteger

Rows for files directly in this folder. Omitted when 0.

itemCountinteger

Items in this folder's subtree (non-items metrics only). Omitted when 0.

childrenarray of TreemapNode

Largest first, up to limit. Omitted at the depth limit or when empty.

otherCountinteger

Rows in children beyond limit. Omitted when 0.

otherDirsinteger

Number of children beyond limit. Omitted when 0.

UngroupedFaceobject#

One face not yet assigned to any person.

idinteger (int64)required

Face id.

pathstringrequired

Media path.

frameTsnumberrequired

Video frame time in seconds (0 for images).

detScorenumberrequired

Detection confidence.

modelstringrequired

Recognizer id.

UploadResultobject#
successbooleanrequired
filesarray | nullrequired

Stored paths; null on failure.

messagestring

Present on success.

errorstring

Present on failure.

WeightBodyobject#
labelstring

Tag label.

weightnumber

Sort weight.

WorkerInfoobject#

A loki-worker's self-description (its GET /v1/info).

workerstringrequired

Worker name.

versionstringrequired
protocolintegerrequired

Wire protocol version.

modestringrequired
readybooleanrequired
gpusarray of objectrequired
indexintegerrequired
namestring
totalMbinteger
freeMbinteger
busystring

Id of the job running on it.

toolsarray of objectrequired
namestringrequired

Engine name.

versionstring
modelstring
pathstring

Executable on the worker; empty when not installed.

statesarray of string

State per GPU slot.

residentarray of object

Models resident on the GPU.

keystringrequired
mbintegerrequired
helpstring

The engine's --help text (only when requested).

slotsintegerrequired

GPU jobs run at once (one per GPU).

runningintegerrequired
queuedintegerrequired
uptimenumberrequired

Seconds.

sessionsintegerrequired

Open interactive sessions.

WorkerNodeobject#

One loki-worker HTTP server.

namestring

Display name; defaults to the URL.

urlstringrequired

Base URL (trailing / removed). Nodes without one are dropped.

tokenstring

Bearer token for the worker. Redacted in responses.

disabledboolean

Stays listed but gets no work (draining).

WorkerPoolobject#

A group of interchangeable GPU workers.

namestringrequired

Unique (case-insensitive) pool name. Pools with a blank name are dropped.

kindstringrequired

nodes = loki-worker HTTP servers; runpod = a RunPod queue-based serverless endpoint.

One of "nodes" "runpod".

disabledboolean

Kept in the config but never used.

enginesarray of string

Engines this pool serves; empty means every engine its workers report.

nodesarray of WorkerNode

nodes pools: the workers.

endpointIdstring

runpod pools: the endpoint id. A RunPod pool without one is ignored.

apiKeystring

runpod pools: the RunPod API key. Redacted (<redacted>) in responses.

maxJobsinteger

runpod pools: jobs in flight on the endpoint (match its max workers). <= 0 is saved as 4.

Default 4.

transferPoolTransfer
providerPoolProvider
fromEnvboolean

Read-only: the pool comes from LOWKEY_ENGINES or LOWKEY_WORKER_NODES and is never written to the config file.

WorkflowRunResponseobject#
idsarray | nullrequired

The created job ids, in node order. null when the DAG was empty (POST /workflow only).

WorkflowTaskobject#

One DAG node; becomes one job.

idstringrequired

Node id, unique within the DAG and referenced by other nodes' dependencies. In saved workflows it is a template id replaced by a fresh UUID at run time; in POST /workflow it becomes the job id.

commandstringrequired

Task id (see GET /tasks).

argumentsarray | null

Option tokens, already split (for example ["--apply", "all"]).

inputstring

The node's own input. At claim time each dependency's output files are appended one per line.

dependenciesarray | null

Ids of nodes in the same DAG that must complete first.

pos_xnumber

Editor canvas position. Omitted when 0.

pos_ynumber

Editor canvas position. Omitted when 0.